6、多线程并发服务器:pthread_create、线程同步(互斥锁、条件变量)、线程池设计、多线程服务器模型
说实话,搞网络编程,单线程的迭代服务器(就是那种accept一个连接,处理完再accept下一个)只能应付教学实验。一旦上了生产环境,并发量稍微上来点,立马卡死。我记得我第一次用单线程去压测一个简单的echo服务,并发100个连接,后面几十个直接超时。嗯,从那时候我就知道,多线程是绕不开的坎。
6.1 从pthread_create说起
创建线程在Linux下就是调一个函数:pthread_create。它的原型长这样:
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
void *(*start_routine) (void *), void *arg);
参数不多,但有个坑我提一下:arg如果传的是局部变量的地址,一定要保证线程结束前这个变量还活着。我曾经在项目里传了一个栈上的int进去,线程还没开始跑,那个int就被回收了,结果读出来全是乱码。后来我习惯用堆上分配的结构体,或者用malloc包一层。
一个最简单的多线程服务器骨架:
void *handle_client(void *arg) {
int client_fd = *(int *)arg;
// 处理业务...
close(client_fd);
free(arg);
return NULL;
}
int main() {
int listen_fd = socket(...);
bind(listen_fd, ...);
listen(listen_fd, 128);
while (1) {
int *client_fd = malloc(sizeof(int));
*client_fd = accept(listen_fd, NULL, NULL);
pthread_t tid;
pthread_create(&tid, NULL, handle_client, client_fd);
pthread_detach(tid); // 不关心线程结束状态
}
}
你看,每来一个连接就创建一个线程。这种做法在连接数少的时候没问题,但连接一多,线程创建销毁的开销就上来了。而且线程太多,上下文切换能把CPU拖死。
6.2 线程同步:互斥锁与条件变量
多线程一多,共享资源就打架。比如多个线程同时往一个日志文件里写,或者同时修改一个全局计数器。这时候就需要互斥锁(mutex)来保护临界区。
互斥锁的使用很简单:
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
void *thread_func(void *arg) {
pthread_mutex_lock(&lock);
// 临界区代码
pthread_mutex_unlock(&lock);
return NULL;
}
但光有锁还不够。有时候一个线程需要等待某个条件成立才能继续,比如任务队列为空时,消费者线程应该等待,而不是空转。这时候条件变量就派上用场了。
条件变量的典型用法:
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
// 生产者
pthread_mutex_lock(&lock);
// 添加任务到队列
pthread_cond_signal(&cond); // 唤醒一个等待的消费者
pthread_mutex_unlock(&lock);
// 消费者
pthread_mutex_lock(&lock);
while (queue_empty()) {
pthread_cond_wait(&cond, &lock); // 自动释放锁,被唤醒后重新获取锁
}
// 取出任务处理
pthread_mutex_unlock(&lock);
这里有个细节:pthread_cond_wait为什么用while而不是if?因为存在“虚假唤醒”——线程可能在没有收到signal的情况下被唤醒。用while重新检查条件,能保证万无一失。我在早期一个项目里吃过这个亏,当时用if判断,结果队列为空时线程继续往下走,直接访问了空指针。
6.3 线程池设计
既然每连接一线程不靠谱,那我们就搞线程池。说白了,就是提前创建好一批线程,让它们等着干活。有任务来了就派一个线程去处理,处理完线程不销毁,继续等下一个任务。
线程池的核心组件:
- 任务队列:存放待处理的任务(通常是一个函数指针+参数)
- 工作线程数组:固定数量的线程,循环从队列取任务
- 互斥锁+条件变量:保护任务队列的并发访问
- 线程池状态:运行中/停止中/已停止
我画了一张线程池的结构图,方便你理解:
线程池的代码实现,我给出一个精简版的核心逻辑:
typedef struct {
void (*function)(void *);
void *arg;
} task_t;
typedef struct {
task_t *tasks; // 任务数组(环形队列)
int capacity;
int head, tail;
int count;
pthread_mutex_t lock;
pthread_cond_t not_empty;
pthread_cond_t not_full;
int shutdown; // 关闭标志
} thread_pool_t;
void *worker(void *arg) {
thread_pool_t *pool = (thread_pool_t *)arg;
while (1) {
pthread_mutex_lock(&pool->lock);
while (pool->count == 0 && !pool->shutdown) {
pthread_cond_wait(&pool->not_empty, &pool->lock);
}
if (pool->shutdown) {
pthread_mutex_unlock(&pool->lock);
return NULL;
}
task_t task = pool->tasks[pool->head];
pool->head = (pool->head + 1) % pool->capacity;
pool->count--;
pthread_cond_signal(&pool->not_full);
pthread_mutex_unlock(&pool->lock);
task.function(task.arg); // 执行任务
}
}
6.4 多线程服务器模型
有了线程池,服务器模型就清晰了。常见的模型有两种:
| 模型 | 特点 | 适用场景 |
|---|---|---|
| 主线程accept + 线程池处理 | 主线程只负责accept,把client_fd丢进任务队列 | 连接数中等,处理时间较短 |
| 多Reactor + 线程池 | 多个线程各自accept,各自处理,用锁协调 | 高并发、多核CPU |
我个人比较喜欢第一种,简单可靠。主线程就干一件事:accept,然后把client_fd封装成任务塞进队列。工作线程从队列取任务,处理完就完事。这样主线程不会被阻塞,能快速响应新连接。
但这里有个问题:如果任务队列满了怎么办?我一般用两种策略:
- 阻塞等待:主线程在
pthread_cond_wait上等,直到队列有空位。 - 丢弃连接:直接返回一个503,告诉客户端“服务器忙,请重试”。
生产环境中,我倾向于阻塞等待,因为丢连接会导致客户端重试,反而增加压力。当然,如果队列长度设置合理,阻塞的概率很低。
最后提一句,线程池的关闭也是个技术活。不能直接pthread_cancel,那样会资源泄漏。正确的做法是设置shutdown标志,然后broadcast条件变量,让所有工作线程醒来后自己退出。嗯,细节决定成败。
好了,多线程并发服务器这块,核心就是线程池+同步机制。你想想看,从每连接一线程到线程池,其实就是用空间换时间,用固定数量的线程去应对动态的并发连接。这个思路在很多地方都通用,比如数据库连接池、HTTP连接池,本质都一样。