11、多线程支持(上):std::thread 详解、std::mutex 与锁管理、std::lock_guard 与 std::unique_lock

多线程,说白了就是让程序同时干几件事。C++11 之前,你要用 POSIX threads 或者 Windows API,那叫一个痛苦。C++11 终于把线程支持纳入了标准库,我当年第一次用 std::thread 的时候,心里就一个感觉:这才对嘛!

这一章我们只讲三样东西:线程怎么创建和管理互斥锁怎么保护共享数据、以及两种锁管理工具怎么用。嗯,这三样搞明白了,多线程就算入门了。

多线程支持(上)知识体系 std::thread • 构造与启动 • join / detach • 参数传递陷阱 • 线程 ID 与硬件 • 异常安全 核心:可调用对象 std::mutex • lock / unlock • try_lock • 死锁风险 • 递归锁 • 定时锁 核心:保护临界区 锁管理工具 • lock_guard • unique_lock • defer_lock • 条件变量配合 • 所有权转移 核心:RAII 封装 配合 管理

一、std::thread:线程的创建与管理

std::thread 是 C++11 多线程的基石。你给它一个可调用对象,它就开一个线程去执行。可调用对象可以是函数、函数对象、lambda 表达式,甚至成员函数。

核心要点:线程一旦创建,就立即开始执行。你不需要手动调用 start 之类的函数。

1.1 基本用法

#include <iostream>
#include <thread>

void hello() {
    std::cout << "Hello from thread!\n";
}

int main() {
    std::thread t(hello);  // 创建线程并启动
    t.join();              // 等待线程结束
    return 0;
}

这里 join() 是必须的。如果你不 join 也不 detach,程序会调用 std::terminate() 直接崩溃。嗯,我刚开始学的时候就被这个坑过——忘记 join,程序莫名其妙就挂了。

1.2 join 与 detach

线程有两种命运:

  • join():主线程阻塞,等待子线程结束。这是最常用的方式。
  • detach():让线程在后台运行,与主线程分离。分离后你不能再 join 它。

警告:detach 后的线程如果访问了主线程的局部变量,而主线程先退出了,那就会访问野指针。我曾经在项目里见过这种 bug,查了两天才定位到——一个 detach 的线程还在用主线程栈上的数据。

1.3 参数传递的陷阱

给线程传参时,参数默认是按值拷贝的。如果你想传引用,必须用 std::ref 包装。

void modify(int& x) {
    x += 10;
}

int main() {
    int a = 5;
    // std::thread t(modify, a);      // 编译错误!a 被拷贝了
    std::thread t(modify, std::ref(a)); // 正确
    t.join();
    std::cout << a;  // 输出 15
    return 0;
}

为什么会这样?因为 std::thread 内部会拷贝参数,即使函数声明了引用,它拷贝的也是值。用 std::ref 告诉它:别拷贝,给我传引用。

1.4 线程 ID 与硬件并发

函数/方法说明
std::this_thread::get_id()获取当前线程的 ID
t.get_id()获取线程对象 t 的 ID
std::thread::hardware_concurrency()返回硬件支持的线程数(通常是 CPU 核心数)

小技巧:我写多线程代码时,习惯先用 hardware_concurrency() 获取核心数,然后根据这个数来创建线程池的大小。这样代码在不同机器上都能有不错的性能。

二、std::mutex:保护共享数据

多线程最怕什么?数据竞争。两个线程同时读写同一个变量,结果就是未定义行为。这时候就需要 std::mutex 来保护临界区。

2.1 基本用法

#include <mutex>

std::mutex mtx;
int counter = 0;

void increment() {
    for (int i = 0; i < 100000; ++i) {
        mtx.lock();
        ++counter;
        mtx.unlock();
    }
}

但手动 lock/unlock 有个大问题:如果中间抛异常了,unlock 就不会执行,锁永远不释放。其他线程就卡死了。所以,千万别这么写。

2.2 try_lock 与死锁

try_lock() 尝试加锁,如果锁已被占用,立即返回 false,不会阻塞。这在某些场景下有用,但要注意死锁问题。

死锁的经典场景:两个线程各自持有一把锁,又去请求对方的锁。我当年在写一个交易系统时遇到过,两个线程互相等对方释放锁,整个系统卡死。排查起来特别痛苦。

避免死锁的原则:始终以相同的顺序加锁。或者使用 std::lock 一次性锁住多个 mutex。

2.3 其他 mutex 变体

类型说明
std::mutex标准互斥锁,不支持递归
std::recursive_mutex允许同一线程多次加锁,不会死锁
std::timed_mutex支持 try_lock_for 和 try_lock_until
std::recursive_timed_mutex递归 + 定时

建议:能用 std::mutex 就别用递归锁。递归锁通常意味着你的设计有问题——函数调用层次太深,或者锁的粒度没控制好。

三、std::lock_guard 与 std::unique_lock:RAII 锁管理

手动 lock/unlock 太容易出错了。C++ 的 RAII 思想告诉我们:资源获取即初始化,析构时自动释放。锁管理工具就是干这个的。

3.1 std::lock_guard:简单粗暴

void increment_safe() {
    std::lock_guard<std::mutex> guard(mtx);
    ++counter;
    // 离开作用域时自动解锁
}

就这么简单。构造时加锁,析构时解锁。你不用担心异常,不用担心忘记 unlock。我个人习惯:只要不需要手动控制锁的时机,一律用 lock_guard

3.2 std::unique_lock:灵活强大

unique_locklock_guard 更灵活。它支持:

  • 延迟加锁:构造时不加锁,后面手动 lock
  • 尝试加锁:支持 try_lock
  • 定时加锁:支持 try_lock_for
  • 所有权转移:可以 move 给另一个 unique_lock
  • 与条件变量配合:这是最重要的用途
std::unique_lock<std::mutex> lock(mtx, std::defer_lock); // 延迟加锁
// 做一些准备工作...
lock.lock();  // 手动加锁
// 访问共享数据
lock.unlock(); // 手动解锁
// 做一些不需要锁的工作...
lock.lock();  // 再次加锁

性能对比:lock_guardunique_lock 轻量一些。如果你不需要 unique_lock 的额外功能,就用 lock_guard。别为了「万一以后要用」而过度设计。

3.3 与条件变量配合

unique_lock 最经典的用法是配合 std::condition_variable。条件变量的 wait() 函数需要 unique_lock,因为它要在等待时解锁,被唤醒时再加锁。lock_guard 做不到这一点。

std::condition_variable cv;
std::mutex cv_mtx;
bool ready = false;

void worker() {
    std::unique_lock<std::mutex> lock(cv_mtx);
    cv.wait(lock, []{ return ready; }); // 等待 ready 为 true
    // 继续工作...
}

这里 wait 内部会解锁,让其他线程可以修改 ready。被唤醒后重新加锁,检查条件。整个过程 unique_lock 管理得妥妥当当。

3.4 所有权转移

unique_lock 是可移动的,但不可拷贝。你可以把锁的所有权从一个函数转移到另一个函数:

std::unique_lock<std::mutex> get_lock() {
    std::unique_lock<std::mutex> lock(mtx);
    // 做一些初始化...
    return lock; // 所有权转移给调用者
}

避坑指南:我曾经在代码里把 unique_lock 作为函数参数传递,结果忘了它是 move-only 的,编译报错半天没反应过来。记住:unique_lock 不能拷贝,只能移动。

总结

这一章我们讲了三个核心工具:

  • std::thread:创建线程,管理生命周期。记得 join 或 detach,别让它析构时崩溃。
  • std::mutex:保护共享数据。别手动 lock/unlock,用 RAII 包装。
  • std::lock_guard / std::unique_lock:自动管理锁。简单场景用 lock_guard,需要灵活性用 unique_lock。

多线程编程,说白了就是管理好「共享」和「同步」这两个问题。工具给你了,怎么用还得看你的设计。下一章我们会深入条件变量和 future,那才是真正体现多线程威力的地方。


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