网络编程学习20--阻塞I/O + 多线程模型

172 阅读9分钟

阻塞I/O + 多线程模型

由于进程的上下文切换代价较高,所以可以使用更轻量级的线程来代替进程。

操作系统中允许在单个进程内运行多个线程。线程由操作系统内核管理,每个线程都有自己的上下文,包括唯一可以标识线程的ID(tid)、栈、程序计数器、寄存器(每个线程独有的资源)等。在同一个进程中,所有线程共享该进程的整个虚拟地址空间,包括代码、数据、堆、共享库等(每个线程共享的资源)。

每个进程一开始都会产生一个线程,称为主线程,主线程可以再产生子线程,这样的 主线程-子线程对 可以叫做一个对等线程。

线程的上下文切换:我们的代码被 CPU 执行的时候,是需要一些数据支撑的,比如程序计数器告诉 CPU 代码执行到哪里了,寄存器里存了当前计算的一些中间值,内存里放置了一些当前用到的变量等,从一个计算场景,切换到另外一个计算场景,程序计数器、寄存器等这些值重新载入新场景的值,就是线程的上下文切换。

POSIX线程

POSIX线程是现代UNIX系统提供的处理线程的标准接口。POSIX定义的线程函数大约有60多个,这些函数可以用来创建线程、回收线程等。

主要线程函数

创建线程

函数原型

 #include <pthread.h>
 int pthread_create(pthread_t* thread, const pthread_attr_t* attr, void* (*start_routine)(void* ), void* arg);

通过pthread_create函数可以创建一个线程。

参数

1)pthread_t的定义如下:

 #include <bits/ pthreadtypes.h>
 typedef unsigned long int pthread_t;

thread参数是一个输出参数,代表了线程ID,它的类型是一个整型类型,如果创建线程成功,可以由thread参数得到返回的线程ID

2)attr参数用于设置新线程的属性。如果给它传递NULL,表示使用默认线程属性。线程拥有很多属性,比如优先级,是否该成为一个守护进程等。

3)start_routine参数代表新线程的入口函数,即新线程将要运行的函数。该函数可以接收一个参数arg,类型为指针,如果我们想给该函数传入多个值,那么就需要将这些值包装成一个结构体,再将这个结构体的地址作为arg参数在入口函数内,再将该地址转换为该结构体的指针对象

由于arg类型是void*,所以我们传入参数时,如果想要传递参数的值的话,需要将参数强制转换为void* 类型,在入口函数内再强制转换为原来的类型:

 // 传值
 int fd;
 pthread_create(&tid, NULL, &thread_run, (void*) fd);
 void* thread_run(void* arg) {
     int fd = (int) arg;
 }
 ​
 // 传结构体
 typedef struct {} Queue;
 Queue q;
 pthread_create(&tid, NULL, &thread_run, (void*) &q);
 void* thread_run(void* arg) {
     Queue* Q = (Queue*) arg;
 }
 ​

在新线程的入口函数内,通过执行 pthread_self 函数返回线程 tid

 pthread_t pthread_self(void);

返回值

pthread_create函数成功时返回0,失败时返回错误码。

一个用户可以打开的线程数量不能超过RLIMIT_NPROC软资源限制。此外,系统上所有用户能创建的线程总数也不得超过 /proc/sys/kernel/threads-max 内核参数所定义的值。

终止线程

多线程编程中,线程结束执行的方式有三种:

  1. 线程将指定函数体中的代码执行完毕后自行结束
  2. 线程执行过程中,被同一进程的其他线程(包括主线程)强制终止。(pthread_cancel函数)
  3. 线程执行过程中,遇到 pthread_exit() 函数结束执行

注意,默认属性的线程执行结束后并不会立即释放占用的资源,直到整个进程执行结束,所有线程的资源以及整个进程占用的资源才会被操作系统回收。

pthread_exit函数原型

 #include <pthread.h>
 void pthread_exit(void* retval);

线程函数在结束时最好调用 pthread_exit() 函数,确保安全、干净地退出。它执行完之后不会返回到调用者,而且永远也不会失败。

如果在父线程内调用该函数,那么父线程会等待其他所有的子线程终止,然后父线程自己也终止。

参数

pthread_exit函数通过 retval参数向线程的回收者传递其退出信息。它指向的数据将作为线程退出时的返回值。如果线程不需要返回任何数据,将 retval 参数置为 NULL 即可。

注意,retval 指针不能指向函数内部的局部数据(比如局部变量)。换句话说,pthread_exit() 函数不能返回一个指向局部数据的指针,否则很可能使程序运行结果出错甚至崩溃。

pthread_cancel函数原型

 #include <pthread.h>
 int pthread_cancel(pthread_t thread);

通过pthread_cancel函数可以异常终止一个线程,即取消线程。通过调用 pthread_cancel 可以主动终止一个子线程,和 pthread_exit 不同的是,它可以指定某个子线程终止。

参数

thread参数是目标线程的标识符

返回值

pthread_cancel函数成功时返回0,失败则返回错误码。

不过接收到取消请求的目标线程可以决定是否允许被取消以及如何取消。这分别由如下两个函数完成:

 #include <pthread.h>
 int pthread_setcancelstate(int state, int* oldstate);
 int pthread_setcanceltype(int type, int* oldtype);

这两个函数的第一个参数分别用于设置线程的取消状态(是否允许取消)和取消类型(如何取消),第二个参数则分别记录线程原来的取消状态和取消类型。

回收终止线程的资源

实现线程资源及时回收的常用方法有两种,一种是修改线程属性,另一种是在另一个线程中调用 pthread_join() 函数。

pthread_join函数原型

 #include <pthread.h>
 int pthread_join(pthread_t thread, void** retval);

一个进程中的所有线程都可以调用pthread_join函数来回收其他线程(前提是目标线程是可回收的),即等待其他线程结束。调用该函数时,当前线程会阻塞,直到对应 tid 的子线程自然终止。和 pthread_cancel不同的是,它不会强迫子线程终止

参数

1)thread参数是目标线程的标识符。

2)retval参数则是目标线程返回的退出信息

返回值

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

可能的错误码,如下:

 EDEADLK  // 可能引起死锁。比如两个线程互相针对对方调用pthread_join,或者线程对自身调用pthread_join
 EINVAL   // 目标线程是不可回收的,或者已经有其他线程在回收该目标线程
 ESRCH    // 目标线程不存在

分离线程

一个线程的重要属性是可结合的(joinable,默认值)或者是分离的(detached)。

一个可结合的线程是能够被其他线程杀死和回收资源的,当一个可结合的线程终止时,它的线程ID和退出状态将留存到另一个线程(该线程对它调用 pthread_join)。

一个分离的线程不能被其他线程杀死或回收资源,分离的线程就像守护进程,当它们终止时,所有相关资源都被释放,我们不能等待它们终止。如果一个线程需要知道另一个线程什么时候终止,那就最好保持第二个线程为可结合的状态。

通过pthread_detach函数可以把指定线程转变为分离状态。

函数原型

 #include <pthread.h>
 int pthread_detach(pthread_t tid);

返回值

成功则为0,出错则返回错误码。

该函数通常由想让自己脱离的线程调用,代码如下:

 pthread_detach(pthread_self());

在高并发的例子里,每个连接都由一个线程单独处理,在这种情况下,服务器程序并不需要对每个子线程进行终止,这样的话,每个子线程可以在入口函数开始的地方,把自己设置为分离的,这样就能在它终止后自动回收相关的线程资源了,就不需要调用 pthread_join 函数了。

构建线程池处理多个连接

如果我们在每次有新连接建立后,创建一个新的线程来处理这个连接,那么当并发连接过多时,会引起线程的频繁创建和销毁。虽然线程的上下文切换的开销不大,但是线程的创建和销毁的开销不小。

所以可以考虑通过预创建线程池的方式来进行优化。在服务器端启动时,可以先按照固定大小预创建出多个线程,当有新连接建立时,往连接字队列里放入新连接描述符线程池里的线程负责从连接字队列中取出连接字描述符进行处理

img

所以关键在于对连接字队列的设计。(初始化队列,放置描述符操作,取出描述符操作)
互斥锁:juejin.cn/post/705412…
条件变量:juejin.cn/post/705412…
连接字队列的设计,代码如下:

// 连接字队列
typedef struct {
    int maxSize; // 队列中描述符最大个数
    int *connectfd; // 存放描述符的数组
    int front; // 队列的头位置
    int rear;  // 队列的尾位置
    pthread_mutex_t mutex; // 互斥锁
    pthread_cond_t cond;  // 条件变量
}ConnectQueue;

// 连接字队列初始化
void ConnectQueue_init(ConnectQueue* cq, int size) {
    cq->maxSize = size;
    cq->connectfd = calloc(size, sizeof(int));
    cq->front = 0;
    cq->rear = 0;
    pthread_mutex_init(&cq->mutex, NULL);
    pthread_cond_init(&cq->cond, NULL);
}

// 往连接字队列中放入一个文件描述符
void ConnectQueue_push(ConnectQueue* cq, int fd) {
    // 必须先加锁,因为可能有多个线程在同时读写队列
    pthread_mutex_lock(&cq->mutex);
    // 将描述符放到队尾位置,如果rear到达队尾,则将其重置为0
    cq->connectfd[cq->rear] = fd;
    if(++cq->rear == cq->maxSize) {
        cq->rear = 0;
    }
    printf("push fd %d \n", fd);
    // 放入描述符后,需要通知其他线程有新的连接字描述符加入队列
    pthread_cond_signal(&cq->cond);
    pthread_mutex_unlock(&cq->mutex);
}

// 从连接字队列中取出一个文件描述符
int ConnectQueue_pop(ConnectQueue* cq) {
    // 必须先加锁,因为可能有多个线程在同时读写队列
    pthread_mutex_lock(&cq->mutex);

    // 进行布尔条件判断,并调用条件变量的wait函数(while循环)
    // 当front == rear 时,说明没有新的文件描述符,此时需要等待
    while(cq->front == cq->rear) {
        pthread_cond_wait(&cq->cond, &cq->mutex);
    }

    // 当条件满足时,取出文件描述符
    int fd = cq->connectfd[cq->front];
    // 将front后移动1位,如果达到了队列尾,就将front重置为0
    if(++cq->front == cq->maxSize) {
        cq->front = 0;
    }
    printf("pop fd %d \n", fd);
    pthread_mutex_unlock(&cq->mutex);
    return fd; // 返回获取的文件描述符
}