从零掌握 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_guard unique_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++11 TinyWebServer(pthread)
创建线程 std::thread 构造函数 pthread_create()
线程回收 join() pthread_detach() 或不 detach + pthread_join()
互斥锁 std::mutex + lock_guard pthread_mutex_t + 自定义 locker 封装类
条件变量/信号量 std::condition_variable sem_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_guard RAII 风格的锁,构造 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 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐