现代C++ | 并发编程全家桶(std::thread / mutex / async / future 等)
前言
C++98/03 时代,多线程只能靠平台 API pthread / Win32 Thread,存在三大痛点:
-
不可移植:pthread 和 Win32 Thread 代码完全不同,写一次只能在一个平台跑;
-
容易死锁:手动管理锁的 lock/unlock,极易忘记或异常时漏解锁;
-
异常安全差:线程崩溃、异常抛出时,资源(线程句柄、锁)难以正确回收。
C++11 委员会决定把并发直接纳入标准库,目标是 “写一次,到处线程安全运行”,同时保证异常安全和 RAII 风格。
-
C++11:引入
std::thread(原生线程)、std::mutex(锁)、std::async(高级任务)、std::atomic(无锁编程)、std::condition_variable(条件变量); -
C++14:完善
std::shared_lock(读写锁的读锁); -
C++17:加入
std::scoped_lock(多锁防死锁)和并行算法(std::execution::par)。
C++ 标准库的所有锁(lock_guard、unique_lock 等)都不是 “锁本身”,而是RAII wrapper,锁的 “管理器”,真正的锁是 std::mutex、std::shared_mutex 这些互斥量,锁管理器只是负责帮你自动调用 lock() 和 unlock()。
温情提示:本文超多字!3.5w字警告!有需要可以点击目录跳转对应的知识点。
std::thread
设计原因
提供原生、可移植的线程创建方式,屏蔽平台 API 差异。
底层原理
内部调用操作系统线程 API,Linux 下是 pthread,Windows 下是 CreateThread;线程启动后立即执行传入的函数或 lambda;析构时若未 join/detach 会直接调用 std::terminate 崩溃(故意设计,防止资源泄漏)。
实际代码示例:
#include <iostream>
#include <thread> // 必须包含
// C++98 老写法(Linux pthread,不可移植)
#include <pthread.h>
void* thread_func(void*) {
std::cout << "我在新线程里运行\n";
return nullptr;
}
pthread_t t;
pthread_create(&t, nullptr, thread_func, nullptr);
pthread_join(t, nullptr);
// C++11 新写法(可移植)
int main() {
std::thread t([]{
std::cout << "我在新线程里运行\n";
});
t.join(); // 必须 join(等待线程结束)或 detach(分离,让它自己跑)
}
线程对象析构前必须 join()等待结束 或 detach()分离。
需要注意的是,std::thread 默认拷贝参数,如果想传引用,必须用 std::ref/std::cref:
#include <iostream>
#include <thread>
#include <functional> // std::ref
void add_one(int& n) { n++; }
int main() {
int x = 10;
// 错误:直接传 x,thread 会拷贝一份,add_one 改的是副本
// std::thread t1(add_one, x); t1.join(); std::cout << x << "\n"; // 输出 10
// 正确:用 std::ref 包装 x,传递引用
std::thread t2(add_one, std::ref(x));
t2.join();
std::cout << x << "\n"; // 输出 11
}
常见坑:
-
std::thread 没 join/detach 就析构 → 直接调用
std::terminate崩溃; -
多把 mutex 按不同顺序 lock → 死锁(用
std::scoped_lock或std::lock解决); -
std::thread 传引用不用 std::ref → 传的是副本,改不动原对象;
QA:
std::thread 析构时没 join 会怎样?
调用
std::terminate(),程序直接崩溃。这是故意设计的,如果线程没 join 就析构,要么是线程还在跑(资源泄漏),要么是忘记 join(逻辑错误),直接崩溃能让开发者快速发现问题。(C++20 的std::jthread解决了这个问题)。
thread_local线程局部存储
设计原因
很多场景下,每个线程都需要自己独立的一份全局数据例如线程 ID、线程局部缓存、TLS 变量、随机数引擎等,而普通的全局变量或 static 变量是进程级共享的,会造成数据竞争或错误。C++11 引入 thread_local 关键字,提供了标准、可移植的线程局部存储机制。
底层原理
-
thread_local变量在每个线程都有独立的一份实例,在线程第一次访问时才构造,在线程结束时析构。 -
底层实现依赖操作系统线程本地存储(TLS)机制(Windows TLS slot、Linux
pthread_key等),编译器自动生成相应的线程局部代码。 -
生命周期和线程绑定,不受其他线程影响。
实际例子:
场景 1:线程 ID
thread_local int thread_id = 0; // 每个线程都有自己独立的 thread_id
void worker() {
if (thread_id == 0) {
thread_id = get_next_thread_id(); // 只在当前线程设置
}
std::cout << "线程 " << thread_id << " 正在工作\n";
}
场景 2:线程局部随机数引擎
thread_local std::mt19937 rng{std::random_device{}()}; // 每个线程独立的随机引擎
void monte_carlo_simulation() {
std::uniform_real_distribution<double> dist(0.0, 1.0);
double r = dist(rng); // 每个线程使用自己的 rng,避免锁竞争
}
场景 3:线程局部缓存
thread_local std::unordered_map<std::string, int> cache; // 每个线程独立的缓存
int get_value(const std::string& key) {
if (auto it = cache.find(key); it != cache.end()) {
return it->second;
}
int value = compute_expensive(key);
cache[key] = value;
return value;
}
性能优势
访问 thread_local 变量比加锁的全局变量快 10~100 倍(无锁竞争),特别适合高并发场景。
常见坑
-
thread_local变量的构造 / 析构是每个线程独立的,构造时机是第一次访问时(延迟构造)。 -
不要在
thread_local中存指向栈上对象的指针(线程结束时栈被回收)。 -
thread_local变量的初始化如果有副作用,要小心多线程初始化顺序。 -
不能用于
static成员变量,只能用于全局 / 命名空间作用域或函数局部静态
QA
thread_local 和普通 static 变量有什么本质区别?
static是进程级共享,所有线程共用一份;thread_local是线程级独立,每个线程都有自己的一份。
thread_local 在什么场景必须使用?
需要每个线程独立的数据如线程 ID、随机数引擎、TLS 缓存、线程局部日志上下文等,避免加锁竞争。
thread_local 的构造时机是什么?
线程第一次访问该变量时才构造,线程结束时析构。
std::lock 死锁避免机制算法
设计原因
多线程编程中,同时锁定多个互斥锁是常见需求如转账操作需同时锁定 “转出账户” 和 “转入账户” 的锁、资源池管理需同时锁定 “资源块” 和 “引用计数” 的锁。但手动按任意顺序加锁会导致循环等待,这是死锁的四大必要条件之一。
C++11 引入 std::lock 函数模板,提供了一种标准、死锁避免的原子性锁定多个互斥锁的机制,从根源上消除循环等待的风险
底层原理
std::lock 的核心是死锁避免算法,C++ 标准虽未强制规定具体实现,但要求必须满足 “死锁安全”。常见实现策略包括:
-
固定顺序锁定:按 mutex 对象的地址或其他唯一标识排序,所有线程都按相同顺序锁定,破坏循环等待条件。
-
尝试 - 回退机制:先尝试锁定所有 mutex,若失败则立即释放已锁定的 mutex,等待随机时间后重试,避免持续占用资源。
无论采用哪种策略,std::lock 都能保证:要么所有 mutex 都被成功锁定,要么一个都不锁(原子性),且不会发生死锁。
实际例子:
场景 1:经典转账操作(双锁场景)
#include <mutex>
#include <iostream>
struct Account {
int balance;
std::mutex mtx;
};
void transfer(Account& from, Account& to, int amount) {
// 原子性锁定两个账户的 mutex,避免死锁
std::lock(from.mtx, to.mtx);
// 用 std::adopt_lock 告诉 lock_guard:mutex 已经锁好了,直接接管
std::lock_guard<std::mutex> lock_from(from.mtx, std::adopt_lock);
std::lock_guard<std::mutex> lock_to(to.mtx, std::adopt_lock);
from.balance -= amount;
to.balance += amount;
std::cout << "转账成功: " << amount << "\n";
}
场景 2:线程池任务队列的双端操作
#include <queue>
#include <mutex>
#include <thread>
std::queue<int> task_queue;
std::mutex queue_mtx; // 保护队列本身
std::mutex status_mtx; // 保护队列状态(如是否为空)
bool queue_empty = true;
void add_task(int task) {
std::lock(queue_mtx, status_mtx);
std::lock_guard<std::mutex> lk1(queue_mtx, std::adopt_lock);
std::lock_guard<std::mutex> lk2(status_mtx, std::adopt_lock);
task_queue.push(task);
queue_empty = false;
}
void get_task() {
std::lock(queue_mtx, status_mtx);
std::lock_guard<std::mutex> lk1(queue_mtx, std::adopt_lock);
std::lock_guard<std::mutex> lk2(status_mtx, std::adopt_lock);
if (!queue_empty) {
int task = task_queue.front();
task_queue.pop();
queue_empty = task_queue.empty();
}
}
场景 3:资源池的多资源锁定
#include <vector>
#include <mutex>
struct Resource {
bool in_use;
std::mutex mtx;
};
std::vector<Resource> resource_pool(10);
// 同时锁定两个资源进行交换操作
void swap_resources(int idx1, int idx2) {
std::lock(resource_pool[idx1].mtx, resource_pool[idx2].mtx);
std::lock_guard<std::mutex> lk1(resource_pool[idx1].mtx, std::adopt_lock);
std::lock_guard<std::mutex> lk2(resource_pool[idx2].mtx, std::adopt_lock);
std::swap(resource_pool[idx1].in_use, resource_pool[idx2].in_use);
}
特点
-
std::lock比手动按固定顺序加锁稍慢,因有死锁避免的算法开销,但远低于死锁带来的调试和维护成本。 -
在高并发场景下,若能通过设计避免 “同时锁多个 mutex”,性能会更好;若必须多锁,
std::lock是最安全的选择。
常见坑点
-
忘记配合 RAII 锁守卫:
std::lock只负责锁定,不负责解锁!必须用std::lock_guard或std::unique_lock配合std::adopt_lock标签接管所有权,否则异常时会导致 mutex 永远不被释放。 -
混合使用
std::lock和手动lock():若部分 mutex 用std::lock锁定,部分手动lock(),会破坏死锁避免的逻辑,仍可能引发死锁。 -
锁定非 Lockable 类型:
std::lock只能锁定满足Lockable要求的类型如std::mutex、std::recursive_mutex,不能用于std::shared_mutex的独占锁定,需用std::lock的变体或 C++17 的std::scoped_lock。
拓展:
C++17 升级:std::scoped_lock 是 std::lock 的 RAII 封装,支持锁定任意数量的 mutex,用法更简洁:
void transfer(Account& from, Account& to, int amount) {
std::scoped_lock lock(from.mtx, to.mtx); // C++17,自动 lock+unlock
from.balance -= amount;
to.balance += amount;
}
std::lock 是 C++11 解决多锁死锁的 “安全网”,而 C++17 的 std::scoped_lock 进一步降低了使用门槛。std::scoped_lock 在后文会讲。
QA
std::lock 是如何避免死锁的?
通过死锁避免算法如固定顺序锁定、尝试 - 回退机制,原子性地尝试锁定所有 mutex,失败则释放已锁的并重试,破坏 “循环等待” 条件,保证死锁安全。
std::lock 和 C++17 的 std::scoped_lock 有什么区别?
std::lock是函数,仅负责锁定,需手动配合 RAII 锁守卫;std::scoped_lock是类模板,构造时自动调用std::lock锁定多个 mutex,析构时自动解锁,代码更简洁安全,推荐优先使用。
使用 std::lock 时为什么必须用 std::adopt_lock?
std::lock已经完成了锁定操作,std::lock_guard/std::unique_lock默认会在构造时再次调用lock(),导致未定义行为。std::adopt_lock标签的作用是告诉锁守卫:“mutex 已经锁好了,直接接管所有权,不要再次锁定”。
mutex + RAII 锁(lock_guard / unique_lock / scoped_lock)
设计原因
手动 lock()/unlock() 极易忘记解锁,或异常时漏解锁 → 死锁。用 RAII资源获取即初始化风格的锁,保证 “构造时 lock,析构时 unlock”,异常安全。
底层原理
-
std::mutex是操作系统互斥量的封装,同一时间只能有一个线程持有锁; -
lock_guard等RAII锁在构造函数中调用mutex.lock(),析构函数中调用mutex.unlock(),即使中间抛异常,析构函数也会执行,保证锁被释放。
代码示例:
#include <iostream>
#include <mutex>
#include <thread>
std::mutex mtx;
int counter = 0;
// 危险老写法:手动 lock/unlock
void dangerous_increment() {
mtx.lock();
counter++;
// 如果这里抛异常,永远不 unlock!其他线程会卡死
mtx.unlock();
}
// 现代写法1:lock_guard(最简单,C++11)
void safe_increment1() {
{
std::lock_guard<std::mutex> lg(mtx); // 构造时 lock
counter++; // 临界区
} // 作用域结束,lg 析构,自动 unlock
}
// 现代写法2:unique_lock(更灵活,C++11)
void safe_increment2() {
std::unique_lock<std::mutex> ul(mtx); // 构造时 lock
counter++;
ul.unlock(); // 可以提前 unlock
// 做其他不需要锁的事
}
// 现代写法3:scoped_lock(多锁防死锁,C++17)
std::mutex mtx1, mtx2;
void multi_lock_safe() {
std::scoped_lock lock(mtx1, mtx2); // 内部用 std::lock 算法,按固定顺序加锁,防死锁
// 同时持有 mtx1 和 mtx2
} // 自动解锁所有锁
int main() {
std::thread t1(safe_increment1);
std::thread t2(safe_increment1);
t1.join();
t2.join();
std::cout << counter << "\n"; // 输出 2
}
C++11基础锁
std::lock_guard(最简单、最常用的 RAII 锁)
设计背景:
C++11 之前,手动管理 mutex.lock()/unlock() 是家常便饭,但极易出错:
-
忘记
unlock()→ 死锁; -
中间代码抛异常 → 永远不会执行到
unlock()→ 死锁。
std::lock_guard 就是为了解决这个问题设计的,它是一个极简的 RAII 锁管理器,只做一件事 :构造时 lock,析构时 unlock。
std::lock_guard 的源码非常简单,大概长这样:
template <typename Mutex>
class lock_guard {
public:
// 构造函数:显式要求传入 Mutex 引用,构造时立即 lock
explicit lock_guard(Mutex& m) : mutex_(m) {
mutex_.lock();
}
// 析构函数:自动 unlock
~lock_guard() {
mutex_.unlock();
}
// 禁止拷贝和赋值(锁的所有权不能拷贝)
lock_guard(const lock_guard&) = delete;
lock_guard& operator=(const lock_guard&) = delete;
private:
Mutex& mutex_; // 存的是 Mutex 的引用,不拥有所有权
};
关键点:
-
它存的是
Mutex&(引用),不拥有 mutex 的所有权,只是帮你管理 lock/unlock; -
禁止拷贝和赋值(
= delete),因为锁的所有权不能拷贝; -
没有默认构造函数,必须传入一个 mutex;
-
没有提前 unlock 的接口,只能等析构。
详细使用方法
#include <iostream>
#include <mutex>
#include <thread>
std::mutex mtx;
int counter = 0;
void increment() {
for (int i = 0; i < 10000; ++i) {
// 用法1:直接构造 lock_guard,传入 mutex
// 注意:必须用 {} 或 () 显式构造,不能写成 std::lock_guard lg = mtx;(因为构造函数是 explicit 的)
std::lock_guard<std::mutex> lg(mtx);
counter++;
} // 离开作用域,lg 析构,自动 unlock
}
void increment_with_scope() {
for (int i = 0; i < 10000; ++i) {
// 用法2:用额外的 {} 缩小临界区范围(推荐!)
// 临界区越小,锁竞争越少,性能越好
{
std::lock_guard<std::mutex> lg(mtx);
counter++;
} // 这里就 unlock 了,后面的代码不需要锁
// 做其他不需要锁的事
}
}
int main() {
std::thread t1(increment);
std::thread t2(increment_with_scope);
t1.join();
t2.join();
std::cout << "counter: " << counter << "\n"; // 输出 20000
}
适用场景
-
单锁、简单临界区:只需要一个 mutex,且不需要提前 unlock;
-
临界区范围明确:用
{}就能清晰界定临界区; -
追求极简:不需要任何额外功能,只要自动 lock/unlock。
特点
-
零开销:编译器优化后,
lock_guard的代码会被完全内联,和手动lock()/unlock()性能一模一样; -
没有运行时开销:所有逻辑都在编译期完成。
std::unique_lock(最灵活的 RAII 锁)
设计背景
std::lock_guard 太简单了,有很多局限性:
-
不能提前 unlock;
-
不能延迟 lock(构造时必须 lock);
-
不能配合
std::condition_variable使用; -
不能移动(所有权不能转移)。
为了解决这些问题,C++11 引入了 std::unique_lock,它是一个 “全能型” 的 RAII 锁管理器,提供了几乎所有能想到的锁操作功能。
std::unique_lock 的源码比 lock_guard 复杂很多,核心简化如下:
template <typename Mutex>
class unique_lock {
public:
// 构造函数1:直接 lock(和 lock_guard 一样)
explicit unique_lock(Mutex& m) : mutex_(&m), owns_lock_(false) {
lock();
}
// 构造函数2:延迟 lock(std::defer_lock)
// 构造时不 lock,后面手动 lock
unique_lock(Mutex& m, std::defer_lock_t) noexcept : mutex_(&m), owns_lock_(false) {}
// 构造函数3:尝试 lock(std::try_to_lock)
// 尝试 lock,失败也不阻塞
unique_lock(Mutex& m, std::try_to_lock_t) : mutex_(&m), owns_lock_(false) {
try_lock();
}
// 构造函数4:已经持有锁(std::adopt_lock)
// 假设你已经手动 lock 了 mutex,unique_lock 直接接管所有权
unique_lock(Mutex& m, std::adopt_lock_t) : mutex_(&m), owns_lock_(true) {}
// 析构函数:如果持有锁,就 unlock
~unique_lock() {
if (owns_lock_) {
mutex_->unlock();
}
}
// 支持移动!(锁的所有权可以转移)
unique_lock(unique_lock&& other) noexcept : mutex_(other.mutex_), owns_lock_(other.owns_lock_) {
other.mutex_ = nullptr;
other.owns_lock_ = false;
}
unique_lock& operator=(unique_lock&& other) noexcept {
if (this != &other) {
if (owns_lock_) {
mutex_->unlock();
}
mutex_ = other.mutex_;
owns_lock_ = other.owns_lock_;
other.mutex_ = nullptr;
other.owns_lock_ = false;
}
return *this;
}
// 禁止拷贝
unique_lock(const unique_lock&) = delete;
unique_lock& operator=(const unique_lock&) = delete;
// 核心操作接口
void lock() {
mutex_->lock();
owns_lock_ = true;
}
bool try_lock() {
owns_lock_ = mutex_->try_lock();
return owns_lock_;
}
void unlock() {
if (owns_lock_) {
mutex_->unlock();
owns_lock_ = false;
}
}
// 检查是否持有锁
bool owns_lock() const noexcept { return owns_lock_; }
explicit operator bool() const noexcept { return owns_lock_; }
// 释放所有权(返回 mutex 指针,不再管理锁)
Mutex* release() noexcept {
Mutex* m = mutex_;
mutex_ = nullptr;
owns_lock_ = false;
return m;
}
// 获取 mutex 指针
Mutex* mutex() const noexcept { return mutex_; }
private:
Mutex* mutex_; // 存的是指针,支持 nullptr(移动后)
bool owns_lock_; // 标记是否持有锁
};
关键点:
-
存的是
Mutex*(指针),不是引用,支持nullptr(移动后原对象的指针置空); -
有
owns_lock_标记,记录是否持有锁; -
支持移动(
&&),锁的所有权可以转移; -
提供了多种构造策略(
defer_lock、try_to_lock、adopt_lock); -
提供了
lock()、try_lock()、unlock()等手动操作接口。
详细使用方法
场景 1:基本用法(和 lock_guard 一样)
#include <iostream>
#include <mutex>
#include <thread>
std::mutex mtx;
int counter = 0;
void basic_usage() {
for (int i = 0; i < 10000; ++i) {
std::unique_lock<std::mutex> ul(mtx); // 构造时 lock
counter++;
} // 析构时 unlock
}
场景 2:延迟 lock(std::defer_lock),构造时不 lock,后面手动 lock,适用于 “需要先做一些准备工作,再进入临界区” 的场景:
void defer_lock_usage() {
std::unique_lock<std::mutex> ul(mtx, std::defer_lock); // 构造时不 lock
// 做一些不需要锁的准备工作
int temp = 0;
for (int i = 0; i < 100; ++i) temp++;
ul.lock(); // 准备工作做完了,手动 lock
counter += temp;
ul.unlock(); // 可以提前 unlock
// 做其他不需要锁的事
}
场景 3:尝试 lock(std::try_to_lock),如果失败,锁被其他线程持有,也不阻塞,直接返回,适用于 “如果拿不到锁就做别的事” 的场景:
void try_lock_usage() {
for (int i = 0; i < 10000; ++i) {
std::unique_lock<std::mutex> ul(mtx, std::try_to_lock); // 尝试 lock
if (ul.owns_lock()) { // 检查是否拿到锁
counter++;
} else {
// 没拿到锁,做别的事(比如打印日志、跳过这次循环)
std::cout << "没拿到锁,跳过\n";
}
}
}
场景 4:接管已有的锁(std::adopt_lock),假设你已经手动 lock 了 mutex,让 unique_lock 接管所有权,适用于 “需要和旧代码配合” 的场景:
void adopt_lock_usage() {
mtx.lock(); // 手动 lock(旧代码遗留)
// 让 unique_lock 接管,后面不用手动 unlock 了
std::unique_lock<std::mutex> ul(mtx, std::adopt_lock);
counter++;
} // ul 析构,自动 unlock
场景 5:提前 unlock,临界区结束后,提前 unlock,减少锁的持有时间,提高并发性能:
void early_unlock_usage() {
std::unique_lock<std::mutex> ul(mtx);
counter++;
ul.unlock(); // 提前 unlock,后面的代码不需要锁
// 做耗时操作(比如 IO、复杂计算),不持有锁
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
场景 6:配合 condition_variable 使用,std::condition_variable 的 wait() 函数必须接受 std::unique_lock,因为它需要在等待时原子地 unlock mutex,唤醒后重新 lock:
#include <iostream>
#include <mutex>
#include <condition_variable>
#include <queue>
#include <thread>
std::mutex mtx;
std::condition_variable cv;
std::queue<int> q;
void producer() {
for (int i = 0; i < 5; ++i) {
{
std::unique_lock<std::mutex> ul(mtx);
q.push(i);
std::cout << "生产:" << i << "\n";
}
cv.notify_one();
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
}
void consumer() {
while (true) {
// wait() 必须用 unique_lock!
std::unique_lock<std::mutex> ul(mtx);
cv.wait(ul, []{ return !q.empty(); }); // 原子地 unlock + 等待
int val = q.front();
q.pop();
std::cout << "消费:" << val << "\n";
if (val == 4) break;
}
}
int main() {
std::thread t_prod(producer);
std::thread t_cons(consumer);
t_prod.join();
t_cons.join();
}
场景 7:锁的所有权转移(移动语义),unique_lock 支持移动,锁的所有权可以从一个对象转移到另一个对象,适用于 “需要把锁传给函数” 的场景:
#include <iostream>
#include <mutex>
#include <utility> // std::move
std::mutex mtx;
void process_with_lock(std::unique_lock<std::mutex> ul) {
// 这里持有锁
std::cout << "处理数据...\n";
} // ul 析构,自动 unlock
void move_usage() {
std::unique_lock<std::mutex> ul(mtx); // 持有锁
// 把锁的所有权转移给 process_with_lock 函数
process_with_lock(std::move(ul));
// 现在 ul 不再持有锁,mutex_ 是 nullptr
}
适用场景
-
需要灵活控制锁的生命周期:提前 unlock、延迟 lock;
-
配合 condition_variable 使用:必须用
unique_lock; -
需要尝试 lock:
try_to_lock; -
需要转移锁的所有权:移动语义;
-
需要和旧代码配合:
adopt_lock。
特点
-
比 lock_guard 略重,因为有
owns_lock_标记,需要多存一个bool,且析构时需要检查owns_lock_;但编译器优化后差异极小:在大多数场景下,性能和lock_guard几乎一样; -
灵活性的代价是轻微的空间开销:每个
unique_lock对象比lock_guard多占一个指针和一个bool的空间(在 64 位系统上,多 16 字节)。
std::adopt_lock
std::lock_guard 和 std::unique_lock 是 C++ 标准库提供的 RAII 锁守卫,默认行为是 “构造时自动 lock,析构时自动 unlock”。但在以下两种场景中,我们需要先手动 lock mutex,再交给 RAII 守卫管理:
-
使用
std::lock同时锁定多个 mutex 以避免死锁,此时 mutex 已被std::lock锁好。 -
需要在 RAII 守卫构造前执行一些前置逻辑,再手动 lock mutex。
如果此时直接用默认构造的 RAII 守卫,会导致重复 lock,普通 std::mutex 不允许同一线程连续 lock 两次,会引发未定义行为或死锁。
C++11 引入 std::adopt_lock 标签常量,专门用于告知 RAII 守卫:“mutex 已经锁好了,直接接管所有权,不要再次 lock”。
std::adopt_lock是 C++ 标准库定义的一个空的标签常量,可以理解为一个 “信物” 或 “口令”。
它唯一的作用:传给 std::lock_guard 或 std::unique_lock,告诉它们:
“这个 mutex 我已经提前锁好了!你构造的时候千万不要再锁一次,只需要在析构的时候帮我解锁就行了。”
代码示例:
std::mutex mtx;
void good_func() {
mtx.lock(); // 我们手动先把锁锁上了
// ✅ 用 std::adopt_lock 打个招呼
std::lock_guard<std::mutex> lock(mtx, std::adopt_lock);
// lock_guard 收到信号:“哦,已经锁好了是吧?行,那我不管锁了,只负责等下帮你解锁。”
// ... 临界区代码 ...
} // 函数结束,lock_guard 析构,自动调用 mtx.unlock()。完美!
常见坑
在未 lock 的 mutex 上使用 std::adopt_lock
std::mutex mtx;
void bad_func() {
// 错误!mtx 还没 lock,直接用 adopt_lock
std::lock_guard<std::mutex> lock(mtx, std::adopt_lock);
// 析构时会对未 lock 的 mutex 执行 unlock,引发未定义行为!
}
与 std::defer_lock 混淆:
-
std::adopt_lock:mutex 已 lock,接管后不 lock,析构时 unlock。 -
std::defer_lock:mutex 未 lock,接管后也不 lock,之后需手动调用lock()。 -
两者功能完全相反,切勿用错。
QA:
lock_guard 和 unique_lock 区别?
lock_guard 最简单,构造时 lock,析构时 unlock,不可提前解锁,不可移动。零开销抽象。
unique_lock 更灵活,支持延迟 lock(构造时不 lock,后面手动 lock)、提前 unlock、重锁、timed lock(
try_lock_for),可移动,且能配合 condition_variable 使用(wait()需要 unique_lock)。稍微有一点点开销,因为它要记录当前是否持有锁。
std::adopt_lock 的作用是什么?
它是一个标签常量,用于传递给
std::lock_guard或std::unique_lock,告知它们 “mutex 已经被当前线程 lock 好了,不要在构造时重复 lock,只需在析构时 unlock”。
std::adopt_lock 最常与哪个函数配合使用?为什么?
最常与
std::lock配合。因为std::lock用于同时锁定多个 mutex 以避免死锁,执行完std::lock后 mutex 已被锁好,需要用std::adopt_lock让 RAII 守卫接管,保证异常安全。
使用 std::adopt_lock 时如果 mutex 未被 lock 会怎样?
会导致未定义行为。RAII 守卫析构时会对未 lock 的 mutex 执行
unlock(),这违反了std::mutex的使用规范,可能引发程序崩溃或死锁。
C++14读写锁
std::shared_lock(读写锁的读锁)
设计背景
在很多场景下,我们有读多写少的需求:
-
多个线程可以同时读数据,互不阻塞;
-
但写数据时,必须独占访问,所有读线程和其他写线程都要阻塞。
C++11 没有提供标准的读写锁,C++14 才引入了 std::shared_mutex(读写锁本身)和 std::shared_lock(读写锁的读锁 RAII 管理器)。
读写锁的工作原理
std::shared_mutex 支持两种锁模式:
-
共享锁(Shared Lock):多个线程可以同时持有,用于读操作;
-
独占锁(Exclusive Lock):同一时间只能有一个线程持有,用于写操作。
规则:
-
一个线程持有共享锁时,其他线程可以继续加共享锁,但不能加独占锁;
-
一个线程持有独占锁时,其他线程既不能加共享锁,也不能加独占锁;
-
写操作优先还是读操作优先,取决于具体实现(通常是写优先,避免写饥饿)。
std::shared_lock 的源码和 std::unique_lock 非常像,只是它调用的是 mutex.lock_shared() 和 mutex.unlock_shared():
template <typename Mutex>
class shared_lock {
public:
// 构造函数:加共享锁
explicit shared_lock(Mutex& m) : mutex_(&m), owns_lock_(false) {
lock();
}
// 析构函数:解共享锁
~shared_lock() {
if (owns_lock_) {
mutex_->unlock_shared();
}
}
// 支持移动,接口和 unique_lock 几乎一样
shared_lock(shared_lock&& other) noexcept;
shared_lock& operator=(shared_lock&& other) noexcept;
// 核心操作:共享锁
void lock() {
mutex_->lock_shared();
owns_lock_ = true;
}
void unlock() {
if (owns_lock_) {
mutex_->unlock_shared();
owns_lock_ = false;
}
}
// 其他接口和 unique_lock 一样:owns_lock(), release(), mutex() 等
private:
Mutex* mutex_;
bool owns_lock_;
};
详细使用方法
生产者 - 消费者 - 读写锁例子
#include <iostream>
#include <shared_mutex> // C++14 必须包含
#include <thread>
#include <vector>
std::shared_mutex rw_mtx;
int data = 0; // 共享数据
// 读线程:用 shared_lock,多个读可以同时进行
void read_data(int thread_id) {
for (int i = 0; i < 3; ++i) {
{
std::shared_lock<std::shared_mutex> sl(rw_mtx); // 加共享锁
std::cout << "线程 " << thread_id << " 读数据:" << data << "\n";
} // 解共享锁
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
}
// 写线程:用 unique_lock(或 lock_guard),独占访问
void write_data(int val) {
for (int i = 0; i < 2; ++i) {
{
std::unique_lock<std::shared_mutex> ul(rw_mtx); // 加独占锁
data = val;
std::cout << "写数据:" << data << "\n";
} // 解独占锁
std::this_thread::sleep_for(std::chrono::milliseconds(50));
}
}
int main() {
// 启动 3 个读线程
std::vector<std::thread> readers;
for (int i = 0; i < 3; ++i) {
readers.emplace_back(read_data, i);
}
// 启动 1 个写线程
std::thread writer(write_data, 42);
// 等待所有线程结束
for (auto& t : readers) {
t.join();
}
writer.join();
}
输出示例
线程 0 读数据:0
线程 1 读数据:0
线程 2 读数据:0
写数据:42
线程 0 读数据:42
线程 1 读数据:42
线程 2 读数据:42
写数据:42
线程 0 读数据:42
线程 1 读数据:42
线程 2 读数据:42
适用场景
-
读多写少,读操作远多于写操作,比如配置文件读取、缓存数据访问;
-
需要高并发读,多个读线程可以同时访问,提高性能。
特点
-
读多写少场景下性能远优于普通 mutex,多个读线程可以同时进行,没有锁竞争;
-
写操作开销比普通 mutex 略大,因为需要管理共享锁和独占锁的状态;
-
不适合写多读少的场景,如果写操作很多,读写锁的优势会消失,甚至比普通 mutex 更慢。
C++17 多锁神器
std::scoped_lock(多锁防死锁)
设计背景
当需要同时持有多个 mutex 时,死锁是最常见的问题:
线程 A 先 lock mtx1,再 lock mtx2;线程 B 先 lock mtx2,再 lock mtx1;结果A 持有 mtx1,等待 mtx2;B 持有 mtx2,等待 mtx1 → 死锁。
死锁的经典例子:
#include <iostream> #include <mutex> #include <thread> std::mutex mtx1, mtx2; void thread_a() { for (int i = 0; i < 10000; ++i) { mtx1.lock(); // 先 lock mtx1 mtx2.lock(); // 再 lock mtx2 // 临界区 mtx2.unlock(); mtx1.unlock(); } } void thread_b() { for (int i = 0; i < 10000; ++i) { mtx2.lock(); // 先 lock mtx2 mtx1.lock(); // 再 lock mtx1 // 临界区 mtx1.unlock(); mtx2.unlock(); } } int main() { std::thread t1(thread_a); std::thread t2(thread_b); t1.join(); t2.join(); std::cout << "完成\n"; // 大概率不会输出,程序死锁了! }
C++11 提供了 std::lock(m1, m2, ...) 算法,可以按固定顺序同时加多个锁,防死锁,但它不是 RAII 的,需要手动 unlock。
C++17 引入了 std::scoped_lock—— 它是一个支持多锁的 RAII 锁管理器,内部使用 std::lock 算法,自动按固定顺序加锁,自动解锁,彻底解决多锁死锁问题。
std::scoped_lock 的核心是可变参数模板和 std::lock 算法,简化如下:
// 基础版本:单锁(和 lock_guard 一样)
template <typename... MutexTypes>
class scoped_lock;
template <typename Mutex>
class scoped_lock<Mutex> {
public:
explicit scoped_lock(Mutex& m) : mutex_(m) {
mutex_.lock();
}
~scoped_lock() {
mutex_.unlock();
}
scoped_lock(const scoped_lock&) = delete;
scoped_lock& operator=(const scoped_lock&) = delete;
private:
Mutex& mutex_;
};
// 多锁版本:用 std::lock 算法按固定顺序加锁
template <typename... Mutexes>
class scoped_lock {
public:
explicit scoped_lock(Mutexes&... m) : mutexes_(m...) {
// 核心:用 std::lock 同时加多个锁,内部按固定顺序,防死锁
std::lock(m...);
}
~scoped_lock() {
// 按构造的反顺序解锁(C++17 标准规定)
std::apply([](auto&... m) { (..., m.unlock()); }, mutexes_);
}
scoped_lock(const scoped_lock&) = delete;
scoped_lock& operator=(const scoped_lock&) = delete;
private:
std::tuple<Mutexes&...> mutexes_; // 用 tuple 存多个 mutex 的引用
};
关键点:
-
支持可变参数(
...),可以同时管理任意数量的 mutex; -
内部使用
std::lock(m...)算法,按固定顺序加锁,防死锁; -
析构时按构造的反顺序解锁;
-
禁止拷贝和移动;
-
单锁时和
lock_guard一样,多锁时自动防死锁。
详细使用方法:
#include <iostream>
#include <mutex>
#include <thread>
std::mutex mtx1, mtx2;
void thread_a() {
for (int i = 0; i < 10000; ++i) {
// C++17:用 scoped_lock 同时管理 mtx1 和 mtx2
std::scoped_lock lock(mtx1, mtx2); // 自动按固定顺序加锁,防死锁
// 临界区
} // 自动解锁
}
void thread_b() {
for (int i = 0; i < 10000; ++i) {
// 即使这里顺序和 thread_a 不一样,也不会死锁!
// 因为 scoped_lock 内部会按固定顺序加锁
std::scoped_lock lock(mtx2, mtx1);
// 临界区
}
}
int main() {
std::thread t1(thread_a);
std::thread t2(thread_b);
t1.join();
t2.join();
std::cout << "完成\n"; // 一定会输出,不会死锁!
}
适用场景
-
需要同时持有多个 mutex:这是
scoped_lock的唯一设计目标; -
C++17 及以上,替代 C++11 的
std::lock+ 手动 unlock,更安全。
特点
-
和手动用
std::lock一样:编译器优化后零开销; -
多锁时的固定顺序开销极小:
std::lock算法的开销可以忽略不计。
四个锁的详细对比表
| 锁 | 引入版本 | 特点 | 支持的操作 | 适用场景 | 性能 |
|---|---|---|---|---|---|
lock_guard | C++11 | 极简,构造时 lock,析构时 unlock,不可提前 unlock,不可移动 | 仅自动 lock/unlock | 单锁、简单临界区 | 零开销 |
unique_lock | C++11 | 最灵活,支持延迟 lock、提前 unlock、try_lock、移动、配合 condition_variable | lock, unlock, try_lock, defer_lock, adopt_lock, move | 需要灵活控制锁、配合 condition_variable | 略高于 lock_guard,优化后几乎一样 |
shared_lock | C++14 | 读写锁的读锁,多个线程可同时持有 | lock_shared, unlock_shared | 读多写少的读操作 | 读时无竞争,写时略重 |
scoped_lock | C++17 | 多锁 RAII,自动防死锁,支持任意数量 mutex | 同时 lock 多个 mutex,自动 unlock | 需要同时持有多个 mutex | 零开销 |
选择指南
-
单锁、简单临界区 → 优先用
lock_guard,最简单、零开销; -
需要灵活控制锁、配合 condition_variable → 用
unique_lock; -
读多写少 → 读操作用
shared_lock,写操作用unique_lock,配合std::shared_mutex; -
需要同时持有多个 mutex → 用
scoped_lock(C++17)或std::lock+lock_guard(C++11); -
C++17 及以上 → 尽量用
scoped_lock替代lock_guard(单锁时一样,多锁时更安全)。
性能优化
死锁的四个必要条件,只要打破其中一个,就能避免死锁:
-
互斥条件:一个资源同一时间只能被一个线程持有(锁的本质,无法打破);
-
请求与保持条件:线程持有一个资源,同时请求另一个资源(打破方法:一次性请求所有资源);
-
不可剥夺条件:资源不能被强行剥夺,只能由持有者主动释放(锁的本质,无法打破);
-
循环等待条件:线程之间形成循环等待链(打破方法:按固定顺序加锁)。
避免死锁的具体方法
-
按固定顺序加锁:所有线程都按相同的顺序加锁,比如按 mutex 的地址顺序、或按名字的字典序;
-
用
std::scoped_lock(C++17)或std::lock(C++11):内部已经按固定顺序加锁; -
避免嵌套锁:一个线程已经持有锁时,不要再申请其他锁;
-
用超时锁:
unique_lock支持try_lock_for(),如果拿不到锁就放弃,避免无限等待; -
尽量减少锁的持有时间:临界区越小越好,锁竞争越少。
锁的性能优化建议
-
尽量缩小临界区:只把真正需要保护的代码放在临界区里,其他代码移到外面;
-
避免在临界区里做耗时操作:比如 IO、复杂计算、睡眠等;
-
读多写少用读写锁:
std::shared_mutex+std::shared_lock; -
优先用无锁编程:高性能场景下,用
std::atomic替代 mutex; -
用线程池减少线程创建开销:频繁创建和销毁线程的开销很大,用线程池复用线程;
-
避免锁的伪共享:多个原子变量或 mutex 不要放在同一个缓存行里(64 字节),否则会导致缓存颠簸。
condition_variable(条件变量)
设计原因
线程间 “等待某个条件成立” 时,不能一直轮询浪费 CPU,需要高效的通知机制:线程 A 等待条件,线程 B 满足条件后通知 A 醒来。
底层原理
配合 std::mutex 使用:
-
wait()会原子地 unlock mutex + 让线程睡眠,直到被notify_one()/notify_all()唤醒; -
唤醒后会重新 lock mutex,检查条件是否成立,防止虚假唤醒。
实际例子
#include <iostream>
#include <mutex>
#include <condition_variable>
#include <queue>
#include <thread>
std::mutex mtx;
std::condition_variable cv;
std::queue<int> q; // 共享队列
// 生产者:往队列里放数据
void producer() {
for (int i = 0; i < 5; ++i) {
{
std::lock_guard<std::mutex> lg(mtx);
q.push(i);
std::cout << "生产:" << i << "\n";
}
cv.notify_one(); // 通知一个消费者
std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 模拟生产耗时
}
}
// 消费者:从队列里取数据
void consumer() {
while (true) {
std::unique_lock<std::mutex> ul(mtx);
// wait 的第二个参数是“谓词”,防止虚假唤醒:只有当 !q.empty() 时才真的醒来
cv.wait(ul, []{ return !q.empty(); });
int val = q.front();
q.pop();
std::cout << "消费:" << val << "\n";
if (val == 4) break; // 消费完最后一个数据退出
}
}
int main() {
std::thread t_prod(producer);
std::thread t_cons(consumer);
t_prod.join();
t_cons.join();
}
为什么需要谓词?
即使没有调用 notify_one(),线程也可能被操作系统 “虚假唤醒”。如果没有谓词,线程醒来后会直接执行后续代码,此时条件可能不成立,比如队列还是空的,导致 bug。谓词的作用是 “醒来后再检查一遍条件,不成立就继续睡”。
async /future/promise /packaged_task
设计原因
不想手动管理线程的创建、join、返回值和异常传播时,用 “任务” 方式写并发,更简单、更安全。这是现代 C++ 推荐的默认并发方式。
核心组件关系
-
std::async:最常用的高级接口,自动选择线程池或新线程执行任务,返回
std::future; -
std::future:代表 “未来会拿到的结果”,调用
get()会阻塞等待结果,并自动传播任务抛出的异常; -
std::promise:手动设置结果或异常的 “生产者”,配合 future 使用;
-
std::packaged_task:把函数包装成可异步执行的任务,内部关联 future。
关系图:
Promise 是结果的输入端(Set)。
Future 是结果的输出端(Get)。
Packaged Task 内部包含了一个 Promise 和一个函数。
Async 内部帮你创建好了 Packaged Task 和 Thread。
简单例子:
实际例子:
例子 1:std::async,适用于 “我有一个函数,想让它在后台跑,一会来拿结果”。
#include <iostream>
#include <future>
#include <chrono>
#include <stdexcept>
// 模拟耗时计算,可能抛异常
int heavy_computation(int x) {
std::this_thread::sleep_for(std::chrono::seconds(1));
if (x < 0) throw std::runtime_error("Negative number!");
return x * x;
}
int main() {
// 关键点:显式指定 std::launch::async 强制创建新线程
// 如果不指定,编译器可能会选择 deferred(懒执行,不并发)
auto fut = std::async(std::launch::async, heavy_computation, 10);
std::cout << "主线程做其他事...\n";
try {
// get() 阻塞等待
// 如果任务抛异常,这里会自动 rethrow,无需手动处理错误码
int result = fut.get();
std::cout << "结果:" << result << "\n";
} catch (const std::exception& e) {
std::cout << "捕获到异常:" << e.what() << "\n";
}
// 注意:future 是 RAII 的,不需要手动清理
return 0;
}
例子 2:std::promise,适用于 “结果由另一个线程手动设置” 的场景,比如结果不是由一个简单的函数返回,而是来自事件循环、回调或者复杂的流程控制。
#include <iostream>
#include <future>
#include <thread>
// promise 是 move-only 的,必须按值传递(move 进去)
void do_work(std::promise<int> p) {
try {
std::this_thread::sleep_for(std::chrono::seconds(1));
// 手动设置值
p.set_value(42);
} catch (...) {
// 也可以手动设置异常
p.set_exception(std::current_exception());
}
}
int main() {
// 1. 创建一个 Promise 对象
std::promise<int> p;
// 2. 从 Promise 获取与其关联的 Future
std::future<int> fut = p.get_future();
// 3. 将 Promise 移动到新线程中去执行逻辑
std::thread t(do_work, std::move(p)); // promise 不能拷贝,必须 move
std::cout << "等待结果...\n";
// 4. 主线程通过 Future 取值
int result = fut.get();
std::cout << "结果:" << result << "\n";
t.join();
return 0;
}
例子 3:std::packaged_task,适用于 “把函数包装成任务,交给其他线程执行” 的场景。它是连接 “函数对象” 和 “future/promise” 的桥梁。常用于构建线程池。
#include <iostream>
#include <future>
#include <thread>
#include <vector>
int add(int a, int b) { return a + b; }
int main() {
// 1. 包装函数
// 模板参数是函数签名 int(int, int)
std::packaged_task<int(int, int)> task(add);
// 2. 获取 future
std::future<int> fut = task.get_future();
// 3. 把任务交给线程执行
// packaged_task 也是 move-only 的
std::thread t(std::move(task), 1, 2);
// 4. 获取结果
int result = fut.get();
std::cout << "结果:" << result << "\n"; // 输出 3
t.join();
// 进阶:packaged_task 可以像普通函数一样被调用,不一定非要在新线程
std::packaged_task<int(int, int)> task2(add);
auto fut2 = task2.get_future();
task2(10, 20); // 在当前线程直接调用
std::cout << "直接调用结果:" << fut2.get() << "\n";
return 0;
}
为了方便理解,我们把这四个组件对应成生活场景:
| C++ 组件 | 奶茶店角色 | 作用 |
|---|---|---|
|
std::promise |
奶茶店店员 |
负责生产结果(做奶茶)。做好了之后,把结果放在一个地方。 |
|
std::future |
取餐号小票 |
你手里的凭证。你可以拿着它去等待结果(奶茶做好了吗?),好了就取走结果。 |
|
std::packaged_task |
奶茶制作订单 |
它把 “做奶茶的流程(函数)” 和 “小票(future)” 绑定在一起。 |
|
std::async |
VIP 一键下单服务 |
最省心。你只要说 “我要一杯奶茶”,它自动帮你搞定:创建订单、找店员做、把小票给你。 |
核心关系:
-
Promise 是输入端(Set):负责往里写结果。
-
Future 是输出端(Get):负责往外读结果。
-
它们是配对使用的,底层通过一个共享状态(Shared State)连接。
[你的线程] [新线程/任务线程]
| |
| std::future | std::promise / std::packaged_task
| (消费者) | (生产者)
| |
| fut.get() <--(等待结果)--> p.set_value()
| (阻塞) (设置结果)
| |
| 拿到结果了! |
从最底层的 std::promise 开始讲,最后到最方便的 std::async
例子 1:std::promise & std::future(底层机制,手动控制),这是最底层的机制。
场景:你去饭店吃饭,你在座位上等(主线程),厨师在厨房做(子线程)
#include <iostream>
#include <future> // 必须包含这个头文件
#include <thread>
#include <chrono>
// 这是一个在子线程运行的函数
// 注意参数:std::promise<int> 是按值传递的,而且必须用 std::move 转移所有权
void chef(std::promise<int> dish_promise) {
std::cout << "厨师:开始做菜...\n";
std::this_thread::sleep_for(std::chrono::seconds(2)); // 模拟耗时
// 关键点 1:设置结果
// promise 负责在一端写入数据
dish_promise.set_value(42);
std::cout << "厨师:菜做好了!\n";
}
int main() {
// 1. 创建一个 Promise 对象(生产者)
// 模板参数是结果的类型 int
std::promise<int> prom;
// 2. 从 Promise 中获取与其关联的 Future 对象(消费者)
// future 是 promise 的“好兄弟”,promise 负责写,future 负责读
std::future<int> fut = prom.get_future();
std::cout << "顾客:点了菜,拿到了取餐号,去玩手机了...\n";
// 3. 启动线程,把 promise 移动给子线程
// 注意:std::promise 是 move-only 的(不能拷贝),必须用 std::move
std::thread t(chef, std::move(prom));
// 4. 主线程做自己的事...
std::this_thread::sleep_for(std::chrono::seconds(1));
std::cout << "顾客:玩完手机了,看看菜做好没...\n";
// 关键点 2:获取结果
// future.get() 会阻塞当前线程,直到 promise 设置了值
// 如果 promise 已经设好了值,get() 会立刻返回
int result = fut.get();
std::cout << "顾客:拿到菜了!结果是:" << result << "\n";
t.join(); // 记得回收线程
return 0;
}
进阶:如果任务抛异常了怎么办?
std::promise 也可以设置异常,future.get() 会自动把异常抛出来:
#include <stdexcept>
void may_throw(std::promise<int> p) {
try {
throw std::runtime_error("厨房着火了!");
p.set_value(42);
} catch (...) {
// 捕获任何异常,通过 set_exception 传给 future
p.set_exception(std::current_exception());
}
}
// main 函数里:
// int result = fut.get(); // 这里会抛出 "厨房着火了" 的异常
例子 2:std::packaged_task
std::promise 太底层了,每次都要手动调用 set_value,std::packaged_task 是一个封装器。它把一个普通函数或可调用对象包起来,当这个函数执行完,它会自动把返回值塞进 Promise 里。
场景:你有一个加法函数,你想把它变成一个异步任务
#include <iostream>
#include <future>
#include <thread>
// 一个普通函数
int add(int a, int b) {
std::cout << "线程:正在计算 " << a << " + " << b << "...\n";
std::this_thread::sleep_for(std::chrono::seconds(1));
return a + b;
}
int main() {
// 1. 创建 packaged_task
// 模板参数是函数签名 int(int, int)
// 它内部自动包含了一个 promise 和一个函数
std::packaged_task<int(int, int)> task(add);
// 2. 依然是获取 future
std::future<int> fut = task.get_future();
std::cout << "主线程:任务已打包,准备执行...\n";
// 3. 执行任务
// 方式 A:像普通函数一样在当前线程调用(没意义,演示用)
// task(10, 20);
// 方式 B:移动到新线程去执行(这才是并发)
// packaged_task 也是 move-only 的
std::thread t(std::move(task), 10, 20);
// 4. 获取结果
std::cout << "主线程:等待结果...\n";
int result = fut.get();
std::cout << "主线程:结果是 " << result << "\n";
t.join();
return 0;
}
总结 packaged_task:它就是把 “函数调用” 变成了 “异步任务”,不用手动 set_value 了,函数 return 什么,它自动帮你 set 什么。
例子 3:std::async,这是最简单、最推荐使用的接口。它把 packaged_task 和 thread 都封装好了,你甚至看不到线程的存在。
场景:我就是想异步算个东西,别让我写那么多废话。
#include <iostream>
#include <future>
#include <chrono>
int long_task(int x) {
std::cout << "任务线程:开始工作...\n";
std::this_thread::sleep_for(std::chrono::seconds(2));
return x * x;
}
int main() {
std::cout << "主线程:发起异步任务...\n";
// 关键点:std::async
// 参数 1:启动策略(后面详解)
// 参数 2:函数名
// 参数 3...:函数参数
// 返回值:std::future
auto fut = std::async(std::launch::async, long_task, 10);
std::cout << "主线程:我可以先去干点别的...\n";
std::this_thread::sleep_for(std::chrono::seconds(1));
std::cout << "主线程:好了,我要拿结果了...\n";
// get() 依然是阻塞等待
// 如果 long_task 抛异常,这里会自动捕获并抛出
int result = fut.get();
std::cout << "主线程:结果是 " << result << "\n";
// 注意:不需要 join 任何线程!async 自动管理生命周期
return 0;
}
std::async 的三种启动策略,调用 std::async 时,第一个参数决定了它到底会不会创建新线程。
| 策略 | 行为 | 注意事项 |
|---|---|---|
| std::launch::async | 立刻启动新线程执行任务 | 保证并发。 |
| std::launch::deferred | 懒执行:调用 future.get() 时才在当前线程执行任务 | 不并发。任务会推迟到 get () 那一刻,且在调用者线程跑。 |
| 默认(不指定) | 实现可以选择:可能是 async,也可能是 deferred | 常见坑:MSVC 和 GCC 的实现策略不同。如果不指定,任务可能不会并行执行!建议永远显式指定策略。 |
如果你写 std::async(func) 而不指定策略,编译器通常会选择 deferred。这意味着你的代码根本没有并发!它只是把函数推迟到 get() 那一刻在主线程跑了一圈。
因此,建议永远显式指定 std::launch::async,除非你明确知道自己想要延迟执行
// 推荐写法(强制并发)
auto fut = std::async(std::launch::async, func);
// 不推荐写法(可能根本不并发)
// auto fut = std::async(func);
std::shared_future(让多个线程等待同一个结果)
std::future 有个限制:它是 move-only 的,而且只能 get() 一次。如果你想让多个线程都等待同一个结果,就需要 std::shared_future。
#include <iostream>
#include <future>
#include <thread>
int get_data() { return 42; }
void worker(std::shared_future<int> sf) {
// 多个线程都可以调用 sf.get()
std::cout << "线程 " << std::this_thread::get_id() << " 拿到数据:" << sf.get() << "\n";
}
int main() {
std::future<int> fut = std::async(std::launch::async, get_data);
// 转换为 shared_future,可以拷贝给多个线程
std::shared_future<int> shared_fut = fut.share();
std::thread t1(worker, shared_fut);
std::thread t2(worker, shared_fut);
t1.join();
t2.join();
return 0;
}
常见坑:
-
future.get () 调用两次 → 第二次崩溃,future 是 “一次性” 的,只能 get 一次,因为它会转移结果的所有权;
-
std::async 默认策略 → 可能是 deferred,任务不会并行执行,建议显式指定
std::launch::async;
QA:
std::async 和 std::thread 区别?什么时候用哪个?
区别:
std::async是高级抽象,自动管理线程(可能用线程池),返回 future 自动获取结果和传播异常。
std::thread是底层接口:需要手动 join/detach,手动处理返回值和异常。使用场景:
中小项目、简单任务:优先用
std::async,更简单安全。需要精确控制线程生命周期、或线程需要长期运行比如一个常驻的 IO 线程,用
std::thread。
std::atomic+无锁编程
设计原因
高性能场景下,mutex 的锁竞争开销太大(上下文切换、内核态切换),用 CPU 原子指令如 compare-and-swap, CAS实现无锁编程,性能更高。锁能保证正确性,但在高并发、高频竞争的场景下,锁的开销是不可接受的。我们需要一种机制,既能保证 “读 - 改 - 写” 操作的原子性,又不经过内核、不阻塞线程。这就是 std::atomic 和 无锁编程。
底层原理
-
CPU 指令级支持:利用 CPU 提供的原子指令如
LOCK前缀在 x86 上,保证操作 “要么全做,要么不做”,不会被其他线程打断。 -
内存可见性:编译器保证内存可见性,通过
memory_order控制一个线程对原子变量的修改,其他线程能及时看到。这涉及到禁止编译器乱序重排和CPU 内存屏障。
现代 CPU 提供了专门的原子指令。
-
x86/x64 架构:
LOCK指令前缀。 -
ARM 架构:
LDREX/STREX指令。
这些指令能保证:在执行 “读 - 改 - 写” 操作时,内存总线被锁住,其他 CPU 核无法访问这块内存,从而保证了操作的原子性。
std::atomic 基本用法
C++11 提供了模板类 std::atomic<T>,让我们可以把任意类型 T(通常是整型、指针)变成原子类型。
用 std::atomic 改写计数器
#include <iostream>
#include <thread>
#include <vector>
#include <atomic> // 必须包含
// 定义一个原子计数器
// 注意:std::atomic 是模板,这里 T 是 int
std::atomic<int> counter{0};
void increment() {
for (int i = 0; i < 100000; ++i) {
// 原子自增操作
// 这行代码在 CPU 层面是一条原子指令,不会被打断
counter.fetch_add(1, std::memory_order_relaxed);
}
}
int main() {
std::vector<std::thread> threads;
for (int i = 0; i < 10; ++i) {
threads.emplace_back(increment);
}
for (auto& t : threads) t.join();
// 结果:永远是 1,000,000!
std::cout << "Atomic Counter: " << counter << "\n";
return 0;
}
std::atomic 常用 API 详解
std::atomic 提供了丰富的操作。我们以最常用的 std::atomic<int> 为例。
1. 基础读写操作
std::atomic<int> a{0};
// 写操作(Store)
a.store(10, std::memory_order_relaxed); // 原子地把 a 设为 10
// 读操作(Load)
int val = a.load(std::memory_order_relaxed); // 原子地读取 a 的值
2. 算术运算,这些函数返回的是修改前的值
std::atomic<int> a{10};
// fetch_add:原子加
int old_val = a.fetch_add(5); // old_val = 10, a 变成 15
// fetch_sub:原子减
a.fetch_sub(3); // a 变成 12
// 位运算也支持
a.fetch_and(0xFF); // 原子与
a.fetch_or(0x01); // 原子或
a.fetch_xor(0x01); // 原子异或
3. 交换操作
std::atomic<int> a{10};
// exchange:原子地把 a 设为 20,并返回 a 之前的值
int old_val = a.exchange(20); // old_val = 10, a 变成 20
4. 最强大的操作:Compare-and-Swap (CAS),这是无锁编程的基石。它的逻辑是:
“如果当前值等于预期值,就把它设为新值;否则,什么都不做。无论如何,都返回当前值。”
std::atomic 提供了两个版本:
-
compare_exchange_weak:可能会 “虚假失败”,性能更好,通常用在循环里。 -
compare_exchange_strong:保证不虚假失败,性能稍差。
CAS 示例:实现原子乘法(原子操作不直接支持乘法,我们用 CAS 自己实现)
std::atomic<int> a{5};
void atomic_multiply(int multiplier) {
int old_val = a.load(std::memory_order_relaxed);
int new_val;
do {
new_val = old_val * multiplier; // 计算新值
// 关键点:CAS
// 如果 a 的值还是 old_val(说明期间没被别人改过),就把 a 设为 new_val
// 如果 a 的值变了(被别人改了),old_val 会被更新为当前的新值,循环继续
} while (!a.compare_exchange_weak(old_val, new_val, std::memory_order_relaxed));
}
int main() {
atomic_multiply(10);
std::cout << "a = " << a << "\n"; // 输出 50
return 0;
}
这就是典型的 “乐观锁” 思想,假设没人竞争,先算,如果算完发现被人改了,就重试
内存序(Memory Order)
这是 C++ 并发编程中最深奥、最容易出错的部分。我们一直用的是std::memory_order_relaxed。现在我们要搞懂这到底是什么。
问题的根源:编译器和 CPU 的 “优化”
为了提高性能,编译器和CPU都会对指令进行重排序。示例:指令重排序
// 源代码
int a = 0;
int b = 0;
void thread1() {
a = 1; // (1)
b = 2; // (2)
}
在编译器或 CPU 看来,(1) 和 (2) 之间没有依赖关系,所以它们可能会被重排序成:
// 实际执行顺序(可能)
b = 2; // (2)
a = 1; // (1)
在单线程下,这没问题,结果都是 a=1, b=2。但在多线程下,这会导致灾难性的后果。
内存序的作用是用来禁止或限制编译器和 CPU 进行重排序的工具。它定义了一个线程对内存的修改,在什么情况下能被另一个线程看到。
C++11 提供了 6 种内存序,我们只需要掌握最核心的 3 种:
|
内存序 |
英文全称 |
重排序限制 |
性能 |
适用场景 |
|---|---|---|---|---|
|
memory_order_relaxed |
Relaxed |
几乎没有 |
最快 |
单纯的计数器,不需要同步其他变量。 |
|
memory_order_acquire / memory_order_release |
Acquire-Release |
单向限制 |
很快 |
线程间同步(生产者 - 消费者)。最常用。 |
|
memory_order_seq_cst |
Sequential Consistency |
完全禁止 |
最慢 |
默认值。需要严格全局顺序。 |
深入详解:Acquire-Release 语义,这是实际工程中最常用、性价比最高的组合。
-
memory_order_release:用于写操作。
-
禁止重排:在这个写操作之前的所有读写操作,都不能被重排到这个写操作之后。
-
-
memory_order_acquire:用于读操作。
-
禁止重排:在这个读操作之后的所有读写操作,都不能被重排到这个读操作之前。
-
经典示例:生产者 - 消费者模式
#include <iostream>
#include <thread>
#include <atomic>
int data = 0; // 普通共享数据
std::atomic<bool> ready{false}; // 原子标志位
void producer() {
data = 42; // (1) 写数据
// 注意这里用 memory_order_release
ready.store(true, std::memory_order_release); // (2) 发信号
}
void consumer() {
// 注意这里用 memory_order_acquire
while (!ready.load(std::memory_order_acquire)) { // (3) 等信号
// 自旋等待
}
// 到这里,我们保证能看到 data = 42
std::cout << "Data: " << data << "\n"; // (4) 读数据
}
int main() {
std::thread t1(producer);
std::thread t2(consumer);
t1.join();
t2.join();
return 0;
}
发生了什么:
-
Release 保证:(1)
data = 42一定会发生在 (2)ready.store(true)之前。编译器不会把 (1) 重排到 (2) 后面。 -
Acquire 保证:(4)
cout << data一定会发生在 (3)ready.load(true)之后。编译器不会把 (4) 重排到 (3) 前面。 -
同步保证:一旦线程 2 在 (3) 读到了
true,它就一定能看到线程 1 在 (1) 写的42。
如果不用 Acquire-Release(用 Relaxed)会怎样?线程 2 可能会看到 ready == true,但 data 还是 0,因为指令被重排了,ready 先被设为 true,data 还没来得及写。
memory_order_seq_cst顺序一致性,这是 std::atomic 所有操作的默认内存序。它保证所有线程看到的所有原子操作的顺序都是完全一致的,就像在一个单线程里按顺序执行一样。
std::atomic<int> a{0};
// 不写内存序,默认就是 memory_order_seq_cst
a.store(10);
// 等价于:
// a.store(10, std::memory_order_seq_cst);
-
优点:简单,不容易出错。
-
缺点:性能开销最大(在 x86 上开销不大,但在 ARM/PowerPC 上开销明显)
特殊原子类型:std::atomic_flag
std::atomic_flag 是最简单的原子类型,它只有两种状态:set 或 clear。它是 C++ 标准中 唯一保证无锁 的类型,其他 std::atomic<T> 在某些平台上可能内部还是用锁实现的。
常用 API:
-
test_and_set():原子地设为 true,并返回之前的值。 -
clear():原子地设为 false
用 std::atomic_flag 实现一个自旋锁:
#include <iostream>
#include <thread>
#include <atomic>
#include <vector>
class SpinLock {
private:
// ATOMIC_FLAG_INIT 初始化为 clear(false)状态
std::atomic_flag flag = ATOMIC_FLAG_INIT;
public:
void lock() {
// test_and_set:如果之前是 clear,就设为 set 并返回 false(成功获取锁)
// 如果之前是 set,就返回 true(循环等待,自旋)
while (flag.test_and_set(std::memory_order_acquire)) {
// 这里什么都不做,就是循环等
// 这就是“自旋”
}
}
void unlock() {
// 释放锁,设为 clear
flag.clear(std::memory_order_release);
}
};
SpinLock spinlock;
int counter = 0;
void increment() {
for (int i = 0; i < 10000; ++i) {
spinlock.lock();
counter++;
spinlock.unlock();
}
}
int main() {
std::vector<std::thread> threads;
for (int i = 0; i < 10; ++i) {
threads.emplace_back(increment);
}
for (auto& t : threads) t.join();
std::cout << "Counter: " << counter << "\n"; // 正确输出 100000
return 0;
}
自旋锁的特点是:如果锁被占用,线程不睡眠,而是原地空转(自旋)等待,直到锁可用。
自旋锁 vs 互斥锁:
-
自旋锁:等待时不睡眠,占用 CPU 但响应快。适用于锁持有时间非常短(比如只是加个计数器)的场景。
-
互斥锁:等待时睡眠,不占用 CPU 但响应慢。适用于锁持有时间长的场景。
-
核心操作:
load(),store(),fetch_add(),compare_exchange_weak()。 -
内存序:
-
relaxed:最快,只保证原子性,不管顺序(计数器)。 -
acquire/release:最常用,保证线程间同步(生产者 - 消费者)。 -
seq_cst:默认,最强保证,最慢。
-
建议
1. 不要对 std::atomic 变量使用普通的 ++ 或 +=虽然 std::atomic<int> 重载了 operator++,但推荐使用显式的 fetch_add(1),因为这样可以显式指定内存序,代码更清晰。
std::atomic<int> a{0};
a++; // 可以,但默认用 memory_order_seq_cst,可能慢
a.fetch_add(1, std::memory_order_relaxed); // 推荐,显式控制
2. 不是所有类型都能无锁
std::atomic<T> 有一个成员函数 is_lock_free()。
-
对于
int,long,void*等基本类型,通常是true(无锁)。 -
对于
std::string或自定义的大结构体,通常是false(内部还是用锁实现的,性能还不如直接用std::mutex)。
3. 优先使用 memory_order_relaxed 或 acquire/release,除非你真的需要严格的顺序一致性,否则不要用默认的 memory_order_seq_cst,它会带来不必要的性能损失。
4. 自旋锁:用 std::atomic_flag 实现,适用于短时间持有锁的场景。
QA:
为什么用 Atomic?
Mutex 太慢(上下文切换、内核态),Atomic 利用 CPU 硬件指令实现无锁并发,性能高。
memory_order 有哪些?最常用哪个?
主要有三个级别:
relaxed:最快,只保证原子性,不保证内存顺序;
acquire/release:最常用,保证 “Acquire 之后的读写一定能看到 Release 之前的读写”;
seq_cst:最强,最慢,保证顺序一致性(默认值)。最常用 acquire/release,平衡性能和同步需求。
noexcept:并发性能优化的隐形推手
设计原因
并发场景下,移动操作经常发生在 vector reallocate、thread 构造等地方。noexcept 可以让编译器和标准库进行更激进的优化。
底层原理
在 std::vector 扩容、std::thread 内部等并发相关容器操作中,只有 noexcept 的移动构造函数才会被优先使用。
实际例子:
class Task {
public:
Task(Task&& other) noexcept { ... } // 必须 noexcept
};
std::vector<Task> tasks;
tasks.push_back(Task{}); // 会使用 noexcept 移动
在并发代码中为什么要给移动构造函数加 noexcept?
std::vector等容器在 reallocate 时,只有noexcept的移动才会使用,否则退化为拷贝,性能大幅下降,同时也影响线程池、任务队列等并发数据结构的效率。
现代并发代码中,几乎所有自定义可移动类型都会把移动构造函数标记为 noexcept。
thread_local 和 noexcept 是 C++11 为并发编程准备的两把利器 , 前者从数据隔离层面解决了线程安全的痛点,后者从性能优化层面为并发代码提速。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)