C++11 引入了标准化的线程库,覆盖了线程创建、同步原语、异步任务等核心能力。此后 C++17/20 持续补强(并行算法、std::jthread、std::latch/std::barrier),让多线程编程从平台相关的 pthread/Win32 走向了可移植的标准范式。
线程创建与管理
std::thread
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| #include <thread>
#include <iostream>
using namespace std;
void worker(int id, const string& msg) {
cout << "thread " << id << ": " << msg << endl;
}
int main() {
thread t1(worker, 1, "hello");
thread t2(worker, 2, "world");
t1.join(); // 等待 t1 结束
t2.join(); // 等待 t2 结束
// 不 join 也不 detach 就析构 → std::terminate!
return 0;
}
|
关键规则:std::thread 对象析构前必须调用 join() 或 detach(),否则程序直接终止。
std::jthread(C++20)
jthread 的改进两处:析构时自动 join()(不用手动调),支持协作式中断(stop_token):
1
2
3
4
5
6
7
8
| void worker(stop_token st) {
while (!st.stop_requested()) {
// 干活...
}
}
jthread t(worker);
// 离开作用域时自动请求停止并 join
|
同步机制
std::mutex —— 互斥锁
最基础的排他锁,同一时刻只允许一个线程进入临界区。C++ 提供了三种 RAII 锁包装,使用方式不同:
lock_guard —— 最简单的 RAII
构造时加锁,析构时解锁。不可手动 unlock,不可拷贝,不可移动。 适用于临界区边界清晰、不需要中途解锁的场景:
1
2
3
4
5
6
7
8
| int counter = 0;
mutex mtx;
void increment() {
lock_guard<mutex> lg(mtx); // 构造 = lock,离开作用域 = unlock
++counter;
// 到 } 自动析构解锁,异常安全
}
|
unique_lock —— 灵活但稍重
比 lock_guard 多包了一个 bool(记录是否持有锁),换来更多能力:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| mutex mtx;
// 1. 延迟加锁
unique_lock<mutex> ul(mtx, defer_lock); // 构造时不 lock
ul.lock(); // 稍后手动加锁
ul.unlock(); // 手动解锁
ul.lock(); // 再次加锁
// 2. 尝试加锁(不阻塞)
if (ul.try_lock()) { /* 拿到锁了 */ }
// 3. 转移所有权
auto ul2 = std::move(ul); // ul 释放锁的所有权给 ul2
// 4. condition_variable 只能用 unique_lock
// 因为 cv.wait() 内部需要反复 unlock/lock,lock_guard 做不到
cv.wait(ul, []{ return ready; });
|
三者对比
| lock_guard | unique_lock | scoped_lock (C++17) |
|---|
| 手动 unlock | ❌ | ✅ | ❌ |
| 延迟加锁 | ❌ | ✅ | ❌ |
| 可移动 | ❌ | ✅ | ❌ |
| 同时锁多个 mutex 不死锁 | ❌ | ❌(需手动用 std::lock) | ✅ 内置此能力 |
| 配合 condition_variable | ❌ | ✅ | ❌ |
| 大小 | 最轻(1B) | 稍大(通常 8-16B) | 与 lock_guard 类似 |
| 什么时候用 | 简单临界区(90% 的场景) | 需要灵活控制锁、配合 cv | 需要一次锁两个及以上 mutex |
经验法则:能用 lock_guard 就不用 unique_lock,代码更简洁,意图也更明确。
为什么 unique_lock 能手动 unlock,lock_guard 不行?
这是故意的设计选择,不是技术做不到。二者本质上都是对 mutex 的 RAII 包装,差别在于要不要暴露灵活控制:
1
2
3
4
5
6
7
8
9
10
11
| lock_guard:
├── 内部只管两件事:构造时 mtx.lock(),析构时 mtx.unlock()
├── 没有任何状态标志、没有任何控制接口
└── 设计意图:看到 lock_guard = 这个锁会被持满整个作用域,一眼明白
unique_lock:
├── 内部多了一个 bool owns_,追踪"当前是否持有锁"
├── unlock() → mtx.unlock(); owns_ = false;
├── lock() → mtx.lock(); owns_ = true;
├── 析构时 → if (owns_) mtx.unlock();
└── 设计意图:需要灵活操控锁的场景(cv、延迟加锁、转移所有权)
|
lock_guard 完全可以加一个 unlock() 方法,但委员会故意没加——C++ 的核心哲学之一是**“用类型表达意图”**。lock_guard 这个名字就在说"我是 RAII 守卫,锁的持有期 = 我的存活期",没有中途释放的可能。如果你看到 unique_lock,你就知道这里可能有更复杂的锁操作。
scoped_lock 能完全替代 lock_guard 吗?
技术上能,但代码意图上不建议。 对于单 mutex 场景:
1
2
| lock_guard<mutex> lg(mtx); // 一眼看出:单锁,简单保护区
scoped_lock<mutex> sl(mtx); // 功能完全相同,零额外开销
|
区别在于读代码时的暗示。scoped_lock 的设计意图是"多锁防死锁",看到它读者会下意识找是不是还有其他 mutex 参与了。用 lock_guard 则明确传达"这是个简单的单锁临界区"。所以实践中:
- 单 mutex →
lock_guard(意图最清晰) - 多 mutex →
scoped_lock(需要防死锁能力) - 需要灵活控制 / 配合 cv →
unique_lock
std::scoped_lock 的核心优势是一次锁多个 mutex 不产生死锁(内部使用 std::lock 算法):
1
2
| mutex mtx1, mtx2;
scoped_lock sl(mtx1, mtx2); // 原子地锁定两个,不会死锁
|
std::shared_mutex —— 读写锁(C++17)
允许多个读者并发访问,写者独占。适用于读多写少的场景:
1
2
3
4
5
6
7
8
9
10
11
12
| shared_mutex rw;
int data = 0;
void reader() {
shared_lock sl(rw); // 共享锁,多个 reader 可以同时持有
// 读取 data...
}
void writer() {
unique_lock ul(rw); // 排他锁,只有一个人能持有
++data;
}
|
std::condition_variable —— 条件变量
条件变量解决了一个核心问题:让线程在某个条件不满足时挂起等待,而不是忙等(busy-wait)空耗 CPU。它和 mutex 配合使用,是生产者-消费者模式的标准工具。
wait() 的工作原理
cv.wait(lock, predicate) 内部执行三个步骤,整个过程原子地完成 lock 释放和等待:
1
2
3
| 1. 检查谓词 → true 就直接返回(不等待)
2. 谓词为 false → 原子地:释放 lock + 线程进入等待队列(阻塞)
3. 被唤醒后 → 重新获取 lock,再次检查谓词(应对虚假唤醒)
|
这就是为什么 condition_variable 必须配合 unique_lock 而非 lock_guard——wait() 内部需要反复 unlock/lock,而 lock_guard 不支持手动解锁。
实战示例:网络线程 → 逻辑线程任务分发
游戏服务端最常见的模式:IO 线程收网络包,逻辑线程处理消息。逻辑线程空闲时挂起不占 CPU,有消息时立即唤醒,同时定期做心跳检查。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
| #include <mutex>
#include <condition_variable>
#include <queue>
#include <thread>
#include <chrono>
#include <iostream>
using namespace std;
using namespace std::chrono;
struct NetMessage {
int conn_id;
string payload;
};
class LogicThread {
mutex mtx_;
condition_variable cv_;
queue<NetMessage> msg_queue_;
bool running_ = true;
public:
// 网络线程调用:投递消息
void post_message(NetMessage msg) {
{
lock_guard lg(mtx_);
msg_queue_.push(std::move(msg));
}
cv_.notify_one(); // 唤醒逻辑线程
}
// 主线程调用:通知停机
void shutdown() {
{
lock_guard lg(mtx_);
running_ = false;
}
cv_.notify_all();
}
// 逻辑线程主循环
void run() {
while (true) {
unique_lock ul(mtx_);
// 等 100ms —— 要么有消息,要么超时
bool has_msg = cv_.wait_for(ul, 100ms, [this] {
return !msg_queue_.empty() || !running_;
});
if (!running_ && msg_queue_.empty()) {
break; // 停机且队列处理完毕
}
// 处理所有积压消息
while (!msg_queue_.empty()) {
NetMessage msg = std::move(msg_queue_.front());
msg_queue_.pop();
ul.unlock(); // 处理时不持锁
process(msg);
ul.lock();
}
if (!has_msg) {
// 100ms 内没消息,做后台维护
ul.unlock();
check_heartbeats();
}
}
}
private:
void process(const NetMessage& msg) {
cout << "conn " << msg.conn_id << ": " << msg.payload << endl;
}
void check_heartbeats() {
cout << "[heartbeat check]" << endl;
}
};
|
这个模式在天底下的游戏服务端里反复出现——IO 线程只管收发包投递,逻辑线程单线程跑主循环,condition_variable 负责在空闲时挂起、有消息时唤醒、超时时做后台维护。
陷入 wait 之后,没有 notify 就不会醒来吗?
这取决于你用的是哪种 wait():
1
2
3
4
5
| // 形式 1:不带谓词 —— 醒来后不会自动重新检查
cv.wait(lock); // 被 notify 或虚假唤醒后返回,不管条件是否真正满足
// 形式 2:带谓词 —— 等价于 while(!pred) { wait(lock); }
cv.wait(lock, [] { return ready; });
|
带谓词的 wait() 在内部做了三件事:
- 先检查谓词——如果已经
true,根本不会 sleep,直接返回 - 谓词为
false 才释放锁并挂起 - 被唤醒后(不管是
notify 还是虚假唤醒),重新检查谓词:false 就继续睡,true 才返回
所以准确的说法是:陷入 wait 后,线程会一直睡到被唤醒,但被唤醒后如果条件不满足,它会自己重新睡回去。 没有 notify(也没有 wait_for 超时)的话,理论上永久阻塞,虽然操作系统可能虚假唤醒,但谓词不满足就又睡回去了,对外表现仍然是"卡住"。
这也是为什么修改条件时必须在锁内——消费者检查谓词和挂起这两个动作之间如果被生产者插入了条件修改+notify,这个 notify 就丢了:
1
2
3
4
5
| 消费者 生产者
持有锁,检查 ready == false
准备释放锁+挂起(原子操作)
lock, ready = true, notify_one (waiting queue 还是空的!)
释放锁+挂起 ---> 永远睡下去了!
|
好在 cv.wait() 的"释放锁+进入等待队列"是在内核层面原子完成的——消费者检查完谓词到真正挂起之间,生产者即使拿到锁也无法插入 notify(因为消费者在释放锁的同时就进入等待队列了,生产者的 notify 能命中它)。这就是 condition_variable 必须和一个 mutex 绑定的根本原因。
notify_one vs notify_all
先用一个具体场景理解:
1
2
3
4
5
6
7
8
9
10
11
12
| 3 个消费者都在 wait,等待队列中处理新消息:
线程 A: [阻塞] 线程 B: [阻塞] 线程 C: [阻塞]
生产者放入 1 条消息,调用 notify_one():
线程 A: [醒来处理消息] 线程 B: [阻塞] 线程 C: [阻塞]
→ 只醒了 1 个,因为只有 1 条消息,唤醒更多也是浪费
生产者调用 shutdown,调用 notify_all():
线程 A: [醒来,看到 done=true,退出]
线程 B: [醒来,看到 done=true,退出]
线程 C: [醒来,看到 done=true,退出]
→ 全醒了,因为"停机"这个状态变更每人都需要知道
|
| notify_one() | notify_all() |
|---|
| 行为 | 唤醒一个(不确定哪个) | 唤醒全部 |
| 开销 | 低 | 高(全部被唤醒后争抢锁,只有一个拿到,其余又睡回去——惊群) |
| 用在哪 | 任务队列每 push 一个任务 | 停机通知、状态重置这种"人人都需响应"的变更 |
简单记:有且仅有一个线程能消费这个事件 → notify_one;每个线程都需要对这个事件做出反应 → notify_all。
虚假唤醒(Spurious Wakeup)
条件变量可能在没有任何 notify 的情况下自己醒来,这是操作系统层面的特性(POSIX 标准明确允许)。所以永远不要这样写:
1
2
3
4
5
6
7
8
9
10
11
| // 错误!虚假唤醒时条件不满足,逻辑错误
cv.wait(ul);
// 直接使用被唤醒后的状态...
// 正确:用带谓词的 wait
cv.wait(ul, [] { return ready; });
// 或者等价的 while 循环写法(wait 内部就是这样做的)
while (!ready) {
cv.wait(ul);
}
|
丢失唤醒(Lost Wakeup)
如果 notify 发生在 wait 之前,而等待线程还没有进入等待状态——这个 notify 就丢了。这正是为什么必须持有 mutex 再修改条件:
1
2
3
4
5
6
7
8
9
10
| // 生产者:先锁,改条件,解锁,再通知
{
lock_guard lg(mtx);
ready = true; // 在锁的保护下修改条件
}
cv.notify_one(); // 通知放在解锁之后(性能更好)
// 消费者:wait 会在检查谓词为 true 时直接返回,不进入等待
unique_lock ul(mtx);
cv.wait(ul, [] { return ready; }); // 先检查谓词,true 就不等
|
如果 notify 放在锁内部,消费者被唤醒后需要等锁释放才能继续——徒增一次无谓的上下文切换。所以 notify 放在解锁之后是推荐做法(当然放在锁里面语义也正确,只是性能差一点)。
带超时的等待
1
2
3
4
5
6
7
8
9
10
11
| unique_lock ul(mtx);
if (cv.wait_for(ul, 100ms, [] { return ready; })) {
// 条件成立
} else {
// 超时——可以做超时处理,比如打日志、检查心跳
}
auto deadline = chrono::steady_clock::now() + 5s;
if (cv.wait_until(ul, deadline, [] { return ready; })) {
// ...
}
|
游戏服务端中,wait_for 常用于心跳检测——等待任务队列时加超时,定期检查连接是否断开。
常见陷阱总结
| 陷阱 | 后果 | 正确做法 |
|---|
用 if 而非 while / 不带谓词 | 虚假唤醒导致状态不一致 | 始终带谓词 |
| notify 在修改条件之前 | 丢失唤醒(消费者可能永远等) | 先改条件再 notify |
| 对同一个 cv 混用不同的条件 | 线程被不该它关心的事件唤醒 | 不同条件用不同 cv |
| 析构时仍有线程在 wait | 未定义行为 | 先 notify_all + join 线程再析构 cv |
| wait 中异常抛出 | mutex 状态不确定 | 谓词中不要抛异常 |
std::atomic 与内存序
std::atomic 提供不可分割的原子操作,是实现无锁编程的基石:
1
2
3
4
5
6
7
8
9
| atomic<int> counter{0};
void increment() {
counter.fetch_add(1, memory_order_relaxed); // 原子自增
}
int get() {
return counter.load(memory_order_acquire); // 原子读取
}
|
关键点是内存序(memory order)——控制原子操作周围的普通内存访问的可见性顺序:
| 内存序 | 含义 | 开销 |
|---|
relaxed | 只保证原子性,不保证顺序 | 最轻,等价于普通原子指令 |
acquire | 后续读写不重排到前面 | 读侧屏障 |
release | 前面的读写不重排到后面 | 写侧屏障 |
acq_rel | 兼具 acquire + release | RMW 操作用 |
seq_cst | 全局顺序一致性(默认) | 最重,通常有完整 memory barrier |
无锁编程在游戏服务端的场景很窄——绝大多数情况用锁就够。只有当临界区极小(几行代码)、竞争频繁、持锁极短时,无锁才有优势。典型场景:引用计数、统计计数器、SPSC(单生产者单消费者)队列。
std::call_once 与 std::once_flag
保证某个初始化逻辑只执行一次,线程安全且比用 mutex + bool flag 更简洁高效:
1
2
3
4
5
6
7
8
| once_flag init_flag;
ExpensiveResource* resource = nullptr;
void init() {
call_once(init_flag, [] {
resource = new ExpensiveResource();
});
}
|
常用于懒加载单例、全局配置初始化。底层通常用 atomic 实现,比手动加锁快得多。
std::latch 与 std::barrier(C++20)
这俩都是线程同步栅栏——让多个线程在某个点上互相等待,到齐了再一起走。它们的本质区别是可以使用几次:
1
2
| latch: 一次性倒计时 → 数到 0 就永久打开,不能重置
barrier: 可复用 → 每个阶段到齐后自动重置,进入下一轮
|
std::latch —— 一次性倒计时门闩
就像一个只能下不能上的计数器。初始化一个数 N,线程到达时 arrive_and_wait() 或 count_down() 让计数减 1,减到 0 时门闩打开,所有等待的线程释放。一旦到 0,永远不会再关上。
实战场景:等所有线程完成初始化再开始服务
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
| #include <latch>
#include <thread>
#include <vector>
using namespace std;
latch init_done(3); // 3 个线程都初始化完才开门
struct GameModule {
int id;
void init() {
// 每个模块独立的初始化工作(读配置、建立连接……)
this_thread::sleep_for(chrono::milliseconds(id * 100));
printf("module %d init done\n", id);
init_done.arrive_and_wait(); // 报告完成,等其他人
}
};
int main() {
vector<thread> threads;
for (int i = 0; i < 3; ++i) {
threads.emplace_back([](int id) {
GameModule{id}.init();
}, i);
}
// 3 个线程都在 init_done 上挂着……
// 最后一个到的时候门打开,大家一起往下走
for (auto& t : threads) t.join();
printf("all modules ready, server starts!\n");
}
|
输出(无论线程先后顺序,最后一行一定在所有 “init done” 之后):
1
2
3
4
| module 0 init done
module 1 init done
module 2 init done
all modules ready, server starts!
|
更轻的用法:一次性等待
1
2
3
4
5
6
7
8
9
| latch done(1);
// 主线程
thread worker([&] {
do_heavy_work();
done.count_down(); // 通知:我完成了
});
done.wait(); // 主线程阻塞等 worker 干完
worker.join();
|
这比 condition_variable + bool flag + mutex 的组合简洁得多,而且没有惊醒/错过唤醒的问题。
std::barrier —— 可复用的阶段同步
每个阶段所有线程到齐后:屏障自动重置计数到初始值,同时触发一个完成回调,然后所有人进入下一阶段。
实战场景:分阶段并行处理 —— 游戏服务端帧循环
经典用法是每帧把世界拆成 N 份给工作线程并行更新,每阶段结束后主线程串行做聚合,然后进入下一阶段:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
| #include <barrier>
#include <thread>
#include <vector>
using namespace std;
// 4 个工作线程 + 1 个主线程 = 5 个参与者
// 每个阶段结束时的回调:主线程负责聚合
auto on_phase_complete = []() noexcept {
printf("--- phase complete, aggregating... ---\n");
// 这里可以做:合并碰撞检测结果、更新 AI 状态、提交渲染命令等
};
barrier sync_point(5, on_phase_complete);
void worker(int id) {
for (int frame = 0; frame < 3; ++frame) {
printf("worker %d: frame %d - update physics\n", id, frame);
sync_point.arrive_and_wait(); // 第 1 阶段:等所有人物理更新完
printf("worker %d: frame %d - update AI\n", id, frame);
sync_point.arrive_and_wait(); // 第 2 阶段:等所有人 AI 更新完
}
}
int main() {
vector<thread> workers;
for (int i = 0; i < 4; ++i) {
workers.emplace_back(worker, i);
}
for (int frame = 0; frame < 3; ++frame) {
sync_point.arrive_and_wait(); // 主线程参与第 1 阶段
sync_point.arrive_and_wait(); // 主线程参与第 2 阶段
}
for (auto& t : workers) t.join();
}
|
时间线会是:
1
2
3
4
5
6
| 帧 0:
worker 0-3: update physics → 到齐 → [回调:aggregating] → 全部释放
worker 0-3: update AI → 到齐 → [回调:aggregating] → 全部释放
帧 1:
worker 0-3: update physics → 到齐 → [回调:aggregating] → 全部释放
...
|
不需要手动计数、不需要手动通知、不需要担心虚假唤醒——barrier 全包了。
latch vs barrier vs condition_variable
| latch | barrier | condition_variable |
|---|
| 核心语义 | 倒计时,到 0 永久开门 | 阶段同步,自动重置 | 条件等待 + 通知 |
| 可复用 | ❌ 一次性 | ✅ 无限次 | ✅ |
| 等 N 个线程到齐 | ✅ 原生支持 | ✅ 原生支持 | ❌ 需要手动计数 |
| 阶段完成回调 | ❌ | ✅ | ❌ |
| 什么时候用 | 初始化的"等所有人准备好再走" | 并行计算的"每阶段到齐→聚合→下一阶段" | 任意条件等待(等数据、等超时、等状态变更) |
thread_local
每个线程独享的变量副本。同一个变量名,不同线程访问时拿到的是完全不同的内存位置——天然线程安全,不需要任何锁。
1
2
3
4
5
6
7
| thread_local int tls_counter = 0; // 每个线程有独立的一份
void worker(int id) {
++tls_counter;
printf("thread %d: counter = %d\n", id, tls_counter);
// 每个线程输出都是 1 —— 互不干扰
}
|
底层原理
关键在操作系统和编译器配合。操作系统给每个线程分配一块私有的 TLS(Thread Local Storage)区域,编译器对 thread_local 变量的寻址会自动重定向到这个区域:
1
2
3
4
5
6
7
8
9
10
11
| 线程 A 线程 B
TLS 区域 TLS 区域
┌──────────────┐ ┌──────────────┐
│ tls_counter │ = 1 │ tls_counter │ = 1
│ 随机种子 │ │ 随机种子 │
│ 日志缓冲区 │ │ 日志缓冲区 │
└──────────────┘ └──────────────┘
↑ ↑
│ │
TLS 基址寄存器(如 Windows 的 gs 段寄存器)
编译器生成的访问指令形如: mov eax, gs:[offset_of_tls_counter]
|
具体流程:
编译期——编译器识别 thread_local 变量,把它放到特殊的 .tdata / .tbss 段中,而不是普通的数据段。同时计算该变量在线程 TLS 内的偏移量(offset)。
运行期——每个线程创建时,操作系统为其分配独立的 TLS 存储块,并把该块的基址存到一个特定的硬件寄存器中(Windows 用 gs 段寄存器,Linux 用 fs 段寄存器)。主线程同样有自己的 TLS 块。
访问时——编译器生成的代码不是普通的 [全局地址] 访问,而是 [TLS基址寄存器 + offset] 的间接寻址。因为每个线程的基址寄存器内容不同,相同的 offset 就落到了不同的物理内存上。
用伪汇编来看就是:
1
2
3
4
5
6
| ; 普通全局变量:直接寻址
mov eax, [global_counter] ; 所有线程访问同一地址
; thread_local 变量:段寄存器 + 偏移
mov eax, gs:[0x38] ; gs 基址随线程不同而不同
; offset 0x38 = tls_counter 在 TLS 块内的位置
|
对于动态加载的 thread_local(如 dlopen 加载的 .so), 因为无法在编译期确定偏移,走的是更慢的 __tls_get_addr() 调用路径,这比段寄存器偏移慢一个数量级。不过主程序的 thread_local 变量没有这个问题。
典型用途
- 线程私有的随机数生成器——每个线程独立种子,互不干扰
- 日志缓冲区——每个线程攒一批再 flush,避免抢全局日志锁
- errno——C 标准库的
errno 就是用 TLS 实现的,所以多线程中 errno 也是安全的
注意事项
- 滥用
thread_local 会导致总内存膨胀——1000 个线程 × 每个线程 4KB 私有数据 = 多出 4MB - 非平凡析构的
thread_local 对象在 C++11~17 中可能有线程退出时的析构顺序问题(C++20 有所改善) - 适合线程私有的缓存、随机数生成器、日志缓冲区。但要注意:滥用 thread_local 会导致内存膨胀。
异步任务:std::future / std::promise / std::async
1
2
3
4
5
6
7
8
9
10
| // async:启动异步任务,返回 future
future<int> f = async(launch::async, [] { return 42; });
int result = f.get(); // 阻塞直到任务完成
// promise:手动设置结果
promise<int> p;
future<int> f2 = p.get_future();
thread t([&p] { p.set_value(100); }); // 另一个线程设置值
int val = f2.get(); // 100
t.join();
|
注意:std::async 如果不用 launch::async 参数,标准库可能选择同步执行(延迟求值),这是常见坑。
线程池
线程池的核心思想很简单:预先创建 N 个常驻线程,这些线程共用一个任务队列,有活就干,没活就挂起。 线程创建和销毁的开销就被分摊到了 N 个线程的生命周期里。
为什么要线程池
每次 std::thread t(fn) 创建线程,操作系统要分配栈空间、初始化 TLS、建立内核调度结构——这些开销大约是几十微秒到上百微秒。而一个轻量任务本身可能只需几微秒。在这种场景下,花在"造线程"上的时间比"干正事"还多,导致整体的任务处理效率反而低于单线程串行执行(因为线程管理的开销完全抵消了并行处理的收益)。
线程池通过复用线程解决这类问题,让同批线程持续运行,从而将任务分配、入队、锁竞争的开销控制在较低水平。
线程、线程池、协程的区别:
| 线程 | 线程池 | 协程 |
|---|
| 调度者 | OS 内核抢占 | 任务队列 + cv | 用户代码主动 yield/await |
| 切换代价 | ~µs 级(内核态) | ~µs 级(仍是线程) | ~ns 级(纯用户态) |
| 栈 | 每个线程独立分配(~1MB) | 同上 | 共享协程帧(~几 KB) |
| 适合什么 | 任意耗时任务 | 避免线程频繁创建销毁 | 大量并发 IO 等待 |
线程池解决的是"用线程干活太贵"的问题,协程解决的是"等 IO 白白浪费线程"的问题。一个池化执行资源,一个让执行资源不被阻塞。实践中两者常组合使用——线程池作为协程的底层执行器,协程在池子里的线程上调度。
一个简易线程池实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
| #include <mutex>
#include <condition_variable>
#include <queue>
#include <thread>
#include <functional>
#include <vector>
using namespace std;
class ThreadPool {
public:
explicit ThreadPool(size_t num_threads) : stop_(false) {
for (size_t i = 0; i < num_threads; ++i) {
workers_.emplace_back([this] {
while (true) {
function<void()> task;
{
unique_lock ul(mtx_);
// 等任务到了,或者 stop
cv_.wait(ul, [this] {
return stop_ || !tasks_.empty();
});
if (stop_ && tasks_.empty()) {
return; // 停机 + 队列空 → 线程退出
}
task = std::move(tasks_.front());
tasks_.pop();
}
task(); // 不持锁执行!
}
});
}
}
~ThreadPool() {
{
lock_guard lg(mtx_);
stop_ = true;
}
cv_.notify_all();
for (auto& t : workers_) {
if (t.joinable()) t.join();
}
}
// 提交一个任务到池子里
void enqueue(function<void()> task) {
{
lock_guard lg(mtx_);
tasks_.push(std::move(task));
}
cv_.notify_one();
}
private:
vector<thread> workers_;
queue<function<void()>> tasks_;
mutex mtx_;
condition_variable cv_;
bool stop_;
};
|
这份实现虽然只有约 50 行,但它包含了线程池的核心机制:
- 预创建线程:构造时根据参数创建指定数量的线程,这些线程在对象生命周期内持续运行
- 共享任务队列:所有线程从同一个
tasks_ 队列中竞争取任务 - 空闲挂起:队列为空时通过
cv_.wait() 让线程进入等待状态,不消耗 CPU 时间 - 优雅停机:析构时先设置
stop_ 标志,再 notify_all() 唤醒所有线程。每个线程醒来后检查 stop_ && tasks_.empty(),条件满足则退出循环并 join。保证已入队的任务全部执行完毕后才真正停止。 - 任务执行间隙释放锁:
task() 调用在 unique_lock 作用域之外——这确保了执行任务时不持有锁,不会阻塞其他线程从队列取下一个任务
使用示例
1
2
3
4
5
6
7
8
9
10
| ThreadPool pool(4); // 4 个工作线程
// 投递一批任务
for (int i = 0; i < 100; ++i) {
pool.enqueue([i] {
printf("task %d on thread %d\n", i, this_thread::get_id());
this_thread::sleep_for(10ms); // 模拟耗时操作
});
}
// ThreadPool 析构时会等所有任务执行完再退出
|
游戏服务端中的实际应用
线程池在游戏服务端最常见的用法是把不依赖时序的耗时操作丢到池子里,让主逻辑线程保持轻盈:
| 场景 | 做法 |
|---|
| 数据库写 | 玩家存档落盘 → 丢线程池异步写,主线程不阻塞 |
| 日志落盘 | 日志缓冲区攒够一批 → 提交线程池 flush |
| 匹配计算 | 收到匹配请求 → 线程池异步算分并写入匹配结果队列 |
| 路径寻路 | 大规模寻路拆分成独立任务 → 线程池并行计算 |
关键原则:不要让主逻辑线程执行任何可能长时间阻塞的操作。主逻辑线程只负责收发网络包、更新状态机、投递耗时任务到线程池,然后立即返回循环处理下一条消息。
锁的适用场景对比
| 锁类型 | 适用场景 | 特点 |
|---|
std::mutex | 临界区有明显读写操作、锁持有时间中等 | 通用,有内核态切换 |
std::shared_mutex | 读远多于写 | 读并发、写独占,比 mutex 重 |
自旋锁(atomic_flag) | 临界区极短(几行代码) | 用户态忙等,不切内核,浪费 CPU |
condition_variable | 需要等待条件满足 | 配合 mutex,支持 notify |
std::atomic + CAS | 单变量原子操作、无锁队列 | 极轻量,正确实现难度高 |
自旋锁的实现示例(C++ 标准库没有直接提供 std::spinlock):
1
2
3
4
5
6
7
8
9
10
| class spinlock {
atomic_flag flag = ATOMIC_FLAG_INIT;
public:
void lock() {
while (flag.test_and_set(memory_order_acquire)) {
// 自旋等待;可加 _mm_pause() / yield() 降低功耗
}
}
void unlock() { flag.clear(memory_order_release); }
};
|
自旋锁在临界区很短(比一次线程切换便宜)时有效,但如果临界区里有系统调用或锁竞争激烈,反而比 mutex 差。游戏服务端中,自旋锁适合保护单个变量或极短的队列操作。
Q&A
多线程编程中,C++ 提供了哪些同步机制?请对比互斥锁、读写锁、自旋锁的适用场景。无锁编程(lock-free)中 CAS 的原理是什么?在游戏服务端哪些场景适合用无锁结构?
C++ 同步机制: 标准库提供了 std::mutex(互斥锁)、std::shared_mutex(读写锁)、std::condition_variable(条件变量)、std::atomic(原子操作)、std::call_once、std::latch/std::barrier(C++20)等。自旋锁可以用 std::atomic_flag 的 TAS(test-and-set)实现。
适用场景对比:
- 互斥锁(mutex): 最通用,临界区中等到较长时使用。有争用时线程进入内核态休眠,不浪费 CPU。
- 读写锁(shared_mutex): 读多写少时使用——多个读者可并发,写者独占。但锁本身比 mutex 重,如果临界区很短或者读也不多,不如直接用 mutex。
- 自旋锁(spinlock): 临界区极短(几条指令)时使用,用户态忙等,避免线程切换的内核开销。但如果临界区出现系统调用或 IO,反而更差。
无锁编程中 CAS 的原理: CAS(Compare-And-Swap)是 CPU 提供的原子指令。逻辑是:比较目标地址的值是否等于期望值,等于就替换为新值,否则不做任何操作;整个过程是原子的。所有操作要么全成功要么全失败。C++ 通过 atomic<T>::compare_exchange_weak/strong 暴露。用 CAS 可以构建无锁栈、无锁队列——把指针修改做成 CAS 循环,失败了就重试,避免了锁的开销。代价是代码正确性极难验证(ABA 问题、内存回收问题)。
游戏服务端无锁适用场景: 无锁适合临界区极短且竞争频繁的地方——比如日志队列(SPSC 无锁队列,日志线程和业务线程各一头)、全局计数器(在线人数统计,直接用 atomic 的 fetch_add 零竞争)、对象池的快速分配(CAS 抢 slot)。大部分业务逻辑直接用 mutex 更简单可靠,不要为了"无锁"而无锁。