目录
- 一、为啥总扯上“语法特性”
- 二、std::thread:创建与生命周期
- 函数、lambda、成员函数都能当入口
- 传参:默认拷贝进线程栈,想改外面用 ref
- thread 只能移动,不能拷贝
- 三、互斥量:std::mutex 和 RAII 锁
- lock_guard:作用域就是临界区
- unique_lock:能先不加锁、能配合条件变量
- C++17 的 scoped_lock:一次锁住多把互斥量
- 还有这些互斥量类型
- 四、条件变量:等人、叫人
- 五、原子与内存序:std::atomic
- 先会写能用
- 为啥要管 memory_order
- 六、异步:std::async、std::future、std::promise
- std::async
- packaged_task 和 promise
- 七、只跑一次:std::call_once 与静态局部变量
- std::call_once
- 静态局部变量:C++11 起的“魔法静态”
- 八、C++17 读写锁:std::shared_mutex
- 九、C++20:std::jthread 和停止令牌
- 十、实践里容易翻车的点
- 十一、综合示例:生产者、消费者与工作线程
- 十二、小结:一张表对照需求
C++11 起,标准库正式提供了线程、互斥量、条件变量、原子类型与异步任务支持,分别位于
一、为啥总扯上“语法特性”
- 可移动不可复制:std::thread 禁止拷贝,只能移动,避免好几个对象共管同一条 OS 线程。
- RAII:std::lock_guard、std::unique_lock 用构造/析构配对加锁解锁,异常路径也不会漏解锁。
- 模板与完美转发:std::thread 构造函数用变参模板,把可调用对象和参数“原样”转给线程入口;要在线程里改外面的变量,得配合 std::ref。
- 内存模型与原子:std::atomic 加上 memory_order,把“别的线程啥时候能看见啥”写进语言,不全靠互斥量来猜。
- C++20:std::jthread 能协作停止;std::barrier、std::latch 等再补一批同步原语。
二、std::thread:创建与生命周期
基本写法
#include <thread>
#include <iostream>
void f(int x) { std::cout << x << "\n"; }
int main() {
std::thread t(f, 42); // 新线程执行 f(42)
t.join(); // 主线程等待 t 结束;必须 join 或 detach,否则 std::terminate
return 0;
}
- join():一直等到线程结束;适合“主线程要拿结果或把收尾做干净”的场景。
- detach():线程和 std::thread 对象脱钩,在后台跑;不能再 join,生命周期得自己兜住(别去引用已经销毁的栈上对象)。
函数、lambda、成员函数都能当入口
#include <thread>
#include <functional>
struct Worker {
void Run(int n) { (void)n; }
};
int main() {
auto lam = [] { return; };
std::thread t1(lam);
Worker w;
std::thread t2(&Worker::Run, &w, 1); // 成员函数要传对象指针/引用
t1.join();
t2.join();
return 0;
}
传参:默认拷贝进线程栈,想改外面用 ref
线程构造里,默认会把参数拷贝或移动到线程自己的栈。要让线程改调用方的变量,用 std::ref / std::cref:
#include <thread>
#include <functional>
void inc(int& x) { ++x; }
int main() {
int a = 0;
std::thread t(inc, std::ref(a));
t.join();
// a == 1
return 0;
}
大对象可以 std::move 进线程,少一次多余拷贝。
thread 只能移动,不能拷贝
#include <thread>
void f(int) {}
int main() {
std::thread t1(f, 1);
std::thread t2 = std::move(t1); // t1 不再代表那条线程
t2.join();
return 0;
}
三、互斥量:std::mutex 和 RAII 锁
头文件
lock_guard:作用域就是临界区
构造时 lock(),析构时 unlock(),出了作用域自动放锁,写法最省事。
#include <mutex>
std::mutex m;
int counter = 0;
void bump() {
std::lock_guard<std::mutex> lk(m);
++counter;
}
unique_lock:能先不加锁、能配合条件变量
可以 晚点再加锁、中间手动 unlock,跟 std::condition_variable 配套;也能 std::adopt_lock 接管你已经拿住的锁。
#include <mutex>
std::mutex m;
void work() {
std::unique_lock<std::mutex> lk(m);
// ... 临界区 ...
lk.unlock();
// 非临界区干活
lk.lock();
// 再进临界区
}
C++17 的 scoped_lock:一次锁住多把互斥量
std::lock 会按固定规则同时抢多把锁,再交给 std::scoped_lock 做 RAII,降低死锁概率。
#include <mutex>
std::mutex m1, m2;
void both() {
std::scoped_lock lk(m1, m2);
// 同时持有 m1、m2
}
还有这些互斥量类型
| 类型 | 干啥用 |
|---|---|
| std::timed_mutex | try_lock_for / try_lock_until,限时试一下 |
| std::recursive_mutex | 同一线程能多次 lock,慎用,容易把设计问题盖住 |
| std::shared_mutex | 多读单写,后面单独说 |
四、条件变量:等人、叫人
std::condition_variable 要跟 std::unique_lockstd::mutex 一起用。虚假唤醒免不了,所以 wait 一定要带谓词,用循环把条件钉死。
#include <mutex>
#include <condition_variable>
#include <queue>
std::mutex m;
std::condition_variable cv;
std::queue<int> q;
void producer(int v) {
{
std::lock_guard<std::mutex> lk(m);
q.push(v);
}
cv.notify_one();
}
int consumer() {
std::unique_lock<std::mutex> lk(m);
cv.wait(lk, [] { return !q.empty(); });
// 等价于 while 循环,自动处理虚假唤醒
int v = q.front();
q.pop();
return v;
}
- notify_one():叫醒一个正在等的。
- notify_all():全叫醒,适合状态一变大家都得重看的场景。
C++20 还有 std::condition_variable_any,能跟 std::shared_lock 之类配合。
五、原子与内存序:std::atomic
头文件
先会写能用
#include <atomic>
std::atomic<int> x{0};
void f() {
x.fetch_add(1, std::memory_order_relaxed);
}
为啥要管 memory_order
std::mutex 这类互斥量一边把临界区护住,一边帮你搭 happens-before。光用原子、不管顺序的话,编译器和 CPU 可能重排,别的线程会看到“中间态”,调试很头疼。
常用序,从弱到强捋一遍:
- memory_order_relaxed:只保证原子性,不当线程间同步用;计数、统计之类可以。
- memory_order_acquire:读侧用;这次读之后的读写,不能跑到这次读前面去。
- memory_order_release:写侧用;这次写之前的读写,不能跑到这次写后面去。
- memory_order_acq_rel:读改写一条指令时,读写两边都带上 acquire/release。
- memory_order_seq_cst:默认就是这个;全局一条总顺序,最强、最省心,代价也略高。
一个典型的 release-acquire 把数据“发布”出去:
#include <atomic>
std::atomic<bool> ready{false};
int data = 0;
void producer() {
data = 42;
ready.store(true, std::memory_order_release);
}
void consumer() {
while (!ready.load(std::memory_order_acquire)) { }
int v = data; // 能看见 42
}
心里没底的时候,先全程默认 seq_cst,真热点再收紧内存序也不迟。
注意:volatile 不能替代 std::atomic 进行线程同步。volatile 仅防止编译器优化重排,不保证操作的原子性,也不建立线程间的 happens-before 关系。
六、异步:std::async、std::future、std::promise
头文件
std::async
#include <future>
#include <iostream>
int slow() { return 1; }
int main() {
auto fut = std::async(std::launch::async, slow);
// ... 干别的 ...
int r = fut.get(); // 阻塞到有结果;只能 get 一次
return 0;
}
- std::launch::async:标准只要求在这种策略下,可调用对象如同在一个新的执行线程中异步执行,并未规定底层必须是独立的系统级线程还是由线程池承载。当前主流实现(GCC / Clang 常用运行库、MSVC 等)里,常见做法是直接新建 std::thread。
- std::launch::deferred:懒,拖到 get() 才在调用线程里跑。
- 默认策略实现说了算;你强依赖并行,就显式写 async。
packaged_task 和 promise
想把“任务”交给某条线去跑、再把结果塞回 future,就用这套。
#include <future>
#include <thread>
int main() {
std::promise<int> p;
std::future<int> f = p.get_future();
std::thread t([&p] {
p.set_value(7);
});
int v = f.get();
t.join();
return v;
}
异常可以 set_exception,会在 future::get() 那边抛出来。
七、只跑一次:std::call_once 与静态局部变量
“整个程序里某段逻辑只执行一次”,常见两条路:call_once 管通用场景;函数内静态局部变量管“懒构造单份对象”,从 C++11 起也是线程安全的。
std::call_once
静态初始化、懒加载做一次初始化,常用 std::once_flag + std::call_once,比手写双重检查锁省心,也不容易写错。
#include <mutex>
std::once_flag flag;
void init() {
std::call_once(flag, [] {
// 只执行一次
});
}
静态局部变量:C++11 起的“魔法静态”
从 C++11 起,块作用域里带初始化的 static 局部变量,在第一次执行到该声明时才会初始化;标准保证初始化只发生一次,且多线程同时首次走到这里时不会数据竞争。这就是常说的 Meyers 单例写法,适合“全局只要一份、且希望用到再建”的对象。
struct Registry {
Registry() { /* 注册表等一次性开销 */ }
};
Registry& GetRegistry() {
static Registry reg;
return reg;
}
和 call_once 怎么选:只要懒构造一个对象、并希望通过函数拿到引用,用 static 局部变量通常更短、更直观;要跑一段任意初始化逻辑、或和 once_flag 绑在类/模块里统一管理,用 call_once 更灵活。若构造函数里抛出异常,实现一般会允许之后再次尝试初始化(与“一次成功即固定”的 call_once 语义细节略有差别,具体以标准与文档为准)。
八、C++17 读写锁:std::shared_mutex
多读一写:读用 std::shared_lock,写用 std::unique_lock。
#include <shared_mutex>
#include <mutex>
std::shared_mutex rw;
int value = 0;
void read_side() {
std::shared_lock<std::shared_mutex> lk(rw);
(void)value;
}
void write_side(int v) {
std::unique_lock<std::shared_mutex> lk(rw);
value = v;
}
读特别多、写很少的时候划算;否则一把普通 std::mutex 往往更简单。
九、C++20:std::jthread 和停止令牌
std::jthread 析构时如果还能 join,会 自动 join,比裸 std::thread 少踩“写完忘了 join”的坑。可以配合 std::stop_token 做协作式取消:
#include <thread>
#include <stop_token>
#include <chrono>
void worker(std::stop_token st) {
while (!st.stop_requested()) {
// 工作一小步
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
}
int main() {
std::jthread t(worker);
t.request_stop();
return 0;
}
另外还有 std::barrier、std::latch、std::atomic::wait 等。
十、实践里容易翻车的点
下面这几条对照一下,能少掉很多坑。
- 析构顺序:线程里如果抓着引用或指针,必须保证指向的对象在线程结束前还活着;detach 尤其容易踩雷。
- join 或 detach:std::thread 析构前必须二选一,否则直接 std::terminate。
- 锁的粒度:临界区越小越好;别扛着锁做 IO、长计算。
- 死锁:多把锁就固定顺序加,或者用 std::scoped_lock / std::lock;别随手嵌套乱序加锁。还要避免在持有锁时调用用户自定义回调、虚函数、未知逻辑,防止隐式嵌套锁导致死锁。
- 条件变量:永远用带谓词的 wait。
- 原子还是互斥量:复杂不变量还是靠锁;原子适合简单标志、计数,或者你专门设计过的无锁结构。
- 数据竞争:C++ 里对同一个非原子对象并发读写、又没有同步,是 未定义行为;该上锁上锁,该原子原子。
十一、综合示例:生产者、消费者与工作线程
前面条件变量示例只给了函数片段,这里凑一个能直接编译运行的小程序:多个生产者往共享队列里塞任务,固定数量的工作线程循环取任务、在锁外“干活”,这就是最常见的线程池骨架(真实池子还会复用线程、配任务类型与拒绝策略等,这里只保留核心同步结构)。
流程可以记三步:先 join 所有生产者,再在锁里置位 done 并 notify_all,最后 join 工人线程。工人用带谓词的 wait:有活就干,没活且已收尾就退出。
#include <chrono>
#include <condition_variable>
#include <iostream>
#include <mutex>
#include <queue>
#include <thread>
#include <vector>
std::mutex mtx;
std::condition_variable cv;
std::queue<int> task_queue;
bool done = false;
void producer(int id, int count) {
for (int i = 0; i < count; ++i) {
int item = id * 1000 + i;
{
std::lock_guard<std::mutex> lk(mtx);
task_queue.push(item);
}
cv.notify_one();
}
}
void worker(int id) {
for (;;) {
int item = 0;
{
std::unique_lock<std::mutex> lk(mtx);
cv.wait(lk, [] { return !task_queue.empty() || done; });
if (task_queue.empty() && done) {
break;
}
item = task_queue.front();
task_queue.pop();
}
std::this_thread::sleep_for(std::chrono::milliseconds(5));
std::cout << "worker " << id << " processed " << item << "\n";
}
}
int main() {
const int producer_count = 2;
const int worker_count = 3;
const int per_producer = 4;
std::vector<std::thread> workers;
for (int w = 0; w < worker_count; ++w) {
workers.emplace_back(worker, w);
}
std::vector<std::thread> producers;
for (int p = 0; p < producer_count; ++p) {
producers.emplace_back(producer, p, per_producer);
}
for (auto& t : producers) {
t.join();
}
{
std::lock_guard<std::mutex> lk(mtx);
done = true;
}
cv.notify_all();
for (auto& t : workers) {
t.join();
}
return 0;
}
编译运行示例:g++ -std=c++17 -pthread main.cpp -o demo && ./demo(MSVC 下用对应选项链接线程支持即可。)
十二、小结:一张表对照需求
| 需求 | 常用标准库手段 |
|---|---|
| 起线程、等结束 | std::thread + join;C++20 能上就上 std::jthread |
| 护住共享数据 | std::mutex + std::lock_guard / std::unique_lock / std::scoped_lock |
| 等条件成立 | std::condition_variable + 带谓词的循环 |
| 计数、标志、无锁底子 | std::atomic + 合适的 memory_order |
| 异步结果 | std::async、std::packaged_task、std::promise/std::future |
| 单次初始化 | std::call_once;C++11 起函数内 static 局部变量(线程安全一次性初始化) |
| 多读单写 | std::shared_mutex + std::shared_lock / std::unique_lock |
把 RAII 锁、线程只能移动、原子内存序 这三块吃透,再按业务选 async 还是裸线程,大部分 C++ 并发代码就能写得又稳又好维护。