从零掌握 Linux C++ 多线程编程:线程、互斥锁、条件变量与线程池

本文适合学过 C++ 基础语法、想理解多线程但被"竞态条件""虚假唤醒"吓退的读者。读完你能自己写出一个线程池,并看懂 TinyWebServer(github的一个项目) 的并发部分。


前置知识(30 秒自检)

  • C++ 基础语法:函数、类、std::vector、Lambda 表达式
  • Linux 命令行:会用 g++ 编译,能在终端运行程序
  • 不需要:没碰过 pthread 没关系,本文用 C++11 标准库

一、为什么需要多线程

假设你开了一家小饭馆。

刚开始只有你一个人:客人来了你招呼、点了菜你做、做完你端上去、然后收钱。一桌客人还好,三桌同时来呢?后面的人要等前面全部流程走完才能点菜 — 这就是单线程程序

你雇了几个厨师:你负责招呼客人和分配任务(主线程),每个厨师接到任务就干活(工作线程)。几个厨师同时炒菜,互不影响 — 这就是多线程

1.1 线程与进程:一张图说清楚

进程 = 一家饭馆(独立营业执照、独立账本)
  ├── 主线程 = 老板(招呼客人、分派任务)
  ├── 工作线程 1 = 厨师 A(炒菜中)
  ├── 工作线程 2 = 厨师 B(切菜中)
  └── 工作线程 3 = 厨师 C(洗碗中)

线程共享:菜板(堆内存)、菜单(全局变量)
线程独立:每人手里的菜刀(栈)、当前在干嘛(寄存器)

线程是操作系统调度的最小单位。 一个进程(饭馆)可以有多个线程(员工),它们共享进程的内存空间,但各自拥有独立的栈和寄存器上下文。

1.2 创建线程(C++11 一行搞定)

#include <thread>
#include <iostream>

void cook(const std::string& dish) {
    std::cout << "正在做: " << dish << std::endl;
}

int main() {
    std::thread chef(cook, "宫保鸡丁");  // 构造函数执行时线程就启动了
    chef.join();  // 老板等厨师做完再下班
    return 0;
}

std::thread 的构造函数接受一个可调用对象(函数、函数对象、Lambda)+ 参数。关键点:构造完成的那一刻线程就启动了,不需要像 Java 那样显式调 start()


二、线程的"善后":join 与 detach

线程启动后,你必须决定怎么处理它。这就像厨师下班:你是等他做完菜再关门(join),还是让他自己走不管了(detach)?

2.1 一张表看懂区别


join()detach()
你等它吗等,调用 join 的线程会阻塞直到目标线程结束不等,调用后各走各的
谁回收资源调用 join 的线程负责操作系统在线程结束时自动回收
能拿返回值吗不能(std::thread 不支持,需要 std::future不能
适用场景我需要线程的结果才能继续发出去的任务,不关心结果
最大风险忘记调用 join → 程序崩溃线程访问了已销毁的变量 → 悬空指针

2.2 最危险的坑:忘记 join

void dangerous() {
    std::thread t(cook, "红烧肉");
    // 函数结束,t 的析构函数被调用
    // 但 t 还是 joinable 状态(既没 join 也没 detach)
    // → std::terminate() 被调用 → 程序崩溃!
}

规则很简单: 每个 std::thread 对象销毁前,要么调了 join(),要么调了 detach()。二选一,必须选,不能两个都选。

还有个隐藏坑:既不 join 也不 detach 的线程结束后,线程的 PCB(进程控制块)和栈资源不会被回收 → 内存泄漏(俗称僵尸线程)。

2.3 线程执行顺序:你说了不算

谁先执行、谁后执行,由操作系统的调度器决定,程序员无法预测,也无法控制。

std::thread t1(task, "A");
std::thread t2(task, "B");
// A 和 B 谁先打印?不知道。每次运行可能不一样。

调度器的基本规则:

  • 调了 sleep 的线程进入等待队列,不参与 CPU 轮转
  • 多个线程同时 sleep 到期,谁先被唤醒取决于内核定时器精度和当前 CPU 负载
  • 时间片轮转只发生在就绪队列的线程之间

三、互斥锁:保护共享数据

3.1 一行 C++ 代码的"三重人格"

先看一段看似人畜无害的代码:

int counter = 0;

void add10000() {
    for (int i = 0; i < 10000; i++) {
        counter++;  // 就这一行,线程安全吗?
    }
}

int main() {
    std::thread t1(add10000);
    std::thread t2(add10000);
    t1.join(); t2.join();
    std::cout << counter << std::endl;  // 你猜输出是多少?
}

你的直觉:20000。实际输出:每次都不一样,15000 到 20000 之间随机

为什么?因为 counter++ 虽然只写了一行,底层 CPU 却是三条指令:

mov  eax, [counter]    ← 第1步:从内存读到寄存器
add  eax, 1            ← 第2步:在寄存器里加 1
mov  [counter], eax    ← 第3步:从寄存器写回内存

两个线程同时执行时,可能发生这样的穿插:

线程 A: mov eax, [counter]   → eax=5
线程 B: mov eax, [counter]   → eax=5  ← 也读到了 5!
线程 A: add eax, 1           → eax=6
线程 A: mov [counter], eax   → counter=6
线程 B: add eax, 1           → eax=6  ← 在 5 的基础上加了 1
线程 B: mov [counter], eax   → counter=6  ← 覆盖!本来应该是 7

两次 ++ 操作,最终只加了一次。这就是竞态条件(Race Condition) — 多个线程同时访问共享数据,结果取决于它们执行的精确时序,而这种时序你无法控制。

3.2 互斥锁:独木桥原理

互斥锁(mutex = mutual exclusion,互斥)就像一个独木桥:每次只允许一个人通过。

#include <mutex>

int counter = 0;
std::mutex mtx;

void add10000_safe() {
    for (int i = 0; i < 10000; i++) {
        mtx.lock();      // 拿锁。如果锁被别的线程持有,这里会阻塞
        counter++;       // 现在我是唯一的访问者
        mtx.unlock();    // 放锁,其他人可以进来了
    }
}

3.3 lock_guard:为什么不推荐手动 lock/unlock

手动 lock/unlock 有一个致命缺陷:如果 lock 和 unlock 之间的代码抛了异常,unlock 永远不会执行 → 死锁。

// 危险写法
mtx.lock();
riskyFunction();  // 如果这里抛异常……
mtx.unlock();     // 这行永远不会执行!锁永远不释放 → 死锁

// 安全写法:用 lock_guard
{
    std::lock_guard<std::mutex> guard(mtx);  // 构造时自动 lock
    riskyFunction();  // 即使抛异常……
}   // 离开作用域时,guard 的析构函数自动 unlock ← 利用 C++ 的栈展开保证

// lock_guard 本质上就两件事:
// 构造函数: mtx.lock()
// 析构函数: mtx.unlock()

这个技巧叫 RAII(Resource Acquisition Is Initialization,资源获取即初始化) — 把资源的释放绑定到对象的生命周期上,利用 C++ 析构函数"离开作用域必定执行"的保证来管理资源。这是 C++ 最重要的惯用法之一。

3.4 锁的代价:并行变串行

加锁不是免费的:

  • 性能:被锁保护的代码段(临界区)同一时刻只有一个线程在执行,等于把并行变回了串行
  • 粒度:临界区越小越好 — 只保护必须保护的数据,不要把不相干的代码也锁进去
  • 死锁:线程 A 拿着锁 1 等锁 2,线程 B 拿着锁 2 等锁 1 → 两人永远等下去

四、条件变量:没活睡觉,有活叫醒

4.1 互斥锁解决不了的问题

互斥锁能保护共享数据不被同时修改,但有一件事它做不到:让线程在没有任务时睡觉,有任务时被叫醒。

想象一个餐馆:厨师没活干的时候怎么办?

如果没有条件变量,只能这样做:

// 愚蠢的轮询(忙等 Busy Waiting)
while (true) {
    mtx.lock();
    if (!tasks.empty()) {
        // 取一个任务干活
    }
    mtx.unlock();
    // 没活 → 回到循环头 → 立刻又 lock → 还是没活 → unlock → lock...
    // CPU 一直在空转,占用率 100%,全部是无效检查!
}

这就好比你每隔 0.1 秒跑到后厨看一眼有没有新订单 — 你什么都没干,但累得满头大汗(CPU 100%)。

条件变量的意义:没活时睡觉(不占 CPU),有活时被摇铃叫醒。

4.2 核心操作

#include <condition_variable>
#include <mutex>
#include <queue>

std::mutex mtx;
std::condition_variable cv;
std::queue<std::string> tasks;

// ============ 消费者(厨师):等待任务 ============
void chef() {
    std::unique_lock<std::mutex> lock(mtx);
    // wait 做了三件事:
    //   1. 检查条件 → 满足 → 直接返回(不睡觉)
    //   2. 不满足 → 解锁 mtx → 进入等待状态(睡觉)
    //   3. 被唤醒 → 重新加锁 mtx → 再检查条件 → 不满足继续睡
    cv.wait(lock, []{ return !tasks.empty(); });

    std::string task = tasks.front();
    tasks.pop();
    lock.unlock();  // 取完任务立刻解锁,不持锁干活
    // 执行 task...
}

// ============ 生产者(老板):添加任务 ============
void boss(const std::string& order) {
    {
        std::lock_guard<std::mutex> lock(mtx);
        tasks.push(order);
    }
    cv.notify_one();  // 摇铃:叫醒一个睡觉的厨师
}

4.3 wait 为什么必须传判断条件

因为虚假唤醒(Spurious Wakeup)

POSIX 标准明确规定:操作系统可能在你没有调用 notify 的情况下把线程踢醒。这是底层内核实现决定的,不是 bug。如果 wait 不检查条件就继续执行,会拿着空队列干活 → 逻辑错误。

// 不安全(被虚假唤醒就会出错)
cv.wait(lock);

// 安全(被唤醒后先验条件,不满足就接着睡)
cv.wait(lock, []{ return !tasks.empty(); });

cv.wait(lock, condition) 等价于:

while (!condition()) {
    wait(lock);  // 解锁 → 睡觉 → 被唤醒 → 加锁 → 再检查条件
}

4.4 notify_one 还是 notify_all


notify_one()notify_all()
唤醒多少线程1 个全部
真正干活的1 个1 个(因为只有一把锁,只有一个能抢到任务)
其他线程继续睡觉,不受影响被吵醒 → 争锁 → 发现任务已经被抢了 → 回去睡
性能无浪费额外的锁竞争 + 空唤醒 = 惊群效应
什么时候用日常来新任务系统关闭(每个线程都要起床检查退出条件)

一个原则足矣:日常用 notify_one,关闭时用 notify_all

4.5 两个常见问题

Q1:取了任务后为什么要先 unlock 再干活?

如果拿着锁干活:其他厨师连看黑板(检查队列)的权利都没有。但黑板已经空了,锁应该释放。

取任务 → 立刻 unlock → 干活(让其他人也能看黑板)

Q2:unique_lock lock_guard 有什么区别?


lock_guardunique_lock
能手动 unlock 吗不能(析构才解锁)能(lock.unlock()
能配合 cv.wait 不能能(wait 内部需要临时解锁再加锁)
开销更小稍大(多一个 bool 管理状态)
适用场景简单的"锁住 → 干活 → 解锁"条件变量、需要灵活锁控制的场景

五、线程池:把三样东西串起来

线程、互斥锁、条件变量,分开学的时候你可能觉得"还行,不难"。但要真正理解它们,必须看一个同时用到了三者的例子:线程池。

5.1 一个请求的一生

主线程(老板)                 工作线程 1..N(厨师们)
────────────                  ──────────────────
                              线程池构造时就启动了,全部在 cv.wait() 睡觉
                              ↓
HTTP 请求到达!
  ↓
老板: append(task) ──→ 任务队列 ──→ cv.notify_one() 摇铃
                                         ↓
                              一个厨师被叫醒
                              加锁 → 取任务 → 解锁
                                         ↓
                              不持锁,从容做菜
                                         ↓
                              做完,回到循环头继续 cv.wait()

5.2 核心代码拆解

class ThreadPool {
    std::vector<std::thread> m_threads;           // 线程数组
    std::queue<std::function<void()>> m_tasks;    // 任务队列
    std::mutex m_mtx;                             // 保护队列
    std::condition_variable m_cv;                 // 通知机制
    bool m_stop = false;                          // 关闭标志
    int m_max_tasks;                              // 队列上限

public:
    // ========== 1. 构造函数:招厨师 ==========
    ThreadPool(int thread_num, int max_tasks)
        : m_max_tasks(max_tasks) {
        for (int i = 0; i < thread_num; i++) {
            // emplace_back 直接在 vector 里构造 thread 对象
            // workerLoop 是成员函数,需要传 this 作为对象指针
            m_threads.emplace_back(&ThreadPool::workerLoop, this);
        }
    }

    // ========== 2. 添加任务(老板视角)==========
    bool addTask(std::function<void()> task) {
        std::unique_lock<std::mutex> lock(m_mtx);
        if (m_tasks.size() >= m_max_tasks) {
            return false;  // 队列满了,拒绝任务(防止内存爆炸)
        }
        m_tasks.push(std::move(task));
        lock.unlock();        // 先解锁
        m_cv.notify_one();    // 再通知(先解锁后通知减少不必要的唤醒)
        return true;
    }

    // ========== 3. 工作循环(厨师视角)==========
    void workerLoop() {
        while (true) {
            std::unique_lock<std::mutex> lock(m_mtx);
            // 等两个条件之一:要么有活,要么要关门了
            m_cv.wait(lock, [this] {
                return m_stop || !m_tasks.empty();
            });

            if (m_stop && m_tasks.empty()) {
                return;  // 要关门且没活了,下班
            }

            std::function<void()> task = std::move(m_tasks.front());
            m_tasks.pop();
            lock.unlock();   // 取完任务立刻解锁
            task();          // 不持锁干活
        }
    }

    // ========== 4. 析构函数:优雅关门 ==========
    ~ThreadPool() {
        {
            std::lock_guard<std::mutex> lock(m_mtx);
            m_stop = true;  // 挂出打烊牌子
        }
        m_cv.notify_all();  // 叫醒所有厨师(不是 notify_one!)
        for (auto& t : m_threads) {
            t.join();       // 等所有人收拾完下班
        }
    }
};

5.3 为什么析构时用 notify_all 而不是 notify_one

因为 notify_one 只叫醒一个线程。假如被叫醒的那个线程正在处理一个长任务,它暂时不会回到 wait 检查 m_stop,那其他线程就永远不会被叫醒 → 析构函数里的 join 永远在等 → 程序卡死。

notify_all 则确保每个线程最终都会醒来检查退出条件。


六、对照 TinyWebServer:理论到实战

TinyWebServer 用 C 风格的 pthreadsem_t,但你学完 C++11 版本后,对照着看就很容易了:

概念C++11TinyWebServer(pthread)
创建线程std::thread 构造函数pthread_create()
线程回收join()pthread_detach() 或不 detach + pthread_join()
互斥锁std::mutex + lock_guardpthread_mutex_t + 自定义 locker 封装类
条件变量/信号量std::condition_variablesem_t(POSIX 信号量)+ 自定义 sem 封装类
等待操作cv.wait(lock, condition)sem.wait()
通知操作cv.notify_one()sem.post()
任务容器std::queue<std::function<void()>>std::list<T*>

注意:TinyWebServer 用信号量而非条件变量。信号量的本质是一个整数计数器,sem.wait() 把计数器减 1(减到负就阻塞),sem.post() 把计数器加 1(有线程在等就唤醒)。条件变量没有"计数"的概念 — 它只管"通知"。


七、常见 Bug 速查表

每行都是真实出现过的问题:

Bug 现象根因修复
counter 结果比预期少counter++ 不是原子操作加互斥锁保护临界区
程序直接崩溃线程对象析构前没 join 也没 detach确保每个线程都 join 或 detach
整个程序卡住不动异常导致 unlock 永远不执行lock_guard / unique_lock 代替手动锁
CPU 占用 100% 但没在处理任务用轮询代替条件变量while(true) 空转为 cv.wait()
偶尔取到空任务wait 没带判断条件,被虚假唤醒必须写 cv.wait(lock, []{ return 条件; })
日志输出乱码/错行多线程同时写 cout加锁保护输出,或用专门日志线程
性能比单线程还差干活时持有锁,其他线程全在等取完任务立刻 unlock,不持锁干活
锁竞争导致 CPU 飙高不必要的 notify_all(惊群)日常用 notify_one
关闭时卡住析构用了 notify_one 而不是 notify_all关闭用 notify_all
detach 后程序随机崩溃detach 的线程访问了已销毁的栈变量detach 前确保线程不依赖会被销毁的数据

八、核心概念速查表

概念一句话类比
线程操作系统调度的最小单位,共享进程内存饭馆里的一个员工
进程操作系统资源分配的最小单位,独立内存空间一家独立的饭馆
竞态条件多线程同时访问共享数据,结果取决于执行时序两个人同时改同一行 Excel
互斥锁(mutex)保证同一时刻只有一个线程访问临界区独木桥,每次只过一个人
临界区被锁保护的代码段独木桥本身
死锁多个线程互相等待对方释放锁两人在独木桥两端互不相让
lock_guardRAII 风格的锁,构造 lock 析构 unlock自动门:进门自动锁,出门自动开
RAII资源获取即初始化,资源释放绑定对象生命周期你把钥匙交给保安,你离开时保安负责锁门
条件变量让线程在条件不满足时睡觉,满足时被叫醒厨师的摇铃
虚假唤醒操作系统在没人 notify 时也可能唤醒线程你做梦被惊醒,看看厨房没活,继续睡
惊群效应notify_all 后所有线程醒来,只有一个抢到活一群人被叫醒冲向唯一的任务
线程池预创建一组线程,任务来了分配,复用线程提前雇好的厨师团队,来单就做

九、自测题

复习时用自己的话回答,答不出来就回头看对应章节:

  1. counter++ 为什么不是线程安全的?(提示:从 CPU 指令角度回答)
  2. join()detach() 的区别?什么时候用哪个?
  3. 为什么 lock_guard 比手动 lock/unlock 安全?
  4. 条件变量的 wait 为什么必须传判断条件?
  5. 互斥锁和条件变量各解决什么问题?为什么不能只用其中一个?
  6. 线程池关闭时为什么用 notify_all 而不是 notify_one
  7. 模拟一遍:3 个任务进来,2 个工作线程,谁先执行?执行过程是怎样的?
Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐