一、线程池——多线程编程的“必考题”

说实话,我做了十几年系统编程,面试过不下两百个候选人。问线程池实现,能真正写明白的,不超过三成。

为什么?因为很多人背了接口,却没理解本质。线程池说白了就是:提前创建一批线程,等着干活。你想想看,如果每次来一个任务就 new 一个线程,那系统开销得多大?线程创建销毁的成本可不低。

我在早期的一个网络代理项目里就吃过这个亏。当时并发请求一上来,系统直接卡死——线程数飙到上千,上下文切换把 CPU 都吃光了。后来改成线程池,问题迎刃而解。

二、线程池的核心设计思路

一个线程池,核心就三样东西:

  • 任务队列:存放待执行的任务
  • 工作线程:从队列里取任务执行
  • 同步机制:保证线程安全

我习惯用 pthread_mutex_t 配合 pthread_cond_t 来实现。为什么?因为条件变量能高效地让线程“等活干”,而不是空转轮询。

2.1 数据结构设计

先看一个我常用的线程池结构体:

typedef struct {
    void (*function)(void *arg);
    void *arg;
} task_t;

typedef struct {
    task_t *tasks;          // 任务数组
    int queue_size;         // 队列容量
    int head, tail;         // 环形队列指针
    int count;              // 当前任务数

    pthread_t *threads;     // 工作线程数组
    int thread_count;       // 线程数

    pthread_mutex_t mutex;  // 互斥锁
    pthread_cond_t cond;    // 条件变量

    int shutdown;           // 销毁标志
} thread_pool_t;

这里有个细节:我用的是环形队列。为什么不用链表?因为任务队列的访问模式是 FIFO,环形队列内存连续、缓存友好,性能更好。我在一个高频交易系统里做过对比,环形队列比链表快了将近 30%。

三、任务提交与执行——核心流程

任务提交的流程其实很简单:

  1. 加锁
  2. 检查队列是否满
  3. 把任务放入队列
  4. 发送信号唤醒一个工作线程
  5. 解锁

工作线程的逻辑则是:

  1. 加锁
  2. 检查队列是否空,空则等待条件变量
  3. 取出任务
  4. 解锁
  5. 执行任务
  6. 回到步骤 1

3.1 任务提交函数

int thread_pool_submit(thread_pool_t *pool, 
                       void (*func)(void *), void *arg) {
    pthread_mutex_lock(&pool->mutex);

    // 队列满了?等一会儿再试
    while (pool->count == pool->queue_size && 
           !pool->shutdown) {
        pthread_cond_wait(&pool->cond, &pool->mutex);
    }

    if (pool->shutdown) {
        pthread_mutex_unlock(&pool->mutex);
        return -1;
    }

    // 放入任务
    pool->tasks[pool->tail].function = func;
    pool->tasks[pool->tail].arg = arg;
    pool->tail = (pool->tail + 1) % pool->queue_size;
    pool->count++;

    // 唤醒一个工作线程
    pthread_cond_signal(&pool->cond);
    pthread_mutex_unlock(&pool->mutex);

    return 0;
}

嗯,这里要注意:pthread_cond_wait 必须在 while 循环里,不能用 if。为什么?因为存在虚假唤醒——线程可能在没有收到信号的情况下被唤醒。这是 POSIX 标准允许的,我当年第一次写线程池就踩了这个坑,排查了整整一个下午。

3.2 工作线程函数

void *worker_thread(void *arg) {
    thread_pool_t *pool = (thread_pool_t *)arg;

    while (1) {
        pthread_mutex_lock(&pool->mutex);

        // 没任务就等着
        while (pool->count == 0 && !pool->shutdown) {
            pthread_cond_wait(&pool->cond, &pool->mutex);
        }

        if (pool->shutdown && pool->count == 0) {
            pthread_mutex_unlock(&pool->mutex);
            pthread_exit(NULL);
        }

        // 取任务
        task_t task = pool->tasks[pool->head];
        pool->head = (pool->head + 1) % pool->queue_size;
        pool->count--;

        // 通知提交者:队列有空位了
        pthread_cond_signal(&pool->cond);
        pthread_mutex_unlock(&pool->mutex);

        // 执行任务(注意:这里已经解锁了)
        task.function(task.arg);
    }

    return NULL;
}

看到没?任务执行是在解锁之后进行的。这是一个非常重要的设计原则:不要在持有锁的时候执行耗时操作。否则其他线程都得等着,线程池就退化成串行执行了。

四、线程池的销毁与资源回收

销毁线程池,比创建要复杂得多。我见过不少代码,销毁时直接 pthread_cancel,结果资源泄漏得一塌糊涂。

正确的做法是:

  1. 设置 shutdown 标志
  2. 广播唤醒所有工作线程
  3. 等待所有线程退出
  4. 释放任务队列和线程数组
  5. 销毁互斥锁和条件变量

4.1 优雅销毁的实现

void thread_pool_destroy(thread_pool_t *pool) {
    pthread_mutex_lock(&pool->mutex);
    pool->shutdown = 1;
    // 广播唤醒所有等待的线程
    pthread_cond_broadcast(&pool->cond);
    pthread_mutex_unlock(&pool->mutex);

    // 等待所有线程结束
    for (int i = 0; i < pool->thread_count; i++) {
        pthread_join(pool->threads[i], NULL);
    }

    // 释放资源
    free(pool->tasks);
    free(pool->threads);
    pthread_mutex_destroy(&pool->mutex);
    pthread_cond_destroy(&pool->cond);
}

这里用 pthread_cond_broadcast 而不是 pthread_cond_signal,是因为我们需要所有线程都醒来检查 shutdown 标志。如果只唤醒一个,其他线程可能永远卡在 pthread_cond_wait 里。

⚠️ 我曾经踩过的坑: 有一次我忘了在销毁前处理队列中剩余的任务。结果线程池销毁了,但还有任务没执行完,数据丢失了。建议在销毁前,先处理完队列中所有任务,或者记录日志。

五、线程池的核心流程图

下面这张图,是我画给团队新人的。它清晰地展示了线程池的完整工作流程:

线程池工作流程图 主线程(提交者) 任务队列(环形缓冲区) 提交任务 工作线程 1 工作线程 2 工作线程 3 ...... 工作线程 N 取任务执行 同步机制 互斥锁 + 条件变量 保护 销毁流程 设置 shutdown → 广播唤醒 → join 通知退出 提交者 任务队列 工作线程 同步机制 销毁流程

六、避坑指南与最佳实践

💡 我总结的几个关键点:
  • 任务粒度要适中:太小的任务(比如加个整数)用线程池反而亏,线程切换开销比任务本身还大
  • 线程数不是越多越好:一般设置为 CPU 核心数 + 1,I/O 密集型可以适当增加
  • 注意死锁:如果任务内部又提交任务到同一个线程池,可能造成死锁
📌 调试小技巧: 我习惯在线程池里加一个 pool->active_count 字段,记录当前正在执行任务的线程数。这样在监控面板上就能看到线程池的负载情况,排查问题非常方便。

最后说一句:线程池看起来简单,但真正写好、写稳,需要你对并发控制有深刻理解。我建议你亲手写一遍,跑几个压力测试,看看会不会出现内存泄漏、死锁、任务丢失等问题。只有踩过坑,才能真正掌握。


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