嵌入式linux_02

第 4 章 线程处理

4.1 Linux线程简介

Linux 中的线程是指轻量级的执行单元,相比于进程,具有以下特点:

(1)进程(Process)是正在执行的程序的实例。每个进程都有自己的地址空间、代码段、数据段和打开的文件描述符等资源。线程(Thread)是进程内的一个执行单元,它共享相同的地址空间和其他资源,包括文件描述符、信号处理等,但每个线程都有自己的栈空间。

(2)由于共享地址空间和数据段,同一进程的多线程之间进行数据交换比进程间通信方便很多,但也由此带来线程同步问题

(3)同一进程的多线程共享大部分资源,除了每个线程独立的栈空间。这代表线程的创建、销毁、切换要比进程的创建、销毁、切换的资源消耗小很多,所以多线程比多进程更适合高并发。

4.2 线程控制

4.2.1 线程创建

1)pthread_create

线程操作相关函数来源于pthread共享库:

#include <pthread.h>

/**
 * 创建一个新线程
 * 
 * pthread_t *thread: 指向线程标识符的指针,线程创建成功时,用于存储新创建线程的线程标识符
 * const pthread_attr_t *attr: pthead_attr_t结构体,这个参数可以用来设置线程的属性,如优先级、栈大小等。如果不需要定制线程属性,可以传入 NULL,此时线程将采用默认属性。 
 * void *(*start_routine)(void *): 一个指向函数的指针,它定义了新线程开始执行时的入口点。这个函数必须接受一个 void * 类型的参数,并返回 void * 类型的结果
 * void *arg: start_routine 函数的参数,可以是一个指向任意类型数据的指针
 * return: int 线程创建结果
 *             成功 0
 *             失败 非0
 */
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,void *(*start_routine)(void *), void *arg);

每个线程都有一个唯一的标识符(即线程ID),这个标识符是通过pthread_t类型的变量来表示的,当pthread_create成功创建一个线程时,它会将新线程的标识符存储在thread参数指向的位置。

pthread_t定义在头文件<pthreadtypes.h>中,实际上是long类型(long和long int是相同类型的不同写法)的别名。

typedef unsigned long int pthread_t;

新线程执行函数的声明为void (start_routine)(void *),其入参和返回值都是void *指针。我们可以将传递给线程函数的参数包装为结构体,并将其指针作为入参,再在函数内部处理;同理我们可以在线程函数内部将要返回的状态码和返回值包装为结构体,并将指针作为返回值return。

返回值的获取需要通过其他方法进行,在下面的章节我们会讲。

2)测试例程

(1)创建create_test.c

#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

#define BUF_LEN 1024

char *buf;

/**
 * @brief 线程input_thread读取标准输入的数据,放入缓存区
 * 
 * @param argv 不需要输入参数,所以固定为NULL
 * @return void* 也不需要返回值
 */
void *input_thread(void *argv)
{
    int i = 0;
    while (1)
    {
        // 从标准输入读取一个字符
        char c = fgetc(stdin);
        // 如果字符不为0也不是换行,写入缓存区
        if (c && c!= '\n')
        {
            buf[i++] = c;
            // 缓存区索引溢出,i归零
            if (i >= BUF_LEN)
            {
                i = 0;
            }
        }
    }
}

/**
 * @brief 线程output_thread从缓存区读取数据写到标准输出,每个字符换行
 * 
 * @param argv 不需要输入参数,所以固定为NULL
 * @return void* 也不需要返回值
 */
void *output_thread(void *argv)
{
    int i = 0;
    while (1)
    {
        if (buf[i])
        {
            // 从缓存区读取一个字节写入标准输出并换行
            fputc(buf[i], stdout);
            fputc('\n', stdout);

            // 清理读取完毕的字节
            buf[i++] = 0;
            if (i >= BUF_LEN)
            {
                i = 0;
            }
        } else {
            sleep(1);
        }
    }
}

int main()
{
    pthread_t pid_input;
    pthread_t pid_output;
    // 分配缓存
    buf = malloc(BUF_LEN);

    // 初始化缓存区
    for (int i = 0; i < BUF_LEN; i++)
    {
        buf[i] = 0;
    }

    // 创建读取线程
    pthread_create(&pid_input, NULL, input_thread, NULL);

    // 创建写出线程
    pthread_create(&pid_output, NULL, output_thread, NULL);

    // 等待指定线程结束,详细作用下一节讲
    pthread_join(pid_input, NULL);
    pthread_join(pid_output, NULL);

    free(buf);

    return 0;
}

上述代码启动了两个线程,输入线程不断检查stdin,将控制台输入的数据存储到缓冲区,输出线程检查缓冲区,如果缓冲区有数据则逐字符输出到stdout,否则等待1s再检查缓冲区。

(2)创建Makefile

CC:=gcc

create_test: create_test.c
    -$(CC) -o $@ $^
    -./$@
    -rm ./$@

(3)运行,在控制台输入hello,查看输出

image-20260822125543014

4.2.2 线程终止

4.2.2.1 相关函数

1)pthread_exit

线程终止有以下几种方法:

(1)线程函数执行return语句;

(2)线程函数内部调用pthread_exit函数;

(3)其他线程调用pthread_cancel函数。

线程终止函数为pthread_exit:

#include <pthread.h>

/**
 * 结束关闭调用该方法的线程,并返回一个内存指针用于存放结果
 * void *retval: 要返回给其它线程的数据
 */
void pthread_exit(void *retval);

当某个线程调用pthread_exit方法后,该线程会被关闭(相当于return)。线程可以通过retval向其它线程传递信息,retval指向的区域不可以放在线程函数的栈内。其他线程(例如主线程)如果需要获得这个返回值,需要调用pthread_join方法。

2)pthread_join

#include <pthread.h>

/**
 * 等待指定线程结束,获取目标线程的返回值,并在目标线程结束后回收它的资源
 * 
 * pthread_t thread: 指定线程ID
 * void **retval: 这是一个可选参数,用于接收线程结束后传递的返回值。如果非空,pthread_join 会在成功时将线程的 exit status 复制到 *retval 所指向的内存位置。如果线程没有显式地通过 pthread_exit 提供返回值,则该参数将被设为 NULL 或忽略
 * return: int 成功 0
 *              失败 1
 */
int pthread_join(pthread_t thread, void **retval);

3)pthread_detach

#include <pthread.h>

/**
 * @brief 将线程标记为detached状态。POSIX线程终止后,如果没有调用pthread_detach或pthread_join,其资源会继续占用内存,类似于僵尸进程的未回收状态。默认情况下创建线程后,它处于可join状态,此时可以调用pthread_join等待线程终止并回收资源。但是如果主线程不需要等待线程终止,可以将其标记为detached状态,这意味着线程终止后,其资源会自动被系统回收。
 * 
 * @param thread 线程ID
 * @return int 成功返回0,失败返回错误码
 */
int pthread_detach(pthread_t thread);

4)pthread_cancel

#include <pthread.h>

/**
 * @brief 向目标线程发送取消请求。目标线程是否和何时响应取决于它的取消状态和类型
 *  取消状态(Cancelability State):可以是enabled(默认)或disabled。如果取消状态为禁用,则取消请求会被挂起,直至线程启用取消功能。如果取消状态为启用,则线程的取消类型决定它何时取消。
 *  取消类型(Cancelability Type):可以是asynchronous(异步)或deferred(被推迟,默认值)。
 *      asynchronous:意味着线程可能在任何时候被取消(通常立即被取消,但系统并不保证这一点)
 *      deferred:被推迟意味着取消请求会被挂起,直至被取消的线程执行取消点(cancellation point)函数时才会真正执行线程的取消操作。
 *      取消点函数:是在POSIX线程库中专门设计用于检查和处理取消请求的函数。当被取消的线程执行这些函数时,如果线程的取消状态是enabled且类型是deferred,则它会立即响应取消请求并终止执行。man 7 pthreads可以看到取消点函数列表。
 * 
 * @param thread 目标线程,即被取消的线程
 * @return int 成功返回0,失败返回非零的错误码
 *      需要注意的是,取消操作和pthread_cancel函数的调用是异步的,这个函数的返回值只能告诉调用者取消请求是否成功发送。当线程被成功取消后,通过pthread_join和线程关联将会获得PTHREAD_CANCELED作为返回信息,这是判断取消是否完成的唯一方式
 */
int pthread_cancel(pthread_t thread);

5)pthread_setcancelstate

#include <pthread.h>

/**
 * @brief 设置调用线程的取消状态
 *  PTHREAD_CANCEL_ENABLE:启用取消功能
 *  PTHREAD_CANCEL_DISABLE:禁用取消功能
 * 
 * @param state 目标状态
 * @param oldstate 指针,用于返回历史状态
 * @return int 成功返回0,失败返回非零错误码
 */
int pthread_setcancelstate(int state, int *oldstate);

6)pthread_setcanceltype

#include <pthread.h>

/**
 * @brief 设置调用线程的取消类型
 *  PTHREAD_CANCEL_DEFERRED:设置取消类型为推迟
 *  PTHREAD_CANCEL_ASYNCHRONOUS:设置取消类型为异步
 * 
 * @param type 目标类型
 * @param oldtype 指针,用于接收历史类型
 * @return int 成功返回0,失败返回非零错误码
 */
int pthread_setcanceltype(int type, int *oldtype);

4.2.2.2 测试例程

1)pthread_join测试例程

(1)创建terminate_test.c

#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <math.h>
#include <string.h> 
//定义结果结构体
typedef struct Result
{
    char *p;
    int len;
} Result;
/**
 * 红玫瑰
 * 
 * void *argv: 指针,可以传递的参数  这里用一个字符表示她的代号
 * return: void* 结果结构体指针 返回结局
 */
void *red_thread(void *argv)
{
    Result *result = malloc(sizeof(Result));
    char code = *((char *)argv);
    // 存放回信
    char *ans = malloc(101);
    while (1)
    {
        fgets(ans,100,stdin);
        if (ans[0]==code)
        {
            //接收到了对应的信息
            free(ans);
            printf("红玫瑰离开了!\n");
            char *redAns = strdup("红玫瑰独自去了纽约.\n");
            result->p = redAns;
            result->len = strlen(redAns);
            // 结束线程 输出返回值
            pthread_exit((void *)result);
        }else{
            printf("红玫瑰还在等你!\n");
        }
    }
    
}
/**
 * 白玫瑰
 * 
 * void *argv: 指针,可以传递的参数  这里用一个字符表示她的代号
 * return: void* 结果结构体指针 返回结局
 */
void *white_thread(void *argv)
{
    Result *result = malloc(sizeof(Result));
    char code = *((char *)argv);
    // 存放回信
    char *ans = malloc(101);
    while (1)
    {
        fgets(ans,100,stdin);
        if (ans[0]==code)
        {
            //接收到了对应的信息
            free(ans);
            printf("白玫瑰离开了!\n");
            char *redAns = strdup("白玫瑰独自去了伦敦.\n");
            result->p = redAns;
            result->len = strlen(redAns);
            // 结束线程 输出返回值
            pthread_exit((void *)result);
        }else{
            printf("白玫瑰还在等你!\n");
        }
    }
}
int main()
{
    pthread_t pid_red;
    pthread_t pid_white;
    char red_code='r';
    char white_code='w';
    Result *red_result = NULL;
    Result *white_result = NULL;
    // 创建红玫瑰线程
    pthread_create(&pid_red, NULL, red_thread, &red_code);
    // 创建白玫瑰线程
    pthread_create(&pid_white, NULL, white_thread, &white_code);
    // 获取红玫瑰结果
    pthread_join(pid_red, (void **)&red_result);
    printf("红玫瑰故事结局:%s\n",red_result->p);
    // 释放内存
    free(red_result->p);
    free(red_result);
    // 获取求素数结果
    pthread_join(pid_white, (void **)&white_result);
    printf("白玫瑰故事结局:%s\n",white_result->p);
    
    // 释放内存
    free(white_result->p);
    free(white_result);
    return 0;
}

(2)Makefile

terminate_test: terminate_test.c
    -$(CC) -o $@ $^
    -./$@
    -rm ./$@

(3)运行

终端输入r开头的字符串,然后输入w开头的字符串。

image-20260822130216461

控制台输出如上。

2)pthread_detach测试例程

(1)创建pthread_detach_test.c

#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

void *task(void *arg)
{
    printf("Thread started\n");
    sleep(2); // 模拟线程工作
    printf("Thread finished\n");
    return NULL;
}

int main()
{
    pthread_t tid;

    // 创建线程
    pthread_create(&tid, NULL, task, NULL);

    // 使用 pthread_detach 让线程自动回收资源
    pthread_detach(tid);

    // 主线程继续工作
    printf("Main thread continues\n");
    sleep(3); // 需要注意的是,pthread_detach不会等待子线程结束,如果在后者执行完毕之前主线程退出,则整个进程退出,子线程被强制终止,因此需要等待足够的时间确保子线程完成自己的任务
    printf("Main thread ending\n");

    return 0;
}

(2)Makefile

pthread_detach_test: pthread_detach_test.c
    -$(CC) -o $@ $^
    -./$@
    -rm ./$@

(3)运行

image-20260822130341449

(4)注释主线程的睡眠操作

需要注意的是,pthread_detach不会等待子线程结束,如果在后者执行完毕之前主线程退出,则整个进程退出,子线程被强制终止。为了验证这一点,注释以下代码

 // sleep(3); // 需要注意的是,pthread_detach不会等待子线程结束,如果在后者执行完毕之前主线程退出,则整个进程退出,子线程被强制终止,因此需要等待足够的时间确保子线程完成自己的任务

(5)保存后重新运行

image-20260822130437254

3)pthread_cancel测试例程

(1)创建pthread_cancel_deferred_test.c

#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

void *task(void *arg)
{
    printf("Thread started\n");

    // 默认取消类型为延迟,无需设置

    // 模拟工作
    printf("Working...\n");
    sleep(1);             // 模拟工作
    pthread_testcancel(); // 取消点函数
    printf("After Cancelled\n");

    return NULL;
}

int main()
{
    pthread_t tid;
    void *res;

    // 创建线程
    pthread_create(&tid, NULL, task, NULL);

    // 取消子线程
    if (pthread_cancel(tid) != 0){
        perror("pthread_cancel");
    }

    // 等待子线程终止并获取其退出状态
    pthread_join(tid, &res);

    // 检查子线程是否被取消
    if (res == PTHREAD_CANCELED)
    {
        printf("Thread was canceled\n");
    }
    else
    {
        printf("Thread was not canceled, exit code: %ld\n", (long)res);
    }

    return 0;
}

(2)Makefile

pthread_cancel_deferred_test: pthread_cancel_deferred_test.c
    -$(CC) -o $@ $^
    -./$@
    -rm ./$@

(3)运行

image-20260822130603510

After Cancelled并未被打印,且主线程执行了取消成功分支,子线程被成功取消。

(4)创建pthread_cancel_async_test.c

#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

void *task(void *arg)
{
    printf("Thread started\n");

    // 设置取消类型为异步
    pthread_setcanceltype(PTHREAD_CANCEL_ASYNCHRONOUS, NULL);

    // 模拟工作
    printf("Working...\n");
    sleep(1);             // 模拟工作
    printf("After Cancelled\n");

    return NULL;
}

int main()
{
    pthread_t tid;
    void *res;

    // 创建线程
    pthread_create(&tid, NULL, task, NULL);

    // 取消子线程
    if (pthread_cancel(tid) != 0)
    {
        perror("pthread_cancel");
    }

    // 等待子线程终止并获取其退出状态
    pthread_join(tid, &res);

    // 检查子线程是否被取消
    if (res == PTHREAD_CANCELED)
    {
        printf("Thread was canceled\n");
    }
    else
    {
        printf("Thread was not canceled, exit code: %ld\n", (long)res);
    }

    return 0;
}

(5)Makefile

pthread_cancel_async_test: pthread_cancel_async_test.c
    -$(CC) -o $@ $^
    -./$@
    -rm ./$@

(6)运行

image-20260822130726200

取消成功。

(7)创建pthread_cancel_disabled_test.c

#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

void *task(void *arg)
{
    printf("Thread started\n");

    // 禁用取消响应
    pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL);
    printf("Thread CancelState is disabled\n");

    // 设置取消类型为异步
    pthread_setcanceltype(PTHREAD_CANCEL_ASYNCHRONOUS, NULL);

    // 模拟工作
    printf("Working...\n");
    sleep(1);             // 模拟工作
    printf("After Cancelled\n");

    return NULL;
}
int main()
{
    pthread_t tid;
    void *res;

    // 创建线程
    pthread_create(&tid, NULL, task, NULL);

    // 取消子线程
    if (pthread_cancel(tid) != 0)
    {
        perror("pthread_cancel");
    }

    // 等待子线程终止并获取其退出状态
    pthread_join(tid, &res);

    // 检查子线程是否被取消
    if (res == PTHREAD_CANCELED)
    {
        printf("Thread was canceled\n");
    }
    else
    {
        printf("Thread was not canceled, exit code: %ld\n", (long)res);
    }

    return 0;
}

(8)Makefile

pthread_cancel_disabled_test: pthread_cancel_disabled_test.c
    -$(CC) -o $@ $^
    -./$@
    -rm ./$@

(9)运行

image-20260822130853088

线程未被取消。

4.3 线程同步

4.3.1 竞态条件和锁

1)竞态条件

当多个线程并发访问和修改同一个共享资源(如全局变量)时,如果没有适当的同步措施,就会遇到线程同步问题。这种情况下,程序最终的结果依赖于线程执行的具体时序,导致了竞态条件。

竞态条件(race condition)是一种特定的线程同步问题,指的是两个或者以上进程或者线程并发执行时,其最终的结果依赖于进程或者线程执行的精确时序。它会导致程序的行为和输出超出预期,因为共享资源的最终状态取决于线程执行的顺序和时机。为了确保程序执行结果的正确性和预期一致,需要通过适当的线程同步机制来避免竞态条件。

上一节的测试例程存在竞态条件,如果命令行输入的消息不以r或w开头,输出信息的线程是不确定的。

image-20260823004649098

下面的程序没有合理控制线程的并发访问,可能会引发竞态条件。

(1)创建race_condition_test.c

#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

#define THREAD_COUNT 20000

/**
 * @brief 对传入值累加1
 *
 * @param argv 传入指针
 * @return void* 无返回值
 */
void *add_thread(void *argv)
{
    int *p = argv;
    (*p)++;
    return (void *)0;
}

int main()
{
    pthread_t pid[THREAD_COUNT];

    int num = 0;

    // 用20000个线程对num作累加
    for (int i = 0; i < THREAD_COUNT; i++)
    {
        pthread_create(pid + i, NULL, add_thread, &num);
    }

    // 等待所有线程结束
    for (int i = 0; i < THREAD_COUNT; i++)
    {
        pthread_join(pid[i], NULL);
    }

    // 打印累加结果
    printf("累加结果:%d\n", num);

    return 0;
}

(2)Makefile

race_condition_test: race_condition_test.c
    -$(CC) -o $@ $^
    -./$@
    -rm ./$@

(3)运行

image-20260823004814574

可以看到20000个线程对num的累加结果是不确定的,没有达到我们的预期值20000。这是因为线程之间出现了竞争,不同线程对于num的累加操作可能重叠,这就会导致多次累加操作可能只生效一次。

2)如何避免竞态条件

上述程序如果想避免竞态条件,有下面两种解决方案:

(1)避免多线程写入一个地址。

(2)给资源加锁,使同一时间操作特定资源的线程只有一个。

方法1可以通过逻辑上组织业务逻辑实现,这里我们讲方法2。

想解决竞争问题,我们需要互斥锁——mutex。

3)常见的锁机制

锁主要用于互斥,即在同一时间只允许一个执行单元(进程或线程)访问共享资源。包括上面的互斥锁在内,常见的锁机制共有三种:

(1)互斥锁(Mutex):保证同一时刻只有一个线程可以执行临界区的代码。

(2)读写锁(Reader/Writer Locks):允许多个读者同时读共享数据,但写者的访问是互斥的。

(1)自旋锁(Spinlocks):在获取锁之前,线程在循环中忙等待,适用于锁持有时间非常短的场景,一般是Linux内核使用。

4.3.2 互斥锁

1)pthread_mutex_t

(1)定义

pthread_mutex_t 是一个定义在头文件<pthreadtypes.h>中的联合体类型的别名,其声明如下。

typedef union
{
  struct __pthread_mutex_s __data;
  char __size[__SIZEOF_PTHREAD_MUTEX_T];
  long int __align;
} pthread_mutex_t;

pthread_mutex_t用作线程之间的互斥锁。互斥锁是一种同步机制,用来控制对共享资源的访问。在任何时刻,最多只能有一个线程持有特定的互斥锁。如果一个线程试图获取一个已经被其他线程持有的锁,那么请求锁的线程将被阻塞,直到锁被释放。

(2)用途

Ø 保护共享数据,避免同时被多个线程访问导致的数据不一致问题。

Ø 实现线程间的同步,确保线程之间对共享资源的访问按照预定的顺序进行。

(3)操作

Ø 初始化(pthread_mutex_init):创建互斥锁并初始化。

Ø 锁定(pthread_mutex_lock):获取互斥锁。如果锁已经被其他线程持有,调用线程将阻塞。

Ø 尝试锁定(pthread_mutex_trylock):尝试获取互斥锁。如果锁已被持有,立即返回而不是阻塞。

Ø 解锁(pthread_mutex_unlock):释放互斥锁,使其可被其他线程获取。

Ø 销毁(pthread_mutex_destroy):清理互斥锁资源。

2)互斥锁操作函数

互斥锁相关的操作函数是POSIX的一部分,默认情况下,当前的Ubuntu系统没有这些函数的手册页。使用指令安装POSIX标准的手册页。

atguigu@ubuntu:~$ sudo apt-get install manpages-posix manpages-posix-dev

查看手册中关于互斥锁操作函数的说明。

查看文档可得:

#include <pthread.h>
/**
 * @brief 获取锁,如果此时锁被占则阻塞
 * 
 * @param mutex 锁
 * @return int 获取锁结果
 */
int pthread_mutex_lock(pthread_mutex_t *mutex);

/**
 * @brief 非阻塞式获取锁,如果锁此时被占则返回EBUSY
 * 
 * @param mutex 锁
 * @return int 获取锁结果
 */
int pthread_mutex_trylock(pthread_mutex_t *mutex);

/**
 * @brief 释放锁
 * 
 * @param mutex 锁
 * @return int 释放锁结果
 */
int pthread_mutex_unlock(pthread_mutex_t *mutex);

(1)pthread_mutex_lock

该函数用于锁定指定的互斥锁。如果互斥锁已经被其他线程锁定,调用此函数的线程将会被阻塞,直到互斥锁变为可用状态。这意味着如果另一个线程持有锁,当前线程将等待直到锁被释放。

成功时返回0;失败时返回错误码。

(2)pthread_mutex_trylock

该函数尝试锁定指定的互斥锁。与pthread_mutex_lock不同,如果互斥锁已经被其他线程锁定,pthread_mutex_trylock不会阻塞调用线程,而是立即返回一个错误码(EBUSY)。

如果成功锁定互斥锁,则返回0;如果互斥锁已被其他线程锁定,返回EBUSY;其他错误情况返回不同的错误码

(3)pthread_mutex_unlock

该函数用于解锁指定的互斥锁。调用线程必须是当前持有互斥锁的线程;否则,解锁操作可能会失败。

成功时返回0;失败时返回错误码。

3)初始化互斥锁

PTHREAD_MUTEX_INITIALIZER是POSIX线程(Pthreads)库中定义的一个宏,用于静态初始化互斥锁(mutex)。这个宏为互斥锁提供了一个初始状态,使其准备好被锁定和解锁,而不需要在程序运行时显式调用初始化函数。

当我们使用PTHREAD_MUTEX_INITIALIZER初始化互斥锁时,实际上是将互斥锁设置为默认属性和未锁定状态。这种初始化方式适用于简单的同步问题,我们可以通过以下代码初始化互斥锁。

static pthread_mutex_t counter_mutex = PTHREAD_MUTEX_INITIALIZER;

4)将mutex加入我们刚刚的程序

为了保证计算结果的正确性,很显然,我们应阻塞式获取互斥锁,应调用的是pthread_mutex_lock函数。共享变量修改完成后,应该释放锁。

(1)创建mutex_test.c

#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

#define THREAD_COUNT 20000

static pthread_mutex_t counter_mutex = PTHREAD_MUTEX_INITIALIZER;

/**
 * @brief 对传入值累加1
 *
 * @param argv 传入指针
 * @return void* 无返回值
 */
void *add_thread(void *argv)
{
    int *p = argv;
    // 累加之前加锁,此时其他获取该锁的线程都会被阻塞
    pthread_mutex_lock(&counter_mutex);
    (*p)++;
    // 累加之后释放锁
    pthread_mutex_unlock(&counter_mutex);
    return (void *)0;
}

int main()
{
    pthread_t pid[THREAD_COUNT];

    int num = 0;

    // 用20000个线程对num作累加
    for (int i = 0; i < THREAD_COUNT; i++)
    {
        pthread_create(pid + i, NULL, add_thread, &num);
    }

    // 等带所有线程结束
    for (int i = 0; i < THREAD_COUNT; i++)
    {
        pthread_join(pid[i], NULL);
    }

    // 打印累加结果
    printf("累加结果:%d\n", num);

    return 0;
}

(2)Makefile

mutex_test: mutex_test.c
    -$(CC) -o $@ $^
    -./$@
    -rm ./$@

(4)执行

image-20260823010246461

累加结果稳定为20000。

(1)注意

上述代码中,互斥锁counter_mutex并未被显式销毁,但这通常不会引起资源泄露问题。上述程序在所有线程执行完毕后直接结束,进程结束时,操作系统会回收该进程的所有资源,包括内存、打开的文件描述符和互斥锁等。因此即便没有显式销毁互斥锁也不会有问题。

在某些情况下,确实需要显式销毁互斥锁资源。如果互斥锁是动态分配的(使用pthread_mutex_init函数初始化),或者互斥锁会被跨多个函数或文件使用,不再需要时必须显式销毁它。但对于静态初始化,并且在程序结束时不再被使用的互斥锁(上述程序中的counter_mutex),显式销毁不是必需的。

4.3.3 读写锁

4.3.3.1 工作原理

读操作:在读写锁的控制下,多个线程可以同时获得读锁。这些线程可以并发地读取共享资源,但它们的存在阻止了写锁的授予。

写操作:如果至少有一个读操作持有读锁,写操作就无法获得写锁。写操作将会阻塞,直到所有的读锁都被释放。

4.3.3.2 相关调用

1)pthread_rwlock_t

声明如下。

typedef union
{
  struct __pthread_rwlock_arch_t __data;
  char __size[__SIZEOF_PTHREAD_RWLOCK_T];
  long int __align;
} pthread_rwlock_t;

2)pthread_rwlock_init()

/**
 * @brief 为rwlock指向的读写锁分配所有需要的资源,并将锁初始化为未锁定状态。读写锁的属性由attr参数指定,如果attr为NULL,则使用默认属性。当锁的属性为默认时,可以通过宏PTHREAD_RWLOCK_INITIALIZER初始化,即
 * pthread_rwlock_t rwlock = PTHREAD_RWLOCK_INITIALIZER; 效果和调用当前方法并为attr传入NULL是一样的
 * 
 * @param rwlock 读写锁
 * @param attr 读写锁的属性
 * @return int 成功则返回0,否则返回错误码
 */
int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock, const pthread_rwlockattr_t *restrict attr);

3)pthread_rwlock_destroy()

#include <pthread.h>

/**
 * @brief 销毁rwlock指向的读写锁对象,并释放它使用的所有资源。当任何线程持有锁的时候销毁锁,或尝试销毁一个未初始化的锁,结果是未定义的。
 * 
 * @param rwlock 
 * @return int 
 */
int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);

4)pthread_rwlock_rdlock()

/**
 * @brief 应用一个读锁到rwlock指向的读写锁上,并使调用线程获得读锁。如果写线程持有锁,调用线程无法获得读锁,它会阻塞直至获得锁。
 * 
 * @param rwlock 读写锁
 * @return int 成功返回0,失败返回错误码
 */
int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);

5)pthread_rwlock_wrlock()

/**
 * @brief 应用一个写锁到rwlock指向的读写锁上,并使调用线程获得写锁。只要任意线程持有读写锁,则调用线程无法获得写锁,它将阻塞直至获得写锁。
 * 
 * @param rwlock 读写锁
 * @return int 成功返回0,失败返回错误码
 */
int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);

6)pthread_rwlock_unlock()

/**
 * @brief 释放调用线程锁持有的rwlock指向的读写锁。
 * 
 * @param rwlock 读写锁
 * @return int 成功返回0.失败返回错误码
 */
int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);

4.3.3.3 写操作不加锁测试

1)创建rwlock_test_writer_unlock.c,此时写操作没有加锁

#include <stdio.h>
#include <pthread.h>
#include <unistd.h>

pthread_rwlock_t rwlock;
int shared_data = 0;

void *lock_reader(void *argv) {
    pthread_rwlock_rdlock(&rwlock);
    printf("this is %s, value is %d\n", (char *)argv, shared_data);

    pthread_rwlock_unlock(&rwlock);
}

void *lock_writer(void *argv) {
    int tmp = shared_data + 1;
    sleep(1);
    shared_data = tmp;
    printf("this is %s, shared_data++\n", (char *)argv);
}

int main() {
    pthread_rwlock_init(&rwlock, NULL);

    pthread_t writer1, writer2, reader1, reader2, reader3, reader4, reader5, reader6;
    pthread_create(&writer1, NULL, lock_writer, "writer1");
    pthread_create(&writer2, NULL, lock_writer, "writer2");
    sleep(3);
    pthread_create(&reader1, NULL, lock_reader, "reader1");
    pthread_create(&reader2, NULL, lock_reader, "reader2");
    pthread_create(&reader3, NULL, lock_reader, "reader3");
    pthread_create(&reader4, NULL, lock_reader, "reader4");
    pthread_create(&reader5, NULL, lock_reader, "reader5");
    pthread_create(&reader6, NULL, lock_reader, "reader6");

    pthread_join(writer1, NULL);
    pthread_join(writer2, NULL);
    pthread_join(reader1, NULL);
    pthread_join(reader2, NULL);
    pthread_join(reader3, NULL);
    pthread_join(reader4, NULL);
    pthread_join(reader5, NULL);
    pthread_join(reader6, NULL);

    pthread_rwlock_destroy(&rwlock);
}

我们在每个写操作中都将共享变量shared_data的值加一,但这不是个原子操作,分成了两步:①先将其值加一,赋给临时变量tmp,②然后将tmp的值赋给shared_data。在这两步之间睡眠一秒。这样一来,两个写线程只有按照严格的先后顺序执行,shared_data的值才会+2变为2,触发了竞态条件。

为了观察两次写操作之后的数据,我们在创建写线程之后睡眠两秒,确保大多数情况下读操作读到的是两次写操作之后的数据。

2)在Makefile中新增以下内容

rwlock_test_writer_unlock: rwlock_test_writer_unlock.c
    -$(CC) -o $@ $^
    -./$@
    -rm ./$@

3)运行

执行上一步的程序,我们可以看到读操作读取到的shared_data均为1,说明两次写操作并没有在前者的基础上+1,实质上两次加一操作只有一次生效。

image-20260823010935025

4.3.3.4 写操作添加读写锁

1)创建rwlock_test.c,写入以下内容。

#include <stdio.h>
#include <pthread.h>
#include <unistd.h>

pthread_rwlock_t rwlock;
int shared_data = 0;

void *lock_reader(void *argv) {
    pthread_rwlock_rdlock(&rwlock);
    printf("this is %s, value is %d\n", (char *)argv, shared_data);

    pthread_rwlock_unlock(&rwlock);
}

void *lock_writer(void *argv) {
    pthread_rwlock_wrlock(&rwlock);
    int tmp = shared_data + 1;
    sleep(1);
    shared_data = tmp;
    printf("this is %s, shared_data++\n", (char *)argv);

    pthread_rwlock_unlock(&rwlock);
}

int main() {
    pthread_rwlock_init(&rwlock, NULL);

    pthread_t writer1, writer2, reader1, reader2, reader3, reader4, reader5, reader6;
    pthread_create(&writer1, NULL, lock_writer, "writer1");
    pthread_create(&writer2, NULL, lock_writer, "writer2");
    sleep(3);
    pthread_create(&reader1, NULL, lock_reader, "reader1");
    pthread_create(&reader2, NULL, lock_reader, "reader2");
    pthread_create(&reader3, NULL, lock_reader, "reader3");
    pthread_create(&reader4, NULL, lock_reader, "reader4");
    pthread_create(&reader5, NULL, lock_reader, "reader5");
    pthread_create(&reader6, NULL, lock_reader, "reader6");

    pthread_join(writer1, NULL);
    pthread_join(writer2, NULL);
    pthread_join(reader1, NULL);
    pthread_join(reader2, NULL);
    pthread_join(reader3, NULL);
    pthread_join(reader4, NULL);
    pthread_join(reader5, NULL);
    pthread_join(reader6, NULL);

    pthread_rwlock_destroy(&rwlock);
}

2)Makefile

rwlock_test: rwlock_test.c
    -$(CC) -o $@ $^
    -./$@
    -rm ./$@

3)运行

image-20260823011117358

此时读到的数据均为2。

4.3.3.5 读写操作执行顺序随机

在上一节的基础上,我们对程序做以下改动:

① 删除写操作的sleep()操作

② 删除主线程中创建写线程之后的睡眠操作

③ 将第二次写操作置于第三次读操作之后。

这样做的目的是尽可能让读写操作间隔执行,但要注意的是,线程的执行顺序是由操作系统内核调度的,其运行规律并不简单地为“先创建先执行”。

1)创建rwlock_rw_alternate.c

#include <stdio.h>
#include <pthread.h>
#include <unistd.h>

pthread_rwlock_t rwlock;
int shared_data = 0;

void *lock_reader(void *argv) {
    pthread_rwlock_rdlock(&rwlock);
    printf("this is %s, value is %d\n", (char *)argv, shared_data);

    pthread_rwlock_unlock(&rwlock);
}
void *lock_writer(void *argv) {
    pthread_rwlock_wrlock(&rwlock);
    int tmp = shared_data + 1;
    shared_data = tmp;
    printf("this is %s, shared_data++\n", (char *)argv);

    pthread_rwlock_unlock(&rwlock);
}

int main() {
    pthread_rwlock_init(&rwlock, NULL);

    pthread_t writer1, writer2, reader1, reader2, reader3, reader4, reader5, reader6;
    pthread_create(&writer1, NULL, lock_writer, "writer1");
    pthread_create(&reader1, NULL, lock_reader, "reader1");
    pthread_create(&reader2, NULL, lock_reader, "reader2");
    pthread_create(&reader3, NULL, lock_reader, "reader3");
    pthread_create(&writer2, NULL, lock_writer, "writer2");
    pthread_create(&reader4, NULL, lock_reader, "reader4");
    pthread_create(&reader5, NULL, lock_reader, "reader5");
    pthread_create(&reader6, NULL, lock_reader, "reader6");

    pthread_join(writer1, NULL);
    pthread_join(writer2, NULL);
    pthread_join(reader1, NULL);
    pthread_join(reader2, NULL);
    pthread_join(reader3, NULL);
    pthread_join(reader4, NULL);
    pthread_join(reader5, NULL);
    pthread_join(reader6, NULL);

    pthread_rwlock_destroy(&rwlock);
}

2)Makefile

rwlock_random_order: rwlock_random_order.c
    -$(CC) -o $@ $^
    -./$@
    -rm ./$@

3)运行

image-20260823011321602

此时读写进程执行的顺序是不确定的。

4.3.3.6 写饥饿测试

1)创建rwlock_write_hungry.c

在上一节的基础上,在读操作中添加1s的休眠。

#include <stdio.h>
#include <pthread.h>
#include <unistd.h>

pthread_rwlock_t rwlock;
int shared_data = 0;

void *lock_reader(void *argv) {
    pthread_rwlock_rdlock(&rwlock);
    printf("this is %s, value is %d\n", (char *)argv, shared_data);
    sleep(1);

    pthread_rwlock_unlock(&rwlock);
}

void *lock_writer(void *argv) {
    pthread_rwlock_wrlock(&rwlock);
    int tmp = shared_data + 1;
    shared_data = tmp;
    printf("this is %s, shared_data++\n", (char *)argv);

    pthread_rwlock_unlock(&rwlock);
}

int main() {
    pthread_rwlock_init(&rwlock, NULL);

    pthread_t writer1, writer2, reader1, reader2, reader3, reader4, reader5, reader6;
    pthread_create(&writer1, NULL, lock_writer, "writer1");
    pthread_create(&reader1, NULL, lock_reader, "reader1");
    pthread_create(&reader2, NULL, lock_reader, "reader2");
    pthread_create(&reader3, NULL, lock_reader, "reader3");
    pthread_create(&writer2, NULL, lock_writer, "writer2");
    pthread_create(&reader4, NULL, lock_reader, "reader4");
    pthread_create(&reader5, NULL, lock_reader, "reader5");
    pthread_create(&reader6, NULL, lock_reader, "reader6");

    pthread_join(writer1, NULL);
    pthread_join(writer2, NULL);
    pthread_join(reader1, NULL);
    pthread_join(reader2, NULL);
    pthread_join(reader3, NULL);
    pthread_join(reader4, NULL);
    pthread_join(reader5, NULL);
    pthread_join(reader6, NULL);

    pthread_rwlock_destroy(&rwlock);
}

2)Makefile

rwlock_write_hungry: rwlock_write_hungry.c
    -$(CC) -o $@ $^
    -./$@
    -rm ./$@

3)多次运行

image-20260823011822784

image-20260823011839575

多次运行后,我们发现,此时读操作总是连续执行的,且读操作休眠未结束时,写操作会被阻塞。与工作原理相符:① 读操作可以并发执行,相互之间不必争抢锁,多个读操作可以同时获得读锁;② 只要有一个线程持有读写锁,写操作就会被阻塞。我们在读操作中加了1s休眠,只要有一个读线程获得锁,在1s内写操作是无法执行的,其它读操作就可以有充足的时间执行,因此读操作就会连续发生,写操作必须等待所有读操作执行完毕方可获得读写锁执行写操作。这就是使用读写锁时存在的潜在问题:写饥饿。

4)写饥饿

① 问题描述

读写锁的写饥饿问题(Writer Starvation)是指在使用读写锁时,写线程可能无限期地等待获取写锁,因为读线程持续地获取读锁而不断地推迟写线程的执行。这种情况通常在读操作远多于写操作时出现。

② 解决方案

Linux提供了可以修改的属性pthread_rwlockattr_t,默认情况下,属性中指定的策略为“读优先”,当写操作阻塞时,读线程依然可以获得读锁,从而在读操作并发较高时导致写饥饿问题。我们可以尝试将策略更改为“写优先”,当写操作阻塞时,读线程无法获取锁,避免了写线程持有锁的时间持续延长,使得写线程获取锁的等待时间显著降低,从而避免写饥饿问题。

5)写饥饿问题解决方案

(1)pthread_rwlockattr_t

声明如下。

typedef union
{
  char __size[__SIZEOF_PTHREAD_RWLOCKATTR_T];
  long int __align;
} pthread_rwlockattr_t;

(2)pthread_rwlockattr_init

#include <pthread.h>

/**
 * @brief 用所有属性的默认值初始化attr指向的属性对象
 * 
 * @param attr 读写锁属性对象指针
 * @return int 成功返回0,失败返回错误码
 */
int pthread_rwlockattr_init(pthread_rwlockattr_t *attr);

(3)pthread_rwlockattr_destroy

#include <pthread.h>
/**
 * @brief 销毁读写锁属性对象
 * 
 * @param attr 读写锁属性对象指针
 * @return int 成功返回0,失败返回错误码
 */
int pthread_rwlockattr_destroy(pthread_rwlockattr_t *attr);

(4)pthread_rwlockattr_setkind_np

(4)thread_rwlockattr_setkind_np

#include <pthread.h>

/**
 * @brief 将attr指向的属性对象中的"锁类型"属性设置为pref规定的值
 * 
 * @param attr 读写锁属性对象指针
 * @param pref 希望设置的锁类型,可以被设置为以下三种取值的其中一种
 * PTHREAD_RWLOCK_PREFER_READER_NP: 默认值,读线程拥有更高优先级。当存在阻塞的写线程时,读线程仍然可以获得读写锁。只要不断有新的读线程,写线程将一直保持"饥饿"。
 * PTHREAD_RWLOCK_PREFER_WRITER_NP: 写线程拥有更高优先级。这一选项被glibc忽略。
 * PTHREAD_RWLOCK_PREFER_WRITER_NONRECURSIVE_NP: 写线程拥有更高优先级,在当前系统环境下,它是有效的,将锁类型设置为该值以避免写饥饿。
 * @return int 成功返回0,失败返回非零的错误码
 */
int pthread_rwlockattr_setkind_np(pthread_rwlockattr_t *attr, int pref);

(5)创建rwlock_hungry_solved.c

#include <stdio.h>
#include <pthread.h>
#include <unistd.h>

pthread_rwlock_t rwlock;
int shared_data = 0;

void *lock_reader(void *argv)
{
    pthread_rwlock_rdlock(&rwlock);
    printf("this is %s, value is %d\n", (char *)argv, shared_data);
    sleep(1);

    pthread_rwlock_unlock(&rwlock);
}
void *lock_writer(void *argv)
{
    pthread_rwlock_wrlock(&rwlock);
    int tmp = shared_data + 1;
    shared_data = tmp;
    printf("this is %s, shared_data++\n", (char *)argv);

    pthread_rwlock_unlock(&rwlock);
}

int main()
{
    pthread_rwlockattr_t attr;
    pthread_rwlockattr_init(&attr);

    // 设置写优先
    pthread_rwlockattr_setkind_np(&attr, PTHREAD_RWLOCK_PREFER_WRITER_NONRECURSIVE_NP);
    pthread_rwlock_init(&rwlock, &attr);
    pthread_rwlockattr_destroy(&attr);

    pthread_t writer1, writer2, reader1, reader2, reader3, reader4, reader5, reader6;
    pthread_create(&writer1, NULL, lock_writer, "writer1");
    pthread_create(&reader1, NULL, lock_reader, "reader1");
    pthread_create(&reader2, NULL, lock_reader, "reader2");
    pthread_create(&reader3, NULL, lock_reader, "reader3");
    pthread_create(&writer2, NULL, lock_writer, "writer2");
    pthread_create(&reader4, NULL, lock_reader, "reader4");
    pthread_create(&reader5, NULL, lock_reader, "reader5");
    pthread_create(&reader6, NULL, lock_reader, "reader6");

    pthread_join(writer1, NULL);
    pthread_join(writer2, NULL);
    pthread_join(reader1, NULL);
    pthread_join(reader2, NULL);
    pthread_join(reader3, NULL);
    pthread_join(reader4, NULL);
    pthread_join(reader5, NULL);
    pthread_join(reader6, NULL);

    pthread_rwlock_destroy(&rwlock);
}

(6)Makefile

rwlock_hungry_solved: rwlock_hungry_solved.c
    -$(CC) -o $@ $^
    -./$@
    -rm ./$@

(7)运行

image-20260823012205855

可以发现,此时的连续六次读操作间夹杂了写操作,不再连续,写操作不必等待所有读操作完成才可以执行。不必长期等待,写饥饿问题已得到解决。

4.3.4 自旋锁

在Linux内核中,自旋锁是一种用于多处理器系统中的低级同步机制,主要用于保护非常短的代码段或数据结构,以避免多个处理器同时访问共享资源。自旋锁相对于其他锁的优点是它们在锁被占用时会持续检查锁的状态(即“自旋”),而不是让线程进入休眠。这使得自旋锁在等待时间非常短的情况下非常有效,因为它避免了线程上下文切换的开销。

自旋锁主要用于内核模块或驱动程序中,避免上下文切换的开销。不能在用户空间使用。

4.3.5 条件变量

1)restrict关键字

restrict是一个C99标准引入的关键字,用于修饰指针,它的作用是告诉编译器,被修饰的指针是编译器所知的唯一一个可以在其作用域内用来访问指针所指向的对象的方法。这样一来,编译器可以放心地执行代码优化,因为不存在其他的别名(即其他指向同一内存区域的指针)会影响到这块内存的状态。

restrict声明了一种约定,主要目的是允许编译器在生成代码时做出优化假设,而不是在程序的不同部分间强制执行内存访问的规则。程序员需要确保遵守restrict的约定,编译器则依赖这个约定来进行优化。如果restrict约定被违反,可能导致未定义行为。

函数参数使用restrict修饰,相当于约定:函数执行期间,该参数指向的内存区域不会被其它指针修改。

2)线程间条件切换函数

如果需要两个线程协同工作,可以使用条件变量完成线程切换。查看文档可得:

#include <pthread.h>

/**
 * @brief 调用该方法的线程必须持有mutex锁。调用该方法的线程会阻塞并临时释放mutex锁,并等待其他线程调用pthread_cond_signal或pthread_cond_broadcast唤醒。被唤醒后该线程会尝试重新获取mutex锁。
 * 
 * @param cond 指向条件变量的指针。条件变量用于等待某个条件的发生。通过某一cond等待的线程需要通过同一cond的signal唤醒
 * @param mutex 与条件变量配合使用的互斥锁的指针。在调用pthread_cond_wait之前,线程必须已经获得了这个互斥锁。
 * @return int 成功时返回0;失败时返回错误码,而非-1。错误码可能包括EINVAL、EPERM等,具体取决于错误的性质。
 */
int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex);

/**
 * @brief 同pthread_cond_wait相似,但是它添加了超时机制。如果在指定的abstime时间内条件变量没有被触发,函数将返回一个超时错误(ETIMEDOUT)。
 * 
 * @param cond 指向条件变量的指针
 * @param mutex 与条件变量配合使用的互斥锁的指针
 * @param abstime 指向timespec结构的指针,表示等待条件变量的绝对超时时间。timespec结构包含秒和纳秒两部分,指定了从某一固定点(如UNIX纪元,1970年1月1日)开始的时间。
 * @return int 成功时返回0;如果超时则返回ETIMEDOUT;其他错误情况返回相应的错误码。
 */
int pthread_cond_timedwait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex, const struct timespec *restrict abstime);

/**
 * @brief 唤醒因cond而阻塞的线程,如果有多个线程因为cond阻塞,那么随机唤醒一个。如果没有线程在等待,这个函数什么也不做。
 * 
 * @param cond 指向条件变量的指针
 * @return int 成功时返回0;失败时返回错误码
 */
int pthread_cond_signal(pthread_cond_t *cond);

/**
 * @brief 唤醒所有正在等待条件变量cond的线程。如果没有线程在等待,这个函数什么也不做。
 * 
 * @param cond 指向条件变量的指针。
 * @return int 成功时返回0;失败时返回错误码。
 */
int pthread_cond_broadcast(pthread_cond_t *cond);

说明:

(1)使用条件变量时,通常涉及到一个或多个线程等待“条件变量”代表的条件成立,而另外一些线程在条件成立时触发条件变量。

(2)条件变量的使用必须与互斥锁配合,以保证对共享资源的访问是互斥的。

(3)条件变量提供了一种线程间的通信机制,允许线程以无竞争的方式等待特定条件的发生。

3)条件变量pthread_cond_t

(1)定义

pthread_cond_t是一个条件变量,它是线程间同步的另一种机制。与pthread_mutex_t相同,它也定义在头文件<pthreadtypes.h>中,其声明如下。

typedef union
{
  struct __pthread_cond_s __data;
  char __size[__SIZEOF_PTHREAD_COND_T];
  __extension__ long long int __align;
} pthread_cond_t;

条件变量允许线程挂起执行并释放已持有的互斥锁,等待某个条件变为真。条件变量总是需要与互斥锁一起使用,以避免出现竞态条件。

(1)用途

Ø 允许线程等待特定条件的发生。当条件尚未满足时,线程通过条件变量等待,直到其他线程修改条件并通知条件变量。

Ø 通知等待中的线程条件已改变,允许它们重新评估条件。

(2)操作

Ø 初始化(pthread_cond_init):创建并初始化条件变量。

Ø 等待(pthread_cond_wait):在给定的互斥锁上等待条件变量。调用时,线程将释放互斥锁并进入等待状态,直到被唤醒。

Ø 定时等待(pthread_cond_timedwait):等待条件变量或直到超过指定的时间。

Ø 信号(pthread_cond_signal):唤醒至少一个等待该条件变量的线程。

Ø 广播(pthread_cond_broadcast):唤醒所有等待该条件变量的线程。

Ø 销毁(pthread_cond_destroy):清理条件变量资源。

4)PTHREAD_COND_INITIALIZER

(1)说明及用法

PTHREAD_COND_INITIALIZER是POSIX线程(Pthreads)库中定义的一个宏,用于在声明时静态初始化条件变量(pthread_cond_t类型的变量)。它提供了一种简单、便捷的方式来初始化条件变量,无需调用初始化函数pthread_cond_init。

使用PTHREAD_COND_INITIALIZER可以让条件变量在程序启动时即处于可用状态,这对于全局或静态分配的条件变量尤其有用。

下面的代码展示了PTHREAD_COND_INITIALIZER的用法。

static pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

(2)注意事项

Ø 使用PTHREAD_COND_INITIALIZER静态初始化的条件变量通常不需要调用pthread_cond_destroy来销毁。但是,如果条件变量在程序执行期间被重新初始化(通过pthread_cond_init),那么在不再需要时应使用pthread_cond_destroy进行清理。

Ø PTHREAD_COND_INITIALIZER只适用于静态或全局变量的初始化。对于动态分配的条件变量(例如,通过malloc分配的条件变量),应使用pthread_cond_init函数进行初始化。

Ø PTHREAD_COND_INITIALIZER提供的是条件变量的默认属性。如果需要自定义条件变量的属性(例如,改变其pshared属性以支持进程间同步),则需要使用pthread_cond_init和pthread_condattr_t类型的属性对象。

5)范例程序

(1)创建condition_var.c

#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>

#define BUFFER_SIZE 5
int buffer[BUFFER_SIZE];
int count = 0;
// 初始化锁
static pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
// 初始化条件变量
static pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
// 生产者线程
void *producer(void *arg)
{
    int item = 1;
    while (1)
    {
        // 获取互斥锁 拿不到等
        pthread_mutex_lock(&mutex);
        // 如果缓冲区满,等消费者读取
        while (count == BUFFER_SIZE)
        {
            // 暂停线程 等待唤醒
            pthread_cond_wait(&cond, &mutex);
        }
        // 能到这里说明缓冲区不满了 可以写一个
        buffer[count++] = item++;
        printf("白月光发送一个幸运数字%d\n", buffer[count - 1]);
        // 通知消费者可以消费数据了
        // 唤醒消费者 同时解锁
        pthread_cond_signal(&cond);
        pthread_mutex_unlock(&mutex);
    }
}
void *consumer(void *arg)
{
    while (1)
    {
        // 获取互斥锁 拿不到等
        pthread_mutex_lock(&mutex);
        // 如果缓冲区为空,则等待生产者生产数据
        while (count == 0)
        {
            // 暂停线程 等待唤醒
            pthread_cond_wait(&cond, &mutex);
        }
        printf("我收到了幸运数字 %d\n", buffer[--count]);
        // 通知生产者可以发送数据了
        // 唤醒生产者 同时解锁
        pthread_cond_signal(&cond);
        pthread_mutex_unlock(&mutex);
    }
}
int main()
{
    pthread_t producer_thread, consumer_thread;
    pthread_create(&producer_thread, NULL, producer, NULL);
    pthread_create(&consumer_thread, NULL, consumer, NULL);
    pthread_join(producer_thread, NULL);
    pthread_join(consumer_thread, NULL);
    return 0;
}

(2)Makefile

condition_var: condition_var.c
    -$(CC) -o $@ $^
    -./$@
    -rm ./$@

(3)终止程序

程序运行过快,为了查看控制台数据,终止程序,光标移至控制台,同时按下Ctrl+C。

(4)查看控制台输出

image-20260823012713946

控制台输出如上所示。

可以看到producer线程生产数据,consumer线程消费数据,二者交替工作。

4.3.6 信号量

4.3.6.1 信号量相关概念

信号量(Semaphore)是一种广泛使用的同步机制,用于控制对共享资源的访问,主要在操作系统和并发编程领域中得到应用。信号量是由Edsger Dijkstra在1960年代提出的,用来解决多个进程或线程间的同步与互斥问题。

与共享存储等不同,在Linux中,信号量是用来协调进程或线程的执行的,并不承担传输数据的职责。

1)基本概念

信号量本质上是一个非负整数变量,可以被用来控制对共享资源的访问。它主要用于两种目的:互斥和同步。

(1)互斥(Mutex):确保多个进程或线程不会同时访问临界区(即访问共享资源的代码区域)。

(2)同步(Synchronization):协调多个进程或线程的执行顺序,确保它们按照一定的顺序执行。

2)基于用途的分类

基于不同的目的,信号量可以分为两类:用于实现互斥的“二进制信号量”和用于同步的“计数信号量”。

(1)二进制信号量(或称作互斥锁):其值只能是0或1,主要用于实现互斥,即一次只允许一个线程进入临界区。通常用于控制共享资源的访问,避免竞态条件的产生。

(2)计数信号量:其值可以是任意非负整数,表示可用资源的数量。计数信号量允许多个线程根据可用资源的数量进入临界区。通常用于控制不同进程或线程执行的顺序,如消费者必须在生产者发送数据后才可以消费。

3)基于名称的分类

在Linux中,根据是否具有唯一的名称,分为有名信号量(named semaphore)和无名信号量(unnamed semaphore)。这两种信号量特性有所不同:

(1)无名信号量

无名信号量不是通过名称标识,而是直接通过sem_t结构的内存位置标识。无名信号量在使用前需要初始化,在不再需要时应该销毁。它们不需要像有名信号量那样进行创建和链接,因此设置起来更快,运行效率也更高。

(2)有名信号量

有名信号量在系统范围内是可见的,可以在任意进程之间进行通信。它们通过名字唯一标识,这使得不同的进程可以通过这个名字访问同一个信号量对象。

在当前Linux系统中,有名信号量在临时文件系统中的对应文件位于/dev/shm目录下,创建它们时可以像普通文件一样设置权限模式,限制不同用户的访问权限。

4)操作

信号量主要提供了两个操作:P操作和V操作。

(1)P操作(Proberen,尝试):也称为等待操作(wait),用于减少信号量的值。如果信号量的值大于0,它就减1并继续执行;如果信号量的值为0,则进程或线程阻塞,直到信号量的值变为非零。

(2)V操作(Verhogen,增加):也称为信号操作(signal),用于增加信号量的值。如果有其他进程或线程因信号量的值为0而阻塞,这个操作可能会唤醒它们。

4.3.6.2 无名信号量

无名信号量和有名信号量都可以用作二进制信号量和计数信号量。

1)知识储备

(1)sem_init()

#include <semaphore.h>

/**
 * @brief 在sem指向的地址初始化一个无名信号量。
 * 
 * @param sem 信号量地址
 * @param pshared 指明信号量是线程间共享还是进程间共享的
 * 0: 信号量是线程间共享的,应该被置于所有线程均可见的地址(如,全局变量或在堆中动态分配的变量)
 * 非0: 信号量是进程间共享的,应该被置于共享内存区域,任何进程只要能访问共享内存区域,即可操作进程间共享的信号量
 * @param value 信号量的初始值
 * @return int 成功返回0,失败返回-1,同时errno被设置以记录错误信息
 */
int sem_init(sem_t *sem, int pshared, unsigned int value);

(2) sem_destroy()

#include <semaphore.h>

/**
 * @brief 销毁sem指向的无名信号量
 * 
 * @param sem 无名信号量
 * @return int 成功返回0,失败返回-1,并设置errno指示错误原因
 */
int sem_destroy(sem_t *sem);

(3)sem_post()

#include <semaphore.h>

/**
 * @brief 将sem指向的信号量加一,如果信号量从0变为1,且其他进程或线程因信号量而阻塞,则阻塞的进程或线程会被唤醒并获取信号量,然后继续执行。POSIX标准并未明确定义唤醒策略,具体唤醒的是哪个进程或线程取决于操作系统的调度策略。
 * 
 * @param sem 信号量指针
 * @return int 成功返回0,失败则信号量的值未被修改,返回-1,并设置errno以指明错误原因
 */
int sem_post(sem_t *sem);

(4)sem_wait()

#include <semaphore.h>

/**
 * @brief 将sem指向的信号量减一。如果信号量的值大于0,函数可以执行减一操作,然后立即返回,调用线程继续执行。如果当前信号量的值是0,则调用阻塞直至信号量的值大于0,或信号处理函数打断当前调用。
 * 
 * @param sem 信号量指针
 * @return int 成功返回0,失败则信号量的值保持不变,返回-1,并设置errno以指明错误原因
 */
int sem_wait(sem_t *sem);

(5)time_t

本质上就是long int即long类型。

(6)time()

#include <time.h>

/**
 * @brief 返回以秒为单位的UNIX时间戳
 * 
 * @param tloc 记录时间的指针,如果不为NULL,则当前的UNIX秒级时间戳也会存在tloc指向的位置,否则不会存储。
 * @return time_t 成功则返回以秒为单位的UNIX时间戳,失败则返回(time_t)-1
 */
time_t time(time_t *tloc);

(7)rand()

#include <stdlib.h>

/**
 * @brief 返回一个 0-RAND_MAX 之间的伪随机数。当前机器环境下,RAND_MAX为int类型的最大值
 * 
 * @return int 伪随机数
 */
int rand(void);

(8)srand()

#include <stdlib.h>

/**
 * @brief 将seed设置为rand()生成随机数时使用的随机种子,如果没有设置随机种子,rand()会自动将1作为随机种子
 * 
 * @param seed 随机种子
 */
void srand(unsigned int seed);

2)作为二进制信号量用于线程间通信

二进制信号量的作用和互斥锁是相同的。

(1)存在竞态条件的测试例程

① 创建unnamed_sem_bin_thread_condition.c

#include <stdio.h>
#include <unistd.h>
#include <semaphore.h>
#include <pthread.h>

int shard_num = 0;

void *plusOne(void *argv) {
    int tmp = shard_num + 1;
    shard_num = tmp;
}

int main() {
    pthread_t tid[10000];
    for (int i = 0; i < 10000; i++) {
        pthread_create(tid + i, NULL, plusOne, NULL);
    }

    for (int i = 0; i < 10000; i++) {
        pthread_join(tid[i], NULL);
    }
    
    printf("shard_num is %d\n", shard_num);

    return 0;
}

② Makefile

unnamed_sem_bin_thread_condition: unnamed_sem_bin_thread_condition.c
    -$(CC) -o $@ $^
    -./$@
    -rm ./$@

③ 运行

多次执行,我们会发现,所有线程执行完毕之后,shared_num的值并不总是10000,这是因为shared_num的加一并不是原子操作,如果某个线程执行了int num=shared_num+1,在它将tmp的值赋给shared_num之前,别的线程执行了第一步,那么加一操作就会被“吞掉”,最终导致10000次加一的结果并未全部生效,因而shared_num小于10000。

结果如下,shared_num的值可能小于10000。

image-20260823013546447

(2)解决了竞态条件的测试例程

① 创建unnamed_sem_bin_thread.c,写入以下内容。

#include <stdio.h>
#include <unistd.h>
#include <semaphore.h>
#include <pthread.h>

sem_t unnamed_sem;
int shard_num = 0;

void *plusOne(void *argv) {
    sem_wait(&unnamed_sem);
    int tmp = shard_num + 1;
    shard_num = tmp;
    sem_post(&unnamed_sem);
}

int main() {
    sem_init(&unnamed_sem, 0, 1);

    pthread_t tid[10000];
    for (int i = 0; i < 10000; i++) {
        pthread_create(tid + i, NULL, plusOne, NULL);
    }

    for (int i = 0; i < 10000; i++) {
        pthread_join(tid[i], NULL);
    }
    
    printf("shard_num is %d\n", shard_num);

    sem_destroy(&unnamed_sem);

    return 0;
}

在这里,我们通过二进制信号量起到了互斥锁的作用,避免了线程间的竞态条件。

② Makefile

unnamed_sem_bin_thread: unnamed_sem_bin_thread.c
    -$(CC) -o $@ $^
    -./$@
    -rm ./$@

③ 运行

image-20260823013717245

多次执行,结果稳定在10000。

3)作为二进制信号量用于进程间通信

需要注意的是,线程比进程的资源共享程度更高,可以用于进程间通信的方式,通常也可以用于线程间通信。

(1)存在竞态条件的例程

① 创建unnamed_sem_bin_process_condition.c

#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <sys/wait.h>

int main() {
    char *shm_value_name = "unnamed_sem_shm_value";
    
    // 创建内存共享对象
    int value_fd = shm_open(shm_value_name, O_CREAT | O_RDWR, 0666);
    // 调整内存共享对象的大小
    ftruncate(value_fd, sizeof(int));
    // 将内存共享对象映射到共享内存区域
    int *value = mmap(NULL, sizeof(int), PROT_READ | PROT_WRITE, MAP_SHARED, value_fd, 0);

    // 初始化共享变量的值
    *value = 0;

    int pid = fork();

    if (pid > 0) {
        int tmp = *value + 1;
        sleep(1);
        *value = tmp;
        
        // 等待子进程执行完毕
        waitpid(pid, NULL, 0);
        printf("this is father, child finished\n");
        printf("the final value is %d\n", *value);
    } else if (pid == 0) {
        int tmp = *value + 1;
        sleep(1);
        *value = tmp;
    } else {
        perror("fork");
    }

    // 无论父子进程都应该解除共享内存的映射,并关闭共享对象的文件描述符
    if (munmap(value, sizeof(int)) == -1)
    {
        perror("munmap value");
    }

    if (close(value_fd) == -1)
    {
        perror("close value");
    }

    // 如果调用时别的进程仍在使用共享对象,则等待所有进程释放资源后,才会销毁相关资源。
    // shm_unlink只能调用一次,这里在父进程中调用shm_unlink
    if (pid > 0)
    {
        if (shm_unlink(shm_value_name) == -1)
        {
            perror("father shm_unlink shm_value_name");
        }
    }

    return 0;
}

② Makefile

unnamed_sem_bin_process_condition: unnamed_sem_bin_process_condition.c
    -$(CC) -o $@ $^
    -./$@
    -rm ./$@

③ 运行

image-20260823013851062

执行后我们发现value指向的位置值为1。两次加一操作只生效一次,出现了竞态条件。

(2)解决了竞态条件的例程

① 创建unnamed_sem_bin_process.c

#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <semaphore.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <sys/wait.h>

int main()
{
    char *shm_sem_name = "unnamed_sem_shm_sem";
    char *shm_value_name = "unnamed_sem_shm_value";

    // 创建内存共享对象
    int sem_fd = shm_open(shm_sem_name, O_CREAT | O_RDWR, 0666);
    int value_fd = shm_open(shm_value_name, O_CREAT | O_RDWR, 0666);

    // 调整内存共享对象的大小
    ftruncate(sem_fd, sizeof(sem_t));
    ftruncate(value_fd, sizeof(int));

    // 将内存共享对象映射到共享内存区域
    sem_t *sem = mmap(NULL, sizeof(sem_t), PROT_READ | PROT_WRITE, MAP_SHARED, sem_fd, 0);
    
    int *value = mmap(NULL, sizeof(int), PROT_READ | PROT_WRITE, MAP_SHARED, value_fd, 0);
    
    // 初始化信号量和共享变量的值
    sem_init(sem, 1, 1);
    *value = 0;

    int pid = fork();

    if (pid > 0)
    {
        sem_wait(sem);
        int tmp = *value + 1;
        sleep(1);
        *value = tmp;
        sem_post(sem);

        // 等待子进程执行完毕
        waitpid(pid, NULL, 0);
        printf("this is father, child finished\n");
        printf("the final value is %d\n", *value);
    }
    else if (pid == 0)
    {
        sem_wait(sem);
        int tmp = *value + 1;
        sleep(1);
        *value = tmp;
        sem_post(sem);
    }
    else
    {
        perror("fork");
    }

    // 父进程执行到这里,子进程已执行完毕,可以销毁信号量
    if (pid > 0)
    {
        if (sem_destroy(sem) == -1)
        {
            perror("sem_destory");
        }
    }

    // 无论父子进程都应该解除共享内存的映射,并关闭共享对象的文件描述符
    if (munmap(sem, sizeof(sem)) == -1)
    {
        perror("munmap sem");
    }

    if (munmap(value, sizeof(int)) == -1)
    {
        perror("munmap value");
    }

    if (close(sem_fd) == -1)
    {
        perror("close sem");
    }

    if (close(value_fd) == -1)
    {
        perror("close value");
    }

    // 如果调用时别的进程仍在使用共享对象,则等待所有进程释放资源后,才会销毁相关资源。
    // shm_unlink只能调用一次,这里在父进程中调用shm_unlink
    if (pid > 0)
    {
        if (shm_unlink(shm_sem_name) == -1)
        {
            perror("father shm_unlink shm_sem_name");
        }

        if (shm_unlink(shm_value_name) == -1)
        {
            perror("father shm_unlink shm_value_name");
        }
    }

    return 0;
}

② Makefile

unnamed_sem_bin_process: unnamed_sem_bin_process.c
    -$(CC) -o $@ $^
    -./$@
    -rm ./$@

③ 运行

image-20260823014026890

执行后发现,value指向的位置值为2,竞态条件已被解决。

(3)注意

无名信号量被用于进程间通信时,需要注意两点:

① sem_init()的第二个参数应设置为非零值,来告诉操作系统内核,这个信号量是用来进程间通信的,如果设置为0,则一个进程通过sem_post()释放的信号量无法被其它进程获取,会导致程序卡死。

i)考虑以下程序,创建unnamed_sem_bin_process_illegal.c

#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <semaphore.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <sys/wait.h>

int main()
{
    char *shm_sem_name = "unnamed_sem_shm_sem";
    char *shm_value_name = "unnamed_sem_shm_value";

    // 创建内存共享对象
    int sem_fd = shm_open(shm_sem_name, O_CREAT | O_RDWR, 0666);
    int value_fd = shm_open(shm_value_name, O_CREAT | O_RDWR, 0666);

    // 调整内存共享对象的大小
    ftruncate(sem_fd, sizeof(sem_t));
    ftruncate(value_fd, sizeof(int));

    // 将内存共享对象映射到共享内存区域
    sem_t *sem = mmap(NULL, sizeof(sem_t), PROT_READ | PROT_WRITE, MAP_SHARED, sem_fd, 0);
    
    int *value = mmap(NULL, sizeof(int), PROT_READ | PROT_WRITE, MAP_SHARED, value_fd, 0);
    
    // 初始化信号量和共享变量的值
    sem_init(sem, 0, 1);
    *value = 0;

    int pid = fork();

    if (pid > 0)
    {
        sem_wait(sem);
        int tmp = *value + 1;
        sleep(1);
        *value = tmp;
        sem_post(sem);

        // 等待子进程执行完毕
        waitpid(pid, NULL, 0);
        printf("this is father, child finished\n");
        printf("the final value is %d\n", *value);
    }
    else if (pid == 0)
    {
        sem_wait(sem);
        int tmp = *value + 1;
        sleep(1);
        *value = tmp;
        sem_post(sem);
    }
    else
    {
        perror("fork");
    }

    // 父进程执行到这里,子进程已执行完毕,可以销毁信号量
    if (pid > 0)
    {
        if (sem_destroy(sem) == -1)
        {
            perror("sem_destory");
        }
    }

    // 无论父子进程都应该解除共享内存的映射,并关闭共享对象的文件描述符
    if (munmap(sem, sizeof(sem)) == -1)
    {
        perror("munmap sem");
    }

    if (munmap(value, sizeof(int)) == -1)
    {
        perror("munmap value");
    }

    if (close(sem_fd) == -1)
    {
        perror("close sem");
    }

    if (close(value_fd) == -1)
    {
        perror("close value");
    }

    // 如果调用时别的进程仍在使用共享对象,则等待所有进程释放资源后,才会销毁相关资源。
    // shm_unlink只能调用一次,这里在父进程中调用shm_unlink
    if (pid > 0)
    {
        if (shm_unlink(shm_sem_name) == -1)
        {
            perror("father shm_unlink shm_sem_name");
        }

        if (shm_unlink(shm_value_name) == -1)
        {
            perror("father shm_unlink shm_value_name");
        }
    }

    return 0;
}

ii)Makefile

unnamed_sem_bin_process_illegal: unnamed_sem_bin_process_illegal.c
    -$(CC) -o $@ $^
    -./$@
    -rm ./$@

iii)运行

image-20260823014145844

执行后,程序卡死。

② 信号量必须置于共享内存区域,以确保多个进程都可以访问,否则每个进程各自管理自己的信号量,后者并没有起到进程间通信的作用。

i)创建unnamed_sem_bin_process_isolate.c

#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <semaphore.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <sys/wait.h>

int main()
{
    char *shm_value_name = "unnamed_sem_shm_value";

    // 创建内存共享对象
    int value_fd = shm_open(shm_value_name, O_CREAT | O_RDWR, 0666);

    // 调整内存共享对象的大小
    ftruncate(value_fd, sizeof(int));

    // 将内存共享对象映射到共享内存区域
    int *value = mmap(NULL, sizeof(int), PROT_READ | PROT_WRITE, MAP_SHARED, value_fd, 0);

    sem_t sem;
    
    // 初始化信号量和共享变量的值
    sem_init(&sem, 1, 1);
    *value = 0;

    int pid = fork();

    if (pid > 0)
    {
        sem_wait(&sem);
        int tmp = *value + 1;
        sleep(1);
        *value = tmp;
        sem_post(&sem);

        // 等待子进程执行完毕
        waitpid(pid, NULL, 0);
        printf("this is father, child finished\n");
        printf("the final value is %d\n", *value);
    }
    else if (pid == 0)
    {
        sem_wait(&sem);
        int tmp = *value + 1;
        sleep(1);
        *value = tmp;
        sem_post(&sem);
    }
    else
    {
        perror("fork");
    }

    // 父进程执行到这里,子进程已执行完毕,可以销毁信号量
    if (pid > 0)
    {
        if (sem_destroy(&sem) == -1)
        {
            perror("sem_destory");
        }
    }

    // 无论父子进程都应该解除共享内存的映射,并关闭共享对象的文件描述符
    if (munmap(value, sizeof(int)) == -1)
    {
        perror("munmap value");
    }

    if (close(value_fd) == -1)
    {
        perror("close value");
    }

    // 如果调用时别的进程仍在使用共享对象,则等待所有进程释放资源后,才会销毁相关资源。
    // shm_unlink只能调用一次,这里在父进程中调用shm_unlink
    if (pid > 0)
    {
        if (shm_unlink(shm_value_name) == -1)
        {
            perror("father shm_unlink shm_value_name");
        }
    }

    return 0;
}



ii)Makefile

unnamed_sem_bin_process_isolate: unnamed_sem_bin_process_isolate.c
    -$(CC) -o $@ $^
    -./$@
    -rm ./$@

iii)运行

image-20260823014352020

执行后,value指向的位置值为1,再次出现了竞态条件。

Tips:此处为便于讲解,只给出了父子进程间通信的示例,但由于共享内存对象可以被任意进程访问,因此,无名信号量实际上可以用于任意进程间的通信,而不仅限于父子进程。在非父子进程通信时,共享资源的初始化和释放要格外注意,必须按照合理的顺序进行。

4)作为计数信号量用于线程间通信

计数信号量主要是用于控制进程或线程执行顺序的。

(1)测试例程

创建unnamed_sem_count_thread.c

#include <stdio.h>
#include <unistd.h>
#include <semaphore.h>
#include <stdlib.h>
#include <time.h>
#include <pthread.h>

sem_t *full;
sem_t *empty;

int shard_num;

int rand_num()
{
    srand(time(NULL));

    return rand();
}

void *producer(void *argv)
{
    for (int i = 0; i < 5; i++)
    {
        sem_wait(empty);
        printf("\n==========> 第 %d 轮数据传输 <=========\n\n", i + 1);
        sleep(1);
        shard_num = rand_num();
        printf("producer has sent data\n");
        sem_post(full);
    }
}

void *consumer(void *argv)
{
    for (int i = 0; i < 5; i++)
    {
        sem_wait(full);
        printf("consumer has read data\n");
        printf("the shard_num is %d\n", shard_num);
        sleep(1);
        sem_post(empty);
    }
}

int main()
{
    full = malloc(sizeof(sem_t));
    empty = malloc(sizeof(sem_t));

    sem_init(empty, 0, 1);
    sem_init(full, 0, 0);

    pthread_t producer_id, consumer_id;
    pthread_create(&producer_id, NULL, producer, NULL);
    pthread_create(&consumer_id, NULL, consumer, NULL);

    pthread_join(producer_id, NULL);
    pthread_join(consumer_id, NULL);

    sem_destroy(empty);
    sem_destroy(full);

    return 0;
}

(2)代码逻辑分析

在上述例程中,我们定义了两个信号量:full和empty,full表示当前的缓冲区(在本例中为shared_num,实质上只能存储一个int类型的数据)是否已满,empty表示缓冲区是否已空。我们启动了一个消费者线程consumer和生产者线程producer,前者用于从缓冲区读取数据(消费),后者用于向缓冲区写入数据(生产)。

① 生产者的逻辑:首先等待缓冲区为空,然后写入数据,最后通过sem_post告诉消费者,缓冲区已满,可以消费数据。

② 消费者的逻辑:首先等待缓冲区满,然后消费数据,最后通过sem_post告诉生产者,缓冲区已空,可以生产数据。

需要注意的是,初始时缓冲区是空的,因此,empty信号量的初值应为1,而full应为0,二者都是用于线程间通信的,sem_init()函数的第二个参数都应该是0。

上述例程通过两个信号量控制生产者和消费者的执行顺序:

① 消费者必须在生产者发送数据后方可消费。

② 除首次发送外,生产者必须等待消费者读取数据后方可发送数据。

(3)Makefile

unnamed_sem_count_thread: unnamed_sem_count_thread.c
    -$(CC) -o $@ $^
    -./$@
    -rm ./$@

(4)执行结果如下

image-20260823014553962

消费者必定在生产者发送数据后才会读取,两个线程的运行顺序被严格控制。

(5)与二进制信号量的区别与联系

上述案例中,信号量的取值仍在0和1之间变动,但这并不意味着本例中的也是二进制信号量。二进制信号量和计数信号量的划分更多地是从控制效果来说的:二进制信号量起到了互斥锁的作用,当多个进程或线程访问共享资源时,确保同一时刻只有一个进程或线程进入了临界区,起到了“互斥”的作用;而计数信号量起到了“控制顺序”的作用,明确了“谁先执行”、“谁后执行”。很显然,本例是通过信号量控制了线程执行的先后顺序,属于计数信号量。

计数信号量和二进制信号量的划分不能只看信号量值的波动范围。

本例只展示了一个生产者和一个消费者在缓冲区为1时的协同工作,如果我们增加生产者和消费者的数量,信号量的取值范围自然就不再是0和1了。

5)作为计数信号量用于进程间通信

(1)创建unnamed_sem_count_process.c

#include <pthread.h>
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <semaphore.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <sys/wait.h>

int main()
{
    char *shm_name = "unnamed_sem_shm";

    // 创建内存共享对象
    int fd = shm_open(shm_name, O_CREAT | O_RDWR, 0666);
    // 调整内存共享对象的大小
    ftruncate(fd, sizeof(sem_t));
    // 将内存共享对象映射到共享内存区域
    sem_t *sem = mmap(NULL, sizeof(sem_t), PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
    // 初始化信号量
    sem_init(sem, 1, 0);

    int pid = fork();

    if (pid > 0)
    {
        sem_wait(sem);
        printf("this is father\n");
        // 父进程等待子进程退出并回收资源
        waitpid(pid, NULL, 0);
    }
    else if (pid == 0)
    {
        sleep(1);
        printf("this is son\n");
        sem_post(sem);
    }
    else
    {
        perror("fork");
    }

    // 父进程执行到此处,子进程已执行完毕,可以销毁信号量
    // 子进程执行到此处,父进程仍在等待信号量,此时销毁会导致未定义行为
    // 只有父进程中应该销毁信号量
    if (pid > 0)
    {
        if (sem_destroy(sem) == -1)
        {
            perror("father sem_destroy");
        }
    }

    // 父子进程都应该解除映射,关闭文件描述符
    if (munmap(sem, sizeof(sem)) == -1)
    {
        perror("munmap");
    }
    if (close(fd) == -1)
    {
        perror("close");
    }

    // shm_unlink只能调用一次,只在父进程中调用
    if (pid > 0)
    {
        if (shm_unlink(shm_name) == -1)
        {
            perror("father shm_unlink");
        }
    }

    return 0;
}

(2)Makefile

unnamed_sem_count_process: unnamed_sem_count_process.c
    -$(CC) -o $@ $^
    -./$@
    -rm ./$@

(3)运行

上述案例中,我们通过计数信号量控制父进程必须在子进程之后执行。如果没有信号量,子进程先休眠1s的情况下,父进程大概率是要先于子进程执行的(取决于操作系统的调度机制和策略),通过信号量,我们确保子进程先于父进程执行。

执行结果如下。

image-20260823014803021

4.3.6.2 有名信号量

有名信号量的名称形如/somename,是一个以斜线(/)打头,\0字符结尾的字符串,打头的斜线之后可以有若干字符但不能再出现斜线,长度上限为NAME_MAX-4(即251)。不同的进程可以通过相同的信号量名称访问同一个信号量。

有名信号量通常用于进程间通信,这是因为线程间通信可以有更高效快捷的方式(全局变量等),不必“杀鸡用牛刀”。但要注意的是,正如上文提到的,可以用于进程间通信的方式通常也可以用于线程间通信。

有名信号量可以用于任意进程间的通信,为了简化程序,此处以父子进程间通信为例。

1)知识储备

(1)sem_open()

#include <fcntl.h>           /* For O_* constants */
#include <sys/stat.h>        /* For mode constants */
#include <semaphore.h>

/**
 * @brief 创建或打开一个已存在的POSIX有名信号量。
 * 
 * @param name 信号量的名称
 * @param oflag 标记位,控制调用函数的行为。是一个或多个值或操作的结果。常用的是O_CREAT。
 * O_CREAT: 如果信号量不存在则创建,指定了这个标记,必须提供mode和value
 * @param mode 有名信号量在临时文件系统中对应文件的权限。需要注意的是,应确保每个需要访问当前有名信号量的进程都可以获得读写权限。
 * @param value 信号量的初始值
 * @return sem_t* 成功则返回创建的有名信号量的地址,失败则返回SEM_FAILED,同时设置errno以指出错误原因
 */
sem_t *sem_open(const char *name, int oflag,
        mode_t mode, unsigned int value);

/**
 * @brief 见四个参数的sem_open()
 * 
 * @param name 同上
 * @param oflag 同上
 * @return sem_t* 同上
 */
sem_t *sem_open(const char *name, int oflag);

(2)sem_close()

#include <semaphore.h>

/**
 * @brief 关闭对于sem指向的有名信号量的引用,每个打开了有名信号量的进程在结束时都应该关闭引用
 * 
 * @param sem 有名信号量指针
 * @return int 成功返回0,失败返回-1,并设置errno以指明错误原因
 */
int sem_close(sem_t *sem);

(3)sem_unlink()

#include <semaphore.h>

/**
 * @brief 移除内存中的有名信号量对象,/dev/shm下的有名信号量文件会被清除。当没有任何进程引用该对象时才会执行清除操作。只应该执行一次。
 * 
 * @param name 有名信号量的名称
 * @return int 成功返回0,失败返回-1,并设置errno以指明错误原因
 */
int sem_unlink(const char *name);

2)有名信号量用作二进制信号量

(1)创建named_sem_bin.c

#include <stdio.h>
#include <semaphore.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <sys/wait.h>

int main()
{
    char *sem_name = "/named_sem";
    char *shm_name = "/named_sem_shm";

    // 初始化有名信号量
    sem_t *sem = sem_open(sem_name, O_CREAT, 0666, 1);

    // 初始化内存共享对象
    int fd = shm_open(shm_name, O_CREAT | O_RDWR, 0666);
    // 调整内存共享对象的大小
    ftruncate(fd, sizeof(int));
    // 将内存共享对象映射到内存空间
    int *value = mmap(NULL, sizeof(int), PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);

    // 初始化共享变量指针指向位置的值
    *value = 0;

    pid_t pid = fork();
    if (pid < 0)
    {
        perror("fork");
    }

    sem_wait(sem);
    int tmp = *value + 1;
    sleep(1);
    *value = tmp;
    sem_post(sem);

    // 每个进程都应该在使用完毕后关闭对信号量的连接
    sem_close(sem);
    if (pid > 0)
    {
        waitpid(pid, NULL, 0);
        printf("子进程执行结束,value = %d\n", *value);

        // 有名信号量的取消链接只能执行一次
        sem_unlink(sem_name);
    }

    // 父子进程都解除内存共享对象的映射,并关闭相应的文件描述符
    munmap(value, sizeof(int));
    close(fd);

    // 只有父进程应该释放内存共享对象
    if (pid > 0)
    {
        if (shm_unlink(shm_name) == -1)
        {
            perror("shm_unlink");
        }
    }

    return 0;
}

(2)Makefile

named_sem_bin: named_sem_bin.c
    -$(CC) -o $@ $^
    -./$@
    -rm ./$@

(3)执行结果如下

image-20260823095554312

通过信号量避免了竞态条件,确保输出的结果为2。

3)有名信号量用作计数信号量

(1)创建named_sem_count.c

#include <stdio.h>
#include <semaphore.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <sys/wait.h>

int main()
{
    char *sem_name = "/named_sem";

    // 初始化有名信号量
    sem_t *sem = sem_open(sem_name, O_CREAT, 0666, 0);

    pid_t pid = fork();
    if (pid > 0) {
        sem_wait(sem);
        printf("this is father\n");
        
        // 等待子进程执行完毕
        waitpid(pid, NULL, 0);
        
        // 释放引用
        sem_close(sem);

        // 释放有名信号量
        if(sem_unlink(sem_name) == -1) {
            perror("sem_unlink");
        }
    } else if(pid == 0) {
        sleep(1);
        printf("this is son\n");
        sem_post(sem);

        // 释放引用
        sem_close(sem);
    } else
    {
        perror("fork");
    }

    return 0;
}

(2)Makefile

named_sem_count: named_sem_count.c
    -$(CC) -o $@ $^
    -./$@
    -rm ./$@

(3)执行结果如下

image-20260823095716325

通过有名信号量,确保父进程在子进程在控制台打印数据之后再打印。

4)有名信号量在文件系统的表示

(1)创建named_sem_no_unlink.c,复制上述代码,注释sem_unlink操作。

#include <stdio.h>
#include <semaphore.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <sys/wait.h>

int main()
{
    char *sem_name = "/named_sem";

    // 初始化有名信号量
    sem_t *sem = sem_open(sem_name, O_CREAT, 0666, 0);

    pid_t pid = fork();
    if (pid > 0) {
        sem_wait(sem);
        printf("this is father\n");
        
        // 等待子进程执行完毕
        waitpid(pid, NULL, 0);
        
        // 释放引用
        sem_close(sem);

        // 释放有名信号量
        // if(sem_unlink(sem_name) == -1) {
        //     perror("sem_unlink");
        // }
    } else if(pid == 0) {
        sleep(1);
        printf("this is son\n");
        sem_post(sem);

        // 释放引用
        sem_close(sem);
    } else
    {
        perror("fork");
    }

    return 0;
}

(2)Makefile

named_sem_no_unlink: named_sem_no_unlink.c
    -$(CC) -o $@ $^
    -./$@
    -rm ./$@

(3)重新执行后查看/dev/shm目录,如下。

image-20260823095840211

可以看到,我们创建了名为/named_sem的信号量,将信号量名称去掉'/'后补充'sem.'前缀得到的字符串就是它在tmpfs中的对应文件名。

4.3.6.4 关于信号量的总结

(1)可用于进程间通信的方式通常都可以用于线程间通信。

(2)无名信号量和有名信号量均可用于进程间通信,有名信号量是通过唯一的信号量名称在操作系统中唯一标识的。无名信号量用于进程间通信时必须将信号量存储在进程间可以共享的内存区域,作为内存地址直接在进程间共享。而内存区域的共享是通过内存共享对象的唯一名称来实现的。

(3)无名信号量和有名信号量都可以作为二进制信号量和计数信号量使用。

(4)二进制信号量和计数信号量的区别在于前者起到了互斥锁的作用,而后者起到了控制进程或线程执行顺序的作用。而不仅仅是信号量取值范围的差异。

(5)信号量是用来协调进程或线程协同工作的,本身并不用于传输数据。

(6)通常,从编码复杂度和效率的角度考虑,进程间通信使用有名信号量,线程间通信使用无名信号量。

(7)信号量用于跨进程通信时,要格外注意共享资源的创建和释放顺序,避免资源泄露或在不恰当的时机释放资源从而导致未定义行为。

(8)在生产环境的开发中,对于关键的步骤应当补充充分的错误处理,以便在错误发生时及时告警和响应。包括根据函数的返回值进行检查,结合使用 perror 或类似机制及时输出错误日志,以便快速排查和解决问题。此外,应确保适当释放资源以避免资源泄露。本文省略了这些步骤,这是为了使代码结构更加清晰以降低学习成本。

4.4 线程池

4.4.1 简介

线程池是一种用于管理和重用多个线程的设计模式。它通过维护一个线程池(线程的集合),可以有效地处理并发任务而无需每次都创建和销毁线程。这种方法可以减少线程创建和销毁的开销,提高性能和资源利用率。

4.4.2 Glib库

官方文档链接:https://docs.gtk.org/glib/

GLib 是 GNOME 项目的一部分,是一个通用的底层库,提供数据结构、实用工具和系统相关的功能。它最初是为了 GIMP(GNU Image Manipulation Program)而开发的,但现在已经成为许多其他应用程序的基础。

4.4.3 Glib库线程池工作流程

(1)线程池创建:首先创建一个线程池,指定任务函数和其他参数。线程池会创建一定数量的线程,这些线程进入等待状态,准备执行任务,或在提交任务后才创建线程(取决于配置)。线程池中的所有任务执行的都是同一个任务函数。

(2)任务队列:线程池维护一个任务队列。当我们向线程池提交任务时,任务会被放入这个队列中。实际上,放入任务队列的是我们在提交任务时传递的任务数据。

(3)线程执行任务:线程池中的线程从任务队列中取出任务数据,然后调用任务函数,执行任务。执行完成后,线程不会退出,而是继续从任务队列中取下一个任务执行。如果没有待执行的任务,线程通常在等待一段时间后被回收(取决于具体的配置)。

4.4.4 相关数据类型

1)GFunc

// 此处的 data 是在启动任务时,传递给每个任务的,而 user_data 是在创建线程池时传入的共享数据,对于每个任务都是一样的

typedef void (*GFunc)(gpointer data, gpointer user_data);

2)gpointer

typedef void *gpointer;

3)gint

typedef int gint;

4)gboolean

typedef gint gboolean;
#define TRUE        1
#define FALSE        0

5)Gerror

/**
 * @brief 记录已发生的错误信息
 * domain: 表示错误的域(或命名空间)。GQuark 是一个无符号整数,用于唯一标识一个字符串。它通常用来区分不同的错误类别,例如文件操作错误、网络错误等。
 * code: 表示错误的代码。这是一个整型值,用于具体描述错误。每个域中的错误代码应该是唯一的,并且通常有一组预定义的错误代码。
 * gchar: 表示错误消息。gchar 是 char 的别名,用于在 GLib 中统一字符类型。message 是一个字符串指针,包含了详细的错误描述信息。
 */
struct GError {
    GQuark domain;
    gint code;
    gchar *message;
}

6)GThreadPool

/**
 * @brief 线程池对象
 * func:线程池中执行的任务
 * user_data:线程池中共享的用户数据指针,会在每个任务函数调用时传递给任务函数。
 * exclusive:标记当前线程池是否独占线程
 */
struct GThreadPool {
    GFunc func;
    gpointer user_data;
    gboolean exclusive;
}

4.4.5 相关函数

1)g_thread_pool_new

/**
 * @brief 创建新的线程池
 *
 * @param func 池中线程执行的函数
 * @param user_data 传递给func的数据,可以为NULL,这里的user_data最终会被存储在GThreadPool结构体的user_data属性中
 * @param max_threads 线程池容量,即当前线程池中可以同时运行的线程数。-1表示没有限制
 * @param exclusive 独占标记位。决定当前的线程池独占所有的线程还是与其它线程池共享这些线程。取值可以是TRUE或FALSE
 *  TRUE:立即启动数量为max_threads的线程,且启动的线程只能被当前线程池使用
 *  FALSE:只有在需要时,即需要执行任务时才创建线程,且线程可以被多个非独享资源的线程池共用
 * @param error 用于报告错误信息,可以是NULL,表示忽略错误
 * @return GThreadPool* 线程池实例指针。无论是否发生错误,都会返回有效的线程池
 */
GThreadPool *g_thread_pool_new(
    GFunc func,
    gpointer user_data,
    gint max_threads,
    gboolean exclusive,
    GError **error);

2)g_thread_pool_push

/**
 * @brief 向pool指向的线程池实例添加数据,这一行为实际上会向任务队列添加新的任务。当存在可用线程时任务立即执行,否则任务数据会一直待在队列中,直至腾出可用线程执行任务
 * 
 * @param pool 指向线程池实例的指针
 * @param data 传递给每个任务的独享数据
 * @param error 错误信息
 * @return gboolean 成功返回TRUE,失败返回FALSE
 */
gboolean g_thread_pool_push(
    GThreadPool *pool,
    gpointer data,
    GError **error);

3)g_thread_pool_free

/**
 * @brief 释放为pool指向的线程池分配的所有资源
 * 
 * @param pool 线程池指针
 * @param immediate 是否立即释放线程池
 *  TRUE:立即释放所有资源,未处理的数据不被处理
 *  FALSE:在最后一个任务执行完毕之前,线程池不会被释放
 *  需要注意的是:执行任务时,线程池的任何一个线程都不会被打断。无论这个参数是何取值,都可以保证至少线程池释放前正在运行的线程可以完成它们的任务。
 * @param wait_ 当前函数是否阻塞等待所有任务完成
 *  TRUE:所有需要处理的任务执行完毕当前函数才会返回
 *  FALSE:当前函数立即返回
 */
void g_thread_pool_free (
  GThreadPool* pool,
  gboolean immediate,
  gboolean wait_
);

4.4.6 测试例程

1)创建thread_pool_test.c

#include <glib.h>
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>

// 任务函数
void task_func(gpointer data, gpointer user_data) {
    int task_num = *(int*)data;
    free(data);
    printf("Executing task is %d...\n", task_num);
    sleep(1);
    printf("Task %d completed\n", task_num);
}

int main() {
    // 创建线程池
    GThreadPool *thread_pool = g_thread_pool_new(task_func, NULL, 5, TRUE, NULL);

    // 向线程池添加任务
    for (int i = 0; i < 10; i++) {
        int *tmp = malloc(sizeof(int));
        *tmp = i + 1;
        g_thread_pool_push(thread_pool, tmp, NULL);
    }

    // 等待所有任务完成
    g_thread_pool_free(thread_pool, FALSE, TRUE);
    printf("All tasks completed\n");

    return 0;
}

2)Makefile

thread_pool_test: thread_pool_test.c
    -$(CC) -o $@ $^ `pkg-config --cflags --libs glib-2.0`
    -./$@
    -rm ./$@

(1)标志

在编译和链接过程中,标志(Flags)是传递给编译器和链接器的参数,它们指示编译器和链接器如何处理源代码和目标代码。

Ø 编译标志(Compiler Flags)

编译标志用于告诉编译器如何处理源代码。这些标志包括头文件的搜索路径、预处理器宏定义等。-I/usr/include/glib-2.0就是一个编译标志,-I是gcc编译时的一个参数,用于指定头文件的搜索路径。

Ø 链接标志(Linker Flags)

链接标志用于告诉链接器如何处理目标文件和库文件。这些标志包括库文件的搜索路径和库文件名等。-lglib-2.0是一个链接标志,-l参数在第一章已有介绍,这个标志表示链接libglib-2.0.so库。

(2)pkg-config

Ø pkg-config是一个用于从已安装库中检索编译器和链接器标志的工具。它帮助开发者轻松地获取用于编译和链接程序所需的库信息。

Ø --cflags:打印编译包时所需的预处理器和编译器标志。示例如下

sudo apt-get update
sudo apt-get install libglib2.0-dev
atguigu@ubuntu:~/thread_test$ pkg-config --cflags glib-2.0
-I/usr/include/glib-2.0 -I/usr/lib/x86_64-linux-gnu/glib-2.0/include

返回了链接器需要的标志,用于指明所需的动态链接库。表示链接libglib-2.0.so库。

Ø pkg-config --cflags --libs glib-2.0实际上是pkg-config --cflags glib-2.0和pkg-config --libs glib-2.0的整合,二者是等价的,如下。

atguigu@ubuntu:~/thread_test$ pkg-config --cflags --libs glib-2.0
-I/usr/include/glib-2.0 -I/usr/lib/x86_64-linux-gnu/glib-2.0/include -lglib-2.0

(3)Shell中的飘号或反引号(`)

cmd的作用是执行命令(cmd),将命令的标准输出作为变量或其他命令的参数。

   -$(CC) -o $@ $^ `pkg-config --cflags --libs glib-2.0`

等价于

    -$(CC) -o $@ $^ -I/usr/include/glib-2.0 -I/usr/lib/x86_64-linux-gnu/glib-2.0/include -lglib-2.0

实际上是通过pkg-config命令帮助我们指明了编译Glib所需的额外的头文件搜索路径和需要链接动态链接库。

3)运行

image-20260823102015930

第 5 章 进程和线程

5.1 计算机系统的软件分层

1)软件分层

在单片机学习中我们使用代码直接与硬件交互,对于这种方式的缺点深有体会:

(1)复杂度高,调用难度高,需要深入理解硬件的工作原理和细节。

(2)细节繁琐,特别是在处理底层寄存器和端口配置时,极易出错。

(3)出错时调试难度也很高,因为涉及到底层硬件的交互,定位问题相对困难。

(4)更换芯片时,往往需要重写驱动,导致代码的可移植性很差,增加了开发和维护的成本。

为了解决这些问题,操作系统应运而生。后者在硬件之上做了一层抽象,提供了统一的接口和服务,隐藏了硬件的复杂性和差异性,从而简化了程序对硬件资源的访问和管理,使得开发者能够更加专注于应用逻辑的实现,而不必深入到复杂的硬件操作细节中。因此,它为上层应用程序提供了一个统一、稳定、高效的运行环境,极大地提升了软件开发的效率和可靠性。

image-20260823105034277

image-20260823110335173

2)内核

内核是操作系统的核心部分,负责管理计算机的硬件资源,包括处理器、内存、存储设备和其他外围设备。内核提供系统服务的基础,如进程管理、内存管理、设备驱动、文件系统和网络通信等。

内核作为硬件和应用程序之间的中介,提供一个抽象层,使得应用程序不需要直接与硬件交互。

3)应用程序

应用程序是运行在操作系统之上的软件,用于执行特定的任务,如文本编辑、图像处理、网络通信等。

应用程序提供用户所需的功能,依赖于操作系统提供的接口与硬件资源进行交互。

5.2 进程和程序

程序是存储在硬盘等存储介质的代码,是一串二进制机器码,是静态的。

进程是正在运行的程序及相关资源的总称,是一种抽象,是动态的

image-20260823105220154

5.3 进程控制块PCB

内核会保存每个进程的一些信息,称为进程控制块(Process Control Block,PCB),方便管理进程,内容如下:

(1)进程编号(PID),每个进程对应的唯一编号,一般为正整数形式。

(2)进程状态信息。

(3)进程切换时需要保存和恢复的一些CPU寄存器,其中关键的有程序计数器(Program Counter)的值,用于记录进程恢复时应执行的指令地址。

(4)内存管理信息,如页表、内存限制、段表等。

(5)当前工作目录(Current Working Directory)。

(6)进程调度信息,包括进程优先级、调度队列指针等。

(7)I/O状态信息,包括分配给进程的I/O设备列表,打开的文件描述符表等,后者包含很多指向file结构体的指针。

(8)同步和通信信息,包括信号量、信号、等用于进程同步和通信机制的信息。

(9)用户id和组id。

在Linux内核中,进程控制块的实现是struct task_struct,上述信息都存储在这个结构体中。

5.4 进程的内存模型

5.4.1 内存模型

一个进程在内存中的布局如下所示:

image-20260823105429895

image-20260823105512102

image-20260823105547611

image-20260823105600738

image-20260823105613570

image-20260823105634378

image-20260823105648357

image-20260823105705163

5.4.2 相关概念

(1)栈

栈(Stack)是个FILO(First In Last Out)即先进后出类型的数据结构,在进程的内存模型中有一块区域作为用户栈,最先进入的数据位于栈底,最后进入的数据位于栈顶。

(2)栈帧

栈帧(Stack Frame)是程序在调用函数时用于存储函数调用和局部变量的一块内存。每次函数调用时,都会在调用者的栈上创建一个新的栈帧。这个栈帧包含了函数的返回地址、参数、局部变量以及保存的必要的寄存器值。

每个栈帧对应于一个函数调用的上下文,它是程序运行时栈内存组织的基本单元。栈帧允许函数调用彼此隔离,同时支持递归调用和嵌套调用。

调用函数时,会在栈顶为函数分配一帧,然后移动栈指针,指向新的栈顶,称为压栈。函数调用结束(返回)时,会释放函数的栈帧,称为弹栈。

(3)栈指针

专用寄存器——栈指针(Stack Pointer,SP)用于记录栈顶位置。

(4)帧指针

专用寄存器——帧指针(Frame Pointer,FP,有些架构中也叫Base Pointer,BP)用于记录调用者栈帧的位置。

image-20260823111621577

image-20260823111643253

image-20260823111657305

image-20260823111712546

image-20260823111730240

image-20260823111804697

image-20260823111819156

image-20260823111836462

image-20260823111854592

image-20260823111912744

image-20260823111927793

2)内核空间

内核空间是进程虚拟内存中保留给操作系统内核的部分,用于存放内核代码和数据。这部分空间对用户程序是不可见、不可直接访问的。内核空间具有最高的访问权限,只有内核态下的代码可以执行这里的操作。

所有进程的内核空间是共享的。

3)用户空间

用户空间是内存中分配给用户程序的部分,与内核空间相隔离。用户程序和库函数在这里执行。用户空间的代码运行在用户态,拥有较低的权限,不能直接执行特权操作或访问内核空间。

image-20260823110022228

5.5 进程的状态

5.5.1 CPU虚拟化

理论上,同一时刻单核CPU只能运行一个进程,但很多时候,我们可以同时运行的程序数量远大于CPU的核心数。这是因为,操作系统的CPU调度单元对CPU的资源做了时间分片,即在时间尺度上对CPU做了划分,如15:15到15:16执行进程A,15:16到15:17执行进程B。实际上进程间的切换是非常迅速的,在用户的角度,就好像多个进程在同一时间运行。因此,看起来好像计算机可以同时运行的进程数远大于CPU核心数。

进程的切换是由操作系统的CPU调度器完成的。

image-20260823112340178

5.5.2 CPU调度

1)调度和调度器

在上面的场景中,进程执行到某一时刻,被内核中断,然后内核可以重新开始之前被中断的进程,这种决策就是调度(Scheduling),是由内核中称为调度器(Scheduler)的代码处理的。当内核选择一个新的进程时,我们说内核调度了这个进程。

2)题外话

实际上,进程的调度是通过时钟中断实现的。设想一下,如果没有中断,当用户进程执行死循环时,CPU会被一直占用,内核是无法夺回控制权的。通过设置时钟中断,每隔一段时间,内核就可以收回CPU的控制权,决定接下来哪个进程可以占用CPU(运行),从而实现进程的调度

5.5.3 抽象的进程状态模型

进程状态是指一个进程在其生命周期中所处的不同阶段或状况。一些权威的操作系统教材对进程状态的抽象模型做了如下介绍:

1)初始态(Initial)

这是进程生命周期的开始阶段,进程被创建时处于初始态,或者叫创建态。在这个阶段,操作系统为新进程分配资源。

2)就绪态(Ready)

就绪态意味着进程已准备好运行,但由于CPU调度算法或其他正在运行的进程,它当前没有运行。

3)运行态(Running)

当进程正在CPU上执行时,它处于运行态。

4)阻塞态(Blocked)

进程由于等待某个事件(如IO操作完成)而无法继续执行时,它就处于阻塞态。

5)终止态(Final)

进程执行完毕,并释放其占用的所有资源,进行必要的清理工作。此时,虽然进程已结束了所有活动,但操作系统内核仍保留它的PCB,进程处于终止态。通常,终止态持续时间非常短暂,PCB很快会被内核释放。

6)僵尸态(Zombie)

僵尸态与终止态非常相似,区别是,如果进程结束了所有工作后PCB长期未被释放,它就处于僵尸态。在进程状态机中,我们对终止态和僵尸态不作区分,因为二者都是进程任务执行完毕之后的状态,意味着进程生命周期的终止。

image-20260823112551925

7)注意

初始态非常短暂,通常是看不到的。终止态和僵尸态实际上都是进程执行完毕之后的状态,严格意义上讲,并不属于进程运行时状态。因此,部分操作系统资料在介绍进程状态时不会在进程转换状态机中包含。下图是《操作系统导论》中的进程转换状态机。

image-20260823112619439

5.5.4 Linux进程状态实现

(1)D: 不可中断睡眠状态(通常是在进行IO操作)

(2)I: 空闲的内核线程

(3)R: 运行或可运行状态(在运行队列中)

(4)S: 可中断睡眠状态(等待事件完成)

(5)T: 由工作控制信号停止

(6)t: 在跟踪过程中由调试器停止

(7)W: 分页(从2.6.xx内核版本开始就不再有效)

(8)X: 死亡状态(永远不应该被看到),PCB已被清理

(9)Z: 僵尸进程,已经终止但尚未被父进程回收

5.5.5 二者的统一

抽象理论 Linux****实现
初始态 这个阶段通常很短暂,不对应于Linux的特定进程状态。
就绪态 对应于R
运行态 对应于R
阻塞态 D、S、T、t均属于阻塞态
僵尸态 Z

内核线程执行内核级任务,不属于用户线程。

X表示PCB已被清理,不应被看到。

5.5.6 调度队列

Linux的进程PCB底层数据结构是task_struct,操作系统调度器将task_struct实例组织到不同的队列,以支持调度器的决策。这些队列基于进程状态区分,常见的如就绪、等待、终止队列等。

1)就绪队列

存放处于就绪态的进程。

2)等待队列

存放处于阻塞态的进程。

3)终止队列

存放处于终止态或僵尸态,等待被回收的进程。

5.6 虚拟内存和物理内存

5.6.1 案例

1)创建virtual_memory_test.c

#include <stdio.h>
#include <unistd.h>

int main() {

    int val = 123;

    // 定义变量接收子进程PID
    __pid_t pid;
    // 创建一个子进程
    if ((pid = fork()) > 0 ){
        sleep(1);
        printf("父进程中val 的内容是: %d\nval 所在的地址是: %p\n", val, &val);
    } else if (pid == 0) {
        val = 321;
        printf("子进程中val 的内容是: %d\nval 所在的地址是: %p\n", val, &val);
    } else {
        printf("子进程创建失败\n");
    }
    
    return 0;
}

我们通过fork创建了一个子进程,子进程会继承父进程的资源,包括val变量。

2)创建Makefile

CC:=gcc

virtual_memory_test: virtual_memory_test.c
    -$(CC) -o $@ $^
    -./$@
    -rm ./$@

3)运行结果如下

image-20260823173237896

父子进程中各自打印了val变量的值和内存地址。子进程将val变量更改为321,我们特意让父进程休眠了一秒,确保子进程的更改先于父进程的输出。我们发现子进程修改了val变量的值,而父进程的val未受影响,说明两个进程的val变量是完全独立的。但是,二者的内存地址却完全相同。既然内存地址是相同的,父子进程中的val变量不就应该是同一个吗?但子进程修改了自己的val,父进程的val却未受影响,从这个角度看,二者又是完全独立的变量?这不是矛盾了吗?

要明白这个问题,我们需要了解操作系统的虚拟内存机制。

5.6.2 虚拟内存映射

1)知识储备

(1)虚拟内存

虚拟内存是计算机系统内存管理的一种技术,它为每个进程提供了一种“虚拟”的地址空间,这个地址空间对于每个程序来说看起来都是连续的,但实际上可能被分散地存储在物理内存和磁盘上(如交换空间或页面文件)。虚拟内存允许系统超额分配内存,即分配的内存总量可以超过物理内存的实际容量。虚拟内存简化了内存的管理,使得应用程序不需要关心物理内存的实际情况。

(2)物理内存

物理内存指的是计算机中安装的实际RAM(随机访问存储器)模块。它是系统用来存储正在运行的程序和数据的硬件资源。

物理内存直接影响到计算机能够同时处理的信息量。更多的物理内存意味着可以同时运行更多的程序,或者处理更大的数据集。

(3)MMU

进程可以直接操作的只有虚拟内存,那么,虚拟内存毕竟是“虚拟”的,进程的代码段、数据、栈等最终一定要存储到真正的物理内存,那么,我们就要建立虚拟内存和物理内存之间的映射关系。在操作系统中,这件事是由MMU来完成的。

MMU(Memory Management Unit)是CPU的一个组成部分,负责处理虚拟地址到物理地址的转换。当程序试图访问一个虚拟内存地址时,MMU会查询页表来找到对应的物理内存地址,然后完成内存访问。MMU还负责检查访问权限,确保程序不会访问未授权的内存区域。

此外,当所请求的虚拟地址没有映射到物理地址或虚拟页对应的数据位于磁盘的交换空间中时,即产生缺页故障,MMU会通知操作系统,由操作系统来处理这种情况,为虚拟页分配页帧,或将数据从硬盘加载到内存的一个页帧中,然后更新页表以建立新的映射关系。完成这些操作后,当程序再次尝试访问原来的虚拟地址时,可以正确地访问到映射后的物理页帧中的数据 。

MMU本身是硬件组件,操作系统负责配置和管理MMU使用的数据结构(如页表),以及处理MMU生成的各种内存管理相关的异常(如缺页故障)。这种协作机制允许操作系统利用MMU提供的硬件支持,实现虚拟内存、内存保护和其他高级内存管理功能。

(4)页

在操作系统的上下文中,“页”(Page)是虚拟内存管理中的一个基本单位,通常大小为4KB或2MB等,具体大小依赖于处理器和操作系统的设计。操作系统使用页来实现虚拟内存。

(5)页帧

页帧(Page Frame),也称为物理页(Physical Page),是物理内存中的一个固定大小的区块。在虚拟内存系统中,物理内存被划分为许多这样大小相等的页帧,以便于内存的管理和映射。

(6)页表

页表是操作系统用于管理虚拟内存系统中的虚拟地址到物理地址映射的数据结构。页表包含页表项(Page Table Entries, PTEs),每个PTE对应一个页,包含该页映射到的页帧的物理地址及访问该页的权限和状态(如是否在物理内存中,是否可写等)。在x86-64架构下,页表大小为4K。

(7)TLB

TLB全称为Translation-Lookaside Buffer,即地址转换旁路缓存。是MMU中一个组成部分,是硬件为提升虚拟内存到物理内存地址映射的效率而提供的硬件支持。

(8)多级页表

在32位系统中,每个进程的虚拟内存地址空间为4GB,假设页大小为4KB,则一个进程的虚拟地址空间要分为1M页,假定每个PTE的大小为4字节,每个进程就需要占用4MB的空间来存储页表,启动100个进程,仅页表就要占用400MB的内存空间。而很少有进程会用到所有的虚拟页,这样无疑会带来巨大的内存浪费。

为了解决这个问题,引入了多级页表。此处不做展开。多级页表中,除最后一级外,都可以叫做页目录(Page Directory)。

2)虚拟内存映射

image-20260823173443657

image-20260823174326001

image-20260823174407296

image-20260823174504926

image-20260823174534619

5.6.3 再看进程共享存储

有了知识储备,我们再回头来看进程共享内存映射的实现,这一过程分为两个步骤。

(1)通过shm_open创建内存共享对象,这一步实际上是内核创建了一个匿名文件,它不与文件系统的具体文件直接关联,只是存在于内核的一个对象。内存共享对象是通过文件名来唯一标识的。

(2)通过mmap()将进程的虚拟内存映射到上一步的共享对象。当多个进程映射到相同的共享对象时,内核会根据后者的文件名确定这一点,并将多个进程的虚拟内存映射到相同的物理内存,这样就实现了进程间内存共享。

image-20260823173552822

5.7 异常和中断

5.7.1 相关概念

1)内核态和用户态

(1)内核态

内核态是CPU的一种运行模式,具有执行所有指令和访问所有硬件资源的权限。在这种模式下,操作系统内核执行其核心功能。所有与硬件交互的操作都必须在内核态下执行。

由于具有完全的系统控制权,任何在内核态执行的代码都必须是高度可靠的,以避免系统崩溃或安全漏洞。

(2)用户态

用户态是CPU的另一种运行模式,权限受限。应用程序在用户态下运行,不能直接执行特权指令或访问受保护的内存区域。

用户态为应用程序提供了一个安全的执行环境,通过系统调用请求操作系统提供的服务。

(3)特权指令

特权指令是指只有在内核态下才可以执行的指令。这些指令提供了对硬件和关键系统资源的直接控制能力,因此它们的执行被严格限制在操作系统内核中,以防止恶意软件或错误的程序代码破坏系统的稳定性和安全性。

只有在内核态下才可以执行特权指令。

2)中断和异常的分类

大类 子类 原因 异步/同步 返回行为
中断(Interrupt) 中断 来自硬件的信号 异步 总是返回到下一条指令
异常(Exception) 陷阱 进程主动触发的异常 同步 总是返回到下一条指令
故障 潜在可恢复的错误 同步 可能返回到当前指令
终止 不可恢复的错误 同步 不会返回

(1)中断

中断(Interrupt)通常是I/O设备或时钟触发的,信号来自处理器外部,不是由任何一条指令造成的,从这个角度讲,它是异步的。中断处理完毕后总是执行下一条指令。

(2)异常

异常是(Exception)CPU执行指令时检测到特定条件触发的。x86-64架构定义了三种异常:陷入(Trap)、故障(Fault)和终止(Abort)。

① 陷入

是由进程执行陷入指令(可以切换到内核态的指令)主动触发的,是同步的,执行完毕后总是执行下一条指令。

② 故障

故障由错误情况引起,可能被故障处理程序修正,如上文提到的缺页故障就是故障的一种。故障发生时,处理器将控制权交由故障处理程序,如果故障被修复,则返回引起故障的指令,并重新执行。否则,处理程序返回到内核中的abort例程,后者终止引起故障的进程。

③ 终止

终止是不可恢复的致命错误造成的结果。如底层硬件错误,或者进程产生的算数异常,无法被修复。终止发生时,CPU将控制权交由终止处理程序,这个程序不会将CPU的控制权返还给应用程序,而是返回到内核中的abort例程,后者终止进程。

3)中断和异常的区别

在x86-64架构中,中断和异常区别在于:中断处理例程被调用时,CPU会清除EFLAGS寄存器中的IF(Interrupt Enable)位,避免其它中断干扰当前中断处理例程的执行。而异常处理例程被调用时IF不会被清除。

4)中断描述符表(Interrupt Descriptor Table)

操作系统为每种中断或异常都分配了唯一的中断向量(Vector),它是一个非负整数。一部分中断向量是由CPU的设计者分配的(如被零除、缺页、内存访问违例即算术运算溢出等),另一部分中断向量是由操作系统内核的设计者分配的(包括系统调用和来自外部I/O设备的信号)。

操作系统启动时,内核会分配和初始化一张称为中断描述符表(Interrupt Descriptor Table IDT)的跳转表,将中断向量和中断或异常处理程序映射。IDT中的每一项(entry)对应一种中断或异常。

中断描述符表寄存器(Interrupt Descriptor Table Register IDTR)指明了IDT的基地址和边界。

5.7.2 中断和异常处理

CPU以相同的方式处理中断和异常。当中断或异常发生的时候,CPU暂停当前程序或任务的执行,通过IDTR定位IDT,通过中断号检索IDT,确定中断或异常处理例程的入口。CPU切换到中断或异常处理例程,执行完毕后,回到被中断的程序或任务。

1)段寄存器

x86-64架构中,段寄存器(CS、DS、SS、ES、FS和GS)是16位的段选择器。

现代Linux系统内存管理的方式是分页,早期的操作系统曾经通过分段管理内存,与分页不同,分段是将内存空间切分为几个大段,内存模型中的每个部分占据一块。x86-64架构的Linux使用的也是分页,保留段寄存器,一方面是为了向前兼容,另一方面是用于存储一些关键的系统状态信息(如特权级信息)。

2)TSS

任务状态段(Task State Segment TSS)是x86-64架构中用于支持任务切换和状态管理的数据结构,在早期的操作系统设计中,它被用于实现硬件级的任务切换功能,但在现代操作系统中,主要用于管理内核栈的切换。

3)内核栈

在x86-64架构下,对于每个线程维护一个内核栈,这些内核栈的大小为2页,即8K。内核栈存储在内核空间。

内核栈用于在中断或异常处理和CPU调度切换线程时保存当前正在执行的进程的状态,并在内核线程的工作中发挥作用。

当线程在用户空间执行时,内核栈中只有thread_info对象。

4)中断或异常处理程序的调用流程

(1)临时存储当前CPU寄存器SS,RSP(栈指针)、RFLAGS(状态寄存器)、CS、RIP(程序计数器)等寄存器的值。

(2)从TSS加载段选择器的信息和栈指针到SS和ESP寄存器,切换到内核栈,并为中断或异常处理例程开辟栈帧,此时切换至内核态。

(3)将第一步临时存储的寄存器值压入内核栈。

(4)将error code压入内核栈。

① error code是硬件层面由CPU自动处理和生成的,与特定的中断或异常关联,错误码包含了导致中断或异常的具体原因。

② 而在讲解perror()时用到的errno是C标准库提供的全局变量。二者是不同的。

(5)更新程序计数器,指向中断或异常处理例程。

(6)如果是中断处理例程,清除RFLAGS寄存器中的IF标志位。

(7)在内核态下开始执行中断或异常处理例程。

image-20260823175357019

image-20260823180233558

image-20260823180337707

5)中断或异常处理程序的返回

中断或异常处理程序的返回是通过执行IRET指令开始的。要注意,如果触发的是终止(Abort),则不会返回用户进程。

(1)执行权限检查。这一步是为了确保接下来的操作在内核态下进行。

(2)恢复CS和RIP寄存器的值。

(3)恢复RFLAGS寄存器的值。

(4)恢复SS和RSP寄存器的值,内核栈中异常处理程序的栈帧弹栈。栈指针重新指向进程用户空间中的用户栈,由内核态切换到用户态。

(5)在用户态下继续执行被中断的程序。

image-20260823175434026

image-20260823180420583

image-20260823180438323

6)中断上文

中断上文是指在异常发生之前,处理器的状态。即中断处理时最先压入内核栈的数据,包括段选择器的值、栈指针、程序计数器、状态相关寄存器等的值。

7)中断下文

中断下文是指涉及中断或异常处理时内核所处的环境和状态,包括内核栈中保存的用户线程状态、状态寄存器、段选择器和内核栈指针等寄存器、内核态下执行的中断或异常处理函数等内容。

5.7.3 案例

1)页表基地址

页表负责虚拟地址到物理地址的映射。地址转换首先要找到页表所在的地址。x86-64架构实现了四级页表,一级页表的地址存储在CR3寄存器,这个寄存器的值会被记录在PCB中,在进程切换时会被保存和更新。MMU可以访问CR3定位一级页表,然后逐级检索,完成地址转换。

2)缺页故障

执行用户代码时,进程工作在用户态,进程访问虚拟内存,MMU首先查询TLB,若TLB命中则将虚拟地址转换为物理地址,对这块地址执行操作。若TLB未命中则会访问CR3寄存器定位一级页表,多级检索定位页帧,最终将虚拟地址转换为物理地址,继续后续操作。

如果查询操作的虚拟内存页尚未映射到物理页帧,则MMU无法在页表中查到相应的PTE,此时会触发缺页故障(Page Fault),操作系统会介入,为虚拟页分配页帧,而后回到触发故障的指令继续执行。

image-20260823175547198

image-20260823180714656

image-20260823180731661

5.8 进程创建过程

5.8.1 进程

进程是操作系统中的一个基本概念,代表了操作系统中正在运行的一个程序的实例。进程不仅包括代码本身,还包括运行该程序所需的各种资源,如虚拟内存空间、文件描述符、环境变量等。每个进程都有一个独立的虚拟地址空间,这意味着一个进程无法直接访问另一个进程的内存。

当一个新进程被创建时,操作系统会为其分配一个唯一的进程标识符(PID)和一个新的虚拟地址空间。随后,操作系统会加载程序代码到虚拟内存中,并设置初始的堆栈和寄存器,包括程序计数器(PC)和栈指针(SP)。通过fork创建的子进程将继承父进程的资源副本,包括打开的文件描述符和环境变量。

5.8.2 进程创建

上文讲解进程树时,我们通过ps -ef看到的信息可以分为两类:进程信息和内核线程的信息,进程工作在用户态,执行用户程序,内核线程工作在内核态,执行内核任务。内核启动时会创建1号进程和2号内核线程,通常1号进程名为systemd,2号内核线程名为kthreadd,其它进程全部由systemd及其子进程通过fork()+execve()的方式创建,

其它内核线程都是由kthreadd及其创建的内核线程通过kthread_create()这样的方式创建的。fork()和kthread_create()是通过创建系统调用clone()实现的,这里不做展开。

上文已经介绍了fork()和execve()的用法,接下来我们将了解进程创建时,操作系统内核做了什么事。

1)task_struct

上文已多次提及,在Linux中,进程PCB的实现是struct task_struct类型的实例,该结构体定义在/usr/src/linux-hwe-6.5-headers-6.5.0-28/include/linux/sched.h文件中,主要字段入下。

struct task_struct {
    struct thread_info thread_info; // 执行环境的必要信息
    ......
    void *stack; // 内核栈
    ......
    unsigned int flags; // 标志位信息
    ......
    /* Filesystem information: */
    struct fs_struct *fs; // 文件系统信息
    ......
    struct mm_struct *mm; // 内存管理信息(包含指向内存区描述符的指针
    ......
    /* Open file information: */
    struct files_struct *files; // 包含指向打开的文件描述符表的指针
    ......
    pid_t pid; // 进程ID
    pid_t tgid; // 线程组ID
    /* Objective and real subjective task credentials (COW): */
    const struct cred __rcu *real_cred; // 真正的证书,包含用户和用户组的信息

    /* Effective (overridable) subjective task credentials (COW): */
    const struct cred __rcu *cred; // 当前有效的证书
    ......
    /* Signal handlers: */
    struct signal_struct *signal; // 与信号处理相关的信息,关注信号状态管理
    struct sighand_struct __rcu *sighand; // 与信号处理函数相关的信息
};

2)fork()

fork()主要完成了以下工作:

(1)为子进程创建内核栈、thread_info实例。

(2)复制父进程的task_struct,后者包含了内核栈、虚拟内存管理信息、打开的文件描述符表等的指针,此时子进程只是复制了这些资源的引用。

(3)清除子进程的统计信息,更新子进程task_struct的标志位。

(4)为子进程分配新的PID,将子进程的PPID设置为调用fork()的进程。

(5)清除与fork()返回值相关的寄存器,使得子进程中fork()返回的是0。

(6)复制打开的文件描述符表,这一过程底层被指向的struct file实例中引用计数加一。复制文件系统信息、复制地址空间(页表相关信息),复制信号处理信息。

(7)最后,如果子进程成功创建则被唤醒,处于就绪态。

image-20260823181146460

3)COW

写时复制机制(Copy on Write COW)可以提高进程创建效率。子进程完整地复制了父进程的地址空间,此时父子进程的虚拟内存空间映射到相同的物理内存空间。只有当二者之一执行了写入操作才会复制写入区域的内容,为父子进程维护不同的物理页帧。

image-20260823181207360

4)execve()

(1)参数和环境准备

内核检查传递给execve()的参数,包括可执行文件的路径、环境变量和命令行参数,以确保它们的有效性和安全性。这个阶段内核会在内核空间中准备一份新程序需要的命令行参数和环境变量的备份。

(2)打开和验证可执行文件

打开指定的二进制文件,验证其格式是否支持(例如,ELF格式),并检查执行权限。如果这一步找不到可执行文件的路径,就会直接终止。

下面的例子中,可执行文件路径有误导致execve()失败。

① 创建execve_invalid_path.c

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main()
{
    char *argv[] = {"ping", "-c", "10", "baidu.com", NULL};

    // 环境变量可以不传
    char *envp[] = {NULL};
    int re = execve(argv[0], argv, envp);
    if (re == -1)
    {
        perror("execve");
        return -1;
    }

    return 0;
}

② Makefile

execve_invalid_path: execve_invalid_path.c
    -$(CC) -o $@ $^
    -./$@
    -rm ./$@

③ 测试结果如下

image-20260823181351630

这里的问题是没有指明ping的路径,导致execve执行时找不到可执行文件。在内核尝试打开可执行文件的环节被终止了。

将ping替换为/usr/bin/ping即可。

④ 创建execve_full_path.c

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main()
{
    char *argv[] = {"/usr/bin/ping", "-c", "10", "baidu.com", NULL};

    // 环境变量可以不传
    char *envp[] = {NULL};
    int re = execve(argv[0], argv, envp);
    if (re == -1)
    {
        perror("execve");
        return -1;
    }

    return 0;
}

⑤ Makefile

execve_full_path: execve_full_path.c
    -$(CC) -o $@ $^
    -./$@
    -rm ./$@

⑥ 结果如下

image-20260823181458065

(3)创建新的内存映射

清除进程当前的内存映射,包括用户空间中的代码、数据、堆和栈。

根据新的程序建立新的代码段、数据段、堆和栈等。

需要注意的是,内存映射不包含内核空间,内核空间的映射是由操作系统内核管理的,对所有进程是共享的。execve切换的只是用户空间。

(4)复制参数和环境变量

在新的地址空间中为命令行参数和环境变量分配空间,并将内核中它们的备份复制到新的位置。

(5)初始化进程上下文

设置新的程序计数器、栈指针等,以便新程序可以正确执行。

清理和重设进程的各种内核资源,如文件描述符表。根据文件描述符的 close-on-exec 标志(FD_CLOEXEC)进行处理,如果有该标志,则文件描述符被关闭。

(6)更新 task_struct 和其他内核结构

更新 task_struct 中关于进程地址空间、堆栈、命令行参数、环境变量的指针。

重置信号控制信息到默认状态。

清理进程的各种内核状态,如未处理的信号、定时器等。

(7)执行新程序

跳转到新加载程序的入口点开始执行。

image-20260823181600760

image-20260823183032755

5.8.3 ps命令

上文我们通过ps -ef查看当前的进程信息,看不到进程组ID,通过以下命令查看进程组ID。

atguigu@ubuntu:~$ ps -eo uid,pid,pgid,ppid,cmd

Ø -e:选择所有进程

Ø -o:用户自定义输出格式,该选项后可以跟一个以逗号分隔的格式化字符串,指明要打印的字段信息,上述命令的字段信息如下。

Ø uid:User ID,用户ID

Ø pid:Process ID,进程ID

Ø pgid:Process Group ID,进程组ID

Ø ppid:Parent Process ID,父进程ID

Ø cmd:Command,进程启动命令。

命令行输出如下。

image-20260823181701684

5.8.4 进程组

进程组ID(Process Group ID,简称PGID)在UNIX和类UNIX系统(如Linux)中用来标识一个或多个进程的集合。进程组用于信号传递和终端控制(如作业控制)。在很多方面,进程组的概念是为了更好地支持在终端中运行的交互式作业。

(1)会话(Session)

会话是一个或多个进程组的集合。通常,当用户登录到系统时,登录shell会成为一个新会话的领导。这个新会话会创建一个新的进程组,该组包含shell进程及其子进程。

(2)进程所属的进程组

① 一个进程的所属的进程组可能是自己,也可能是它的父进程,默认情况下,应该是它的父进程。但是在某些场景下如创建新的会话或终端时,进程所属的进程组可能是自己。在VSCode中运行main()函数启动一个进程,如下。

创建process_group_block_test.c

#include <sys/types.h>
#include <unistd.h>

int main()
{
    while(1);

    return 0;
}

Makefile

process_group_block_test: process_group_block_test.c
    -$(CC) -o $@ $^
    -./$@
    -rm ./$@

启动后,通过以下命令查看进程组ID。

atguigu@ubuntu:~$ ps -eo uid,pid,pgid,ppid,cmd

image-20260823181826920

此时,进程组ID是main进程自己的PID。

② 更改程序,在main函数中创建子进程。

创建process_group_father_son_test.c

#include <sys/types.h>
#include <unistd.h>
#include <sys/wait.h>
#include <stdio.h>
#include <stdlib.h>

int main()
{
    pid_t son_pid = fork();
    
    if (son_pid > 0) {
        waitpid(son_pid, NULL, 0);
    } else if (son_pid == 0) {
        char *str = malloc(100);
        fgets(str, 100, stdin);
        printf("收到子进程数据: %s\n", str);
        free(str);
        str = NULL;
    } else {
        perror("fork");
    }

    return 0;
}

Makefile

process_group_father_son_test: process_group_father_son_test.c
    -$(CC) -o $@ $^
    -./$@
    -rm ./$@

启动后,查看进程信息。

image-20260823181925121

两个进程同属于一个进程组。通过fork()创建的子进程属于父进程创建的进程组,父进程是该组的组长。

③ 通过setpgid()将PID设置为各自的PGID,两个进程各自创建一个进程组。

创建process_group_two_group.c

#include <sys/types.h>
#include <unistd.h>
#include <sys/wait.h>
#include <stdio.h>
#include <stdlib.h>

int main()
{
    pid_t son_pid = fork();
    
    setpgid(getpid(), getpid());

    if (son_pid > 0) {
        waitpid(son_pid, NULL, 0);
    } else if (son_pid == 0) {
        char *str = malloc(100);
        fgets(str, 100, stdin);
        printf("收到子进程数据: %s\n", str);
        free(str);
        str = NULL;
    } else {
        perror("fork");
    }

    return 0;
}

Makefile

process_group_two_group: process_group_two_group.c
    -$(CC) -o $@ $^
    -./$@
    -rm ./$@

运行查看进程

image-20260823182023569

两个进程各自创建了一个进程组,进程组ID等于各自的进程ID。

(3)进程组领导

每个进程组都有一个进程组组长,其进程ID(PID)与进程组ID(PGID)相同。进程组组长是最先创建的进程,但它退出并不意味着进程组的消失。只要进程组中还有其他进程,该进程组就会继续存在。

(4)信号传递

可以向一个进程组中的所有进程发送信号,而不是单独向每个进程发送。

5.9 进程切换过程

5.9.1 进程切换

1)进程切换的场景

如果进程的运行不会被打断,那么操作系统内核想要回CPU的控制权就只能寄希望于进程主动归还,或者强制重启计算机。现代计算机提供了中断和异常机制,二者都可以打断正在执行的进程,将CPU的控制权交还给内核。进程的切换需要内核介入,必然要通过中断或异常来实现。进程切换主要在以下几种情况下发生。

(1)时钟中断触发,被中断的进程获得的CPU时间片耗尽,操作系统决定切换进程。

(2)当前进程发生故障,内核夺回CPU控制权,如果故障无法被修复,则内核终止该进程,切换至其它进程。

(3)时钟中断触发,当前进程在等待IO操作,为避免资源浪费,切换至其他进程。

(4)时钟中断触发,高优先级进程处于就绪状态,内核将CPU使用权由当前进程转交给高优先级进程。

2)进程切换过程

进程的切换需要借助中断或异常,流程如下。假设正在运行的进程A要被切换到进程B。

(1)CPU暂存栈指针、程序计数器、段选择器和状态寄存器的值。

(2)栈指针由进程A的用户栈切换至它的内核栈,操作系统切换至内核态。

(3)CPU将第一步暂存的寄存器值压入内核栈。

(4)将错误码压入内核栈。

(5)程序计数器指向中断或异常处理程序。

(6)操作系统执行中断或异常处理程序。

(7)在中断或异常处理程序中,调度器会判断是否满足进程切换条件,如果满足则执行以下操作:

① 将进程A所有相关寄存器的值保存至进程A的PCB(Linux底层实现为struct task_struct)。这会包含它的页表基址。

② 有些架构会清除TLB。

③ 将进程B的PCB中记录的页表基址、栈指针等寄存器信息加载(恢复)到对应寄存器。此时栈指针指向进程B的内核栈。进程A回到调度队列。如果进程A是因为CPU时间片耗尽,则处于就绪状态,回到就绪队列。

要注意,打开的文件描述符表等相关资源的切换不需要通过寄存器实现,这些资源存储在struct task_struct结构体中,调度器可以从调度队列获得task_struct,完成资源切换。

(8)执行权限检查,判断当前是否处于内核态。

(9)从进程B的内核栈恢复CS和程序计数器,后者指向B的用户进程代码。

(10)恢复进程B的状态寄存器RFLAGS。

(11)从进程B的内核栈恢复SS和栈指针,后者指向进程B的用户栈,此时切换到用户态。

(12)在用户态下继续进程B的执行,进程切换完成。

image-20260823183738299

image-20260823184742579

image-20260823184808336

image-20260823184852511

image-20260823184924751

image-20260823184952840

image-20260823185009957

5.9.2 进程上下文

1)进程上文

进程上文是指进程被挂起时其执行状态的集合,这使得进程能够在未来某个时间点继续执行。它包括进程的程序计数器、栈指针等寄存器状态,进程的内核栈和用户栈的信息、内存映射信息(如页表条目),打开的文件描述符表等。

2)进程下文

进程下文是指将要被加载和执行的挂起进程的执行状态集合。包括将要被执行的进程的程序计数器、栈指针等寄存器状态,进程的内核栈和用户栈的信息、内存映射信息(如页表条目),打开的文件描述符表等。

5.10 系统调用和库函数

(1)系统调用

系统调用是操作系统提供的服务接口,允许用户空间的应用程序请求操作系统执行特定的功能,这些功能通常涉及到资源管理、文件操作、进程控制等更深层次的系统任务。系统调用是操作系统内核功能的直接接口。

用户在程序中执行系统调用,CPU会执行陷入指令,切换到内核态,执行相应的异常处理程序,实现用户进程对于内核功能的调用。

系统调用运行在内核态,具有访问硬件和管理系统资源的权限。涉及用户态到内核态的切换,执行开销较大。

(2)库函数

库函数通常是用高级语言编写,库函数存储在函数库中,最常见的是C标准函数库。

在使用标准输入输出的时候,我们都需要引入一个头文件:stdio.h,这个头文件是C标准库的IO接口的声明。

C标准函数库(C Standard Library,简称libc)是一个由ANSI C标准定义的函数库集合,它为C语言程序员提供了一组标准化的程序接口,这些接口实现了基础的程序功能,如输入输出处理、字符串操作、数学计算等。这个库是C语言标准的一部分,所有遵循标准的C语言实现都必须提供这个库的功能。

在现代Linux发行版中,使用最多的libc实现为GNU C函数库,简称glibc,它是C标准库的一个实现,它是自由软件基金会(Free Software Foundation, FSF)GNU项目的一部分。glibc为C语言提供了标准库所要求的所有功能,并且还包括了一些扩展功能和优化,以支持GNU/Linux系统和其他类UNIX系统。

我们可以用下面的命令查看自己系统的glibc版本:

atguigu@ubuntu:~ $ /lib/x86-64_64-linux-gnu/libc.so.6

命令反馈如下:

GNU C Library (Ubuntu GLIBC 2.35-0ubuntu3.6) stable release version 2.35.
Copyright (C) 2022 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.
There is NO warranty; not even for MERCHANTABILITY or FITNESS FOR A
PARTICULAR PURPOSE.
Compiled by GNU CC version 11.4.0.
libc ABIs: UNIQUE IFUNC ABSOLUTE
For bug reporting instructions, please see:
<https://bugs.launchpad.net/ubuntu/+source/glibc/+bugs>.

可以看到我们的Linux中使用的glibc版本为2.35。

库函数运行在用户态,不涉及用户态和内核态的切换,执行开销较小。许多库函数在内部使用系统调用实现其功能。

image-20260823183949542

5.11 进程和线程

5.11.1 线程

(1)概念

线程是进程中的执行单元,它共享进程的资源和地址空间,但拥有自己的执行堆栈、程序计数器和一组寄存器。由于线程共享相同进程内的资源,它们之间的通信和数据共享相对容易。在很多类Unix系统中,线程被称为轻量级的进程,创建和上下文切换的开销小于进程。

(2)Shell中查看线程

执行以下命令。

atguigu@ubuntu:~$ ps -eo uid,lwp,tid,tgid,pid,pgid,ppid,cmd

出现了几个新的字段名。

Ø lwp:Low Weight Process ID,轻量级线程ID,Linux中等同于线程ID。

Ø tid:Thread ID,线程ID。

Ø tgid:Thread Group ID,线程组ID。

image-20260823184112972

对于只有一个线程的进程,它的线程ID等于进程ID。对于拥有多个线程的进程,主线程的线程ID等于进程ID,主线程是该进程的第一个线程,是创建其它线程的线程。

5.11.2 线程和进程的区别与联系

在Linux中,线程等同于轻量级进程,二者都有独立的task_struct结构体实例。线程创建和进程创建在技术上是完全等同的,fork()和进程创建函数pthread_create()底层都调用了系统调用clone()。

1)创建进程

当我们调用fork()时,等同于调用clone(SIGCHLD, 0),SIGCHLD标志的作用是告诉操作系统:当子进程终止时,父进程应当接收到SIGCHLD信号。这个信号是默认的方式,用于通知父进程其子进程已经结束。这样一来,父进程就可以在子进程退出后执行清理操作。

2)创建线程

创建线程时,底层会调用clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND, 0),这些Flag的含义如下。

Ø CLONE_VM:共享地址空间。从技术上将,被创建的线程和创建者的struct task_struct实例中struct mm_struct指针类型的字段mm和active_mm指向相同的实例。

Ø CLONE_FS:共享文件系统信息。从技术上将,被创建的线程和创建者的struct task_struct实例中struct fs_struct指针类型的字段fs指向相同的实例。

Ø CLONE_FILES:共享打开的文件描述符表,从技术上讲,被创建的线程和创建者的struct task_struct实例中struct files_struct指针类型的字段files指向相同的实例。

Ø CLONE_SIGHAND:共享信号处理函数表。从技术上讲,被创建的线程和创建者的struct task_struct实例中struct signal_struct指针类型的字段和struct sighand_struct指针类型的字段指向相同的实例。

3)进程创建和线程创建的区别

从clone()系统调用的角度,我们可以得出结论:如果多个进程共享了地址空间、文件系统信息、打开的文件信息、信号处理信息,那么他们就是同属于一个进程的线程

task_struct结构体中的pid实际上表示的是进程或线程ID,tgid字段表示的是线程组ID,等同于传统意义上的线程ID。对于单线程的进程,pid字段和tgid字段相同。对于多线程进程,主线程的pid等于tgid,pid此时可以理解为主线程的线程ID或者进程ID,普通线程的tgid等于主线程的pid和tgid,即当前线程所属的线程组ID和进程ID,而pid字段此时相当于线程ID。

image-20260823184202399

image-20260823185944410

5.11.3 线程的特点

1)资源共享

线程之间共享进程资源,包括地址空间、文件系统信息、打开的文件描述符和信号处理函数,而不同进程之间的资源是隔离的。

2)通信

线程间的通信通常比进程间的通信(例如,通过管道、共享内存)更为高效。因为地址空间是共享的,线程间可以直接通过如全局变量这样的方式通信。

3)创建和管理开销

线程的创建和上下文切换通常比进程更轻量级,因此在需要频繁创建和销毁执行单元的场景中,线程可能是更合适的选择。

5.11.4 线程组

在操作系统中,线程组(Thread Group)是一种将多个线程组织在一起的机制,使得它们可以作为一个单元进行管理和操作。Linux中,同一进程的所有线程同属于一个线程组。线程组ID(Thread Group ID,TGID)用于标识一个线程组,它等于进程号,等于主线程(该进程的第一个线程,创建其它线程的线程)的线程ID(TID)。

5.11.5 内核线程

上文我们不止一次提到了内核线程。Linux的内核线程是在内核空间中运行的轻量级进程,它们没有独立的地址空间和大部分用户空间资源。内核线程是操作系统内核功能的一部分,主要用于管理和执行内核级任务,如硬件中断处理、系统调用服务、内存管理等。

1)内核栈

内核线程拥有自己的内核栈。

2)控制内核线程的数据结构

内核线程的信息也存储在task_struct结构体中。

3)地址空间

不同于普通进程,struct mm_struct类型的字段mm及active_mm取值为NULL,它们通常共享内核的全局地址空间。

4)标记字段

内核线程的task_struct中flags字段被标记为PF_KTHREAD,用于表示这是一个内核线程。

5)内核线程的ID

内核线程工作在内核态,相互之间地位是等同的,因此,任意内核线程的TID、TGID、PID都是相同的。并且,所有内核线程的PGID都是0。这是因为内核线程不与任何特定的终端相关联,也不参与普通的作业控制和信号处理,这些通常是用户空间进程的特性。PGID设置为0是设计上的选择,用于确保内核线程在操作系统中的特殊性和隔离性。

通过以下命令查看。

atguigu@ubuntu:~$ ps -eo uid,tid,tgid,pid,pgid,ppid,cmd

image-20260823184350538

6)打开的文件描述

此外,内核进程不需要执行I/O操作或者直接操作文件描述符,因此,不需要文件描述符表,它的task_struct中,指向struct files_struct实例的字段为NULL。

7)文件系统信息

同样地,指向struct fs_struct实例的字段也通常是NULL。fs_struct用于管理文件系统相关的信息。内核线程不与特定的文件系统路径交互,所以不需要此信息。

8)信号处理信息

struct signal_struct和struct sighand_struct实例主要用于处理用户级信号,因此,内核线程的task_struct实例中,指向它们的指针也通常为NULL。

image-20260823184433045

第 6 章 Socket编程

6.1 计算机网络的分类

6.1.1 按地理范围分类

1)个人区域网(PAN) - Personal Area Network

(1)范围:通常是几米到十几米。

(2)用途:连接个人设备,如手机、平板、笔记本电脑、智能手表等。

(3)示例:蓝牙(Bluetooth)、红外线通信(Infrared Communication)。

2)局域网(LAN) - Local Area Network

(1)范围:通常覆盖一个建筑物、办公室或校园。

(2)用途:在小范围内实现高速数据传输和资源共享。

(3)示例:以太网(Ethernet)、Wi-Fi(Wireless Fidelity)。

3)城域网(MAN) - Metropolitan Area Network

(1)范围:覆盖一个城市或多个相邻的城市。

(2)用途:连接多个局域网,提供城域范围内的数据传输服务。

(3)示例:光纤网络(Fiber Optic Network)、有线电视和DSL(Digital Subscriber Line)连接。

4)广域网(WAN) - Wide Area Network

(1)范围:覆盖国家、洲际甚至全球范围。

(2)用途:连接多个局域网和城域网,实现长距离数据传输。

(3)示例:互联网(Internet)、企业专用网(Enterprise Private Network)。

6.1.2 按网络拓扑分类

1)总线型拓扑(Bus Topology)

所有设备共享一条通信介质(总线)。

(1)优点:布线简单,成本低。

(2)缺点:总线故障会导致整个网络瘫痪。

2)星型拓扑(Star Topology)

所有设备通过单独的通信链路连接到一个中央节点(如交换机)。

(1)优点:易于管理和扩展,单个设备故障不会影响整个网络。

(2)缺点:中央节点故障会导致整个网络瘫痪。

3)环型拓扑(Ring Topology)

每个设备连接到两个相邻设备,形成一个环。

(1)优点:数据传输延迟低,适合实时通信。

(2)缺点:任何一个节点或链路故障都会影响整个网络。

4)网状拓扑(Mesh Topology)

每个设备都有多个连接点,与多个其他设备直接相连。

(1)优点:高度冗余,可靠性强,任意节点或链路故障不影响整体通信。

(2)缺点:布线复杂,成本高。

6.1.3 按网络用途分类

1)互联网(Internet)

全球范围内连接各种网络的集合。

用途:提供全球范围的通信和信息共享。

2)企业内部网(Intranet)

仅限于企业或组织内部使用的网络。

用途:提供内部资源共享和通信。

3)外联网(Extranet)

连接企业内部网与外部合作伙伴或客户的网络。

用途:在安全受控的环境下,与外部实体进行通信和协作。

6.1.4 按网络的交换技术分类

1)电路交换网络(Circuit-Switched Network)

通信路径在传输前建立,并在通信期间保持不变。

示例:传统电话网络(Traditional Telephone Network)。

2)分组交换网络(Packet-Switched Network)

数据分成小包(分组)传输,每个分组可以通过不同的路径到达目的地。

示例:互联网(Internet)。

3)报文交换网络(Message-Switched Network)

整个消息作为一个单元传输,通过节点存储并转发。

示例:早期的电报网络(Early Telegraph Networks)。

6.1.5 按网络的通信方式分类

1)广播网络(Broadcast Network)

一个节点发送的数据包可以被网络中所有节点接收到。

示例:以太网(Ethernet)、Wi-Fi(Wireless Fidelity)。

2)点对点网络(Point-to-Point Network)

数据包从一个节点直接发送到目标节点。

示例:点对点协议(PPP - Point-to-Point Protocol)、专用线路(Leased Line)。

6.2 计算机网络分层模型

6.2.1 相关概念

计算机网络分层模型是网络通信的基础框架,它将复杂的网络通信过程划分为若干层次,每一层都执行特定的功能,并为上一层提供服务。这种分层的目的是简化网络设计,确保不同网络技术之间的兼容性和互操作性。与分层模型相关的概念包括分层、实体、协议、接口和服务。

1)分层(Layer)

分层是将网络通信过程划分为多个层次的过程。每一层关注网络通信的一个特定方面,比如物理传输、数据链路、网络路由、传输可靠性等。最著名的分层模型是OSI(开放系统互连)模型,它包含七层,以及TCP/IP模型,通常被认为包含四层。前者是学术和法律上的国际标准,后者是事实上的国际标准,即现实生活中被广泛遵循的分层模型。

2)实体(Entity)

在分层模型中,每一层都有实体,这些实体指的是执行特定层次功能的硬件或软件组件。同一层内的实体可以在不同的机器上,通过遵循相同层的协议进行通信。例如,两台计算机上的传输层实体可以是负责建立端到端连接的软件。同一层次的实体为对等实体。

3)协议(Protocol)

协议是一套规则和标准,用于控制同一层次内的实体如何相互通信。协议定义了通信的格式、时序、错误处理等。例如,TCP(传输控制协议)定义了如何在网络中的两个点之间可靠地传输数据。

4)接口(Interface)

接口是网络分层模型中,定义相邻两层之间如何交互的规范。它规定了一层如何向另一层提出服务请求,以及这些请求怎样被另一层接收和响应。接口包含了一系列的规则、命令、数据格式和过程,确保不同网络层之间的有效通信和数据交换。

服务访问点(Service Access Point,SAP)是接口概念的一个组成部分,具体化了接口在实现层次服务中的作用。

接口定义了相邻层之间交互的规范和方式,而SAP则是这种交互发生的具体逻辑位置。换句话说,SAP为接口提供了一个具体的实施机制,使得上层能够访问下层提供的服务。

5)服务(Service)

服务是指下层为紧邻上层提供的功能调用,是垂直方向的。描述了上层可以利用的具体功能和操作,但不涉及这些功能是如何实现的。服务强调的是功能性的提供,而不是实现细节。服务描述了“做什么”(功能),而不是“如何做”(实现细节)。服务是抽象的,隐藏了下层如何完成这些任务的具体细节,使得上层可以不依赖于下层的具体实现来进行设计和开发。

上层是通过SAP访问下层服务的。

6.2.2 OSI七层模型

OSI(Open Systems Interconnection,开放系统互联)模型是由国际标准化组织(ISO)在1984年提出的一个网络架构模型,它将网络通信分为七个层次,每层都定义了特定的网络功能。自下而上分别为物理层、数据链路层、网络层、传输层、会话层、表示层和应用层。

image-20260823205147009

1)物理层(Physical Layer)

负责传输原始比特流。它涉及的是物理设备及介质,如电缆类型、电信号传输和接收等。

2)数据链路层(Data Link Layer)

确保物理链路上的无误传输。它提供了如帧同步、流量控制和错误检测等功能。

3)网络层(Network Layer)

负责数据包从源到目的地的传输和路由选择。它定义了地址和路由的概念,如IP协议。

4)传输层(Transport Layer)

提供端到端的数据传输服务,保证数据的完整性。它定义了如TCP和UDP协议。

5)会话层(Session Layer)

管理会话,控制建立、维护和终止会话。

6)表示层(Presentation Layer)

处理数据的表示、编码和解码,如加密和解密。

7)应用层(Application Layer)

提供网络服务给终端用户的应用程序,如HTTP、FTP、SMTP等协议。

6.2.3 五层模型

五层模型是将OSI模型的应用层、表示层和会话层合并为应用层,其它层不变。是为了教学而设计的一种网络分层模型。

image-20260823205238377

6.2.4 TCP/IP 四层模型

TCP/IP四层模型,亦称互联网协议套件(Internet Protocol Suite),是一种按照功能标准组织互联网及类似计算机网络中使用的一系列通信协议的框架。该套件中的基础协议包括传输控制协议(TCP)、用户数据报协议(UDP)和互联网协议(IP)。

TCP/IP协议栈(Protocol Stack)是指TCP/IP协议套件的软件实现。

1)应用层(Application Layer)

(1)定义

这层是用户最直接交互的部分,是软件应用通过网络进行通信的地方。应用层使用下层提供的服务来创建用户数据,将这些数据传输给同一台机器上或远程机器上的其他应用。

(2)协议

该层包括了SMTP、FTP、SSH、HTTP等协议,这些协议都在应用层上实现,以实现客户端与服务器之间的通信和数据交换。

2)传输层(Transport Layer)

(1)定义

传输层管理着端到端的通信,即主机到主机的通信。它的主要职责是为不同主机上的应用程序提供数据传输,同时保证这些数据的完整性和可靠性。

(2)协议

在传输层,主要的协议有TCP(Transmission Control Protocol),它提供顺序的、可靠的、双向的连接流,并管理报文段的发送,确保无错误、不丢失、不重复、按序到达;以及UDP(User Datagram Protocol),它提供一种无连接的服务,数据以数据报的形式发送,不保证顺序或响应。

3)互联网层(Internet Layer)

(1)定义

互联网层处理跨网络界限的数据包交换,负责将数据报文段从源地址路由到目的地址。这层抽象了实际的物理网络拓扑结构,并且定义了如何在各种网络结构中发送和接收数据包。

网络层的IP协议是构成Internet的基础。Internet上的主机都是通过IP地址来互相识别。IP协议不保证传输数据的可靠性,可能出现丢包等情况。

(2)协议

主要协议是IP(Internet Protocol),它定义了数据包的路由方式和网络地址。其他重要的协议包括ICMP(Internet Control Message Protocol),用于错误报告和网络诊断。

4)链路层(Link Layer)

(1)定义

链路层涉及到在物理网络上的数据通信。链路层确保网络层传来的IP数据报可以在网络的物理链接上进行传输,不管是通过有线还是无线媒介。它还负责处理与物理网络链接相关的问题,例如MAC(Media Access Control)地址寻址、帧同步、错误检测和校正。

(2)协议

它包括了在物理网络链接中使用的所有协议,如以太网(Ethernet)、Wi-Fi以及PPP(Point-to-Point Protocol)。

6.2.5 OSI七层模型和TCP/IP四层模型对比以及TCP/IP协议栈

OSI模型是学术和法律上的国际标准,TCP/IP模型是事实上的国际标准,是现实生活中被广泛遵循的分层模型。

image-20260823205407198

6.2.6 网络传输中的数据单元

1)PDU

协议数据单元(Protocol Data Unit,PDU),计算机网络各层对等实体间交换信息的数据单位。PDU包括头部(PCI)和负载(SDU)。不同层次的PDU有专门的术语,例如在网络层,PDU称为数据包,在传输层,PDU称为报文段或数据报,在数据链路层,PDU称为帧(frame)。

(1)数据包(Packet)

通常指网络层(如IP网络层)的数据单位。

(2)报文段(Segment)

通常用于描述传输层(TCP传输层)的数据单位。

(3)数据报(Datagram)

通常用于描述UDP协议的数据单位,它也是传输层的一个概念。

2)SDU

服务数据单元(Service Data Unit,SDU)是在通信协议的特定层次上传递的数据单元。这些数据被传递给下一层,由下一层进行处理或封装,并附加控制信息(PCI),将其转换为那一层的PDU。SDU是用户数据或者来自上一层的PDU,未包括当前层可能添加的头部或其他控制信息。

3)PCI

协议控制信息(Protocol Control Information,PCI)是PDU中的元数据部分,它包括用于在网络中传输数据的控制信息,比如地址、端口号、控制标志、协议类型等。它与SDU一起被封装成PDU。PCI基本上是头部和尾部信息,它使得网络或传输实体能够理解如何处理包含的SDU。

PDU=SDU+PCI。

image-20260823205508949

5)头部、尾部与PCI

头部(Header)和尾部(Trailer)都属于PCI。

头部和尾部是物理的,实际上是SDU前面或后面附加的一系列比特。而PCI是逻辑的,它是协议控制信息的集合,指导网络中的各层对数据做正确处理,确保网络协议正常运作。

通常PCI只包含头部,一个特例是以太网的数据帧不止包含头部,还包含尾部的帧检验序列(FCS,Frame Check Sequence,用于差错控制)。以太网帧的头部和尾部共同构成了它的PCI。

网络分层模型的层与层之间相互依赖,数据由上层到底层的过程中,每层都要加上该层协议的Header,我们称之为封装。

6.2.7 基于以太网的通信流程示例

假设我们的设备接入了以太网,通过浏览器访问 http://www.atguigu.com/ ,这一过程中,我们会向网站后台的服务器发送数据,在TCP/IP模型中,数据的传输过程如下所示。

image-20260823205605961

image-20260823213358282

6.2.8 服务、协议和接口的关系

服务描述了下层可以为相邻上层提供的功能,是抽象的。而接口提供了访问这些功能的方法。接口是实现服务交付的框架,如网络编程框架(API库)。要实现服务,需要遵循特定的协议,协议定义了实现服务所需的数据格式、操作顺序、错误处理等规则。

服务告诉我们“可以提供什么功能”,接口解决的是“如何调用功能”,而协议定义了“如何实现功能”。

通过接口,上层不需要知道服务是如何实现的,只需要知道如何通过接口访问这些服务。而服务的具体实现需要遵循相应的协议。

以OSI七层模型中的传输层和网络层为例。

Ø 服务:网络层提供的服务是数据包的传输和路由选择。传输层可能会请求网络层提供的传输和路由服务来将数据包传输到目的地址。传输层不关心网络层如何实现这些服务,它只关心如何使用这些服务。

Ø 接口:在上述过程中,传输层使用的是网络层暴露的接口(比如使用IP协议栈的API)来发送和接收IP数据包。接口定义了传输层和网络层之间的交互方式,包括传输层如何向网络层提交数据包,以及网络层提供反馈信息的方式。这些API以一种对传输层透明的方式遵循网络层的协议。

Ø 协议:传输层通过网络层的接口发送数据时,实际上是在使用网络层的协议。网络层遵循的IP协议定义了如何在设备间路由数据包,包括如何寻址、打包和处理数据传输中的错误。实现IP协议栈时,必须遵守IP协议。

6.3 传输层-TCP协议

6.3.1 什么是TCP协议

TCP(传输控制协议,Transmission Control Protocol)是一种面向连接的、可靠的、基于字节流的传输层通信协议,广泛应用于互联网中。它旨在提供可靠的端到端通信,在发送数据之前,需要在两个通信端点之间建立连接。TCP通过一系列机制确保数据的可靠传输,这些机制包括序列号、确认应答、重传控制、流量控制和拥塞控制。

6.3.2 TCP 协议的特征

1)面向连接

TCP是一种面向连接的协议,这意味着在数据交换之前,两个通信端必须先建立连接。这个连接通过一个三次握手过程(SYN、SYN-ACK、ACK)来建立,确保双方都准备好数据交换。

2)可靠传输

TCP通过序列号和确认应答机制确保数据的可靠传输。发送方为每个报文段分配一个序列号,接收方通过发送确认应答(ACK)来确认已经收到特定序列号的报文段。如果发送方没有在合理的超时时间内收到确认应答,它将重传该报文段。

3)流量控制

TCP使用窗口大小调整机制来进行流量控制,防止发送方过快地发送数据,导致接收方来不及处理。通过调整窗口大小,TCP能够动态地管理数据的传输速率,避免网络拥塞和数据丢失。

4)拥塞控制

TCP实现了拥塞控制算法(如慢启动、拥塞避免、快重传和快恢复),以避免网络中的过度拥塞。这些算法可以根据网络条件动态调整数据的发送速率,从而提高整个网络的效率和公平性。

5)数据排序

由于网络延迟和路由变化,TCP报文段可能会乱序到达接收方。TCP能够根据序列号重新排序乱序到达的报文段,确保数据以正确的顺序交付给应用层。

6)端到端通信

TCP提供端到端的通信。每个TCP连接由四个关键元素唯一确定:源IP地址、源端口号、目标IP地址、目标端口号。这种方式确保了数据能够在复杂的网络环境中准确地从一个端点传输到另一个端点。

6.3.3 TCP 报文段格式

image-20260823213840433

image-20260823220240122

头部字段介绍如下:

1)源端口号(Source Port)和目标端口号(Destination Port):

每个字段都是16位,用于标识发送和接收数据的应用层服务。端口号允许操作系统区分不同的网络服务。

2)序列号(sequence number field)

一个32位的数,用于标识从TCP源到目标的数据字节流,它用于确保数据的有序传输和重组。

TCP把数据看成一个无结构的、有序的字节流。TCP的序号是建立在传送的字节流之上,而不是建立在传送的报文段的序列之上。一个报文段的序号是该报文段首字节的字节流编号。举例来说,假设主机A上的一个进程想通过一条TCP连接向主机B上的一个进程发送一个数据流。主机A中的TCP将隐式地对数据流中的每一个字节编号。假定数据流是一个包含1000字节的序列,每次传输的报文段长度为100个字节,数据流的首字节编号是0。将为该数据流构建10个报文段。给第一个报文段分配序号0,第二个报文段分配序号100,第三个报文段分配序号200,以此类推。每一个序号被写入相应TCP报文段首部的序列号字段中。

3)确认号(acknowledgement number field)

也是一个32位的数,如果设置了ACK标志,报文段的确认号是发送方期望从对方收到的下一字节的序号。它是对之前接收到的数据的确认。假设主机A上的一个进程通过一条TCP连接和主机B上的一个进程通信,主机A已经收到了来自主机B的序号为0~122的所有字节,同时假设主机A接下来将要发送一个报文段给主机B,主机A等待主机B的数据流中序号为123及之后的所有字节,因此主机A将要发送给主机B的报文段中确认号应为123。

4)数据偏移(Data Offset)

一个4位字段,表示TCP头部的长度(以32位字为单位)。TCP头部可能包含可变数量的选项,因此长度是不确定的,需要这个字段记录长度信息。

5)保留(Reserved)

这是一个预留的6位字段,当前必须设置为0。

6)控制位(Control Bits)

标志字段共有六个比特,每个比特对应一个标志:URG、ACK、PSH、RST、SYN、FIN。这些标志用于控制TCP的不同行为,例如建立连接(SYN),终止连接(FIN),指示数据急迫性(URG)等。

Ø URG:紧急标志,为1表示当前报文段存在被发送端上层实体置为“紧急”的数据。接收方应当优先处理这些数据。紧急指针字段指出了这部分数据的结束位置。

Ø ACK:确认标志,为1指示确认字段的值是有效的。该报文段包含对已被成功接收报文段的确认。连接建立后,直至释放前传输的所有报文段ACK标志均为1。

Ø PSH:为1指示接收方应立即将数据交给上层。

Ø RST:为1表示链接出现错误,要求接收方终止连接,并重新建立连接。

Ø SYN:该标志位用于建立连接。TCP连接建立时的前两次握手SYN为1。

Ø FIN:为1表示发送方已经没有数据发送,想要断开连接。

7)窗口大小(Window Size)

一个16位的数值,用于控制对方发送的数据量,以避免接收方缓冲区溢出。窗口大小是流量控制的一部分,可以动态调整。

8)校验和(Checksum)

一个16位的字段,用于检查整个TCP报文段的错误。校验和确保数据在传输过程中的完整性。

9)紧急指针(Urgent Pointer)

一个16位的字段,如果URG标志被设置则为正数,表明从当前序列号开始的紧急数据的字节偏移量。

10)选项(Options)

TCP头部可能包含各种可选字段,这些字段不是固定的,可能包括如最大报文段大小(MSS)、窗口缩放选项、选择性确认(SACK)等,用于优化TCP连接的性能。

11)填充(Padding)

TCP是在32位体系结构上设计的,为了内存对齐以提升处理效率,需要确保它的头部长度为32位的整数倍。选项长度可变,填充的目的是确保TCP头部的长度按照32位字对齐,填充位于TCP头部的末尾。

注意:TCP数据部分不要求按照32位字对齐。

6.3.4 TCP 状态

在TCP连接的生命周期中,根据不同的操作和事件,会经历多种状态。以下是TCP协议定义的11种状态,每种状态对应连接的不同阶段:

1)CLOSED

这是初始状态和最终状态,表示没有活动的连接,也没有正在进行的通信。

2)LISTEN

在服务器端监听来自客户端的连接请求。服务器在这个状态下等待进入的SYN报文。

3)SYN_SENT

客户端发送一个连接请求到服务器(SYN包),然后转入SYN_SENT状态等待服务器确认。

4)SYN_RECEIVED

服务器接收到客户端的SYN报文后,响应一个SYN+ACK报文,同时进入SYN_RECEIVED状态。这个状态表明服务器端已响应连接请求。

5)ESTABLISHED

连接已建立成功,数据可以开始传输。这是在三次握手成功完成后,双方都会进入的状态。

6)FIN_WAIT_1

在一个TCP连接中,当一端完成它的数据发送任务后,它会发送一个FIN报文,表示它已经完成数据发送,进入FIN_WAIT_1状态。

7)FIN_WAIT_2

在发送了FIN报文并接收到对方的ACK响应后,进入FIN_WAIT_2状态。在这个状态下,连接一端等待对方的FIN报文。

8)CLOSE_WAIT

在接收到对方的FIN报文后,进入CLOSE_WAIT状态。在这个状态下,TCP连接的这一端知道对方已经没有数据发送,但本地可能还有数据需要发送。

9)CLOSING

在同时关闭的情况下,当两端几乎同时发送FIN报文时,可能会进入CLOSING状态,表示双方都在等待对方的FIN报文的确认。

10)LAST_ACK

在发送完自己的所有数据并发送了FIN报文后,如果还需要等待来自对方的最后一个ACK,就会进入LAST_ACK状态。

11)TIME_WAIT

当TCP连接的一端接收到对方的FIN报文并发送了ACK报文后,它会进入TIME_WAIT状态。该状态会持续一段时间(2倍的MSL,最大报文生存时间),以确保对方接收到最后一个ACK报文。这样可以正确地关闭连接,并确保旧的重复报文不会在网络中造成混乱。

6.3.5 TCP连接的建立和释放

1)TCP连接建立过程

TCP的连接建立过程又称为三次握手。

我们用小写的seq表示TCP报文头部的序号,用小写的ack表示确认号。未提到的标志位均为0。

(1)TCP服务器准备好接受连接,进入LISTEN状态,这一过程称为被动打开。

(2)第一次握手:客户端发送SYN标志为1(表示这是一个同步报文段),且seq随机的报文段,请求建立连接。此时的seq记为ISN(c)(Initial Sequence Number,初始序列号),括号中的c表示这是和客户端的序列号。客户端发送后变为SYN-SENT状态。

(3)第二次握手:服务端收到客户端的第一次握手信号,变为SYN-RCVD状态。随即确认客户端的SYN报文段,发送一个ACK和SYN标志均为1的报文段。该报文段中ack=ISN(c)+1,seq随机,标记为ISN(s),此处的s表示这是服务端的序列号。服务端变为SYN-RCVD状态。

(4)第三次握手:客户端收到服务端的第二次握手信号,变为ESTABLISHED状态,随即确认服务端的报文段,发送ACK标志为1的报文段。该报文段中ack=ISN(s)+1,seq=ISN(c)+1。服务端收到客户端的第三次握手信号之后变为ESTABLISHED状态。

下面的PPT中,大写字母组合表示相应标志位为1。

image-20260823214022451

2)TCP连接释放过程

TCP的连接释放过程又称为四次挥手。

四次挥手可以由客户端发起,也可以由服务端发起。此处假设连接请求的断开操作由客户端发起。连接断开前,双方都处于ESTABLISHED状态。需要注意的是,连接建立后,即客户端和服务端处于ESTABLISHED时,双方发送的报文段ACK标志均为1。

我们用小写的seq表示TCP报文头部的序号,用小写的ack表示确认号。未提到的标志位均为0。

(1)第一次挥手:客户端发送FIN标志为1(即FINISH,表示通信结束)的报文段,请求断开连接,执行主动关闭(active close)。此时,报文段中包含对于服务端数据的确认,ACK为 1,假设ack=V。连接断开前已经历了一系列的数据传输,seq取决于之前已发送的报文段,假设seq=U。客户端状态变为FIN-WAIT-1。

(2)第二次挥手:服务端接收到第一次挥手信息,切换为CLOSE-WAIT状态,随即发送ACK标志为1,ack=U+1的报文段,此时seq=V。客户端接收到服务端的第二次挥手信号,变为FIN-WAIT-2状态。第二次挥手后,服务端仍可发送数据,客户端仍可接收。

(3)第三次挥手:服务端完成数据传送后,发送FIN标志和ACK标志均为1的报文段,ack=U+1,seq大于V,假设为W,请求断开连接,这一过程称为被动关闭。服务端发送第三次挥手信号后,变为LAST-ACK状态。

(4)第四次挥手:客户端收到第三次挥手信号,随即发送ACK标志为1,seq=U+1,ack=W+1的报文段,变为TIME-WAIT状态。服务端收到第四次挥手信号,变为CLOSED状态。客户端从变为TIME-WAIT状态开始计时,等待2MSL(2倍最大报文时长,约定值)后进入CLOSED状态。四次挥手结束。

image-20260823214118088

6.3.6 TCP 数据传输

6.3.6.1 可靠传输保障

1)累积确认

上文介绍了TCP连接的建立和释放,当客户端或服务器接收到来自对方的FIN报文或SYN报文后,会立即发送一条ACK报文,ACK的值为接收到的FIN或SYN报文中seq+1。在数据传输过程中,ack也会根据接收到的报文段中seq的值发生变化。

TCP的累积确认(Cumulative Acknowledgment)是指接收方发送的ACK报文中的确认号表示的是接收方期望接收的下一个字节的序列号。这意味着所有比这个确认号小的字节都已经被成功接收。

2)延时确认

接收方在接收到每个报文段后,不会立即发送确认报文(ACK),而是会等待一段时间,看是否有其他报文段到达或者接收方是否有数据要发送。在这种情况下,接收方可以将多个确认合并为一个ACK报文。这种机制被称为延迟确认,目的是减少ACK报文的数量,从而降低网络开销。

3)超时重传

当发送方发送数据后,如果在预定时间内未收到接收方的确认(ACK),发送方会假设该数据段丢失,并重新发送该数据段。

image-20260823214239808

6.3.6.2 滑动窗口

在TCP通信中,双方各自维护一个缓冲区。当发送速率大于接收速率时,接收方的缓冲区可能会被填满。如果发送方继续发送数据,后面的数据只能被丢弃,造成资源浪费。为了避免这种情况,TCP提供了滑动窗口机制来控制发送方的发送速率。

TCP报文段的头部有一个字段是窗口大小(Window Size),这里的窗口是指接收窗口(Receive Window,rwnd)。接收方可以在返回的ACK报文段中通过窗口大小字段告诉发送方当前可用的缓冲区即接收窗口大小。接收窗口大小随数据传输动态变化,当窗口大小变为0时,发送方会暂停数据发送。一段时间后,接收方腾出了足够的空间来接收数据,它会发送报文段通知发送方继续发送。

滑动窗口机制通过动态调整窗口大小,确保发送的数据不会超出接收方的处理能力,从而避免数据丢失和资源浪费,同时保证了TCP连接的可靠性,实现了流量控制。

image-20260823214330564

image-20260823222025257

image-20260823222117983

6.3.6.3 拥塞控制

上文提到,基于接收窗口,TCP实现了滑动窗口机制,限制了发送方在某一时刻可以发送的数据量,从而有效地进行流量控制。接收窗口是根据接收方的处理能力计算的。

设想这样的场景:rwnd为1000B,每个报文段大小为100B,发送方可以同时发送10个报文段,而此时网络环境较差,同时发送5个报文段已经达到了网络传输能力的极限。那么10个报文段必然会发生拥堵和丢失,发送方无法在超时前收到ACK,不断重传,进一步加剧拥堵,恶性循环。

为了避免这样的情况,TCP维护了拥塞窗口(Congestion Window,cwnd)。拥塞窗口是发送方根据网络状况计算得到的,是发送方用来实现流量控制的基础,它的大小可以动态调整。基于拥塞窗口,结合下面的五种机制或策略,发送方实现了流量控制。

1)慢启动

连接建立后将拥塞窗口的初始大小cwnd设置为一倍的SMSS(发送方最大报文段大小)(协议的实现可能并不是一倍,为了便于讨论,我们假设为一倍),即cwnd=1*SMSS。为了便于分析,我们省略SMSS,称初始的cwnd为1,下文同理。

在慢启动阶段,每接收到一个ACK,cwnd翻倍,这一阶段窗口大小呈指数增长。

2)拥塞避免

当cwnd达到慢启动阈值ssthresh(Slow Start Threshold)时,进入拥塞避免阶段。此阶段每接收到一个ACK,cwnd+1。

3)超时重传

随着窗口的增长,网络中传输的数据越来越多,最终超出带宽限制,就会出现报文段的丢失。发送方为每条数据维护一个定时器,如果限定时间内未收到ACK则判定超时。发送端检测到超时,将ssthresh调整为当前cwnd的一半,然后将cwnd重置为1,重新开始慢启动过程。

4)快速重传

我们知道,TCP采用了累积确认的方式,如果某个报文段发生了丢失,即便收到了后面的数据,接收方返回的ACK也是一样的。当发送方连续收到3个重复的ACK时,判定相应的报文段丢失,立即重传。这样可以在不等待超时的情况下重传丢失的报文段,避免了数据丢失时的长期等待,提升传输效率。

5)快速恢复

当快速重传发生时,sshthresh调整为当前cwnd的一半,cwnd不必重新从1开始,而是与新的sshthresh相等,重新执行拥塞避免策略。

image-20260823214441062

image-20260823222635575

6.3.6.4 发送窗口

接收窗口保证发送的数据不会超过接收方的处理能力,拥塞窗口保证发送的数据不会超过网络的承载能力,那么发送方发送数据时应该以哪个窗口为准呢?很显然,我们既要保证数据传输不能超出接收方的处理能力,又要保证不能超出网络的承载能力。发送方可用的窗口应该是拥塞窗口和接收窗口的最小值,这就是发送窗口(Send Window,swnd),这个窗口真正决定了可以发送的数据量。真正的TCP协议实现中,滑动窗口和拥塞控制机制是协同工作的。

6.3.7 TCP 开发常用函数

根据以上图示,TCP协议通讯需要用到的函数如下:

1)socket

(1)套接定义

套接字(Socket)是计算机网络数据通信的基本概念和编程接口,允许不同主机上的进程(运行中的程序)通过网络进行数据交换。它为应用层软件提供了发送和接收数据的能力,使得开发者可以在不用深入了解底层网络细节的情况下进行网络编程。socket英文直译为“插座”,可以理解为应用层调用网络服务的接口。

(2)套接字的组成

一个套接字主要由以下三个属性组成:

Ø 网络地址:通常是IP地址,用于标识网络上的设备。

Ø 端口号:用于标识设备上的特定应用或进程。端口号是一个16位的数字,范围从0到65535。

Ø 协议:如TCP(传输控制协议)或UDP(用户数据报协议),定义了数据传输的规则和格式。

(3)套接字的类型

根据数据传输方式的不同,主要有两种类型的套接字:

Ø 流套接字(Stream Sockets):基于TCP协议,提供面向连接、可靠的数据传输服务。数据像流水一样连续传输,接收方按发送顺序接收数据,适用于需要准确无误传输数据的应用,如网页服务器。

Ø 数据报套接字(Datagram Sockets):基于UDP协议,提供无连接的数据传输服务。每个报文段独立传输,可能会丢失或无法保证顺序,适用于对传输速度要求高但可以容忍一定丢包率的应用,如在线视频会议。

(4)套接字的工作原理

套接字通过封装TCP/IP协议细节,提供了一组API,允许应用程序创建套接字、绑定地址和端口、监听连接、接受连接、发送和接收数据等。在网络通信中,通常一个套接字负责监听和接受外部连接(服务器套接字),另一个套接字负责发起连接(客户端套接字)。

(5)为何使用套接字

套接字的引入极大地简化了网络编程的复杂度,使得开发者可以专注于应用逻辑的实现,而无需深入了解网络协议栈的内部工作原理。通过使用套接字,可以轻松实现不同计算机之间的数据交换,支持构建分布式系统和多种网络应用。

(6)socket函数

#include <sys/socket.h>
#include <sys/types.h>

/**
 * @brief 在通信域中创建一个未绑定的socket,并返回一个文件描述符,该描述符可以在后续对socket进行操作的函数调用中使用
 * 
 * @param domain 指定要创建套接字的通信域。
 *      AF_UNIX:本地通信,通常用于 UNIX 系统间的进程间通信。
 *      AF_LOCAL:AF_UNIX 的别名。
 *      AF_INET:IPv4 互联网协议。
 *      AF_AX25:业余无线电 AX.25 协议。
 *      AF_IPX:IPX - Novell 协议。
 *      AF_APPLETALK:AppleTalk 协议。
 *      AF_X25:ITU-T X.25 / ISO-8208 协议。
 *      AF_INET6:IPv6 互联网协议。
 *      AF_DECnet:DECnet 协议套接字。
 *      AF_KEY:密钥管理协议,最初用于 IPsec。
 *      AF_NETLINK:内核用户接口设备。
 *      AF_PACKET:低级报文段接口。
 *      AF_RDS:可靠数据报套接字(RDS)协议。
 *      AF_PPPOX:通用 PPP 传输层,用于设置 L2 隧道(L2TP 和 PPPoE)。
 *      AF_LLC:逻辑链路控制(IEEE 802.2 LLC)协议。
 *      AF_IB:InfiniBand 本机寻址。
 *      AF_MPLS:多协议标签交换。
 *      AF_CAN:控制器区域网络汽车总线协议。
 *      AF_TIPC:TIPC,“集群域套接字”协议。
 *      AF_BLUETOOTH:蓝牙低级套接字协议。
 *      AF_ALG:与内核加密 API 接口。
 *      AF_VSOCK:VSOCK(最初为“VMWare VSockets”)协议,用于虚拟机和宿主机之间的通信。
 *      AF_KCM:KCM(内核连接复用器)接口。
 *      AF_XDP:XDP(快速数据通道)接口。
 * @param type 指定要创建的socket类型
 *      SOCK_STREAM:提供序列化、可靠的、双向的、基于连接的字节流。可以支持带外数据传输机制。
 *      SOCK_DGRAM:支持数据报(无连接、不可靠的固定最大长度的消息)。
 *      SOCK_SEQPACKET:为固定最大长度的数据报提供了一个序列化的、可靠的、双向的基于连接的数据传输路径;消费者需要在每次输入系统调用中读取整个报文段。
 *      SOCK_RAW:提供原始的网络协议访问。
 *      SOCK_RDM:提供一个不保证顺序的可靠数据报层。
 * 自 Linux 2.6.27 以来,type 参数还具有第二个目的:除了指定套接字类型外,它还可以包含任何以下值的按位 OR,以修改 socket() 的行为:
 *      SOCK_NONBLOCK:在新文件描述符引用的打开文件描述符上设置 O_NONBLOCK 文件状态标志(参见 open(2))。使用此标志可以节省调用 fcntl(2) 来实现相同结果的额外调用。
 *      SOCK_CLOEXEC:在新文件描述符上设置关闭时执行(FD_CLOEXEC)标志。有关为什么可能有用的原因,请参阅 open(2) 中 O_CLOEXEC 标志的描述。
 * @param protocol 指定要与socket一起使用的特定协议。指定协议为 0 会导致 socket() 使用适用于所请求的socket类型的未指定的默认协议
 * @return int 文件描述符,如果失败返回-1
 */
int socket(int domain, int type, int protocol);

在下面的案例中,我们选择创建用于IPv4互联网协议(AF_INET)通信的SOCK_STREAM类型套接字。当套接字类型为SOCK_STREAM时,意味着创建的套接字用于提供一个序列化的、可靠的、双向的、基于连接的字节流。换言之,TCP/IP网络中传输的数据是一个连续的流,接收方收到的数据顺序与发送方发送的数据顺序相同。SOCK_STREAM类型的套接字通常使用以下协议:

Ø TCP (Transmission Control Protocol):这是最常用的SOCK_STREAM协议。TCP提供了一种可靠的数据传输服务,在数据传输过程中,它能自动处理确认接收、超时重传等多种情况,确保数据的正确传输。

Ø SCTP (Stream Control Transmission Protocol):SCTP是一种传输层协议,提供了类似TCP的可靠传输服务,但是在某些方面比TCP更先进,例如它支持多路径传输和多流传输。SCTP更多被应用于特定的领域,如电信网络。

我们将protocol设置为0,自动选择通信协议(TCP、SCTP)。

2)bind

/**
 * @brief 当使用 socket(2) 创建套接字时,它存在于一个名称空间(地址族)中,但没有为其分配地址。bind() 将由 addr 指定的地址分配给文件描述符 sockfd 所引用的套接字。addrlen 指定了 addr 指向的地址结构的大小(以字节为单位)。传统上,这个操作被称为“给套接字分配一个名称”
 * 
 * @param sockfd 套接字文件描述符
 * @param addr 指定的地址。地址的长度和格式取决于socket的地址族
 * @param addrlen addr 指向的地址结构的大小(以字节为单位)。
 * @return int 成功 0
 *             失败 -1
 */
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

(1)地址族

在网络编程中,地址族(Address Family)指定了套接字(socket)使用的网络协议类型以及地址的格式。简而言之,地址族决定了网络通信的范围和方式,比如是在同一台机器上的进程间通信,还是在网络上不同主机间的通信。每种地址族都支持特定类型的通信协议和地址格式。下面是一些常见的地址族:

AF_INET

代表IPv4网络协议的地址族,使用32位地址。

主要用于互联网上的通信。

地址格式通常为点分十进制,如192.168.1.1。

AF_INET6

代表IPv6网络协议的地址族,使用128位地址。

是IPv4的后继,旨在解决IPv4地址耗尽问题,并提供更多的功能。

地址格式为冒号分隔的十六进制,如2001:0db8:85a3:0000:0000:8a2e:0370:7334。

③ AF_UNIX (或 AF_LOCAL)

用于同一台机器上的进程间通信(IPC)。

使用文件系统路径名作为地址。

这种方式不通过网络层进行数据传输,而是在操作系统内部完成,因此效率较高。

④ AF_PACKET

用于直接访问底层网络设备的数据链路层。

允许发送和接收在网络设备驱动层的帧,常用于实现低级网络协议。

⑤ 其他地址族

还有其他一些特定用途的地址族,如AF_IPX(IPX协议)、AF_NETLINK(用于内核用户空间通信)等,它们各自支持不同的网络协议和地址格式。

开发一个需要在互联网上通信的应用程序,通常会选择AF_INET或AF_INET6地址族。

(2)函数说明

当我们通过上一节的socket()函数创建套接字之后,它存储在一个命名空间(地址族)中,但是并未绑定地址。bind()函数将addr指定的地址绑定到文件描述符sockfd对应的套接字。addrlen记录了addr指针指向的地址结构体占用的字节大小。

(3)地址相关数据类型声明

① struct sockaddr结构体声明如下。

/* 
在名称绑定中使用的规则在地址族之间有所不同。请参阅第 7 节中的手册条目以获取详细信息。
对于 AF_INET,请参阅 ip(7);
对于 AF_INET6,请参阅 ipv6(7);
对于 AF_UNIX,请参阅 unix(7);
对于 AF_APPLETALK,请参阅 ddp(7);
对于 AF_PACKET,请参阅 packet(7);
对于 AF_X25,请参阅 x25(7);
对于 AF_NETLINK,请参阅 netlink(7)。
传递给 addr 参数的实际结构取决于地址族。sockaddr 结构定义如下:
*/

struct sockaddr {
    sa_family_t sa_family; // 地址家族,如 AF_INET、AF_INET6、AF_UNIX 等
    char        sa_data[14]; // 用于存储具体地址数据的数组,其布局取决于地址
}
/*
此结构的唯一目的是将 addr 中传递的结构指针进行转换,以避免编译器警告。
*/

我们接下来的程序编写用的是IPV4协议,所以我们用的应该是AF_INET通信域。按照手册说明使用man 7 ip查看手册:

② sockaddr_in

/* 
IP 套接字地址被定义为 IP 接口地址和一个 16 位端口号的组合。基本的 IP 协议不提供端口号,它们由高层协议如 udp(7) 和 tcp(7) 实现。在原始套接字中,sin_port 被设置为 IP 协议。
*/

struct sockaddr_in {
    sa_family_t    sin_family; /* 地址族:AF_INET */
    in_port_t      sin_port;   /* 端口号,网络字节顺序 */
    struct in_addr sin_addr;   /* 互联网地址 */
};
/* 
sin_family 总是设置为 AF_INET。

sin_port 包含端口号,以网络字节顺序表示。低于 1024 的端口号称为特权端口(或有时称为:保留端口)。只有特权进程(在 Linux 中:具有 CAP_NET_BIND_SERVICE 用户命名空间中的权限,控制其网络命名空间)可以绑定到这些套接字。注意,原始 IPv4 协议本身没有端口的概念,它们仅由 tcp(7) 和 udp(7) 等高级协议实现。

sin_addr 是 IP 主机地址。

③ in_addr

/* 互联网地址 */
struct in_addr {
    uint32_t       s_addr;     /* 网络字节顺序中的地址 */
};
struct in_addr 的 s_addr 成员以网络字节顺序表示主机接口地址。应使用 htonl(3) 分配 INADDR_* 值(例如,INADDR_LOOPBACK)或使用 inet_aton(3)、inet_addr(3)、inet_makeaddr(3) 库函数或直接使用名称解析器(参见 gethostbyname(3))进行设置。
*/

(4)地址结构体说明

sockaddr 结构体是所有特定地址结构体(如 sockaddr_in 和 sockaddr_un)的基类,这些特定结构体继承了 sockaddr 的结构并添加了各自所需的额外信息

由于 struct sockaddr 是通用结构体,所以在实际使用中,通常会根据地址类型将其转换为相应的具体结构体类型(如 struct sockaddr_in 对于 IPv4 地址),以方便访问和操作地址信息。在调用诸如 bind(), connect(), accept() 等套接字函数时,通常需要使用 struct sockaddr * 类型的指针,并结合 socklen_t 类型的变量来传递和接收地址信息。

所以我们需要声明的地址结构体类型应为struct sockaddr_in,并按照网络字节序去设置IP地址和端口。

(5)其他数据类型

socklen_t相关宏定义如下

typedef __socklen_t socklen_t;
__STD_TYPE __U32_TYPE __socklen_t;
# define __STD_TYPE     typedef
#define __U32_TYPE      unsigned int

由上述定义可得,socklen_t实际上是__socklen_t的别名,而__socklen_t实质上是无符号整型的别名

typedef unsigned int __socklen_t;

综上,socklen_t是unsigned int的别名。

3)listen

/**
 * @brief 将 sockfd 指定的套接字标记为被动套接字,即将用于使用 accept(2) 接受传入的连接请求。
 * 
 * @param sockfd 套接字文件描述符
 * @param backlog 指定还未accpet但是已经完成链接的队列长度
 * @return int 成功 0 
 *             失败 -1
 */
int listen(int sockfd, int backlog);

(1)说明

listen()是Linux提供的用于TCP网络编程的系统调用,作用是让一个套接字进入监听状态,准备接受连接请求。当listen函数被调用之后,sockfd指定的套接字会从一个主动套接字转变为一个被动套接字,表明它将被用来接受进来的连接请求,而不是主动发起连接。accept函数随后用于响应连接请求。

(2)参数

Ø sockfd:监听连接请求的套接字文件描述符,要求该描述符已经通过bind函数绑定到一个本地地址。

Ø backlog:未被及时响应的连接可以被放入队列等待连接,该参数指定等待队列可以容纳的最大连接数。

(3)返回值

listen函数的返回值如下:

Ø 成功:返回0。

Ø 失败:返回-1,并设置全局变量errno以指示错误原因。

4)accept

/**
 * @brief 从监听套接字 sockfd 的待处理连接队列中提取第一个连接请求,创建一个新的连接套接字,并返回指向该套接字的新文件描述符。新创建的套接字不处于监听状态。原始套接字 sockfd 不受此调用的影响。
 * 
 * @param sockfd 一个使用 socket(2) 创建、使用 bind(2) 绑定到本地地址,并在 listen(2) 后监听连接的套接字。
 * @param addr 要么是一个空指针,要么是一个指向 sockaddr 结构的指针,用于返回连接socket的地址。
 * @param addrlen 如果 address 是空指针,则为一个空指针;如果 address 不是空指针,则为一个指向 socklen_t 对象的指针,该对象在调用前指定提供的 sockaddr 结构的长度,并在调用后指定存储地址的长度。
 * @return int 返回一个新的套接字文件描述符,用于与客户端通信,如果失败返回-1,并设置errno来表示错误原因
 */
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

5)connect

/**
 * @brief 由客户端调用,来与服务端建立连接。
 * 
 * @param sockfd 客户端套接字的文件描述符
 * @param addr 指向sockaddr结构体的指针,包含目的地地址信息
 * @param addrlen 指定addr指向的结构体的大小
 * @return int 成功 0 
 *             失败 -1,并设置errno以指示错误原因
 */
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

6)send

/**
 * @brief 用于向另一个套接字传输消息。
 * 
 * @param sockfd 发送套接字的文件描述符。
 * @param buf 发送缓冲区,并非操作系统分配为服务端和客户端分配的缓冲区,而是用户为了发送数据,自己维护的字节序列。const修饰表名它是“只读”的,即send函数不会修改这块内存的内容。
 * @param len 要发送的数据的字节长度。它决定了从buf指向的缓冲区中将发送多少数据。
 * @param flags flags 参数是以下标志之一或多个的按位或。对于大多数应用,这个参数被设置为0,表示不使用任何特殊行为。
 *      MSG_CONFIRM 告知链路层发生了前进:您从另一端收到了成功的回复。如果链路层没有收到此消息,它将定期重新探测邻居(例如,通过单播 ARP)。仅对 SOCK_DGRAM 和 SOCK_RAW sockets 有效,当前仅对 IPv4 和 IPv6 实现。有关详情,请参阅 arp(7)。
 *      MSG_DONTROUTE 不要使用网关发送报文段,只发送到直接连接的网络中的主机。通常仅由诊断或路由程序使用。仅为具有路由功能的协议族定义;报文段套接字不支持。
 *      MSG_DONTWAIT 启用非阻塞操作;如果操作会阻塞,则返回 EAGAIN 或 EWOULDBLOCK。这提供了类似于设置 O_NONBLOCK 标志(通过 fcntl(2) F_SETFL 操作)的行为,但不同之处在于 MSG_DONTWAIT 是一个每次调用的选项,而 O_NONBLOCK 是对打开文件描述符(参见 open(2))的设置,将影响调用进程中的所有线程以及持有引用相同打开文件描述符的其他进程。
 *      MSG_EOR 终止记录(当支持此概念时,例如 SOCK_SEQPACKET 类型的套接字)。
 *      MSG_MORE 调用方有更多数据要发送。此标志与 TCP sockets 一起使用,以获得与 TCP_CORK 套接字选项相同的效果(请参阅 tcp(7)),不同之处在于此标志可以基于每次调用设置。自 Linux 2.6 起,此标志还适用于 UDP sockets,并告知内核将使用此标志设置的所有调用发送的数据打包到单个数据报中,仅在执行不指定此标志的调用时才传输。(另请参阅 udp(7) 中描述的 UDP_CORK 套接字选项。)
 *      MSG_NOSIGNAL 如果面向流的套接字的对等端关闭了连接,则不生成 SIGPIPE 信号。仍会返回 EPIPE 错误。这提供了类似于使用 sigaction(2) 忽略 SIGPIPE 的行为,但 MSG_NOSIGNAL 是每次调用的特性,而忽略 SIGPIPE 设置了一个影响进程中的所有线程的进程属性。
 *      MSG_OOB 在支持此概念的套接字上发送带外数据(例如,类型为 SOCK_STREAM 的套接字);底层协议还必须支持带外数据。
 * @return ssize_t成功发送的字节数。如果出现错误,它将返回-1,并设置errno以指示错误的具体原因。
 */
ssize_t send(int sockfd, const void *buf, size_t len, int flags);

7)recv

/**
 * @brief 从套接字关联的连接中接收数据。
 * 
 * @param sockfd 套接字文件描述符。
 * @param buf 接收缓冲区,同样地,此处也并非内核维护的缓冲区。
 * @param len 缓冲区长度,即buf可以接收的最大字节数。
 * @param flags flags 参数是以下标志之一或多个的按位或。对于大多数应用,这个参数被设置为0,表示不使用任何特殊行为。
 *      MSG_DONTWAIT 启用非阻塞操作;如果操作会阻塞,则调用失败
 *      MSG_ERRQUEUE 此标志指定应该从套接字错误队列中接收排队的错误。
 *      MSG_OOB 此标志请求接收在正常数据流中不会接收到的带外数据。
 *      MSG_PEEK 此标志导致接收操作从接收队列的开头返回数据,而不从队列中删除该数据。因此,后续的接收调用将返回相同的数据。
 *      MSG_TRUNC 对于原始(AF_PACKET)、Internet 数据报、netlink和 UNIX 数据报套接字:返回报文段或数据报的实际长度,即使它比传递的缓冲区长。
 *      MSG_WAITALL 此标志请求操作阻塞,直到满足完整的请求。
 * @return ssize_t 返回接收到的字节数,如果连接已经正常关闭,返回值将是0。如果出现错误,返回-1,并且errno变量将被设置为指示错误的具体原因。
 */
ssize_t recv(int sockfd, void *buf, size_t len, int flags);

8)shutdown

/**
 * @brief关闭套接字的一部分或全部连接
 * 
 * @param sockfd 套接字文件描述符
 * @param how 指定关闭的类型。其取值如下:
 *      SHUT_RD:关闭读。之后,该套接字不再接收数据。任何当前阻塞在recv调用上的操作都将返回0,表示连接的另一端已经关闭。
 *      SHUT_WR:关闭写。之后,试图通过该套接字发送数据将导致错误。如果使用此选项,TCP连接将发送一个FIN包给连接的对端,表明此方向上的数据传输已经完成。此时对端的recv调用将接收到0。
 *      SHUT_RDWR:关闭读写。同时关闭套接字的读取和写入部分,等同于分别调用SHUT_RD和SHUT_WR。之后,该套接字既不能接收数据也不能发送数据。
 * @return int 成功 0 
 *             失败 -1,并设置errno变量以指示具体的错误原因。
 */
int shutdown(int sockfd, int how);

9)close

#include <unistd.h>

/*
    用于关闭一个之前通过open()、socket()等函数打开的文件描述符
    int __fd: 这是一个整数值,表示要关闭的文件描述符
    return: 成功关闭文件描述符时,close()函数返回0
            发送失败,例如试图关闭一个已经关闭的文件描述符或系统资源不足,close()会返回-1
*/
int close(int __fd);

TCP通信中,套接字也是通过文件描述符操控的,底层同样存储在struct file结构体中,socket相关的数据存在该类型结构体实例的私有数据字段,因此,我们通过close()关闭套接字,效果和关闭文件是类似的,都是使得底层文件描述的引用计数减一,若引用计数减为0则释放套接字相关的资源。

6.3.8 网络字节序和主机字节序转化

1)两种字节序

在网络编程中,特别是在跨平台和网络通信时,字节序(Byte Order)是非常重要的概念。字节序指的是多字节数据在内存中的存储顺序。主要有两种字节序:

(1)大端字节序(Big-Endian):高位字节存储在内存的低地址处,低位字节存储在高地址处。这种字节序遵循自然数字的书写习惯,也被称为网络字节序(Network Byte Order)或网络标准字节序,因为它在网络通信中被广泛采用,如IP协议就要求使用大端字节序。

(2)小端字节序(Little-Endian):低位字节存储在内存的低地址处,高位字节存储在高地址处。这是Intel x86-64架构以及其他一些现代处理器普遍采用的字节序,称为主机字节序(Host Byte Order)。

2)相关函数

(1)使用man 3 htonl可得

#include <arpa/inet.h>

/**
 * @brief 将无符号整数 hostlong 从主机字节顺序(h)转换为网络字节顺序(n)。
 */
uint32_t htonl(uint32_t hostlong);

/**
 * @brief 将无符号短整数 hostshort 从主机字节顺序(h)转换为网络字节顺序(n)。
 */
uint16_t htons(uint16_t hostshort);

/**
 * @brief 将无符号整数 netlong 从网络字节顺序(n)转换为主机字节顺序(h)。
 */
uint32_t ntohl(uint32_t netlong);

/**
 * @brief 将无符号短整数 netshort 从网络字节顺序(n)转换为主机字节顺序(h)。
 */
uint16_t ntohs(uint16_t netshort);

在网络通信中,为了让不同字节序的主机能够相互理解对方的数据,常常需要进行字节序转换。例如,发送数据前需要将主机字节序转换为网络字节序,接收数据后则需要将网络字节序转换为主机字节序。例如,如果你有一个IP地址或端口号(通常存储为整数)需要在网络上传输,就需要先使用htons()或htonl()将其转换为网络字节序,然后在网络另一端接收时,使用ntohs()或ntohl()将其转换回主机字节序。这样可以确保数据在网络中的传输不受不同主机字节序的影响。

笔者用于测试的PC架构是x86-64,因此主机字节序为小端字节序。网络字节序为大端字节序。

(2)使用man 3 inet_aton可得

#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

/**
 * @brief 将来自 IPv4 点分十进制表示法的 Internet 主机地址 cp 转换为二进制形式(以网络字节顺序)并将其存储在 inp 指向的结构体中。
 * @return int 成功返回 1;失败 返回 0
 */
int inet_aton(const char *cp, struct in_addr *inp);

/**
 * @brief 将来自 IPv4 点分十进制表示法的 Internet 主机地址 cp 转换为网络字节顺序的二进制数据。
 * @return 如果输入无效,则返回 INADDR_NONE(通常为 -1)。使用此函数存在问题,因为 -1 是一个有效地址(255.255.255.255)。请优先使用 inet_aton()、inet_pton(3) 或 getaddrinfo(3),它们提供了更清晰的错误返回方式。
 */
in_addr_t inet_addr(const char *cp);

/**
 * @brief 函数将字符串 cp(以 IPv4 点分十进制表示法表示)转换为适合用作 Internet 网络地址的主机字节顺序中的数字。
 * @return in_addr_t 成功时,返回转换后的地址。如果输入无效,则返回 -1。
 */
in_addr_t inet_network(const char *cp);

/* @brief   字符串格式转换为sockaddr_in格式
   @param int af: 通常为 AF_INET 用于IPv4地址,或 AF_INET6 用于IPv6地址
   @param  char *src: 包含IP地址字符串的字符数组,如果是IPv4地址,格式为点分十进制(如 "192.168.1.1");如果是IPv6地址,格式为冒号分隔的十六进制表示(如 "2001:0db8:85a3:0000:0000:8a2e:0370:7334")
   @param  void *dst:指向一个足够大的缓冲区(对于IPv4是一个struct in_addr结构体,对于IPv6是一个struct in6_addr结构体),用于存储转换后的二进制IP地址
   @return int : 成功转换返回0; 输入地址错误返回1;发生错误返回-1
*/
int inet_pton(int af, const char *src, void *dst);


/**
 * @brief 
 * 
 * @param in 将以网络字节顺序给出的 Internet 主机地址 in 转换为 IPv4 点分十进制表示法的字符串。字符串存储在静态分配的缓冲区中,后续调用将覆盖该缓冲区。
 * @return char* 缓冲区指针
 */
char *inet_ntoa(struct in_addr in);

/**
 * @brief 是 inet_netof() 和 inet_lnaof() 的反函数。它返回一个以网络字节顺序表示的 Internet 主机地址,由主机字节顺序中的网络号 net 和本地地址 host 组成。
 */
struct in_addr inet_makeaddr(in_addr_t net, in_addr_t host);

/**
 * @brief 返回 Internet 地址 in 的本地网络地址部分。返回的值以主机字节顺序表示。
 */
in_addr_t inet_lnaof(struct in_addr in);

/**
 * @brief 返回 Internet 地址 in 的网络号部分。返回的值以主机字节顺序表示。

 */
in_addr_t inet_netof(struct in_addr in);

3)测试例程

(1)创建num_endianess_convert.c

#include <sys/socket.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <pthread.h>

int main(int argc, char const *argv[])
{
    
    //网络地址赋值
    unsigned short local_num = 0x1F, network_num = 0;
    network_num = htons(local_num);
    printf("将主机字节序的无符号整数: 0x%hX 转换为网络字节序的结果为: 0x%hX\n", local_num, network_num);

    local_num = ntohs(network_num);
    printf("将网络字节序的无符号整数: 0x%hX 转换为主机字节序的结果为: 0x%hX\n", network_num, local_num);
    
    return 0;
}

(2)创建Makefile,写入以下内容

CC:=gcc

num_endianess_convert: num_endianess_convert.c
    -$(CC) -o $@ $^
    -./$@
    -rm ./$@

(3)运行

image-20260823215547382

可以看到,大端序和小端序的转换是以字节为单位进行的。

(4)创建inet_endian_convert.c

#include <sys/socket.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <pthread.h>

int main(int argc, char const *argv[])
{

    // 网络地址赋值
    struct sockaddr_in server_addr;
    struct in_addr server_in_addr;
    in_addr_t server_in_addr_t;
    memset(&server_addr, 0, sizeof(server_addr));
    memset(&server_in_addr, 0, sizeof(server_in_addr));
    memset(&server_in_addr_t, 0, sizeof(server_in_addr_t));

    // 打印16进制IP地址作为对照
    printf("192.168.6.101 的16进制表示为 0x%X 0x%X 0x%X 0x%X\n", 192, 168, 6, 101);

    // 不推荐使用,因为输入-1返回的地址是一个有效地址(255.255.255.255)
    server_in_addr_t = inet_addr("192.168.6.101");
    printf("inet_addr convert: 0x%X\n", server_in_addr_t);

    inet_aton("192.168.6.101", &server_in_addr);
    printf("inet_aton convert: 0x%X\n", server_in_addr.s_addr);

    // 推荐使用
    // 字符串转sockin_addr结构体
    inet_pton(AF_INET, "192.168.6.101", &server_addr.sin_addr);
    printf("inet_pton 后 server_addr.sin_addr 的16进制表示为 0x%X\n", server_addr.sin_addr.s_addr);

    // 结构体转化为字符串
    printf("通过inet_ntoa打印inet_pton转化后的地址: %s\n", inet_ntoa(server_addr.sin_addr));

    // 打印本地网络地址部分
    printf("local net section: 0x%X\n", inet_lnaof(server_addr.sin_addr));
    
    // 打印网络号部分
    printf("netword number section: 0x%X\n", inet_netof(server_addr.sin_addr));

    // 使用本地网络地址和网络号可以拼接成in_addr
    server_addr.sin_addr = inet_makeaddr(inet_netof(server_addr.sin_addr), 102);

    // 以网络字节序16进制打印拼接的地址
    printf("inet_makeaddr: 0x%X\n", server_addr.sin_addr.s_addr);
    // 打印拼接的地址
    printf("通过inet_ntoa打印inet_makeaddr拼接后的地址%s\n", inet_ntoa(server_addr.sin_addr));
    return 0;
}

(5)Makefile

inet_endian_convert: inet_endian_convert.c
    -$(CC) -o $@ $^
    -./$@
    -rm ./$@

(6)运行

image-20260823215658382

6.4 传输层-基于TCP协议的程序

6.4.1 只接受一个连接的范例程序

为便于分析连接建立及销毁过程,本例程仅支持同时建立单个TCP连接。

1)服务端(single_conn_server.c)

#include <sys/socket.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <arpa/inet.h>
#include <pthread.h>
#include <unistd.h>

#define handle_error(cmd, result) \
    if (result < 0)               \
    {                             \
        perror(cmd);              \
        return -1;                \
    }

void *read_from_client(void *argv)
{
    int client_fd = *(int *)argv;
    char *read_buf = NULL;
    ssize_t count = 0;

    read_buf = malloc(sizeof(char) * 1024);
    if (!read_buf)
    {
        perror("malloc server read_buf");
        return NULL;
    }

    while ((count = recv(client_fd, read_buf, 1024, 0)))
    {
        if (count < 0)
        {
            perror("recv");
        }
        fputs(read_buf, stdout);
    }

    printf("客户端请求关闭连接......\n");
    free(read_buf);

    return NULL;
}

void *write_to_client(void *argv)
{
    int client_fd = *(int *)argv;
    char *write_buf = NULL;
    ssize_t send_count = 0;

    write_buf = malloc(sizeof(char) * 1024);
    if (!write_buf)
    {
        printf("写缓存分配失败,断开连接\n");
        shutdown(client_fd, SHUT_WR);
        perror("malloc server write_buf");
        return NULL;
    }
    while (fgets(write_buf, 1024, stdin) != NULL)
    {
        send_count = send(client_fd, write_buf, 1024, 0);
        if (send_count < 0)
        {
            perror("send");
        }
    }

    printf("接收到命令行的终止信号,不再写入,关闭连接......\n");
    shutdown(client_fd, SHUT_WR);
    free(write_buf);

    return NULL;
}

int main(int argc, char const *argv[])
{
    int sockfd, temp_result, client_fd;
    pthread_t pid_read, pid_write;

    struct sockaddr_in server_addr, client_addr;

    memset(&server_addr, 0, sizeof(server_addr));
    memset(&client_addr, 0, sizeof(client_addr));

    // 声明IPV4通信协议
    server_addr.sin_family = AF_INET;
    // 我们需要绑定0.0.0.0地址,转换成网络字节序后完成设置
    server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
    // 端口随便用一个,但是不要用特权端口
    server_addr.sin_port = htons(6666);

    // 创建server socket
    sockfd = socket(AF_INET, SOCK_STREAM, 0);
    handle_error("socket", sockfd);

    // 绑定地址
    temp_result = bind(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr));
    handle_error("bind", temp_result);

    // 进入监听模式
    temp_result = listen(sockfd, 128);
    handle_error("listen", temp_result);

    // 接受第一个client连接
    socklen_t cliaddr_len = sizeof(client_addr);

    client_fd = accept(sockfd, (struct sockaddr *)&client_addr, &cliaddr_len);
    handle_error("accept", client_fd);

    printf("与客户端 from %s at PORT %d 文件描述符 %d 建立连接\n",
           inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port), client_fd);

    // 启动一个子线程,用来读取客户端数据,并打印到 stdout
    pthread_create(&pid_read, NULL, read_from_client, (void *)&client_fd);
    // 启动一个子线程,用来从命令行读取数据并发送到客户端
    pthread_create(&pid_write, NULL, write_to_client, (void *)&client_fd);

    // 阻塞主线程
    pthread_join(pid_read, NULL);
    pthread_join(pid_write, NULL);

    printf("释放资源\n");
    close(client_fd);
    close(sockfd);

    return 0;
}

2)客户端(single_conn_client.c)

#include <sys/socket.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <pthread.h>

// 192.168.10.150 IP 地址的16进制表示
#define INADDR_LOCAL 0xC0A80A96

#define handle_error(cmd, result) \
    if (result < 0)               \
    {                             \
        perror(cmd);              \
        return -1;                \
    }

void *read_from_server(void *argv)
{
    int sockfd = *(int *)argv;
    char *read_buf = NULL;
    ssize_t count = 0;

    read_buf = malloc(sizeof(char) * 1024);
    if (!read_buf)
    {
        perror("malloc client read_buf");
        return NULL;
    }

    while (count = recv(sockfd, read_buf, 1024, 0))
    {
        if (count < 0)
        {
            perror("recv");
        }
        fputs(read_buf, stdout);
    }

    printf("收到服务端的终止信号......\n");
    free(read_buf);

    return NULL;
}

void *write_to_server(void *argv)
{
    int sockfd = *(int *)argv;
    char *write_buf = NULL;
    ssize_t send_count = 0;

    write_buf = malloc(sizeof(char) * 1024);

    if (!write_buf)
    {
        printf("写缓存分配失败,断开连接\n");
        shutdown(sockfd, SHUT_WR);
        perror("malloc client write_buf");

        return NULL;
    }

    while (fgets(write_buf, 1024, stdin) != NULL)
    {
        send_count = send(sockfd, write_buf, 1024, 0);
        if (send_count < 0)
        {
            perror("send");
        }
    }

    printf("接收到命令行的终止信号,不再写入,关闭连接......\n");
    shutdown(sockfd, SHUT_WR);
    free(write_buf);

    return NULL;
}

int main(int argc, char const *argv[])
{
    int sockfd, temp_result;
    pthread_t pid_read, pid_write;

    struct sockaddr_in server_addr, client_addr;

    memset(&server_addr, 0, sizeof(server_addr));
    memset(&client_addr, 0, sizeof(client_addr));

    server_addr.sin_family = AF_INET;
    // 连接本机 127.0.0.1
    server_addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
    // 连接端口 6666
    server_addr.sin_port = htons(6666);

    client_addr.sin_family = AF_INET;
    // 连接本机 192.168.10.150
    client_addr.sin_addr.s_addr = htonl(INADDR_LOCAL);
    // 连接端口 8888
    client_addr.sin_port = htons(8888);

    // 创建socket
    sockfd = socket(AF_INET, SOCK_STREAM, 0);
    handle_error("socket", sockfd);

    temp_result = bind(sockfd, (struct sockaddr *)&client_addr, sizeof(client_addr));
    handle_error("bind", temp_result);

    // 连接server
    temp_result = connect(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr));
    handle_error("connect", temp_result);

    // 启动一个子线程,用来读取服务端数据,并打印到 stdout
    pthread_create(&pid_read, NULL, read_from_server, (void *)&sockfd);
    // 启动一个子线程,用来从命令行读取数据并发送到服务端
    pthread_create(&pid_write, NULL, write_to_server, (void *)&sockfd);

    // 阻塞主线程
    pthread_join(pid_read, NULL);
    pthread_join(pid_write, NULL);

    printf("关闭资源\n");
    close(sockfd);

    return 0;
}

在上述例程中,我们将客户端绑定到了192.168.10.150的8888端口,192.168.10.150实际上是本机IP,此处等价于localhost或127.0.0.1。此外,通常服务端不需要绑定到具体的IP和端口,如果不绑定,启动后会操作系统会随机为客户端分配本机的某个端口。我们这里将客户端绑定至指定的IP和端口,主要是为了在分析时便于区分客户端和服务端,实际的客户端程序完全可以省去这一步。

3)编译并测试

(1)Makefile

在Makefile开头补充伪目标定义和变量定义

.PHONY: single_conn single_conn_clean

single_conn_executables:=single_conn_server single_conn_client

Makefile末尾补充目标声明

single_conn_server: single_conn_server.c
    -$(CC) -o $@ $^
single_conn_client: single_conn_client.c
    -$(CC) -o $@ $^

single_conn: $(single_conn_executables)

single_conn_clean:
    -rm ./$(word 1, $(single_conn_executables)) ./$(word 2, $(single_conn_executables))

(2)测试

通过Makefile编译后,执行以下操作。

① 开启两个XShell标签页,然后“垂直排列”

image-20260824184612150

效果如下

image-20260824184629499

② 分别在两个窗口中开启服务端和客户端

先开服务端

image-20260824184644880

再开客户端

image-20260824184702663

④ 服务端向客户端发送数据

image-20260824184719213

⑤ 客户端向服务端发送数据

image-20260824184735327

⑥ 在客户端窗口按下Ctrl+D,断开连接

image-20260824184751273

⑦ 在服务端按下Ctrl+D,断开连接

image-20260824184808060

6.4.2 关于缓冲区的补充说明

1)进程的I/O缓冲区

进程的缓冲区机制是一种中间存储技术,用于在数据处理和传输中暂存数据。对于提高计算效率、优化资源利用、以及确保数据在处理和传输过程中的稳定性和可靠性至关重要。

进程的I/O缓冲区用于文件读写或通过标准输入输出与终端交互。分为输入缓冲区和输出缓冲区。

(1)输入缓冲区存储从文件或其他外部源接收的数据,直到进程准备好处理它。

(2)输出缓冲区暂存要发送到外部设备或系统的数据,直到能够执行写操作。

2)网络编程中的缓冲区

网络编程中,尤其是使用socket通信时,内核会在内核空间维护网络缓冲区,它也分为输入输出缓冲区。与I/O缓冲区不同,网络缓冲区的管理是由内核来完成的,用户无法干预。当然,用户可以在应用程序中通过在堆空间动态分配内存的方式构建额外的缓冲区。

(1)数据接收

当网络数据到达网络接口(如以太网卡)时,数据首先被操作系统的网络驱动接收。操作系统会将这些数据存储在内核空间的接收缓冲区中。应用程序之后通过读取系统调用(如recv或read),从内核空间的缓冲区中获取这些数据。

(2)数据发送

当应用程序想要发送数据时,它通过写入系统调用(如send或write)将数据传递给内核。内核将这些数据存放在内核空间的发送缓冲区中,然后根据网络协议的需要适时将数据发送到网络上。

3)进程的缓冲模式

进程在执行I/O操作时,可以有以下三种类型的缓冲模式。

(1)行缓冲(Line Buffering)

在这种模式下,碰到换行符或缓冲区已满时刷写到目标文件。

行缓冲通常用于标准输出(stdout),尤其是当标准输出关联到终端(控制台)时。这样做可以确保用户每输入一行命令就能看到响应的输出,而不必等到缓冲区完全填满。

(2)全缓冲(Fully Buffering)

在全缓冲模式下,数据只会在缓冲区满时刷新,这意味着数据被存储在缓冲区直到缓冲区被填满,然后整个缓冲区的内容一次性刷写。

通常向文件写数据的默认模式就是全缓冲,这样可以减少对底层系统资源的调用次数,提高数据处理效率。

(3)无缓冲(No Buffering)

无缓冲模式意味着数据直接刷写,不经过缓冲区。

标准错误(stderr)通常是无缓冲的,以确保错误信息能够立即输出。

(4)设置缓冲模式

在C语言中,可以通过调用setvbuf()函数来设置文件流的缓冲模式。

4)相关函数

(1)setvbuf()

#include <stdio.h>

/**
 * @brief 设置文件流的缓冲模式
 * 
 * @param stream 要设置的文件流,可以是输入缓冲流也可以是输出缓冲流
 * @param buf 指向缓冲区的指针,这个参数为NULL则自动分配缓冲区。
 * @param mode 缓冲区模式
 *        _IOFBF:全缓冲,数据会存储在缓冲区中直到缓冲区满。
 *        _IOLBF:行缓冲,数据会存储在缓冲区中直到碰到换行符或缓冲区满。
 *        _IONBF:无缓冲,输出操作将直接从调用进程到目标设备,不经过缓冲区。
 * @param size 缓冲区大小,以字节为单位。size为0时,
 *        如果buf为NULL,则标准C库将为该文件流自动分配一个默认大小的缓冲区
 *        如果buf不为NULL,将size设置为0不合逻辑,行为是未定义的
 * @return int 成功返回0,失败返回非零值
 */
int setvbuf(FILE *stream, char *buf, int mode, size_t size);

(2)fflush()

#include <stdio.h>

/**
 * @brief 刷新一个流,如果是输出流,强制将用户空间所有缓冲的数据输出到下游设备或目标文件
 *                   如果是输入流,若是与可寻址文件关联的输入流(不包含管道和终端),丢弃缓冲区未被应用消费的数据
 *                                对管道和终端关联的输入流执行fflush()通常不会有任何效果
 *        fflush() 主要是用来处理输出流的
 * @param stream 待刷新的数据流,如果为NULL,则刷新所有打开的输出流。
 * @return int 成功返回0.失败返回EOF,并设置errno
 */
int fflush(FILE *stream);

5)案例

(1)写文件设置无缓冲模式

① 创建without_buf_test.c

#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <fcntl.h>

int main() {
    FILE *file = fopen("/home/atguigu/socket_test/testfile.txt", "w");
    if (file == NULL) {
        perror("Failed to open file");
        return 1;
    }

    // 设置文件为行缓冲模式
    if (setvbuf(file, NULL, _IONBF, 0) != 0) {
        perror("Failed to set buffer mode");
        return 1;
    }

    fprintf(file, "hello");

    char *argv[] = {"/usr/bin/ping", "-c", "1", "www.baidu.com", NULL};
    char *envp[] = {NULL};

    execve("/usr/bin/ping", argv, envp);
    perror("execve");

    return 0;
}

上述代码中,我们将file设置为无缓冲模式,数据被立即写入testfile.txt。

② Makefile

without_buf_test: without_buf_test.c
    -$(CC) -o $@ $^
    -./$@
    -rm ./$@

③ 运行

image-20260824185033780

(2)写文件设置行缓冲模式

清空testfile.txt。

① 创建line_buf_test.c

#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <fcntl.h>

int main() {
    FILE *file = fopen("/home/atguigu/socket_test/testfile.txt", "w");
    if (file == NULL) {
        perror("Failed to open file");
        return 1;
    }

    // 设置文件为行缓冲模式
    if (setvbuf(file, NULL, _IOLBF, 0) != 0) {
        perror("Failed to set buffer mode");
        return 1;
    }

    fprintf(file, "hello");

    char *argv[] = {"/usr/bin/ping", "-c", "1", "www.baidu.com", NULL};
    char *envp[] = {NULL};

    execve("/usr/bin/ping", argv, envp);
    perror("execve");

    return 0;
}

② Makefile

line_buf_test: line_buf_test.c
    -$(CC) -o $@ $^
    -./$@
    -rm ./$@

③ 运行

image-20260824185148847

此时testfile.txt为空,因为我们没有写入换行符。

(3)行缓冲模式数据末尾有换行符

① 创建line_buf_with_enter_test.c

在上述案例的基础上,在hello后面添加换行符\n。

#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <fcntl.h>

int main() {
    FILE *file = fopen("/home/atguigu/socket_test/testfile.txt", "w");
    if (file == NULL) {
        perror("Failed to open file");
        return 1;
    }

    // 设置文件为行缓冲模式
    if (setvbuf(file, NULL, _IOLBF, 0) != 0) {
        perror("Failed to set buffer mode");
        return 1;
    }

    fprintf(file, "hello\n");

    char *argv[] = {"/usr/bin/ping", "-c", "1", "www.baidu.com", NULL};
    char *envp[] = {NULL};

    execve("/usr/bin/ping", argv, envp);
    perror("execve");

    return 0;
}

② Makefile

line_buf_with_enter_test: line_buf_with_enter_test.c
    -$(CC) -o $@ $^
    -./$@
    -rm ./$@

③ 运行

image-20260824185826212

testfile.txt会写入hello和换行符。

(4)写文件设置全缓冲模式

清空testfile.txt。

① 创建full_buf_test.c

#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <fcntl.h>

int main() {
    FILE *file = fopen("/home/atguigu/socket_test/testfile.txt", "w");
    if (file == NULL) {
        perror("Failed to open file");
        return 1;
    }

    // 设置文件为全缓冲模式
    if (setvbuf(file, NULL, _IOFBF, 0) != 0) {
        perror("Failed to set buffer mode");
        return 1;
    }

    fprintf(file, "hello\n");

    char *argv[] = {"/usr/bin/ping", "-c", "1", "www.baidu.com", NULL};
    char *envp[] = {NULL};

    execve("/usr/bin/ping", argv, envp);
    perror("execve");

    return 0;
}

② Makefile

full_buf_test: full_buf_test.c
    -$(CC) -o $@ $^
    -./$@
    -rm ./$@

③ 运行

image-20260824185945973

此时testfile.txt为空,因为设置了全缓冲模式,即便有换行符也不会触发刷写操作。

(5)全缓冲模式下手动触发刷写操作

① 创建full_buf_fflush_test.c

#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <fcntl.h>

int main() {
    FILE *file = fopen("/home/atguigu/socket_test/testfile.txt", "w");
    if (file == NULL) {
        perror("Failed to open file");
        return 1;
    }

    // 设置文件为全缓冲模式
    if (setvbuf(file, NULL, _IOFBF, 0) != 0) {
        perror("Failed to set buffer mode");
        return 1;
    }

    fprintf(file, "hello\n");

    fflush(file);

    char *argv[] = {"/usr/bin/ping", "-c", "1", "www.baidu.com", NULL};
    char *envp[] = {NULL};

    execve("/usr/bin/ping", argv, envp);
    perror("execve");

    return 0;
}

② Makefile

full_buf_fflush_test: full_buf_fflush_test.c
    -$(CC) -o $@ $^
    -./$@
    -rm ./$@

③ 运行

image-20260824190057591

添加了fflush(),即便设置为全缓冲模式,hello\n也会被刷写入目标文件,此时testfile.txt中可以看到hello和换行。

6.4.3 连接建立及断开过程分析

1)命令行查看TCP进程

我们可以通过netstat命令查看当前计算机中正在运行的TCP进程。

atguigu@ubuntu:~/helloworld$ sudo netstat -tanlp

上述命令可以列出当前主机中所有活跃的TCP进程。

Ø -t:仅显示TCP连接。

Ø -a:显示所有套接字。

Ø -n:以数字形式显示地址和端口号,不尝试解析名称。

Ø -l:显示正在监听的服务端口。

Ø -p:显示监听某端口的进程的PID和名称。

效果如下。

image-20260824203047027

atguigu@ubuntu:~/helloworld$ sudo netstat -tanlp | grep 8888

启动上面的客户端和服务端,执行上述命令,我们可以根据端口号筛选相关进程。

效果如下:

image-20260824203117310

2)分析

(1)安装wireshark

wireshark是一个抓包工具,可以捕获基于TCP客户端和服务端发送的报文段。

atguigu@ubuntu:~/helloworld$ sudo apt install wireshark

(2)打开wireshark

wireshark有图形化界面,XShell不支持。在VM Ware中开启终端,输入以下命令打开wireshark。

atguigu@ubuntu:~$ sudo wireshark

双击Loopback:lo打开回环网卡的监控页面

image-20260824203232548

image-20260824203249356

(3)配置过滤条件

image-20260824203316540

在wireshark页面上方的对话框中输入“tcp”后回车,表示仅抓取基于TCP协议通讯的报文段,如上图所示。

(4)启动服务端

启动服务端后,查看wrieshark监控页面的报文段

image-20260824203346802

启动服务端,连接尚未建立,所以wireshark不会抓到报文段

(5)启动客户端

image-20260824203414278

① 查看wireshark监控页面

image-20260824203431941

可以清晰地看到三次握手的过程,客户端首先发送SYN标志为1的报文段请求连接,然后服务端返回SYN及ACK标志均为1的报文段做应答,最后客户端发回ACK为1的报文段应答服务端,三次握手完成,TCP连接建立成功。

② 通过命令行查看

再开启一个XShell标签页,执行以下命令

atguigu@ubuntu:~/helloworld$ sudo netstat -tanlp | grep 8888

image-20260824203503341

可以看到,客户端与服务端均处于ESTABLISHED状态,连接已建立,准备好发送数据。

(6)收发数据测试

① 客户端和服务端分别向对方发送一条数据

image-20260824203537359

② 查看wireshark监控页面

image-20260824203554049

可以看到两组报文段,分别为“服务端发送,客户端响应”以及“客户端发送,服务端响应”。

③ 查看客户端和服务端状态

image-20260824203611351

状态不变。

(7)客户端通过Ctrl+D发送终止信号

image-20260824203628746

① 查看wireshark

image-20260824203643802

② 此时客户端无法继续发送数据

所有从stdin输入的数据都会被丢弃。

③ 服务端仍可发送数据,客户端仍可接收

image-20260824203705556

④ 查看wireshark

image-20260824203733600

⑤ 查看客户端和服务端状态

image-20260824203751288

此时,服务端处于CLOSE_WAIT状态,客户端处于FIN-WAIT2状态。

第一次挥手之后,内核控制被动关闭的一方立即响应,因此FIN-WAIT1持续时间极短,基本无法通过上述方式看到这个状态。

(8)服务端通过Ctrl+D发送终止信号

image-20260824203824137

① 查看wireshark

image-20260824203839377

② 查看客户端和服务端状态

image-20260824203859957

服务端资源已释放,看不到相关信息,客户端进入TIME_WAIT状态,持续时间为2MSL。之后客户端资源也会释放。

6.4.4 服务端基于多线程的支持多个连接的范例程序

1)服务端(multi_conn_threads_server.c)

#include <sys/socket.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <arpa/inet.h>
#include <pthread.h>
#include <unistd.h>

#define handle_error(cmd, result) \
    if (result < 0)               \
    {                             \
        perror(cmd);              \
        return -1;                \
    }

void *read_from_client_then_write(void *argv)
{
    int client_fd = *(int *)argv;

    ssize_t count = 0, send_count = 0;
    char *read_buf = NULL;
    char *write_buf = NULL;

    read_buf = malloc(sizeof(char) * 1024);
    // 判断内存是否分配成功
    if (!read_buf)
    {
        printf("服务端读缓存创建异常,断开连接\n");
        shutdown(client_fd, SHUT_WR);
        close(client_fd);
        perror("malloc sever read_buf");
        return NULL;
    }

    // 判断内存是否分配成功
    write_buf = malloc(sizeof(char) * 1024);
    if (!write_buf)
    {
        printf("服务端写缓存创建异常,断开连接\n");
        free(read_buf);
        shutdown(client_fd, SHUT_WR);
        close(client_fd);
        perror("malloc server write_buf");
        return NULL;
    }

    while ((count = recv(client_fd, read_buf, 1024, 0)))
    {
        if (count < 0)
        {
            perror("recv");
        }
        printf("reveive message from client_fd: %d: %s\n", client_fd, read_buf);

        strcpy(write_buf, "reveived~\n");
        send_count = send(client_fd, write_buf, 1024, 0);
        if (send_count < 0)
        {
            perror("send");
        }
    }

    printf("客户端client_fd: %d请求关闭连接......\n", client_fd);
    strcpy(write_buf, "receive your shutdown signal\n");

    send_count = send(client_fd, write_buf, 1024, 0);
    if (send_count < 0)
    {
        perror("send");
    }

    printf("释放client_fd: %d资源\n", client_fd);
    shutdown(client_fd, SHUT_WR);
    close(client_fd);
    free(read_buf);
    free(write_buf);

    return NULL;
}

int main(int argc, char const *argv[])
{
    int sockfd, temp_result;

    struct sockaddr_in server_addr, client_addr;

    memset(&server_addr, 0, sizeof(server_addr));
    memset(&client_addr, 0, sizeof(client_addr));

    // 声明IPV4通信协议
    server_addr.sin_family = AF_INET;
    // 我们需要绑定0.0.0.0地址,转换成网络字节序后完成设置
    server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
    // 端口随便用一个,但是不要用特权端口
    server_addr.sin_port = htons(6666);

    // 创建server socket
    sockfd = socket(AF_INET, SOCK_STREAM, 0);
    handle_error("socket", sockfd);

    // 绑定地址
    temp_result = bind(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr));
    handle_error("bind", temp_result);

    // 进入监听模式
    temp_result = listen(sockfd, 128);
    handle_error("listen", temp_result);

    socklen_t cliaddr_len = sizeof(client_addr);

    // 接受client连接
    while (1)
    {
        int client_fd = accept(sockfd, (struct sockaddr *)&client_addr, &cliaddr_len);
        handle_error("accept", client_fd);

        printf("与客户端 from %s at PORT %d 文件描述符 %d 建立连接\n",
               inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port), client_fd);

        pthread_t pid_read_write;

        // 启动一个子线程,用来读取客户端数据,并打印到 stdout
        // 要注意,此处的pid_read_write并不是线程ID,而是用于线程处理函数的标识符
        if (pthread_create(&pid_read_write, NULL, read_from_client_then_write, (void *)&client_fd))
        {
            perror("pthread_create");
        }

        // 将子线程处理为detached状态,使其终止时自动回收资源,同时不阻塞主线程
        pthread_detach(pid_read_write);
        printf("创建子线程并处理为detached状态\n");
    }

    printf("释放资源\n");
    close(sockfd);

    return 0;
}

2)客户端(multi_conn_threads_client.c)

#include <sys/socket.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <pthread.h>

#define handle_error(cmd, result) \
    if (result < 0)               \
    {                             \
        perror(cmd);              \
        return -1;                \
    }

void *read_from_server(void *argv)
{
    int sockfd = *(int *)argv;
    char *read_buf = NULL;
    ssize_t count = 0;

    read_buf = malloc(sizeof(char) * 1024);
    if (!read_buf)
    {
        perror("malloc client read_buf");
        return NULL;
    }

    while (count = recv(sockfd, read_buf, 1024, 0))
    {
        if (count < 0)
        {
            perror("recv");
        }
        fputs(read_buf, stdout);
    }

    printf("收到服务端的终止信号......\n");
    free(read_buf);

    return NULL;
}

void *write_to_server(void *argv)
{
    int sockfd = *(int *)argv;
    char *write_buf = NULL;
    ssize_t send_count;

    write_buf = malloc(sizeof(char) * 1024);

    if (!write_buf)
    {
        printf("写缓存申请异常,断开连接\n");
        shutdown(sockfd, SHUT_WR);
        perror("malloc client write_buf");
        return NULL;
    }

    while (fgets(write_buf, 1024, stdin) != NULL)
    {
        send(sockfd, write_buf, 1024, 0);
        if (send_count < 0)
        {
            perror("send");
        }
    }

    printf("接收到命令行的终止信号,不再写入,关闭连接......\n");
    shutdown(sockfd, SHUT_WR);
    free(write_buf);

    return NULL;
}

int main(int argc, char const *argv[])
{
    int sockfd, temp_result;
    pthread_t pid_read, pid_write;

    struct sockaddr_in server_addr;

    memset(&server_addr, 0, sizeof(server_addr));

    server_addr.sin_family = AF_INET;
    // 连接本机 127.0.0.1
    server_addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
    // 连接端口 6666
    server_addr.sin_port = htons(6666);

    // 创建socket
    sockfd = socket(AF_INET, SOCK_STREAM, 0);
    handle_error("socket", sockfd);

    // 连接server
    temp_result = connect(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr));
    handle_error("connect", temp_result);

    // 启动一个子线程,用来读取服务端数据,并打印到 stdout
    pthread_create(&pid_read, NULL, read_from_server, (void *)&sockfd);
    // 启动一个子线程,用来从命令行读取数据并发送到服务端
    pthread_create(&pid_write, NULL, write_to_server, (void *)&sockfd);

    // 主线程等待子线程退出
    pthread_join(pid_read, NULL);
    pthread_join(pid_write, NULL);

    printf("关闭资源\n");
    close(sockfd);

    return 0;
}

3)编译并测试

(1)Makefile

补充顶部的伪目标声明,补充变量定义

.PHONY: single_conn single_conn_clean multi_conn_threads multi_conn_threads_clean

multi_conn_threads_executables:=multi_conn_threads_server multi_conn_threads_client

在文件末尾追加目标定义

multi_conn_threads_server: multi_conn_threads_server.c
    -$(CC) -o $@ $^

multi_conn_threads_client: multi_conn_threads_client.c
    -$(CC) -o $@ $^

multi_conn_threads: $(multi_conn_threads_executables)

multi_conn_threads_clean:
    -rm ./$(firstword $(multi_conn_threads_executables)) ./$(lastword $(multi_conn_threads_executables))   

(2)测试

运行multi_conn_threads目标后,执行以下操作

① 开启三个Xshell标签页,然后“垂直排列”

image-20260824204930213

② 开启服务端

image-20260824204946478

③ 开启两个客户端

image-20260824205003745

服务端提示客户端已连接,并给出客户端sockfd的文件描述符。

④ 用两个客户端分别发送数据

image-20260824205020642

⑤ 在客户端窗口Ctrl+D关闭连接

image-20260824205038389

连接全部断开,服务端仍在阻塞,等待新的连接。

6.4.5 基于多进程的支持多个连接的范例程序

1)服务端(multi_conn_processes_server.c)

#include <sys/socket.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <arpa/inet.h>
#include <pthread.h>
#include <unistd.h>
#include <signal.h>

#define handle_error(cmd, result) \
    if (result < 0)               \
    {                             \
        perror(cmd);              \
        return -1;                \
    }

void zombie_dealer(int sig) {
    pid_t pid;
    int status;
    // 一个SIGCHLD可能对应多个子进程的退出
    // 使用while循环回收所有退出的子进程,避免僵尸进程的出现
    while ((pid = waitpid(-1, &status, WNOHANG)) > 0) {
        if (WIFEXITED(status)) {
            printf("子进程: %d 以 %d 状态正常退出,已被回收\n", pid, WEXITSTATUS(status));
        } else if (WIFSIGNALED(status)) {
            printf("子进程: %d 被 %d 信号杀死,已被回收\n", pid, WTERMSIG(status));
        } else {
            printf("子进程: %d 因其它原因退出,已被回收\n", pid);
        }
    }
}

void *read_from_client_then_write(void *argv)
{
    int client_fd = *(int *)argv;

    ssize_t count = 0, send_count = 0;
    char *read_buf = NULL;
    char *write_buf = NULL;

    read_buf = malloc(sizeof(char) * 1024);
    // 判断内存是否分配成功
    if (!read_buf)
    {
        printf("服务端pid: %d: 读缓存创建异常,断开连接\n", getpid());
        shutdown(client_fd, SHUT_WR);
        close(client_fd);
        perror("malloc sever read_buf");
        return NULL;
    }

    // 判断内存是否分配成功
    write_buf = malloc(sizeof(char) * 1024);
    if (!write_buf)
    {
        printf("服务端pid: %d: 写缓存创建异常,断开连接\n", getpid());
        free(read_buf);
        shutdown(client_fd, SHUT_WR);
        close(client_fd);
        perror("malloc server write_buf");
        return NULL;
    }

    while ((count = recv(client_fd, read_buf, 1024, 0)))
    {
        if (count < 0)
        {
            perror("recv");
        }
        printf("服务端pid: %d: reveive message from client_fd: %d: %s\n", getpid(), client_fd, read_buf);

        sprintf(write_buf, "服务端pid: %d: reveived~\n", getpid());
        send_count = send(client_fd, write_buf, 1024, 0);
        if (send_count < 0)
        {
            perror("send");
        }
    }

    printf("服务端pid: %d: 客户端client_fd: %d请求关闭连接......\n", getpid(), client_fd);
    sprintf(write_buf, "服务端pid: %d: receive your shutdown signal\n", getpid());

    send_count = send(client_fd, write_buf, 1024, 0);
    if (send_count < 0)
    {
        perror("send");
    }

    printf("服务端pid: %d: 释放client_fd: %d资源\n", getpid(), client_fd);
    shutdown(client_fd, SHUT_WR);
    close(client_fd);
    free(read_buf);
    free(write_buf);

    return NULL;
}

int main(int argc, char const *argv[])
{
    int sockfd, temp_result;

    struct sockaddr_in server_addr, client_addr;

    memset(&server_addr, 0, sizeof(server_addr));
    memset(&client_addr, 0, sizeof(client_addr));

    // 声明IPV4通信协议
    server_addr.sin_family = AF_INET;
    // 我们需要绑定0.0.0.0地址,转换成网络字节序后完成设置
    server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
    // 端口随便用一个,但是不要用特权端口
    server_addr.sin_port = htons(6666);

    // 创建server socket
    sockfd = socket(AF_INET, SOCK_STREAM, 0);
    handle_error("socket", sockfd);

    // 绑定地址
    temp_result = bind(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr));
    handle_error("bind", temp_result);

    // 进入监听模式
    temp_result = listen(sockfd, 128);
    handle_error("listen", temp_result);

    socklen_t cliaddr_len = sizeof(client_addr);

    // 注册信号处理函数,处理SIGCHLD信号,避免僵尸进程出现
    signal(SIGCHLD, zombie_dealer);

    // 接受client连接
    while (1)
    {
        int client_fd = accept(sockfd, (struct sockaddr *)&client_addr, &cliaddr_len);
        handle_error("accept", client_fd);

        pid_t pid = fork();

        if (pid > 0)
        {
            printf("this is father, pid is %d, continue accepting...\n", getpid());

            // 父进程不需要处理client_fd,释放文件描述符,使其引用计数减一,以便子进程释放client_fd后,其引用计数可以减为0,从而释放资源
            close(client_fd);
        }
        else if (pid == 0)
        {
            // 子进程不需要处理sockfd,释放文件描述符,使其引用计数减一
            close(sockfd);
            printf("与客户端 from %s at PORT %d 文件描述符 %d 建立连接\n",
                   inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port), client_fd);
            printf("新的服务端pid为: %d\n", getpid());
            // 读取客户端数据,并打印到 stdout
            read_from_client_then_write((void *)&client_fd);

            // 释放资源并终止子进程
            close(client_fd);
            exit(EXIT_SUCCESS);
        }
    }

    printf("释放资源\n");
    close(sockfd);

    return 0;
}

2)客户端(multi_conn_processes_client.c)

这里的客户端程序与multi_conn_threads_client.c完全一致

#include <sys/socket.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <pthread.h>

#define handle_error(cmd, result) \
    if (result < 0)               \
    {                             \
        perror(cmd);              \
        return -1;                \
    }

void *read_from_server(void *argv)
{
    int sockfd = *(int *)argv;
    char *read_buf = NULL;
    ssize_t count = 0;

    read_buf = malloc(sizeof(char) * 1024);
    if (!read_buf)
    {
        perror("malloc client read_buf");
        return NULL;
    }

    while (count = recv(sockfd, read_buf, 1024, 0))
    {
        if (count < 0)
        {
            perror("recv");
        }
        fputs(read_buf, stdout);
    }

    printf("收到服务端的终止信号......\n");
    free(read_buf);

    return NULL;
}

void *write_to_server(void *argv)
{
    int sockfd = *(int *)argv;
    char *write_buf = NULL;
    ssize_t send_count;

    write_buf = malloc(sizeof(char) * 1024);

    if (!write_buf)
    {
        printf("写缓存申请异常,断开连接\n");
        shutdown(sockfd, SHUT_WR);
        perror("malloc client write_buf");
        return NULL;
    }

    while (fgets(write_buf, 1024, stdin) != NULL)
    {
        send(sockfd, write_buf, 1024, 0);
        if (send_count < 0)
        {
            perror("send");
        }
    }

    printf("接收到命令行的终止信号,不再写入,关闭连接......\n");
    shutdown(sockfd, SHUT_WR);
    free(write_buf);

    return NULL;
}

int main(int argc, char const *argv[])
{
    int sockfd, temp_result;
    pthread_t pid_read, pid_write;

    struct sockaddr_in server_addr;

    memset(&server_addr, 0, sizeof(server_addr));

    server_addr.sin_family = AF_INET;
    // 连接本机 127.0.0.1
    server_addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
    // 连接端口 6666
    server_addr.sin_port = htons(6666);

    // 创建socket
    sockfd = socket(AF_INET, SOCK_STREAM, 0);
    handle_error("socket", sockfd);

    // 连接server
    temp_result = connect(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr));
    handle_error("connect", temp_result);

    // 启动一个子线程,用来读取服务端数据,并打印到 stdout
    pthread_create(&pid_read, NULL, read_from_server, (void *)&sockfd);
    // 启动一个子线程,用来从命令行读取数据并发送到服务端
    pthread_create(&pid_write, NULL, write_to_server, (void *)&sockfd);

    // 主线程等待子线程退出
    pthread_join(pid_read, NULL);
    pthread_join(pid_write, NULL);

    printf("关闭资源\n");
    close(sockfd);

    return 0;
}

3)编译并测试

(1)Makefile

在头部的伪目标声明中补充内容,补充变量定义

.PHONY: single_conn single_conn_clean multi_conn_threads multi_conn_threads_clean multi_conn_processes multi_conn_processes_clean

multi_conn_processes_executables:=multi_conn_processes_server multi_conn_processes_client

multi_conn_processes_server: multi_conn_processes_server.c
    -$(CC) -o $@ $^

multi_conn_processes_client: multi_conn_processes_client.c
    -$(CC) -o $@ $^

multi_conn_processes: $(multi_conn_processes_executables)

multi_conn_processes_clean:
    -rm ./$(word 1, $(multi_conn_processes_executables)) ./$(word 2, $(multi_conn_processes_executables))

(2)测试

运行multi_conn_processes目标后,执行以下步骤

① 垂直排列开启三个XShell窗口

② 在其中一个窗口开启服务端

image-20260824211706305

③ 在另外两个窗口开启客户端

image-20260824211723826

可以看到,每当一个新连接到来,服务端父进程都会创建一个新的子进程专门用于处理该连接。

此外,两个连接创建的client_fd文件描述符竟然都是4,这是因为每次创建子进程后,父进程就会释放client_fd,对应的文件描述符4被释放,新的连接到来时会从可用的最小文件描述符开始占用,因此新连接的client_fd也是4。虽然它们的client_fd是一样的,但对应的底层文件描述却是完全不同的。

④ 在两个客户端中发送数据

image-20260824211748525

每个连接都有独立的服务端进程处理。

⑤ 在客户端发送Ctrl+D终止连接

image-20260824211810176

上文提到,服务端的父进程中,两个连接对应的client_fd都是4,fork()之后,子进程会继承父进程的文件描述符表,因此两个服务端子进程的client_fd也都是4。

⑥ 服务端通过Ctrl+C终止

image-20260824211826080

6.5 传输层-基于UDP协议的程序

6.5.1 UDP通讯流程

image-20260824212633523

6.5.2 UDP开发常用函数

UDP通讯也使用socket,但是接收和发送的函数与TCP不一样。由于UDP不存在握手这一步骤,所以在绑定地址之后,服务端不需要listen,客户端也不需要connect,服务端同样不需要accept。只要服务端绑定以后,就可以相互发消息了,由于没有握手过程,两端都不能确定对方是否收到消息,这也是UDP协议不如TCP协议可靠的地方。

UDP协议接收和发送数据不再用send和recv方法,这两个方法一般用于TCP通信,UDP通信使用sendto和recvfrom方法,声明如下:

1)recvfrom

#include <sys/types.h>
#include <sys/socket.h>
/**
 * @brief 将接收到的消息放入缓冲区 buf 中。
 * 
 * @param sockfd 套接字文件描述符
 * @param buf 缓冲区指针
 * @param len 缓冲区大小
 * @param flags 通信标签,详见recv方法说明
 * @param src_addr 可以填NULL,如果 src_addr 不是 NULL,并且底层协议提供了消息的源地址,则该源地址将被放置在 src_addr 指向的缓冲区中。
 * @param addrlen 如果src_addr不为NULL,它应初始化为与 src_addr 关联的缓冲区的大小。返回时,addrlen 被更新为包含实际源地址的大小。如果提供的缓冲区太小,则返回的地址将被截断;在这种情况下,addrlen 将返回一个大于调用时提供的值。
 * @return ssize_t 实际收到消息的大小。如果接收失败,返回-1
 */
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen);

2)sendto

/**
 * @brief 向指定地址发送缓冲区中的数据(一般用于UDP模式)
 * 
 * @param sockfd 套接字文件描述符
 * @param buf 缓冲区指针
 * @param len 缓冲区大小
 * @param flags 通信标签,详细减send方法说明
 * @param dest_addr 目标地址。如果用于连接模式,该参数会被忽略
 * @param addrlen 目标地址长度
 * @return ssize_t 发送的消息大小。发送失败返回-1
 */
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen);

6.5.3 范例程序

1)UDP协议服务端(udp_server.c)

#include <sys/socket.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <arpa/inet.h>

#define handle_error(cmd, result) \
    if (result < 0)               \
    {                             \
        perror(cmd);              \
        return -1;                \
    }

int main(int argc, char const *argv[])
{
    int sockfd, temp_result, client_fd;
    struct sockaddr_in server_addr, client_addr;
    char *buf = malloc(1024);

    memset(&server_addr, 0, sizeof(server_addr));
    memset(&client_addr, 0, sizeof(client_addr));

    // 声明IPV4通信协议
    server_addr.sin_family = AF_INET;
    // 我们需要绑定0.0.0.0地址,转换成网络字节序后完成设置
    server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
    // 端口随便用一个,但是不要用特权端口
    server_addr.sin_port = htons(6666);

    // 创建server socket,注意通信类型
    sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    handle_error("socket", sockfd);

    // 绑定地址
    temp_result = bind(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr));
    handle_error("bind", temp_result);

    do
    {
        // 清空缓冲区
        memset(buf, 0, 1024);

        // 尝试接受数据
        socklen_t client_addr_len = sizeof(client_addr);
        temp_result = recvfrom(sockfd, buf, 1024, 0, (struct sockaddr *)&client_addr, &client_addr_len);
        handle_error("recvfrom", temp_result);

        // 如果客户端发来的不是EOF
        if (strncmp(buf, "EOF", 3) != 0)
        {
            printf("received msg from %s at port %d: %s", inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port), buf);
            strcpy(buf, "OK\n");
        } else {
            printf("received EOF from client, existing...\n");
        }

        // 收到数据后回复OK或EOF(客户端发送的是EOF则回复EOF,否则回复OK)
        temp_result = sendto(sockfd, buf, 4, 0, (struct sockaddr *)&client_addr, client_addr_len);
        handle_error("sendto", temp_result);
    } while (strncmp(buf, "EOF", 3) != 0);

    free(buf);

    return 0;
}

2)UDP协议客户端(udp_client.c)

#include <sys/socket.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <arpa/inet.h>
#include <unistd.h>

#define handle_error(cmd, result) \
    if (result < 0)               \
    {                             \
        perror(cmd);              \
        return -1;                \
    }

int main(int argc, char const *argv[])
{
    int sockfd, temp_result, client_fd;
    struct sockaddr_in server_addr;
    char *buf = malloc(1024);

    memset(&server_addr, 0, sizeof(server_addr));

    // 声明IPV4通信协议
    server_addr.sin_family = AF_INET;
    // 我们需要绑定0.0.0.0地址,转换成网络字节序后完成设置
    server_addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
    // 端口随便用一个,但是不要用特权端口
    server_addr.sin_port = htons(6666);

    // 创建server socket,注意通信类型
    sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    handle_error("socket", sockfd);

    do
    {
        write(STDOUT_FILENO, "Type something you want to send: ", 34);
        // 从标准输入读取数据
        int buf_len = read(STDIN_FILENO, buf, 1023);

        temp_result = sendto(sockfd, buf, buf_len, 0, (struct sockaddr *)&server_addr, sizeof(server_addr));
        handle_error("sendto", temp_result);

        // 清空缓冲区
        memset(buf, 0, 1024);

        // 尝试接受数据
        temp_result = recvfrom(sockfd, buf, 1024, 0, NULL, NULL);
        handle_error("recvfrom", temp_result);

        // 如果服务端发来的不是EOF
        if (strncmp(buf, "EOF", 3) != 0)
        {
            printf("received msg from %s at port %d: %s", inet_ntoa(server_addr.sin_addr), ntohs(server_addr.sin_port), buf);
        }
    } while (strncmp(buf, "EOF", 3) != 0);

    free(buf);

    return 0;

3)编译并执行

(1)Makefile

在顶部的伪目标声明中补充内容,并补充变量定义

.PHONY: single_conn single_conn_clean multi_conn_threads multi_conn_threads_clean multi_conn_processes multi_conn_processes_clean udp_test upd_test_clean

udp_test_executables:=udp_server udp_client

在文件末尾补充目标定义

udp_server: udp_server.c
    -$(CC) -o $@ $^

udp_client: udp_client.c
    -$(CC) -o $@ $^

udp_test: $(udp_test_executables)

upd_test_clean:
    -rm ./$(firstword $(udp_test_executables)) ./$(lastword $(udp_test_executables))

(3)运行

编译后执行以下步骤。

① 在XShell中打开两个标签页,并垂直分割

② 分别在两个标签页中启动服务端和客户端,先启动服务端,后启动客户端

image-20260824213006797

③ 在客户端发送一些数据

image-20260824213021797

④ 客户端发送EOF字符串,断开连接

image-20260824213035825

6.6 Unix Domain Socket IPC

6.6.1 简介

Socket编程原本是为了网络服务的,后来逐渐发展成一种进程间通信的方式:Unix Domain Socket IPC。它允许在同一台主机上运行的进程之间进行高效的数据传输,无需经过网络协议栈,因此具有低延迟和高性能的特点。通过文件系统中的特殊文件(通常是一个套接字文件),进程可以通过套接字(socket)来进行通信,实现双向的数据传输。Unix Domain Socket IPC被广泛用于各种应用场景,如进程间通信、客户端-服务器模型等,是Unix/Linux系统中实现进程间通信的重要工具之一。

6.6.2 套接字地址结构体说明

使用man 7 unix查看文档,可得:

/*
地址格式
UNIX 域套接字地址表示为以下结构:
*/
struct sockaddr_un {
    sa_family_t sun_family;               /* AF_UNIX */
    char        sun_path[108];            /* Pathname */
};
/*
sun_family 字段始终包含 AF_UNIX。在 Linux 上,sun_path 的大小为 108 字节;请参见下面的注意事项。

各种系统调用(例如,bind(2)、connect(2) 和 sendto(2))将一个 sockaddr_un 参数作为输入。一些其他系统调用(例如,getsockname(2)、getpeername(2)、recvfrom(2) 和 accept(2))返回此类型的参数。

在 sockaddr_un 结构中区分三种类型的地址:

路径名:可以使用 bind(2) 将 UNIX 域套接字绑定到以空字符结尾的文件系统路径名。当返回路径名套接字的地址(由上述系统调用之一返回)时,其长度为
sizeof(sa_family_t) + strlen(sun_path) + 1

未命名:未使用 bind(2) 将流套接字绑定到路径名的套接字没有名称。同样,socketpair(2) 创建的两个套接字也没有名称。返回未命名套接字的地址时,其长度为 sizeof(sa_family_t),并且不应检查 sun_path。

抽象:通过 sun_path[0] 是空字节('\0') 来区分抽象套接字地址(与路径名套接字)。此命名空间中套接字的地址由地址结构指定长度覆盖的 sun_path 中的附加字节给出。(名称中的空字节没有特殊意义。)名称与文件系统路径名无关。当返回抽象套接字的地址时,返回的 addrlen 大于 sizeof(sa_family_t)(即大于 2),并且套接字的名称包含在 sun_path 的前 (addrlen - sizeof(sa_family_t)) 字节中。
*/

关于pathname socket,文档中还有详细介绍。

/*
绑定套接字到路径名时,应遵循以下规则以实现最大的可移植性和编码便利性:

sun_path 中的路径名应以空字符结尾。

路径名的长度,包括终止的空字节,不应超过 sun_path 的大小。

描述封装 sockaddr_un 结构的 addrlen 参数应指定为 sizeof(struct sockaddr_un)。

在 Linux 实现中,路径名套接字遵循它们所在目录的权限。如果进程在创建套接字的目录中没有写和搜索(执行)权限,则创建新套接字将失败。

在 Linux 上,连接到流套接字对象需要对该套接字具有写权限;同样,向数据报套接字发送数据报也需要对该套接字具有写权限。POSIX 对套接字文件的权限效果没有做出任何声明,在某些系统上(例如,旧版 BSD),套接字权限会被忽略。可移植的程序不应依赖此功能来进行安全性保障。

路径名套接字的所有者、组和权限可以更改(使用 chown(2) 和 chmod(2))。
*/

简单来说,使用AF_UNIX通信域,可以声明一个使用本地文件的socket,其他进程可以绑定该文件完成进程间通信。

6.6.3 范例程序

1)创建socket_ipc_test.c

#include <stdlib.h>
#include <stdio.h>
#include <stddef.h>
#include <sys/socket.h>
#include <sys/un.h>
#include <sys/stat.h>
#include <errno.h>
#include <string.h>
#include <unistd.h>

#define SOCKET_PATH "unix_domain.socket"
#define SERVER_MODE 1
#define CLIENT_MODE 2
#define BUF_LEN 1024

static struct sockaddr_un socket_addr;
static char *buf;

void handle_error(char *err_msg)
{
    perror(err_msg);
    unlink(SOCKET_PATH);
    exit(-1);
}

void server_mode(int sockfd)
{
    int client_fd, msg_len;
    static struct sockaddr_un client_addr;
    if (bind(sockfd, (struct sockaddr *)&socket_addr, sizeof(socket_addr)) < 0)
    {
        handle_error("bind");
    }

    if (listen(sockfd, 128) < 0)
    {
        handle_error("listen");
    }

    socklen_t client_addr_len = sizeof(client_addr);

    if ((client_fd = accept(sockfd, (struct sockaddr *)&client_addr, &client_addr_len)) < 0)
    {
        handle_error("accept");
    }

    write(STDOUT_FILENO, "Connected to client!\n", 21);

    do
    {
        memset(buf, 0, BUF_LEN);
        msg_len = recv(client_fd, buf, BUF_LEN, 0);
        printf("Received msg: %s", buf);
        if (strncmp(buf, "EOF", 3) != 0)
        {
            strcpy(buf, "OK!\n\0");
        }
        send(client_fd, buf, strlen(buf), 0);
    } while (strncmp(buf, "EOF", 3) != 0);

    if (shutdown(client_fd, SHUT_RDWR) < 0)
    {
        handle_error("shutdown server");
    }
    unlink(SOCKET_PATH);
}

void client_mode(int sockfd)
{
    int msg_len, header_len;
    if (connect(sockfd, (struct sockaddr *)&socket_addr, sizeof(socket_addr)))
    {
        handle_error("connect");
    }

    write(STDOUT_FILENO, "Connected to server!\n", 21);
    strcpy(buf, "Msg received: ");

    // 计算buf中头的长度
    header_len = strlen(buf);

    do
    {
        msg_len = read(STDIN_FILENO, buf + header_len, BUF_LEN - header_len);
        send(sockfd, buf + header_len, msg_len, 0);
        msg_len = recv(sockfd, buf + header_len, BUF_LEN - header_len, 0);
        write(STDOUT_FILENO, buf, msg_len + header_len);
    } while (strncmp(buf + header_len, "EOF", 3) != 0);
}

int main(int argc, char const *argv[])
{
    int fd = 0, mode = 0;

    if (argc == 1 || strncmp(argv[1], "server", 6) == 0)
    {
        mode = SERVER_MODE;
    }
    else if (strncmp(argv[1], "client", 6) == 0)
    {
        mode = CLIENT_MODE;
    }
    else
    {
        perror("参数错误");
        exit(-1);
    }

    // address初始化
    memset(&socket_addr, 0, sizeof(struct sockaddr_un));
    buf = malloc(BUF_LEN);

    // 给address赋值
    socket_addr.sun_family = AF_UNIX;
    strcpy(socket_addr.sun_path, SOCKET_PATH);

    if ((fd = socket(AF_UNIX, SOCK_STREAM, 0)) < 0)
    {
        handle_error("socket");
    }

    // 分服务端和客户端
    switch (mode)
    {
    case SERVER_MODE:
        server_mode(fd);
        break;
    case CLIENT_MODE:
        client_mode(fd);
        break;
    }

    if (shutdown(fd, SHUT_RDWR) < 0)
    {
        handle_error("shutdown");
    }
    free(buf);

    exit(0);
}

2)Makefile

(1)在头部补充伪目标声明

.PHONY: single_conn single_conn_clean multi_conn_threads multi_conn_threads_clean multi_conn_processes multi_conn_processes_clean udp_test upd_test_clean socket_ipc_test_clean

(2)尾部补充目标定义

socket_ipc_test: socket_ipc_test.c
    $(CC) -o $@ $^

socket_ipc_test_clean:
    -rm ./socket_ipc_test

3)运行

(1)开启两个XShell标签页

(2)启动服务端

./socket_ipc_test server或./socket_ipc_test均可启动服务端,即命令行不传参,默认启动服务端。

image-20260824220250819

(3)启动客户端

image-20260824220308405

(4)客户端发送数据

image-20260824220327220

(5)客户端发送EOF断开连接

image-20260824220344573

6.7 网络层-IP协议

6.7.1 简介

IP(Internet Protocol,互联网协议)是网络通信中最基础的协议之一,用于在不同的网络设备之间传输数据。IP协议定义了数据包的格式和寻址方法,使得数据能够从源地址传输到目标地址。

6.7.2 IP地址

IP地址(Internet Protocol Address,互联网协议地址)是分配给连接到计算机网络的每个设备的唯一标识符,用于在网络中进行通信。IP地址使数据包在网络上能够找到其目标位置,确保数据从源设备传输到目标设备。

6.7.3 IP协议版本

1)IPv4(Internet Protocol version 4)

IPv4是最早和最常用的IP协议版本,使用32位地址,地址范围为0.0.0.0到255.255.255.255。IPv4的地址空间约为43亿个,但由于地址分配效率问题和互联网的快速增长,IPv4地址已接近枯竭。

Ø 地址长度:32位(4字节)

Ø 地址表示:点分十进制(例如:192.168.0.1)

Ø 地址数量:约43亿个

2)IPv6(Internet Protocol version 6)

IPv6是为解决IPv4地址枯竭问题而设计的新一代IP协议,使用128位地址,能够提供数量巨大的地址空间。

Ø 地址长度:128位(16字节)

Ø 地址表示:十六进制数和冒号分隔(例如:2001:0db8:85a3:0000:0000:8a2e:0370:7334)

Ø 地址数量:几乎无限(2的128次方)

Ø 内置了IPsec(用于网络安全)

6.7.4 IP地址分类

1)根据作用范围

(1)私有IP地址

私有IP地址用于局域网(LAN)内部,不在互联网中传输。常见的私有IP地址范围有:

10.0.0.0 - 10.255.255.255

172.16.0.0 - 172.31.255.255

192.168.0.0 - 192.168.255.255

(2)公有IP地址

公有IP地址由互联网服务提供商(ISP)分配,可以在全球互联网中唯一识别。

2)根据是否可以更改

(1)静态IP地址

静态IP地址是手动分配的,不会改变。适用于需要长期保持相同IP地址的设备,如服务器。

(2)动态IP地址

动态IP地址由DHCP服务器动态分配,每次连接网络时可能会改变。适用于普通用户设备,如电脑和手机。

6.7.5 网络层硬件设备

1)注意

在网络分层模型中,某层的设备通常需要执行该层及以下所有层的功能。

2)路由器

路由器是一种网络设备,用于在计算机网络之间转发数据包。它主要用于连接不同的网络,并确定数据包的最佳路径。路由器通过分析数据包的目标地址,选择最有效的路由路径,将数据包传输到目的地。

路由器主要工作网络分层模型的网络层。

6.8 应用层-DNS协议

6.8.1 简介

访问网站实际上是与某台服务器通信。为了定位这台服务器,我们需要使用IP地址。然而,IP地址是一串数字,不易读且难以记忆。

域名(Domain Name)是互联网上用于标识网站的易于记忆的名称,代替了难记的IP地址,使用户能够方便地访问网站。域名由一系列标签组成,这些标签由点(.)分隔,每个标签代表域名层次结构中的一个级别。如www.atguigu.com

DNS(Domain Name System,域名系统)协议是一种用于将人类易读的域名(如www.atguigu.com)转换为计算机可以识别的IP地址(如192.0.2.1)的网络协议。它是互联网的关键组件之一,使用户能够使用友好的域名而不是难记的IP地址来访问网站和其他互联网资源。

6.8.2 一个典型的DNS查询示例

(1)用户请求:用户在浏览器中输入一个域名(如www.atguigu.com)。

(2)本地缓存检查:客户端首先检查本地缓存中是否有该域名的IP地址。如果有,直接返回结果;如果没有,继续下一步。

(3)递归查询:

Ø 查询本地DNS服务器:客户端向本地DNS服务器发送递归查询请求。

Ø 查询根域名服务器:本地DNS服务器向根域名服务器查询。根域名服务器返回顶级域名服务器(TLD Server)的地址。

Ø 查询顶级域名服务器:本地DNS服务器向顶级域名服务器查询,顶级域名服务器返回权威DNS服务器的地址。

Ø 查询权威DNS服务器:本地DNS服务器向权威DNS服务器查询,权威DNS服务器返回目标IP地址。

(4)返回结果:本地DNS服务器将获得的IP地址返回给客户端。

(5)访问目标网站:客户端使用获得的IP地址访问目标网站。

6.9 数据链路层

6.9.1 主要功能

在两个网络实体之间提供数据链路连接的建立、维持和释放管理。构成数据链路数据单元(frame:数据帧或帧),并对帧定界、同步、收发顺序的控制。并且包括传输过程中的网络流量控制、差错检测和差错控制等方面。

只提供导线的一端到另一端(本质是点到点)的数据传输。

6.9.2 MAC地址

1)功能

MAC地址(Media Access Control Address,介质访问控制地址)是网络设备的硬件地址,用于在局域网(LAN)中唯一标识设备。MAC地址嵌入在设备的网络接口控制器(NIC)中,每个设备在全球范围内都应该具有一个唯一的MAC地址。

MAC地址工作在数据链路层,用于设备间通信。

2)格式

MAC地址通常是48位(二进制)的数字,通常表示为12位的十六进制数。例如,00:1A:2B:3C:4D:5E。常见的表示形式有两种:

Ø 用冒号分隔的六个十六进制数对(如 00:1A:2B:3C:4D:5E)

Ø 用连字符分隔的六个十六进制数对(如 00-1A-2B-3C-4D-5E)

6.9.3 ARP协议

1)作用

Address Resolution Protocol,地址解析协议,用于将IP地址转换为物理MAC地址。这个转换过程在局域网内是必须的,因为以太网帧是通过MAC地址传输的,而网络层数据包使用IP地址。

2)工作流程

(1)ARP请求

当一个设备(如计算机)需要发送数据到另一个设备,但只知道目标设备的IP地址时,它会在网络上广播一个ARP请求帧。这个请求帧包含发送方的IP地址和MAC地址,以及目标设备的IP地址。

(2)ARP响应

网络上所有设备都接收到ARP请求,但只有目标设备会响应。目标设备会发送一个包含其MAC地址的ARP响应帧直接回到发送方。

(3)地址缓存

发送方接收到ARP响应后,会将目标设备的IP地址和MAC地址映射关系缓存到本地的ARP缓存中,以便下次发送数据时无需再进行地址解析。

6.9.4 RARP协议

1)作用

RARP(Reverse Address Resolution Protocol,反向地址解析协议)是一种用于计算机网络的协议,主要作用是通过已知的物理地址(MAC地址)获取相应的IP地址。RARP在一些早期的网络设备上用于无盘工作站或没有永久存储IP地址的设备,使这些设备能够在网络上正常通信。

2)工作流程

(1)广播请求

当设备启动时,它会生成一个RARP请求包,其中包含自己的MAC地址。设备将该请求包以广播的方式发送到网络上,以便所有网络上的RARP服务器都能接收到。

(2)RARP服务器接收请求

网络中的RARP服务器监听并接收所有的RARP请求。RARP服务器维护着一个映射表,包含每个设备的MAC地址和对应的IP地址。

(3)服务器查找映射表

RARP服务器接收到请求后,会在其映射表中查找请求中包含的MAC地址。如果找到对应的IP地址,服务器会生成一个RARP响应包,其中包含该设备的IP地址。

(4)发送响应

RARP服务器将RARP响应包发送回请求设备。这通常是通过单播方式直接发送到请求设备的MAC地址。

(5)设备配置IP地址

请求设备接收到RARP响应包后,从中提取出自己的IP地址并配置到其网络接口上。

6.9.5 数据链路层硬件设备

1)交换机

交换机是一种网络设备,主要用于在局域网(LAN)内连接多台设备,并根据设备的MAC地址来转发数据帧。交换机的主要功能是提高网络性能和管理网络流量。与集线器(Hub)相比,交换机更智能,因为它能够根据数据帧的目标地址进行精确的转发,从而减少网络冲突和提高传输效率。

交换机工作在数据链路层

6.10 物理层

6.10.1 相关概念

1)传输介质

传输介质是用于在网络节点之间传输数据的物理通道。它是网络物理层的组成部分,直接影响数据传输的速度、距离和可靠性。传输介质分为有线和无线两大类。

2)模拟信号(Analog Signal)

模拟信号是一种连续的信号,它在某一范围内可以取任何值。模拟信号的变化是连续的,与被表示的信息成比例。例如,声音信号是空气压力的连续变化,它可以被转换成相应的电压变化,形成模拟电信号。

3)数字信号(Digital Signal)

数字信号是一种离散的信号,它只有特定的、离散的取值(通常是二进制的0和1)。数字信号表示的信息是以离散的时间间隔取样的,并且每个样本的值是离散的。

4)编码(Encoding)

编码是将数据转换为适合传输或存储的格式。编码通常用于在数字信号之间进行转换,以提高数据传输的效率和可靠性。编码有多种类型,每种类型用于特定的应用和目的。如曼彻斯特编码、NRZ编码、4B/5B编码等。

5)信号调制(Modulation)

调制是将信号转换为适合在传输介质上传输的形式。调制通常用于将数字信号转换为模拟信号,以便通过无线电波、光纤或其他传输介质传输。调制通过改变载波信号的某些特性来表示数据。调制分为四种:调幅、调频、调相和正交振幅调制。

6)带宽(Bandwidth)

带宽在网络通信中有两种主要解释,一种是指物理带宽,另一种是指有效带宽。

(1)物理带宽(频率带宽)

物理带宽是指传输介质能够传输的频率范围,通常以赫兹(Hz)为单位。它表示信道在某一时间内能够传输的信号频率范围。

(2)有效带宽(数据带宽)

有效带宽是指在一段时间内传输数据的最大速率,通常以比特每秒(bps)为单位。它表示传输介质或信道在单位时间内能够传输的最大数据量。

7)物理拓扑(Physical Topology)

网络设备的物理布局和连接方式,如星型拓扑、环型拓扑、总线型拓扑和网状拓扑。

6.10.2 物理层的职责

物理层是网络分层模型的最底层,其主要作用是负责在网络节点之间传输原始的比特流。它涉及实际的硬件和物理传输介质,并定义了数据在传输介质上的表示和传输方法。

1)比特传输

将数据链路层生成的帧转换为适合传输介质的信号(如电信号、光信号或无线电波),并在接收端将信号还原为比特流。

2)物理连接

提供计算机与网络之间的实际物理连接,包括电缆、连接器和接口。

3)信号编码和调制

将数字比特流编码成适合传输介质的信号形式,例如曼彻斯特编码、NRZ编码、QAM调制等。

4)数据速率控制

定义了数据传输速率(带宽),例如10Mbps、100Mbps、1Gbps等。

5)传输模式

定义传输的模式,包括单工(单向传输)、半双工(双向但不能同时传输)和全双工(双向同时传输)。

6)物理拓扑

规定了网络的物理布局,如星型、环型、总线型和网状拓扑。

6.10.3 传输介质

1)有线传输介质

(1)双绞线(Twisted Pair)

Ø 未屏蔽双绞线(UTP):常用于以太网(如Cat5e、Cat6电缆)。

Ø 屏蔽双绞线(STP):用于需要更高干扰防护的环境。

(2)同轴电缆(Coaxial Cable)

用于有线电视和早期的以太网。

(3)光纤(Optical Fiber)

Ø 单模光纤(SMF):适用于长距离通信。

Ø 多模光纤(MMF):适用于短距离通信。

2)无线传输介质

(1)无线电波(Radio Waves):用于Wi-Fi、蓝牙、Zigbee等。

(2)微波(Microwaves):用于长距离通信和卫星通信。

(3)红外线(Infrared):用于短距离设备间的通信,如遥控器。

6.10.4 协议

IEEE802.3、IEEE802.4、IEEE802.5的物理层协议、RS-232、RS-485、RS-449、FDDI等。

6.10.5 物理层硬件设备

1)集线器(Hub)

集线器是一种基本的网络设备,用于将多个以太网设备连接在一起。它在所有连接的端口之间广播收到的比特流,所有端口共享同一信道,数据的冲突和碰撞频繁发生,效率低下,逐渐被交换机取代。

2)调制解调器(Modem)

将数字信号转换为模拟信号(调制),或将模拟信号转换回数字信号(解调)。

3)光纤收发器(Fiber Optic Transceiver)

用于在光纤通信系统中发送和接收光信号。它将电信号转换为光信号,并将光信号转换回电信号。通常用在长距离的数据传输中,用于扩展光纤网络的传输距离。

4)光猫(Optical Network Terminal)

光猫是光纤接入终端设备,通常用于家庭和小型办公室,连接光纤服务提供商的网络与用户的内部网络。

5)中继器(Repeater)

用于延长网络信号的传输距离。它接收、放大并重新发送信号,以补偿信号衰减。常用于扩展有线网络覆盖范围,特别是在长距离连接中。

第 7 章 守护进程和I/O多路复用

7.1 守护进程

7.1.1 定义

守护进程是在操作系统后台运行的一种特殊类型的进程,它独立于前台用户界面,不与任何终端设备直接关联。这些进程通常在系统启动时启动,并持续运行直到系统关闭,或者它们完成其任务并自行终止。守护进程通常用于服务请求、管理系统或执行周期性任务。

7.1.2 控制终端

控制终端是与会话关联的终端设备,它是用户输入和输出的通道。进程通常通过其控制终端与用户交互,接收输入和向用户显示输出。对于守护进程来说,它必须从任何控制终端中脱离,确保其独立于任何用户会话在后台运行,这样才能保证其稳定性和安全性,不受用户直接控制和会话结束等事件的影响。

7.1.3 编程规则

1)创建子进程并结束父进程

在UNIX和类UNIX系统中,进程是通过复制(使用fork())创建的。守护进程需要在后台独立运行。

2)设置会话ID

setsid()创建一个新会话,并使调用它的进程成为新会话的领导者,这样做的主要目的是让守护进程摆脱原来的控制终端。这样,守护进程就不会接收到终端发出的任何信号,例如挂断信号(SIGHUP),从而保证其运行不受前台用户操作的影响。

3)第二次fork()

使得守护进程不是会话领导,没有获取控制终端的能力,避免意外获取控制终端。

4)更改工作目录

将工作目录更改到根目录(/),主要是为了避免守护进程继续占用其启动时的文件系统。这对于可移动的或网络挂载的文件系统尤其重要,确保这些文件系统不需要时可以被卸载。

5)重设文件权限掩码

调用umask(0)确保守护进程创建的文件权限不受继承的umask值的影响,守护进程可以更精确地控制其创建的文件和目录的权限。

6)关闭文件描述符

守护进程通常不需要标准输入、输出和错误文件描述符,因为它们不与终端交互。关闭这些不需要的文件描述符可以避免资源泄露,提高守护进程的安全性和效率。

7)处理信号

SIGHUP和SIGTERM信号。

SIGHUP:虽然守护进程和终端断开,但仍然有可能收到其它进程或内核发来的SIGHUP信号,守护进程不应该因为它而终止。

SIGTERM:SIGTERM信号是终止信号,用于请求守护进程优雅地终止。通过命令行执行kill 命令可以发送SIGTERM信号,接收到这个信号之后,守护进程终止子进程,并清理回收资源,最后退出。

8)执行具体任务

这一步是守护进程的核心,它开始执行为其设计的特定功能,如监听网络请求、定期清理文件系统、执行系统备份等。

7.1.4 相关系统调用和库函数

1)setsid

#include <sys/types.h>
#include <unistd.h>
/**
 * @brief 如果调用进程不是进程组的领导者,则创建一个新的会话。创建者是新会话的领导者
 * 
 * @return pid_t 成功则返回调用进程的新会话ID,失败则返回(pid_t)-1,并设置errno以指明错误原因
 */
pid_t setsid(void);

2)umask

#include <sys/types.h>
#include <sys/stat.h>

/**
 * @brief 设置调用进程的文件模式创建掩码。
 * 
 * @param mask 掩码。是一个八进制数,它指定哪些权限位在文件或目录创建时应被关闭。我们通过umask(0)确保守护进程创建的文件和目录具有最开放的权限设置。
 * @return mode_t 这个系统调用必然成功,返回之前的掩码值
 */
mode_t umask(mode_t mask);

3)chdir

#include <unistd.h>

/**
 * @brief 更改调用进程的工作目录
 * 
 * @param path 更改后的工作路径
 * @return int 成功返回0,失败返回-1,并设置errno
 */
int chdir(const char *path);

4)openlog

#include <syslog.h>

/**
 * @brief 为程序开启一个面向系统日志的连接
 * 
 * @param ident 每条消息的字符串前缀,按照惯例通常设置为程序名称
 * @param option option:指定控制 openlog 和后续 syslog 调用的标志。常见标志包括:
 *  LOG_PID:在每条日志消息中包含进程ID。
 *  LOG_CONS:如果无法将消息发送到日志守护进程,则直接将消息写入控制台。
 *  LOG_NDELAY:立即打开与系统日志守护进程的连接。
 *  LOG_ODELAY:延迟打开与系统日志守护进程的连接,直到实际写入日志时。
 *  LOG_PERROR:将日志消息同时输出到标准错误输出。
 * @param facility facility:指定日志消息的来源类别,用于区分系统不同部分的日志消息。包括:
 *  LOG_AUTH:认证系统
 *  LOG_CRON:cron 和 at 调度守护进程
 *  LOG_DAEMON:系统守护进程
 *  LOG_KERN:内核消息
 *  LOG_LOCAL0 至 LOG_LOCAL7:本地使用
 *  LOG_MAIL:邮件系统
 *  LOG_SYSLOG:syslog 自身的消息
 *  LOG_USER:用户进程
 *  LOG_UUCP:UUCP 子系统
 */
void openlog(const char *ident, int option, int facility);

5)syslog

#include <syslog.h>

/**
 * @brief 生成一条日志消息
 * 
 * @param priority 由一个facility和一个level值或操作得到,如果未指定facility,则使用openlog指定的默认值,如果上文没有调用openlog(),则将使用默认值LOG_USER。level取值如下
 *  LOG_EMERG(系统无法使用)表示系统已经不可用,通常用于严重的紧急情况。例如:系统崩溃或关键硬件故障。
 *  LOG_ALERT(必须立即采取行动)表示必须立即采取措施解决的问题。例如:磁盘空间用尽或数据库崩溃。
 *  LOG_CRIT(严重条件)表示严重的错误或问题,但不需要立即采取行动。例如:应用程序的某个重要功能失败。
 *  LOG_ERR(错误条件)表示一般错误情况,需要注意和修复。例如:无法打开文件或网络连接失败。
 *  LOG_WARNING(警告条件)表示潜在问题或警告,建议检查,但不会立即影响系统功能。例如:磁盘空间接近用尽或配置文件缺失。
 *  LOG_NOTICE(正常但重要的情况)表示正常运行过程中需要特别注意的事件。例如:系统启动或关闭成功。
 *  LOG_INFO(信息性消息)表示一般信息,用于记录正常操作的事件。例如:用户登录或定时任务完成。
 *  LOG_DEBUG(调试级别消息)表示详细的调试信息,通常用于开发和调试阶段。例如:函数调用跟踪或变量值变化。
 * @param format 类似于printf()的格式化字符串
 * @param ... 可变参数,可以传递给格式化字符串
 */
void syslog(int priority, const char *format, ...);

6)closelog

#include <syslog.h>

/**
 * @brief 关闭用于写入系统日志的文件描述符
 * 
 */
void closelog(void);

7)sysconf

#include <unistd.h>

/**
 * @brief 获取运行时配置信息
 * 
 * @param name 配置名称,取值太多,可以通过 man 3 sysconf 自行查阅,我们只用到_SC_OPEN_MAX,记录了当前进程可以打开的文件描述符的最大数量
 * @return long 配置的值
 */
long sysconf(int name);

7.1.5 测试案例

1)创建daemon_test.c

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/wait.h>
#include <syslog.h>
#include <string.h>
#include <fcntl.h>
#include <signal.h>
#include <string.h>
#include <errno.h>

pid_t pid;

int is_shutdown = 0;

void signal_handler(int sig)
{
    switch (sig)
    {
    case SIGHUP:
        syslog(LOG_WARNING, "收到SIGHUP信号...");
        break;
    case SIGTERM:
        syslog(LOG_NOTICE, "接收到终止信号,准备退出守护进程...");
        syslog(LOG_NOTICE, "向子进程发送SIGTERM信号...");
        is_shutdown = 1;
        kill(pid, SIGTERM);
        break;
    default:
        syslog(LOG_INFO, "Received unhandled signal");
    }
}

void my_daemonize()
{
    pid_t pid;

    // Fork off the parent process
    pid = fork();

    if (pid < 0)
        exit(EXIT_FAILURE);

    if (pid > 0)
        exit(EXIT_SUCCESS);

    if (setsid() < 0)
        exit(EXIT_FAILURE);

    // 处理 SIGHUP、SIGTERM 信号
    signal(SIGHUP, signal_handler);
    signal(SIGTERM, signal_handler);

    pid = fork();

    if (pid < 0)
        exit(EXIT_FAILURE);

    if (pid > 0)
        exit(EXIT_SUCCESS);

    // 重置umask
    umask(0);

    // 将工作目录切换为根目录
    chdir("/");

    // 关闭所有打开的文件描述符
    for (int x = 0; x <= sysconf(_SC_OPEN_MAX); x++)
    {
        close(x);
    }

    openlog("this is our daemonize process: ", LOG_PID, LOG_DAEMON);
}

int main()
{
    my_daemonize();

    while (1)
    {
        pid = fork();

        if (pid > 0)
        {
            syslog(LOG_INFO, "守护进程正在监听服务端进程...");
            waitpid(-1, NULL, 0);
            if (is_shutdown) {
                syslog(LOG_NOTICE, "子进程已被回收,即将关闭syslog连接,守护进程退出");
                closelog();
                exit(EXIT_SUCCESS);
            }
            syslog(LOG_ERR, "服务端进程终止,3s后重启...");
            sleep(3);
        }
        else if (pid == 0)
        {
            syslog(LOG_INFO, "子进程fork成功");
            syslog(LOG_INFO, "启动服务端进程");

            char *path = "/home/atguigu/daemon_and_multiplex/tcp_server";
            char *argv[] = {"my_tcp_server", NULL};
            errno = 0;
            execve(path, argv, NULL);

            char buf[1024];
            sprintf(buf, "errno: %d", errno);
            syslog(LOG_ERR, "%s", buf);

            syslog(LOG_ERR, "服务端进程启动失败");
            exit(EXIT_FAILURE);
        }
        else
        {
            syslog(LOG_ERR, "子进程fork失败");
        }
    }
    
    return EXIT_SUCCESS;
}

2)创建tcp_server.c

#include <sys/socket.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <arpa/inet.h>
#include <pthread.h>
#include <unistd.h>
#include <signal.h>
#include <syslog.h>

int sockfd;

void zombie_dealer(int sig)
{
    pid_t pid;
    int status;
    char buf[1024];
    memset(buf, 0, 1024);
    // 一个SIGCHLD可能对应多个子进程的退出
    // 使用while循环回收所有退出的子进程,避免僵尸进程的出现
    while ((pid = waitpid(-1, &status, WNOHANG)) > 0)
    {
        if (WIFEXITED(status))
        {
            sprintf(buf, "子进程: %d 以 %d 状态正常退出,已被回收\n", pid, WEXITSTATUS(status));
            syslog(LOG_INFO, "%s", buf);
        }
        else if (WIFSIGNALED(status))
        {
            sprintf(buf, "子进程: %d 被 %d 信号杀死,已被回收\n", pid, WTERMSIG(status));
            syslog(LOG_INFO, "%s", buf);
        }
        else
        {
            sprintf(buf, "子进程: %d 因其它原因退出,已被回收\n", pid);
            syslog(LOG_WARNING, "%s", buf);
        }
    }
}

void sigterm_handler(int sig) {
    syslog(LOG_NOTICE, "服务端接收到守护进程发出的SIGTERM,准备退出...");
    syslog(LOG_NOTICE, "释放sockfd");
    close(sockfd);
    syslog(LOG_NOTICE, "释放syslog连接,服务端进程终止");
    closelog();

    // 退出
    exit(EXIT_SUCCESS);
}

void read_from_client_then_write(void *argv)
{
    int client_fd = *(int *)argv;

    ssize_t count = 0, send_count = 0;
    char *read_buf = NULL;
    char *write_buf = NULL;

    char log_buf[1024];
    memset(log_buf, 0, 1024);

    read_buf = malloc(sizeof(char) * 1024);
    // 判断内存是否分配成功
    if (!read_buf)
    {
        sprintf(log_buf, "服务端pid: %d: 读缓存创建异常,断开连接\n", getpid());
        syslog(LOG_ERR, "%s", log_buf);
        shutdown(client_fd, SHUT_WR);
        close(client_fd);
        return;
    }

    // 判断内存是否分配成功
    write_buf = malloc(sizeof(char) * 1024);
    if (!write_buf)
    {
        sprintf(log_buf, "服务端pid: %d: 写缓存创建异常,断开连接\n", getpid());
        syslog(LOG_ERR, "%s", log_buf);
        free(read_buf);
        shutdown(client_fd, SHUT_WR);
        close(client_fd);
        return;
    }

    while ((count = recv(client_fd, read_buf, 1024, 0)))
    {
        if (count < 0)
        {
            syslog(LOG_ERR, "server recv error");
        }
        sprintf(log_buf, "服务端pid: %d: reveive message from client_fd: %d: %s", getpid(), client_fd, read_buf);
        syslog(LOG_INFO, "%s", log_buf);
        memset(log_buf, 0, 1024);

        sprintf(write_buf, "服务端pid: %d: reveived~\n", getpid());
        send_count = send(client_fd, write_buf, 1024, 0);
    }

    sprintf(log_buf, "服务端pid: %d: 客户端client_fd: %d请求关闭连接......\n", getpid(), client_fd);
    syslog(LOG_NOTICE, "%s", log_buf);
    sprintf(write_buf, "服务端pid: %d: receive your shutdown signal\n", getpid());

    send_count = send(client_fd, write_buf, 1024, 0);

    sprintf(log_buf, "服务端pid: %d: 释放client_fd: %d资源\n", getpid(), client_fd);
    syslog(LOG_NOTICE, "%s", log_buf);
    shutdown(client_fd, SHUT_WR);
    close(client_fd);
    free(read_buf);
    free(write_buf);

    return;
}

int main(int argc, char const *argv[])
{
    int temp_result;

    struct sockaddr_in server_addr, client_addr;

    memset(&server_addr, 0, sizeof(server_addr));
    memset(&client_addr, 0, sizeof(client_addr));

    // 声明IPV4通信协议
    server_addr.sin_family = AF_INET;
    // 我们需要绑定0.0.0.0地址,转换成网络字节序后完成设置
    server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
    // 端口随便用一个,但是不要用特权端口
    server_addr.sin_port = htons(6666);

    // 创建server socket
    sockfd = socket(AF_INET, SOCK_STREAM, 0);

    // 绑定地址
    temp_result = bind(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr));

    // 进入监听模式
    temp_result = listen(sockfd, 128);

    socklen_t cliaddr_len = sizeof(client_addr);

    // 注册信号处理函数,处理SIGCHLD信号,避免僵尸进程出现
    signal(SIGCHLD, zombie_dealer);
    // 处理SIGTERM函数,以优雅退出
    signal(SIGTERM, sigterm_handler);

    char log_buf[1024];
    memset(log_buf, 0, 1024);

    // 接受client连接
    while (1)
    {
        int client_fd = accept(sockfd, (struct sockaddr *)&client_addr, &cliaddr_len);

        pid_t pid = fork();

        if (pid > 0)
        {
            sprintf(log_buf, "this is father, pid is %d, continue accepting...\n", getpid());
            syslog(LOG_INFO, "%s", log_buf);
            memset(log_buf, 0, 1024);

            // 父进程不需要处理client_fd,释放文件描述符,使其引用计数减一,以便子进程释放client_fd后,其引用计数可以减为0,从而释放资源
            close(client_fd);
        }
        else if (pid == 0)
        {
            // 子进程不需要处理sockfd,释放文件描述符,使其引用计数减一
            close(sockfd);
            sprintf(log_buf, "与客户端 from %s at PORT %d 文件描述符 %d 建立连接\n", inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port), client_fd);
            syslog(LOG_INFO, "%s", log_buf);
            memset(log_buf, 0, 1024);

            sprintf(log_buf, "新的服务端pid为: %d\n", getpid());
            syslog(LOG_INFO, "%s", log_buf);
            memset(log_buf, 0, 1024);

            // 读取客户端数据,并打印到 stdout
            read_from_client_then_write((void *)&client_fd);

            // 释放资源并终止子进程
            close(client_fd);
            exit(EXIT_SUCCESS);
        }
    }

    return 0;
}

3)tcp_client.c

#include <sys/socket.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <pthread.h>

#define handle_error(cmd, result) \
    if (result < 0)               \
    {                             \
        perror(cmd);              \
        return -1;                \
    }

void *read_from_server(void *argv)
{
    int sockfd = *(int *)argv;
    char *read_buf = NULL;
    ssize_t count = 0;

    read_buf = malloc(sizeof(char) * 1024);
    if (!read_buf)
    {
        perror("malloc client read_buf");
        return NULL;
    }

    while (count = recv(sockfd, read_buf, 1024, 0))
    {
        if (count < 0)
        {
            perror("recv");
        }
        fputs(read_buf, stdout);
    }

    printf("收到服务端的终止信号......\n");
    free(read_buf);

    return NULL;
}

void *write_to_server(void *argv)
{
    int sockfd = *(int *)argv;
    char *write_buf = NULL;
    ssize_t send_count;

    write_buf = malloc(sizeof(char) * 1024);

    if (!write_buf)
    {
        printf("写缓存申请异常,断开连接\n");
        shutdown(sockfd, SHUT_WR);
        perror("malloc client write_buf");
        return NULL;
    }

    while (fgets(write_buf, 1024, stdin) != NULL)
    {
        send(sockfd, write_buf, 1024, 0);
        if (send_count < 0)
        {
            perror("send");
        }
    }

    printf("接收到命令行的终止信号,不再写入,关闭连接......\n");
    shutdown(sockfd, SHUT_WR);
    free(write_buf);

    return NULL;
}

int main(int argc, char const *argv[])
{
    int sockfd, temp_result;
    pthread_t pid_read, pid_write;

    struct sockaddr_in server_addr;

    memset(&server_addr, 0, sizeof(server_addr));

    server_addr.sin_family = AF_INET;
    // 连接本机 127.0.0.1
    server_addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
    // 连接端口 6666
    server_addr.sin_port = htons(6666);

    // 创建socket
    sockfd = socket(AF_INET, SOCK_STREAM, 0);
    handle_error("socket", sockfd);

    // 连接server
    temp_result = connect(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr));
    handle_error("connect", temp_result);

    // 启动一个子线程,用来读取服务端数据,并打印到 stdout
    pthread_create(&pid_read, NULL, read_from_server, (void *)&sockfd);
    // 启动一个子线程,用来从命令行读取数据并发送到服务端
    pthread_create(&pid_write, NULL, write_to_server, (void *)&sockfd);

    // 主线程等待子线程退出
    pthread_join(pid_read, NULL);
    pthread_join(pid_write, NULL);

    printf("关闭资源\n");
    close(sockfd);

    return 0;
}

4)Makefile

.PHONY: tcp_clean
tcp_executables:=tcp_server tcp_client
CC:=gcc

tcp_server: tcp_server.c
    -$(CC) -o $@ $^

tcp_client: tcp_client.c
    $(CC) -o $@ $^

tcp_clean:
    -rm ./$(word 1, $(tcp_executables)) ./$(word 2, $(tcp_executables))
daemon_test: daemon_test.c tcp_clean tcp_server tcp_client
    -$(CC) -o $@ $<
    -./$@
    -rm ./$@

5)运行

(1)监控系统日志文件

当前Linux系统的系统日志文件路径为/var/log/syslog,命令行执行以下命令。

atguigu@ubuntu:~$ tail -F /var/log/syslog

image-20260824225725736

(2)在Makefile中运行daemon_test目标

image-20260824225746015

查看日志

image-20260824225800570

(3)命令行查看进程

atguigu@ubuntu:~$ ps -ef

image-20260824225830093

(4)启动客户端

在新的XShell标签页中执行以下命令

atguigu@ubuntu:~/daemon_and_multiplex$ ./tcp_client

image-20260824225904754

(5)客户端发送数据

image-20260824225924306

(6)客户端Ctrl+D发送终止符

image-20260824225942777

(7)杀掉服务端进程

image-20260824230006531

(8)3s后查看

image-20260824230024713

查看进程,服务端已被重启。

image-20260824230040495

(9)优雅关闭守护进程

image-20260824230059296

再次查看进程列表,守护进程已退出。

image-20260824230115029

7.2 I/O多路复用

7.2.1 什么是I/O多路复用

上文我们介绍了两种服务端接收多个TCP连接的实现方式:分别基于多线程和多进程实现。我们知道,进程和线程的创建都是有开销的,如果需要同时维护上万连接,这两种方式都是扛不住的。此时,需要使用更加节省资源、更加高效的方式:I/O多路复用。

1)定义

IO多路复用(I/O Multiplexing)是Linux中用于处理多个I/O操作的机制,使得单个线程或进程可以同时监视多个文件描述符,以处理多路I/O请求。它主要通过以下系统调用实现:select、poll 和 epoll。

2)实现机制

select:select 是早期的IO多路复用机制,它允许程序监视多个文件描述符,判断是否可以进行I/O操作。程序通过提供三个文件描述符集(读、写、异常)和一个超时时间来调用select,在任何一个文件描述符变得可读、可写或出现错误时返回。

poll:poll 与 select 类似,但它使用一个包含文件描述符和事件的结构数组来代替三个文件描述符集。它可以处理更多文件描述符,并且更容易管理。

epoll:epoll 是 Linux 特有的、性能优化的 I/O 多路复用机制。它比 select 和 poll 更高效,特别适用于大规模并发连接。epoll 提供了两种工作模式:水平触发(Level-Triggered, LT)和边沿触发(Edge-Triggered, ET)。ET 模式下,epoll 只在状态变化时通知,因此更高效,但也更复杂。

3)作用和意义

(1)节省资源:IO多路复用允许单个进程或线程同时监视多个文件描述符,而不是为每个I/O操作创建一个线程或进程。只需要维护文件描述符,极大地提高了节约了资源,减少了系统开销。

(2)效率高:使用IO多路复用省去了进程或线程上下文切换的开销,提升了处理效率,减少了系统资源(如内存和CPU时间)的消耗,从而提高了应用程序的整体性能和响应速度。

(3)简化编程模型:尽管IO多路复用增加了代码的复杂性,但它简化了高并发程序的设计,使得程序员可以更容易地管理多个I/O操作,而不必处理大量的线程同步问题。

4)应用场景

(1)网络服务器:例如HTTP服务器、FTP服务器等需要同时处理大量客户端请求的场景。

(2)聊天系统:实时聊天应用程序需要高效地管理多个用户的消息。

(3)监控系统:例如日志监控、数据库连接池管理等需要同时监视多个输入源的系统。

IO多路复用是Linux系统中处理多路I/O操作的重要技术,它通过提高资源利用率和系统性能,为开发高效、高并发的应用程序提供了强大的支持。

select和poll底层都是基于线性结构实现的,需要对文件描述符集执行多次遍历和拷贝,效率低下,而epoll底层是基于红黑树(一种平衡二叉树)实现的,且通过维护就绪事件链表,效率更高,本文只介绍epoll

7.2.2 相关系统调用及数据类型

1)边缘触发和水平触发

在 epoll 的使用中,有两种事件触发模式:边缘触发(Edge Triggered, ET)和水平触发(Level Triggered, LT)。这两种模式决定了 epoll 如何通知应用程序有事件发生。可以类比单片机的边缘触发和电平触发。

水平触发是epoll的默认模式。在这种模式下,只要文件描述符上有未处理的事件,epoll就会不断通知应用程序。

边缘触发模式下,当文件描述符从未就绪状态变为就绪状态时,epoll 会通知应用程序。如果应用程序没有在通知后及时处理事件(例如,读出所有可读的数据),epoll 不会再次通知,除非文件描述符再次从未就绪变为就绪状态。即只在状态变化时通知一次,因而叫边缘触发。

2)epoll_data

/**
 * @brief 记录内核需要存储的epoll实例相关数据的联合体。fd可用来存储文件描述符。
 * 
 */
typedef union epoll_data {
void        *ptr;
int          fd;
uint32_t     u32;
uint64_t     u64;
} epoll_data_t;

3)epoll_event

/**
 * @brief epoll实例维护了一个文件描述符的集合,称为感兴趣列表,这个列表中的文件描述符会被监听,并在就绪时被加入就绪链表。感兴趣列表存储的是文件描述符和struct epoll_event结构体实例组成的entry。
 * events: 需要监听的就绪事件,是由以下0或更多事件类型或操作得到的:EPOLLIN、EPOLLOUT、EPOLLRDHUP、EPOLLPRI、EPOLLERR、EPOLLHUP、EPOLLET、EPOLLONESHOT、EPOLLWAKEUP、EPOLLEXCLUSIVE。下面的案例中我们只会用到EPOLLIN、EPOLLET
 *  EPOLLIN: epoll_event实例相关联的文件描述符可用于读操作
 *  EPOLLET: 将关联的文件描述符设置为边缘触发模式
 * data: 指明内核应该存储并在文件描述符就绪时返回的数据
 */
struct epoll_event {
uint32_t     events;      /* Epoll events */
epoll_data_t data;        /* User data variable */
};

4)epoll_create

#include <sys/epoll.h>

/**
 * @brief epoll创建一个新的epoll实例。
 * 
 * @param size 从Linux内核2.6.8版本开始(当前Ubuntu系统版本为6.5.0)该参数被忽略,但是必须大于0
 * @return int 成功返回指向新的epoll实例的文件描述符,失败返回-1并设置errno指明错误原因
 */
int epoll_create(int size);

5)epoll_create1

#include <sys/epoll.h>

/**
 * @brief 如果传入的flags参数为0,则与epoll_create完成的工作相同。
 * 
 * @param flags 标记以获得不同的行为,可以是EPOLL_CLOEXEC,该标记将创建的epoll实例的文件描述符标记为close-on-exec。
 * @return int 成功返回指向新的epoll实例的文件描述符,失败返回-1并设置errno指明错误原因
 */
int epoll_create1(int flags);

6)epoll_ctl

#include <sys/epoll.h>

/**
 * @brief epoll_ctl提供了对fd和event组成的entry执行增、删、改操作的方式。
 * 
 * @param epfd epoll实例的文件描述符
 * @param op 对文件描述符执行的操作
 *  EPOLL_CTL_ADD: 将fd和event组成的entry添加到感兴趣列表
 *  EPOLL_CTL_MOD: 将感兴趣列表中与fd关联的event替换为此处传入的event
 *  EPOLL_CTL_DEL: 从感兴趣列表删除与fd关联的entry,此时event被忽略
 * @param fd 待处理的文件描述符
 * @param event fd关联的描述信息。
 * @return int 成功返回0,失败返回-1,并设置errno以指明错误原因
 */
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

7)epoll_wait

#include <sys/epoll.h>

/**
 * @brief 等待epfd指向的epoll实例感兴趣列表中的事件发生。
 * 
 * @param epfd epoll实例文件描述符
 * @param events 提供给内核,用于返回已就绪的文件描述符信息
 * @param maxevents 可以返回的最大文件描述符数量
 * @param timeout 超时时间,指明epoll_wait()在事件触发前阻塞等待的最大毫秒数。
 *                -1表示一直等待至有事件发生,0表示无论是否有事件发生立即返回。
 * @return int 成功返回就绪的文件描述符数量,超时时间耗尽仍没有就绪事件返回0。失败则返回-1,并设置errno指明错误原因
 */
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

8)fcntl

#include <unistd.h>
#include <fcntl.h>

/**
 * @brief 对fd指向的文件描述符执行cmd指明的操作。
 * 
 * @param fd 文件描述符
 * @param cmd 执行的操作,很多,此处不一一列出,常用的有F_GETFL和F_SETFL
 *  F_GETFL: 返回文件的权限模式和状态标记,不需要额外的参数
 *  F_SETFL: 将文件的状态标记设置为第三个参数指定的值
 * @param ... cmd需要的参数,可以没有
 * @return int 如果是F_GETFL,成功则返回文件状态标记值,如果是F_SETFL成功返回0。失败均返回-1,同时设置errno指明错误原因
 */
int fcntl(int fd, int cmd, ...);

7.2.3 测试例程

1)创建epoll_test.c

#include <sys/socket.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <arpa/inet.h>
#include <pthread.h>
#include <unistd.h>
#include <sys/epoll.h>
#include <fcntl.h>
#include <errno.h>

#define SEVER_PORT 6666
#define BUFFER_SIZE 1024
#define MAX_EVENTS 10

#define handle_error(cmd, result) \
    if (result < 0)               \
    {                             \
        perror(cmd);              \
        exit(EXIT_FAILURE);       \
    }

char *read_buf = NULL;
char *write_buf = NULL;

void init_buf()
{

    read_buf = malloc(sizeof(char) * BUFFER_SIZE);
    // 判断内存是否分配成功
    if (!read_buf)
    {
        printf("服务端读缓存创建异常,断开连接\n");
        perror("malloc sever read_buf");
        exit(EXIT_FAILURE);
    }

    // 判断内存是否分配成功
    write_buf = malloc(sizeof(char) * BUFFER_SIZE);
    if (!write_buf)
    {
        printf("服务端写缓存创建异常,断开连接\n");
        free(read_buf);
        perror("malloc server write_buf");
        exit(EXIT_FAILURE);
    }

    memset(read_buf, 0, BUFFER_SIZE);
    memset(write_buf, 0, BUFFER_SIZE);
}

void clear_buf(char *buf)
{
    memset(buf, 0, BUFFER_SIZE);
}

void set_nonblocking(int sockfd)
{
    int opts = fcntl(sockfd, F_GETFL);
    if (opts < 0)
    {
        perror("fcntl(F_GETFL)");
        exit(EXIT_FAILURE);
    }

    opts |= O_NONBLOCK;
    int res = fcntl(sockfd, F_SETFL, opts);
    if (res < 0)
    {
        perror("fcntl(F_SETFL)");
        exit(EXIT_FAILURE);
    }
}

int main(int argc, char const *argv[])
{
    init_buf();

    // 声明sockfd、clientfd和函数返回状态变量
    int sockfd, client_fd, temp_result;

    // 声明服务端和客户端地址
    struct sockaddr_in server_addr, client_addr;

    memset(&server_addr, 0, sizeof(server_addr));
    memset(&client_addr, 0, sizeof(client_addr));

    // 声明IPV4通信协议
    server_addr.sin_family = AF_INET;
    // 我们需要绑定0.0.0.0地址,转换成网络字节序后完成设置
    server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
    // 端口随便用一个,但是不要用特权端口
    server_addr.sin_port = htons(SEVER_PORT);

    // 创建server socket
    sockfd = socket(AF_INET, SOCK_STREAM, 0);
    handle_error("socket", sockfd);

    // 绑定地址
    temp_result = bind(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr));
    handle_error("bind", temp_result);

    // 进入监听模式
    temp_result = listen(sockfd, 128);
    handle_error("listen", temp_result);

    // 将sockfd设置为非阻塞模式
    set_nonblocking(sockfd);

    int epollfd, nfds;
    struct epoll_event ev, events[MAX_EVENTS];

    epollfd = epoll_create1(0);
    handle_error("epoll_createl", epollfd);
    ev.data.fd = sockfd;
    ev.events = EPOLLIN;

    temp_result = epoll_ctl(epollfd, EPOLL_CTL_ADD, sockfd, &ev);
    handle_error("epoll_ctl", temp_result);

    socklen_t cliaddr_len = sizeof(client_addr);
    // 接受client连接
    while (1)
    {
        nfds = epoll_wait(epollfd, events, MAX_EVENTS, -1);
        handle_error("epoll_wait", nfds);

        for (int i = 0; i < nfds; i++)
        {
            if (events[i].data.fd == sockfd)
            {
                client_fd = accept(sockfd, (struct sockaddr *)&client_addr, &cliaddr_len);
                handle_error("accept", client_fd);
                set_nonblocking(client_fd);

                printf("与客户端 from %s at PORT %d 文件描述符 %d 建立连接\n",
                       inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port), client_fd);
                ev.data.fd = client_fd;

                ev.events = EPOLLIN | EPOLLET;
                epoll_ctl(epollfd, EPOLL_CTL_ADD, client_fd, &ev);
            }
            else if (events[i].events & EPOLLIN)
            {
                int count = 0, send_count = 0;
                client_fd = events[i].data.fd;
                while ((count = recv(client_fd, read_buf, BUFFER_SIZE, 0)) > 0)
                {
                    printf("reveive message from client_fd: %d: %s\n", client_fd, read_buf);
                    clear_buf(read_buf);

                    strcpy(write_buf, "reveived~\n");
                    send_count = send(client_fd, write_buf, strlen(write_buf), 0);
                    handle_error("send", send_count);
                    clear_buf(write_buf);
                }

                if (count == -1 && errno == EAGAIN)
                {
                    printf("来自客户端client_fd: %d当前批次的数据已读取完毕,继续监听文件描述符集\n", client_fd);
                }
                else if (count == 0)
                {
                    printf("客户端client_fd: %d请求关闭连接......\n", client_fd);
                    strcpy(write_buf, "receive your shutdown signal\n");
                    send_count = send(client_fd, write_buf, strlen(write_buf), 0);
                    handle_error("send", send_count);
                    clear_buf(write_buf);

                    // 从epoll文件描述符集中移除client_fd
                    printf("从epoll文件描述符集中移除client_fd: %d\n", client_fd);
                    epoll_ctl(epollfd, EPOLL_CTL_DEL, client_fd, NULL);

                    printf("释放client_fd: %d资源\n", client_fd);
                    shutdown(client_fd, SHUT_WR);
                    close(client_fd);
                }
            }
        }
    }

    printf("释放资源\n");
    close(epollfd);
    close(sockfd);
    free(read_buf);
    free(write_buf);

    return 0;
}

2)Makefile

epoll_test: epoll_test.c
    -$(CC) -o $@ $^
    -./$@
    -rm ./$@

3)运行

(1)启动epoll_test

image-20260824230702741

(2)启动客户端并发送数据

客户端例程使用上文的tcp_client

image-20260824230721705

(3)客户端断开连接

image-20260824230742196

我们通过epoll,只用单线程完成了之前需要多线程或多进程才可以完成的事情。

posted @ 2026-08-25 07:27  Ref-brief  阅读(5)  评论(1)    收藏  举报