Linux C++ 高并发编程:线程池全链路深度解析,从原理到手撕实现

前言

在 Linux 后端开发里,线程池几乎是绕不开的基础组件。

Web 服务器要处理大量客户端请求,日志系统要异步落盘,批量计算任务要并行执行。如果每来一个任务就临时创建一个线程,不仅会产生频繁的线程创建和销毁开销,还可能在请求高峰期创建出过多线程,导致 CPU 调度压力陡增,甚至把系统资源打满。

线程池解决的正是这个问题:提前创建一批工作线程,把任务统一放入任务队列,由空闲线程不断取任务执行。线程不再频繁创建和销毁,而是被反复复用。

本文从线程池的核心思想讲起,一步步拆到任务队列、工作线程、条件变量、线程回收、拒绝策略和 C++ 代码实现,把线程池这条链路完整吃透。

一、为什么需要线程池

1.1 直接创建线程的问题

最直观的多线程写法是:来一个任务,创建一个线程处理。

复制代码
std::thread t([]{
    // 执行业务逻辑
});
t.detach();

这种方式看起来简单,但在高并发场景下有几个明显问题。

  • 线程创建和销毁不是零成本操作,需要操作系统参与;
  • 线程数量不可控,任务暴增时容易创建大量线程;
  • 线程过多会增加上下文切换成本,CPU 时间被浪费在调度上;
  • 线程生命周期分散,异常退出和资源回收都不好管理;
  • 任务提交方和任务执行方耦合得太紧,不利于统一限流和监控。

也就是说,临时创建线程适合少量、偶发任务,不适合长期运行的高并发服务。

1.2 线程池的核心思想

线程池本质上是一种池化思想。

池化的核心不是"多开几个线程",而是把昂贵资源提前准备好,然后重复利用。

常见的池化组件包括:

  • 线程池:复用线程;
  • 连接池:复用数据库连接或网络连接;
  • 内存池:复用内存块;
  • 对象池:复用创建成本较高的对象。

线程池做的事情可以概括成三句话:

  • 提前创建固定数量的工作线程;
  • 外部提交任务时,只把任务放入队列;
  • 工作线程从队列中取任务并执行,执行完继续等待下一个任务。

这样一来,线程创建成本被摊薄,任务执行过程也更容易统一管理。

二、线程池的核心组成

一个最小可用的线程池,一般由下面几部分组成:

  • 工作线程集合;
  • 任务队列;
  • 互斥锁;
  • 条件变量;
  • 运行状态标记;
  • 启动和停止接口。

2.1 工作线程集合

工作线程是线程池真正执行任务的角色。

线程池启动时,会创建若干个工作线程。每个线程都会进入一个循环:

  1. 尝试从任务队列取任务;
  2. 如果队列为空,就阻塞等待;
  3. 如果拿到任务,就执行任务;
  4. 执行完成后,再回到第 1 步。

这个循环是线程复用的关键。线程不会因为执行完一个任务就退出,而是继续等待下一个任务。

2.2 任务队列

任务队列用于解耦任务提交方和任务执行方。

提交方只负责把任务放进去,不需要关心哪个线程执行;工作线程只负责从队列中取任务,不需要关心任务是谁提交的。

任务队列通常需要满足两个要求:

  • 多个提交方可能同时 Push 任务;
  • 多个工作线程可能同时 Pop 任务。

因此任务队列必须配合互斥锁使用,保证内部结构不会被并发修改破坏。

2.3 互斥锁

互斥锁主要保护任务队列。

无论是提交任务,还是工作线程取任务,只要涉及队列的增删操作,都必须进入临界区。

复制代码
std::mutex _mutex;
std::queue<Task> _tasks;

典型操作如下:

复制代码
{
    std::lock_guard<std::mutex> lock(_mutex);
    _tasks.push(task);
}

这里锁保护的是队列本身,而不是任务执行过程。任务真正执行时,通常应该离开临界区,避免一个耗时任务长期占用锁,阻塞其他线程取任务。

2.4 条件变量

如果任务队列为空,工作线程不能一直空转检查,否则会浪费 CPU。

条件变量的作用就是:当没有任务时,让工作线程睡眠;当有新任务提交时,再唤醒工作线程。

复制代码
std::condition_variable _cond;

工作线程等待:

复制代码
_cond.wait(lock, [&] {
    return !_tasks.empty() || !_running;
});

提交方唤醒:

复制代码
_cond.notify_one();

条件变量和互斥锁通常成对出现:锁用于保护共享状态,条件变量用于等待共享状态发生变化。

三、任务在线程池中的完整流转过程

一个任务从提交到执行完成,大致会经历下面几步。

3.1 提交任务

外部调用 Submit() 接口,把一个可调用对象提交给线程池。

这个可调用对象可以是普通函数、lambda、函数对象,也可以是绑定了参数的任务。

3.2 进入任务队列

线程池先加锁,然后把任务放入队列。

复制代码
{
    std::lock_guard<std::mutex> lock(_mutex);
    _tasks.push(std::move(task));
}

放入队列之后,提交方就可以返回。任务什么时候执行、由哪个线程执行,都交给线程池内部调度。

3.3 唤醒工作线程

任务入队后,需要唤醒一个等待中的工作线程。

复制代码
_cond.notify_one();

如果当前有线程正在等待,它会被唤醒;如果没有线程等待,说明可能所有线程都在忙,新任务就继续留在队列中排队。

3.4 工作线程取任务

工作线程被唤醒后,会重新检查条件。

注意这里必须用 while 或者带谓词版本的 wait,不能简单用 if

原因是条件变量存在伪唤醒:线程被唤醒,并不一定代表队列里真的有任务。所以唤醒后必须再次检查条件。

3.5 执行任务

工作线程拿到任务后,应该先释放锁,再执行任务。

复制代码
Task task;
{
    std::unique_lock<std::mutex> lock(_mutex);
    _cond.wait(lock, [&] {
        return !_tasks.empty() || !_running;
    });

    task = std::move(_tasks.front());
    _tasks.pop();
}

task();

这个顺序非常关键。

如果拿着锁执行任务,那么其他工作线程就无法继续从队列中取任务,提交方也可能无法继续投递任务。线程池就会退化成"多个线程排队抢一把大锁",并发能力会被严重削弱。

四、C++ 手写线程池核心代码

下面实现一个简洁但完整的线程池版本,支持:

  • 固定数量工作线程;
  • 提交无返回值任务;
  • 条件变量阻塞等待;
  • 优雅停止;
  • 自动回收线程。

4.1 头文件与成员变量

复制代码
#pragma once

#include <condition_variable>
#include <functional>
#include <mutex>
#include <queue>
#include <thread>
#include <vector>

class ThreadPool
{
public:
    using Task = std::function<void()>;

    explicit ThreadPool(size_t thread_num)
        : _running(true)
    {
        for (size_t i = 0; i < thread_num; ++i) {
            _workers.emplace_back([this] {
                WorkerLoop();
            });
        }
    }

    ~ThreadPool()
    {
        Stop();
    }

    void Submit(Task task)
    {
        {
            std::lock_guard<std::mutex> lock(_mutex);
            if (!_running) {
                return;
            }
            _tasks.push(std::move(task));
        }

        _cond.notify_one();
    }

    void Stop()
    {
        {
            std::lock_guard<std::mutex> lock(_mutex);
            if (!_running) {
                return;
            }
            _running = false;
        }

        _cond.notify_all();

        for (auto& worker : _workers) {
            if (worker.joinable()) {
                worker.join();
            }
        }
    }

private:
    void WorkerLoop()
    {
        while (true) {
            Task task;

            {
                std::unique_lock<std::mutex> lock(_mutex);
                _cond.wait(lock, [this] {
                    return !_tasks.empty() || !_running;
                });

                if (!_running && _tasks.empty()) {
                    return;
                }

                task = std::move(_tasks.front());
                _tasks.pop();
            }

            task();
        }
    }

private:
    std::vector<std::thread> _workers;
    std::queue<Task> _tasks;

    std::mutex _mutex;
    std::condition_variable _cond;
    bool _running;
};

4.2 使用示例

复制代码
#include "ThreadPool.hpp"
#include <chrono>
#include <iostream>

int main()
{
    ThreadPool pool(4);

    for (int i = 0; i < 10; ++i) {
        pool.Submit([i] {
            std::cout << "task " << i << " running" << std::endl;
            std::this_thread::sleep_for(std::chrono::milliseconds(200));
        });
    }

    return 0;
}

程序结束时,ThreadPool 析构函数会调用 Stop(),通知所有工作线程退出,并通过 join() 等待它们回收。

五、线程池关闭与资源回收

线程池最容易写错的地方,不是提交任务,而是停止线程池。

停止逻辑如果处理不好,常见问题包括:

  • 工作线程永远睡在条件变量上,程序无法退出;
  • 主线程析构线程池时,工作线程还在访问已经销毁的对象;
  • 队列里还有任务,但线程池提前退出,导致任务丢失;
  • 重复调用 Stop(),触发不可预期行为。

5.1 为什么停止时要 notify_all

如果线程池停止时,部分工作线程正阻塞在条件变量上,它们不会主动醒来。

所以 Stop() 里设置 _running = false 后,必须调用:

复制代码
_cond.notify_all();

这样所有等待线程都会醒来,重新检查退出条件。

5.2 为什么退出条件是 running 和 empty 一起判断

工作线程里这句判断很关键:

复制代码
if (!_running && _tasks.empty()) {
    return;
}

它表达的意思是:

  • 线程池已经停止;
  • 并且队列里没有剩余任务;
  • 此时工作线程才真正退出。

如果线程池停止了,但队列里还有任务,工作线程会继续把剩余任务执行完。这样就能做到相对优雅的关闭。

5.3 为什么任务执行不能放在锁里

错误写法:

复制代码
std::unique_lock<std::mutex> lock(_mutex);
Task task = std::move(_tasks.front());
_tasks.pop();
task(); // 错误:拿着锁执行任务

正确写法:

复制代码
Task task;
{
    std::unique_lock<std::mutex> lock(_mutex);
    task = std::move(_tasks.front());
    _tasks.pop();
}
task();

锁只保护队列操作,不保护任务执行。任务执行时间不可控,如果放在锁里,会拖慢整个线程池。

六、线程池常见参数与拒绝策略

真实工程里的线程池通常不会只有一个固定线程数,还会涉及更多配置。

6.1 核心参数

参数 含义 作用
核心线程数 常驻工作线程数量 保证基础处理能力
最大线程数 允许创建的最大线程数量 应对短时峰值
任务队列容量 等待执行的任务上限 防止任务无限堆积
空闲超时时间 非核心线程空闲多久后退出 回收临时扩容线程
拒绝策略 队列满且无法继续接收任务时的处理方式 保护系统不被压垮

本文代码是固定线程数版本,适合入门理解线程池原理。工程版本可以在此基础上扩展队列容量、返回值、动态扩缩容和拒绝策略。

6.2 为什么任务队列不能无限大

很多初学者会觉得:队列设成无限大,就不会丢任务。

但在服务端开发里,无限队列往往更危险。

如果生产速度长期大于消费速度,任务会在内存里越堆越多。短时间看只是延迟升高,继续发展就可能导致内存耗尽。

所以线程池一般需要有界队列,并在队列满时触发拒绝策略或反压机制。

6.3 常见拒绝策略

策略 处理方式 适用场景
直接拒绝 提交失败,返回错误 对延迟敏感的在线服务
调用者执行 提交线程自己执行任务 轻量反压,降低提交速度
丢弃最旧任务 移除队列头部旧任务 只关心最新数据的场景
阻塞等待 提交方等待队列有空位 后台任务、允许排队的场景

拒绝策略的本质不是"放弃任务",而是告诉系统:当前处理能力已经到边界了,必须做取舍。

七、面试高频问题总结

1. 线程池为什么能提升性能?

线程池减少了频繁创建和销毁线程的系统开销,并通过固定数量的工作线程限制并发度,降低线程过多带来的上下文切换成本。

2. 线程池里的条件变量为什么要配合 while 或谓词使用?

因为条件变量存在伪唤醒。线程被唤醒后,队列不一定真的有任务,所以必须重新检查条件。

带谓词的 wait 内部等价于循环判断,写起来更安全。

3. 为什么取出任务后要先解锁再执行?

锁保护的是任务队列,不是任务执行。

如果拿着锁执行任务,其他线程无法取任务,提交方也可能被阻塞,线程池并发能力会明显下降。

4. notify_onenotify_all 怎么选?

提交一个普通任务时,一般使用 notify_one,唤醒一个工作线程即可。

线程池停止时,必须使用 notify_all,因为所有等待线程都需要醒来检查退出条件。

5. 线程池线程数是不是越多越好?

不是。

线程数过少,CPU 利用不充分;线程数过多,会增加上下文切换和锁竞争。

一般来说:

  • CPU 密集型任务:线程数接近 CPU 核心数;
  • IO 密集型任务:线程数可以适当多一些;
  • 混合任务:需要结合压测结果调整。

6. 线程池析构时应该怎么做?

析构时应该:

  1. 设置停止标记;
  2. 唤醒所有工作线程;
  3. 等待线程退出;
  4. 回收线程资源。

不能直接销毁线程池对象,否则工作线程可能还在访问已经释放的成员变量。

结尾

线程池表面上只是"提前创建一批线程",但真正重要的是它背后的几个并发控制点:

  • 用任务队列解耦提交方和执行方;
  • 用互斥锁保护共享队列;
  • 用条件变量避免空转;
  • 用运行标记控制生命周期;
  • join() 保证线程资源被正确回收;
  • 用有界队列和拒绝策略保护系统边界。

把这条链路理解清楚之后,再看 Web 服务器、异步日志、任务调度器、数据库连接池等组件,就会发现它们背后的设计思路其实是相通的:控制资源数量,削峰填谷,复用昂贵对象,让系统在高并发下依然稳定。

线程池不是一个孤立知识点,而是理解高并发工程设计的一扇门。

相关推荐
码兄科技1 小时前
Java AI智能体开发实战:从零构建智能对话系统指南
java·开发语言·人工智能
折哥的程序人生 · 物流技术专研2 小时前
第8篇:六大设计原则在工厂模式中的体现
java·设计模式·设计原则·工厂模式·编程进阶·扩充系列
小明bishe182 小时前
计算机毕业设计之基于JAVA的植物科普网站
java·spring boot·spring·架构·课程设计
Drone_xjw2 小时前
从 GDB 到 CDB:C/C++ 程序调试的两把“手术刀”
c语言·开发语言·c++
r_oo_ki_e_2 小时前
Java Map 集合学习笔记
java·笔记·学习
SL-staff3 小时前
智慧园区2000+设备统一管理:JVS-IoT如何降低运维成本40%
java·开发语言·物联网·智慧园区·设备管理·运维优化·jvs-iot
咖啡八杯3 小时前
GoF设计模式——模板方法模式
java·后端·spring·设计模式
爱笑的源码基地4 小时前
一款全开源的信创云PACS影像云平台解决方案,支持多模态医学影像处理(CT/MR/DR/超声/病理等)
java·源码·国产化·pacs·云影像·区域pacs
我命由我123454 小时前
Android 开发问题:ClickableSpan 的点击事件没有生效
java·java-ee·android studio·android jetpack·android-studio·android runtime
2zcode4 小时前
基于MATLAB图像处理的饮料瓶灌装液位检测系统设计与实现
开发语言·图像处理·matlab