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拖死。

注意: 上面的代码没有做线程数量限制。如果并发连接数超过系统上限(比如ulimit -u),pthread_create会直接返回EAGAIN。生产环境绝对不能这么写。

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 线程池设计

既然每连接一线程不靠谱,那我们就搞线程池。说白了,就是提前创建好一批线程,让它们等着干活。有任务来了就派一个线程去处理,处理完线程不销毁,继续等下一个任务。

线程池的核心组件:

  • 任务队列:存放待处理的任务(通常是一个函数指针+参数)
  • 工作线程数组:固定数量的线程,循环从队列取任务
  • 互斥锁+条件变量:保护任务队列的并发访问
  • 线程池状态:运行中/停止中/已停止

我画了一张线程池的结构图,方便你理解:

任务队列 任务1 (fd=5, 处理函数=handle_echo) 任务2 (fd=6, 处理函数=handle_http) 任务3 (fd=7, 处理函数=handle_file) 互斥锁 保护队列操作 条件变量 唤醒等待线程 工作线程池 线程1 (空闲中) 线程2 (处理任务1) 线程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);  // 执行任务
    }
}
经验之谈: 线程池的线程数量怎么定?我一般用CPU核心数+1作为I/O密集型任务的基准。如果是CPU密集型,就设为核心数。当然,最好通过压测来调优,别拍脑袋。

6.4 多线程服务器模型

有了线程池,服务器模型就清晰了。常见的模型有两种:

模型 特点 适用场景
主线程accept + 线程池处理 主线程只负责accept,把client_fd丢进任务队列 连接数中等,处理时间较短
多Reactor + 线程池 多个线程各自accept,各自处理,用锁协调 高并发、多核CPU

我个人比较喜欢第一种,简单可靠。主线程就干一件事:accept,然后把client_fd封装成任务塞进队列。工作线程从队列取任务,处理完就完事。这样主线程不会被阻塞,能快速响应新连接。

但这里有个问题:如果任务队列满了怎么办?我一般用两种策略:

  • 阻塞等待:主线程在pthread_cond_wait上等,直到队列有空位。
  • 丢弃连接:直接返回一个503,告诉客户端“服务器忙,请重试”。

生产环境中,我倾向于阻塞等待,因为丢连接会导致客户端重试,反而增加压力。当然,如果队列长度设置合理,阻塞的概率很低。

核心要点: 多线程服务器的瓶颈往往不在CPU,而在锁竞争。尽量减少临界区的长度,能用局部变量就别用全局变量。我见过一个项目,日志打印加了锁,结果所有线程都在等锁,性能直接腰斩。

最后提一句,线程池的关闭也是个技术活。不能直接pthread_cancel,那样会资源泄漏。正确的做法是设置shutdown标志,然后broadcast条件变量,让所有工作线程醒来后自己退出。嗯,细节决定成败。

好了,多线程并发服务器这块,核心就是线程池+同步机制。你想想看,从每连接一线程到线程池,其实就是用空间换时间,用固定数量的线程去应对动态的并发连接。这个思路在很多地方都通用,比如数据库连接池、HTTP连接池,本质都一样。


公众号:蓝海资料掘金营,微信deep3321