一、为什么会有线程池:先讲「池化」这个思想
1.1 我们先从预制菜和内存池说起
池化思想:
你要吃饭,可以每次现买菜、现洗现切现炒 ;也可以提前把菜备好放冰箱,吃的时候直接下锅 。
前者每顿都要重复一遍完整流程,后者只多占一个冰箱的位置。
程序里的道理一模一样:
| 场景 | 不池化(每次现做) | 池化(提前备好复用) |
|---|---|---|
| 吃饭 | 现买菜现洗现炒 | 预制菜 / 备菜 |
| 内存 | 每次 malloc / free |
内存池 |
| 线程 | 每次 pthread_create / pthread_join |
线程池 |
✔ 池化的本质:拿「多占一点闲置资源」去换「省掉反复创建销毁的开销」。
1.2 为什么创建销毁线程"贵"
这是理解线程池的关键,也是老师反复强调的一句:
底层凡是向操作系统申请资源,都要走系统调用;走系统调用就有时间成本。
创建线程时内核要做的事并不少:分配 task_struct、分配内核栈、建立与进程的地址空间关系、加入调度队列......销毁时又要反过来拆一遍。这些都是系统调用级别的开销。
所以线程池干的事非常朴素:
线程提前创建好,放在池子里"待命";来任务了就叫一个起来干活,干完不销毁,回去继续待命。
1.3 成本与收益
| 不用池 | 用池 | |
|---|---|---|
| 线程创建销毁 | 每个任务一次 | 只在启动时一次 |
| 系统调用次数 | 多 | 少 |
| 峰值线程数 | 不可控(突发流量会炸) | 固定,可控 |
| 代价 | 无 | 多占闲置线程资源 |
收益有三:
- 避免了在处理短时间任务时创建与销毁线程的代价
- 保证内核的充分利用
- 防止过分调度
💡 "防止过分调度"这句要单独拎出来:线程太多时,CPU 大量时间花在上下文切换 上,真正干活的时间反而变少。线程池把线程数钉死在一个合理值,从源头掐掉了这个问题。
1.4 适用场景:任务"短而多"
| 适合 | 为什么 |
|---|---|
| 大量短任务,如 Web 服务器处理网页请求 | 单个任务小、数量巨大(想想热门网站的点击量) |
| 对性能要求苛刻的应用,要求迅速响应 | 省掉创建开销 = 响应更快 |
| 突发性大量请求,但不该因此产生大量线程 | 没有池的话短时间创建海量线程可能把内存打满 |
一句话判据:任务时间 ≫ 线程创建时间 → 不需要池;任务时间 ≪ 线程创建时间 → 必须用池。
1.5 固定线程池 vs 浮动线程池
课件列了两种:
- a. 固定数量线程池:创建 N 个线程,循环从任务队列取任务执行
- b. 浮动线程池:线程数可以按负载伸缩
我们的:固定线程个数。 结构最简单,也最能体现线程池的思想内核。浮动池多出来的是"扩容/缩容策略",那是工程问题,不是原理问题。
二、动手前先备三件套
线程池不是从零写起,它站在前面几节课的肩膀上。老师要求先把三个组件备好:
| 组件 | 文件 | 作用 |
|---|---|---|
| 线程封装 | Thread.hpp |
把 pthread_create/join 包成 C++ 类,能传 std::function、能自动命名 |
| 锁与条件变量封装 | Lock.hpp + Cond.hpp |
Mutex / LockGuard / Cond,把 pthread_mutex_*、pthread_cond_* 包起来 |
| 日志 | Log.hpp |
多线程环境下看清谁在干活 |
2.1 为什么多线程必须有日志
理由非常实在:
多线程程序一旦出问题,
printf是没法用的 ------ 多个线程同时往屏幕上打,输出会交错、会缺字,你根本分不清哪一行是哪个线程打的。
日志至少给你三样东西:时间 (谁先谁后)、线程身份 (谁干的)、位置(哪一行代码)。缺了这三样,多线程 bug 基本没法查。
加锁前后的效果很不一样:
| 现象 | |
|---|---|
| 日志不加锁 | 输出会交叉:"我这条信息就和你的那条信息拼到一块儿,拼到你后面了" |
| 日志加锁 | 每行日志完整、不交叉,输出本身变成了线程安全的 |
✔ 这直接说明了一个容易被忽略的事实:日志类本身也是临界资源。 每个线程都在往里写,ofstream / cout 的写入不是原子的 ------ 不加锁,你的日志就会变成"乱码",本来用来查 bug 的工具反而成了新的 bug 来源。
"虽然打印的时候出现了交叉,但这种情况完全正常,它也充分体现了竞态问题" ------ 然后加锁,交叉就消失了。
2.2 为什么不用 std::lock_guard
有一条注意:
C++11 已经封装了锁,比如
std::lock_guard<std::mutex> lock(_mutex);------ 但这里我们直接用自己封装的锁。
理由不是"重复造轮子",而是:自己封装的锁才好和条件变量配对使用 (pthread_cond_wait 必须传原生 pthread_mutex_t*),而且课上要讲清楚原理,用黑盒反而讲不透。
三、日志:三要素 + 策略模式
3.1 一条日志的必备三要素
| 要素 | 说明 | 必需性 |
|---|---|---|
| 时间戳 | 精确到秒,能排出先后顺序 | ★ 必需 |
| 等级 | DEBUG / INFO / WARNING / ERROR / FATAL | ★ 必需 |
| 内容 | 你到底想说什么 | ★ 必需 |
| 文件名 + 行号 | 定位到代码位置 | ✔ 可选(但强烈建议) |
| pid | 多进程场景区分进程 | ✔ 可选 |
3.2 格式
[2024-08-04 15:09:29] [INFO] [206342] [ThreadPool.hpp] [62] - ThreadPool Construct()
↑时间 ↑等级 ↑pid ↑文件 ↑行号 ↑内容
这个格式不是随便定的 ------ 它决定了日志能不能被 grep 出来 。时间放最前、等级用方括号包住、字段之间空格分隔,grep "\[ERROR\]" 就能一把捞出所有错误。
3.3 支持可变参数
cpp
LOG(LogLevel::INFO) << name << " is running...";
用的是 operator<< 重载 + 内部 std::stringstream,这样能像 cout 一样随便拼,还天然支持任意类型(只要该类型可 <<)。
3.4 ★ 策略模式:输出到哪,运行时再定
这是这个日志类设计上最值得学的一点。日志要往哪写?控制台 还是文件?这是个会变的需求:
- 本地调试 → 控制台,看得见
- 服务器上跑 → 文件,能留存
如果写成 if (xxx) printf(...) else fprintf(...),那么每加一种输出方式就要改一次日志类 ------ 违反开闭原则。
策略模式的做法:
Logger → 持有一个 LogStrategy* (抽象基类)
↑
┌───────┴────────┐
ConsoleStrategy FileStrategy
- 定义抽象基类
LogStrategy,只留一个SyncLog(const std::string&) ConsoleStrategy里cout,FileStrategy里ofstreamLogger内部只持有基类指针,运行时换实现
课件里的用法是一个宏:
cpp
ENABLE_CONSOLE_LOG_STRATEGY(); // 切到控制台
// 或
ENABLE_FILE_LOG_STRATEGY(); // 切到文件
✔ 策略模式一句话:把"怎么做"从"做什么"里抽出来,让它在运行时可以被替换。
3.5 只写 LOG(INFO) 就能自动带上文件名行号?
靠宏:
cpp
#define LOG(level) logger(level, __FILE__, __LINE__)
__FILE__→ 当前源文件名(字符串字面量)__LINE__→ 当前行号(整数字面量)
💡 这两个是预处理器在「预处理阶段」就替换掉的 ,不是运行时算出来的 ------ 所以零开销,但代价是它记录的是宏展开处 的位置。这也是为什么日志里的行号永远指向你写 LOG() 的那一行,非常准确。
四、★ 线程池就是一个生产者消费者模型
这是整节课的"题眼"。老师在板书上写完任务队列之后,直接写了一句:
「这不就是一个生产者消费者模型吗!!!」
4.1 角色一一对应

| 生产者消费者模型 | 线程池里对应谁 |
|---|---|
| 生产者 | 用户 / 主线程 (调用 Enqueue 放任务) |
| 消费者 | 池子里的 worker 线程 (HandlerTask 取任务) |
| 仓库 / 缓冲区 | 任务队列 _task_queue |
| "仓库满了" | 线程池里通常不限(std::queue 可无限增长) |
| "仓库空了" | worker 全部阻塞等待 |
4.2 上一章的三二一原则,原封不动搬过来
| 原则 | 在线程池里的落点 |
|---|---|
| 3 种关系 | 生产者-生产者(多个用户同时 Enqueue)、消费者-消费者(多个 worker 同时取)、生产者-消费者 |
| 2 种角色 | 线程(用户线程 / worker 线程)、缓冲区(_task_queue) |
| 1 个交易场所 | 任务队列 |
✔ 所以线程池不需要任何新理论 ------ 它就是"阻塞队列 + 固定个数的消费者线程"。上节课的 BlockingQueue 换个皮就叫线程池。
4.3 那到底新在哪
新在两点,都不在同步机制上:
- 生命周期管理 :worker 线程是常驻 的,要能优雅启动(
Start)和优雅退出(Stop+Wait) - 任务抽象 :队列里存什么?存
std::function<void()>------ 任何可调用对象都能当任务,这是线程池通用性的来源
五、★ HandlerTask:整个线程池的心脏
代码实现按你说的简略处理,但这个循环必须完整看懂,因为线程池所有"坑"都在这二十行里。
5.1 循环本体
cpp
void HandlerTask()
{
std::string name = GetThreadNameFromNptl(); // 拿到自己的线程名,日志要用
LOG(LogLevel::INFO) << name << " is running...";
while (true)
{
// 1. 保证队列安全
_mutex.Lock();
// 2. 队列中不一定有数据 ------ 没数据就等
while (_task_queue.empty() && _isrunning)
{
_waitnum++;
_cond.Wait(_mutex); // 等待时会自动释放锁,醒来重新竞争
_waitnum--;
}
// 2.1 已经退出 && 队列空 → 该走了
if (_task_queue.empty() && !_isrunning)
{
_mutex.Unlock();
break;
}
// 3. 走到这里一定有任务
T t = _task_queue.front();
_task_queue.pop();
_mutex.Unlock(); // ★ 先解锁
LOG(LogLevel::DEBUG) << name << " get a task";
// 4. 执行任务 ------ 这一步必须在锁外面
t();
}
}
5.2 ★ 易错点一:执行任务前必须先解锁

这是最容易被写错、后果又最严重的一处。
✖ 错误写法 :t(); 写在 Unlock() 之前
后果非常致命:
整个线程池会退化成串行。
推一遍就明白了:假设池子里有 10 个 worker。worker-1 拿到任务后持着锁 去执行 t();此时 worker-2 到 worker-9 全都卡在 _mutex.Lock() 上;连生产者 Enqueue 也被卡在锁上。
结果就是:同一时刻只有一个线程在干活,其他 9 个在等锁。线程池变成了"一个带 10 层壳的单线程"。
✔ 正确写法 :取任务 → Unlock() → 再 t()
关键认知:锁保护的只有"队列"这一个共享资源 ,不保护"任务执行"。任务一旦从队列里
pop出来,t就是这个线程独占的,谁也不会碰它,根本不需要锁。
5.3 ★ 易错点二:判断用 while 不是 if
cpp
while (_task_queue.empty() && _isrunning) // ✔
if (_task_queue.empty() && _isrunning) // ✖
这是上上一篇讲条件变量时反复强调的伪唤醒 问题:pthread_cond_wait 返回不代表条件成立,可能只是被信号打断、或别的线程抢走了任务。
用 if 的话:一个线程被 NotifyAll 唤醒,但醒来发现任务已经被别人抢走了,它还是会往下走,然后对着空队列 做 front() / pop() → 未定义行为。
✔ 规则:wait 必须放在 while 里,醒来第一件事是重新检查条件。
5.4 _waitnum:一个"没什么用但很有用"的计数器
进出 Wait 时对 _waitnum 做 ++ / --,它记录当前有多少线程正在睡觉。
cpp
while (_task_queue.empty() && _isrunning)
{
_waitnum++; // 我要睡了,记一笔
_cond.Wait(_mutex); // 睡(内部会释放锁)
_waitnum--; // 我醒了,销账
}
💡 为什么 ++ 要写在 Wait 里面、-- 写在 Wait 后面? 因为 Wait 返回那一刻线程已经重新拿回锁了,所以这两行都在锁的保护范围内 ------ 不会漏账。
这个计数器有两个用处,都是"省掉无谓操作":
用处一:Stop() 里判断要不要广播
cpp
void Stop()
{
_mutex.Lock();
_isrunning = false;
if (_waitnum > 0) // 有人在睡才需要广播
_cond.NotifyAll();
_mutex.Unlock();
}
✖ 如果无脑 NotifyAll(),一个线程都没睡的时候也要走一遍系统调用。
用处二:Enqueue() 里判断要不要唤醒
cpp
_task_queue.push(t);
if (_waitnum > 0)
_cond.Notify(); // 有人在睡,叫醒一个
_waitnum > 0→ 确实有人在睡,叫一个就够_waitnum == 0→ 大家都在忙,没人睡 ,没必要Notify
✔ 从另一个角度描述同一件事的:"我总共有 N 个线程,当前有 sleeper 个在睡,那 N - sleeper 就是正在干活的。如果 N - sleeper == 0,说明全睡了,我必须叫一个起来;否则我不用管。" 两种写法等价,本质都是"避免白叫"。
两个必须注意的点:
| 点 | 说明 |
|---|---|
★ 用 Notify 不用 NotifyAll |
一个任务只需唤醒一个 worker。叫醒一窝会造成"惊群" ------ 全醒来抢一个任务,抢输的再睡回去,白折腾一圈 |
★ _waitnum 本身也是临界资源 |
它会被所有线程 ++ / --,而 ++/-- 不是原子操作 。所以它的读写必须全部在锁内 ------ 这也是为什么 _waitnum++ 要写在 _mutex.Lock() 之后 |
5.5 ★ 退出语义:Stop 之后剩下的任务还算不算数
方法论:
"一个线程应该如何退出。这一个点稍微有点绕,但它是整个线程池唯一难想的地方。"
第一步:先分析「线程此刻可能处于什么状态」
Stop() 要退出的是 N 个线程,而这 N 个线程当下可能各自处在完全不同的状态:
| 状态 | 在代码的哪个位置 | 能不能自己发现 _isrunning == false |
|---|---|---|
| ① 正在等待 | 卡在 _cond.Wait() 里睡 |
✖ 完全不知道 ------ 它在睡,没人叫就永远不醒 |
| ② 正在取任务 | 持锁、刚从 Wait 返回或正在 front/pop |
✔ 会重新检查条件 |
| ③ 正在处理任务 | 已经 Unlock 并在执行 t() |
✔ 处理完会回到循环顶部重新检查 |
✖ 问题就出在状态 ① :老师原话 ------ "你即便把它设成 false 了,那些正在睡的线程它也不知道呀。"
✔ 所以 Stop() 光改标志位是不够的,必须配一次广播唤醒。
第二步:再确定「什么条件才能真退」
第一,历史遗留在任务队列里的任务,必须被完全取完。
第二,任务被取完之后,并且线程池整体状态是
false的,才让这个线程退出。
并且他把这个条件的反面也列出来了,用来验证逻辑是否严密:
- 反面 = 队列不空(还有任务没被取完)
- 或 = 线程池依旧
_isrunning == true- "只要任务队列还有任务,或者线程池还在运行,你就不应该让线程退出。"
💡 这正好解释了 while 里的条件为什么是 empty() && _isrunning ------ 它是"能睡"的唯一条件。
"你要进入休眠状态,只有唯一的条件:队列为空 并且 线程池正常。否则我们不要让你休眠。"
第三步:落到代码
看 Stop():
cpp
void Stop()
{
_mutex.Lock();
_isrunning = false;
_cond.NotifyAll(); // ★ 必须广播,否则状态①的线程永远醒不来
_mutex.Unlock();
}
关键在 HandlerTask 里这一步的判断逻辑,三种情况要分清楚:
| 情况 | 条件 | 行为 |
|---|---|---|
| 2.1 | 池子退出 且 队列空 | 直接退出 (break) |
| 2.2 | 池子不退出 且 队列非空 | 取任务执行 |
| 2.3 | 池子退出 且 队列非空 | 把剩余任务处理完,再退出 |
✔ 也就是说:Stop() 不再接收新任务,但已经入队的任务不会被丢弃。
这个语义非常重要 ------ 想象一个处理订单的线程池,Stop 时如果直接丢掉队列里剩下的订单,就是业务事故。所以正确做法是"关入口、清库存、再关门":
Stop() → _isrunning = false,入口关闭(Enqueue 返回 false)
NotifyAll() → 所有 sleep 的 worker 醒来
worker 醒来 → 发现 !_isrunning,但队列还有任务 → 继续干
队列干完 → _task_queue.empty() && !_isrunning → break
Wait() → 主线程 Join 所有 worker,确认全部退出
注意 Enqueue 里的这个 if:
cpp
_mutex.Lock();
if (_isrunning) // ★ 只有运行中才允许入队
{
_task_queue.push(t);
...
ret = true;
}
_mutex.Unlock();
return ret;
✔ 这就是"关闭入口"的落点 ------ Stop 之后 Enqueue 一律返回 false。
5.6 ★ 一个坑:Join 不要和 _isrunning 绑在一起
写 Join() 时,很自然会想"线程池在运行才需要 Join 吧",于是写成这样:
cpp
void Join()
{
if (_isrunning) // ✖ 坑在这里
{
for (auto &t : _threads) t.Join();
}
}
结果:Stop() 之后 _isrunning 已经是 false 了,Join 直接整段跳过,一个线程都不等,主线程立刻往下跑 ------ 屏幕上看不到任何 "Thread-x 退出..." 的日志,让人以为线程没退出。
✔ 关键认知:_isrunning 描述的是"该不该干活",不是"该不该回收"。
正好相反 ------ _isrunning == false 才是你该 Join 的时刻。线程池还在跑的时候你根本不该 Join(会永久阻塞)。
cpp
void Join()
{
for (auto &t : _threads) // ✔ 直接遍历等,不判断
t.Join();
}
💡 这是个通用教训:"操作的前置条件"和"操作本身"不要混在一个
if里判断。 这里真正的前置条件是"线程已经启动过",而不是"线程池在运行"。
六、代码骨架(简略)
理解了第五节,剩下都是装配工作,快速过一遍。
6.1 成员变量
cpp
int _threadnum; // 线程个数
std::vector<Thread> _threads; // 线程数组(固定 N 个)
std::queue<T> _task_queue; // 任务队列(T = std::function<void()>)
Mutex _mutex; // 保护队列
Cond _cond; // 队列非空时通知
int _waitnum; // 正在等待的线程数
bool _isrunning; // 运行标志
6.2 五个接口的分工
| 接口 | 干什么 | 关键点 |
|---|---|---|
InitThreadPool() |
创建 N 个 Thread 对象,但不启动 |
用 std::bind(&ThreadPool::HandlerTask, this) 把成员函数变成可调用对象 |
Start() |
_isrunning = true,然后逐个 thread.Start() |
先置标志再启动,避免 worker 刚起来就因 _isrunning=false 退出 |
Stop() |
置 _isrunning = false + NotifyAll() |
见 5.5 |
Wait() |
逐个 thread.Join() |
主线程阻塞到所有 worker 真正退出 |
Enqueue(t) |
加锁 → 判断 _isrunning → push → 按需 Notify |
返回 bool 表示是否入队成功 |
std::bind(&ThreadPool::HandlerTask, this)这一步是重点:成员函数有隐含的this参数,不能直接当回调,bind把this绑进去,就变成了一个无参可调用对象 ------ 可以塞给std::function<void()>。
6.3 用起来长什么样
cpp
using task_t = std::function<void()>;
void DownLoad() { std::cout << "this is a task" << std::endl; }
int main()
{
ENABLE_CONSOLE_LOG_STRATEGY();
int cnt = 10;
while (cnt--)
{
ThreadPool<task_t>::GetInstance()->Enqueue(DownLoad); // 投任务
sleep(1);
}
ThreadPool<task_t>::GetInstance()->Stop(); // 关入口
sleep(5); // 给 worker 一点时间清库存
ThreadPool<task_t>::GetInstance()->Wait(); // 等全部退出
return 0;
}
从日志能直接看出线程池在干活:
[INFO] [ThreadPool.hpp] [62] - ThreadPool Construct()
[INFO] [ThreadPool.hpp] [70] - init thread Thread-0 done
...
[DEBUG] [ThreadPool.hpp] [109] - 任务入队列成功
[DEBUG] [ThreadPool.hpp] [52] - Thread-0 get a task
this is a task
[DEBUG] [ThreadPool.hpp] [52] - Thread-3 get a task
...
[DEBUG] [ThreadPool.hpp] [88] - 线程池退出中...
[INFO] [ThreadPool.hpp] [95] - Thread-0 退出...
✔ 注意任务被不同线程 (Thread-0、Thread-2、Thread-3......)轮流取走 ------ 这就是负载被分摊到多个执行流的直接证据。这就是为什么必须写日志。
💡 上面那段
sleep(5);是课件里的写法,其实并不必要 :Wait()内部挨个Join,本身就会阻塞到所有 worker 真正退出为止。课堂上老师后来直接把它去掉了 ------ 留着只是为了让"线程池退出中"和"Thread-x 退出"这两拨日志在屏幕上分得开一点。✔
Stop()+Wait()就是完整的退出:Stop负责"关入口",Wait负责"等门真的关好"。 中间的 sleep 是观察手段,不是设计的一部分。
七、单例模式:从「洗碗」到「妈妈给学费」
线程池全局只该有一份(否则每个池子各占 N 个线程,等于没池化),所以要做成单例。
7.1 单例的本质:不是"我自觉只建一个",而是"语法上不让你建第二个"
这是要纠正的认知:
"有人说我自己创建一个,我就不再创建不就完了吗?但我们不要相信人,不要相信成品 ------ 因为它有可能会误用你的类。我要在语法上约束这个程序,不要让它能够创建一个以上的对象。"
✔ 所以单例不是"约定",是编译器层面的一道锁 。要让"创建第二个对象"这件事编译不过,需要四件事同时做到:
| 手段 | 挡住什么 |
|---|---|
构造函数设 private |
外面 ThreadPool<task_t> tp; / new ThreadPool<task_t>() 编译不过 |
拷贝构造 = delete |
ThreadPool<task_t> tp2(tp); 编译不过 |
赋值重载 = delete |
tp2 = tp; 编译不过 |
类内 static 成员 + static GetInstance() |
提供一个唯一合法入口 |
💡 构造函数必须保留(只是私有化)------ 因为单例毕竟还有一个对象要被构造出来。私有化的目的是"只有我自己能调",不是"不能调"。
课件提到的典型场景:服务器要把上百 G 数据加载到内存,此时必须用一个单例类来管这些数据 ------ 体积大、全局共享、绝不能有第二份。
7.2 饿汉:加载即创建
cpp
template <typename T>
class Singleton {
static T data; // ★ 注意:是对象,不是指针
public:
static T* GetInstance() { return &data; }
};
这里讲了一个特别值得记的底层事实:
"不管是 C 的
static还是 C++ 的static成员,将来都会被编译到代码的全局数据区 ------ 它本质就是全局变量。"
推一遍就能理解为什么饿汉"天然线程安全":
源代码 → 编译 → 可执行文件(.data / .bss 段里有那块空间)
↓
进程加载(execve)→ 建立地址空间 → 全局数据区被映射
↓
★ 此时静态对象已经构造完成
↓
main() 开始 → 线程才被创建
✔ 也就是说:等任何线程有机会碰到这个对象时,它早就存在了 ------ 根本不存在竞态窗口。
✖ 代价 :构造和初始化发生在进程加载阶段 ,会延长程序启动时间。类体积越大,这个代价越明显。
很明确:
"饿汉是不太常用的一种方式,除非你未来对应的单例体积不大、而且大家都在用。但我们 90% 的情况下创建单例都采用懒汉模式。"
7.3 ★ 懒汉:延时加载,以及它真正的价值
cpp
template <typename T>
class Singleton {
static T* inst; // ★ 是指针,只占 4/8 字节
public:
static T* GetInstance() {
if (inst == NULL) // 第一次才 new
inst = new T();
return inst;
}
};
区别就在那个 *:指针本身只有 4/8 字节,加载时几乎不花时间 ;真正的对象等第一次用到才 new。
懒汉最核心的思想是「延时加载」。
延时加载到底好在哪:一个妈妈给学费的比喻
课件上只说"优化启动速度",但老师花了近十分钟讲更深的那层价值,这一段是整节课最精彩的部分。
场景设定:
| 比喻 | 对应 |
|---|---|
| 你 | 你的进程 |
| 你妈 | 操作系统 |
| 家里的钱 | 物理内存 |
| 5000 块 | 你要 new 的 1000 字节 |
| 你舅舅 | 另一个进程 |
恶汉版:
你今晚跟你妈说"明天早上要交学费,给我 5000"。你妈当场就把钱给你了 。可你明天早上才用 ------ 这一整夜,这 5000 块在你兜里,你不用,别人也用不了。
"这就是典型的占着茅坑不干活。"
懒汉版:
你妈说"行,钱给你准备好了,明天早上你要用的时候再给你 "。你去睡觉了。
半夜你舅舅突然上门:家里有人生病要去医院,急用 5000 块,两三小时就还。你妈就把这笔钱先借给他了,两三小时后又还回来。
第二天早上,你照样拿到 5000 块去交学费 ------ 你完全不知道发生过什么。
✔ 对你的成本:零。对整机的好处:这一夜这笔钱多干了一件事。
"如果你家里有无数个你、有无数个舅舅,那么采用懒汉模式是不是就可以让你家里的钱利用率变得非常高?就相当于在内存里让物理内存的利用率变得非常高。"
这不只是比喻:操作系统真的这么干
紧接着把话落到 OS 上,这一段直接把"懒汉"从一个设计模式拔高到了操作系统的基本策略:
"你自己
new或者malloc申请空间时,操作系统是真的把空间给你了吗?并没有。"
new / malloc
↓
OS 只给你【虚拟地址空间】+ 填好【页表】
↓ ← 到这一步就返回了,物理页一页都没给
你真正去读写那一块内存
↓
MMU 发现该页没有映射 → 触发【缺页中断】
↓
内核执行页面申请算法 → 分配物理页 → 修改页表
↓
你才真正拿到可访问的内存
✔ 所以:
- "延时加载"是操作系统的默认策略 ------ 虚拟内存、按需调页、写时拷贝(COW),本质都是懒汉
- 正因为如此,在系统里用懒汉模式是"百利几乎无一害"的事 ------ 它不增加任何成本(该做的总得做),只是把时间往后挪,而挪出来的这段时间内存可以借给别人用
| 好处 | 代价 | |
|---|---|---|
| 饿汉 | 无竞态,实现简单 | 延长进程加载时间,闲置期资源被白占 |
| 懒汉 | ★ 延迟加载,优化启动速度 ;★ 提高整机内存利用率 | 需要处理线程安全(见 7.5) |
7.4 ★ 两个"必须 static",很多人卡在这里
非常典型的"为什么非得这么写"。
第一个:GetInstance() 必须是 static
先写了个普通的成员函数,然后自己质疑自己:
"这个
GetInstance目前是类的成员方法。而你要访问类的成员方法,必须得有对象才能访问 ------ 可我第一次用的时候还没有对象啊!我得先有一个对象才能调用你,你得先给我一个对象......"
✔ 死循环。所以 GetInstance 必须是 static ------ 静态成员函数属于类 而不属于对象,可以在一个对象都没有的情况下直接调用:
cpp
ThreadPool<task_t>::GetInstance() // ✔ 不需要先有对象
第二个:那把锁也必须是 static
加锁保护单例时,很自然会想"用 _mutex 就行了"。但推一下:
"你今天要加锁,必须得有对象(
_mutex是成员变量)。可你连对象都没有,你不是单例吗?"
✔ 又是同一个死循环。所以保护单例的锁必须是类的静态成员:
cpp
static Mutex _lock; // ★ 必须是 static
// 类外定义
template <typename T>
Mutex ThreadPool<T>::_lock;
💡 佐证:静态成员函数只能直接访问静态成员,不能访问非静态成员 ------ 因为静态函数没有 this,压根不知道是哪个对象的 _mutex。编译器会直接拦住你。
| 要保护的东西 | 用什么 | 为什么不能用非静态 |
|---|---|---|
_task_queue(已存在对象的成员) |
普通成员 Mutex _mutex |
✔ 可以用,因为此时对象已经存在 |
_instance(还没创建出来的单例) |
★ static Mutex _lock |
对象还不存在,拿不到成员锁 |
7.5 懒汉的三个问题,一个都不能少
版本一:朴素懒汉 ------ 线程不安全
cpp
static T* GetInstance() {
if (inst == NULL) // ✖ 两个线程可能同时判空成功
inst = new T(); // ✖ 于是 new 了两份
return inst;
}
第一次调用 GetInstance 时,如果两个线程同时调用,可能会创建出两份 T 对象的实例。但后续再次调用就没问题了。
为什么"后续就没问题"?因为后续 inst != NULL,走的都是 return inst 这条只读路径。问题只出在"第一次"这个窗口期。
用抢票的例子来类比这个"同时判定"的危险:
线程1:读 inst → NULL
↓(被切走)
线程2:读 inst → NULL ← 两个线程都以为"还没创建"
↓(切回)
线程1:new 对象 → 写入 inst
线程2:new 对象 → 覆盖 inst ✖ 两份对象,第一份泄漏
✖ 后果是双重的:既不是单例了,还内存泄漏(第一份对象没人指向它,永远回收不了)。
提醒:"这不就是我们当年写抢票抢到负数那个问题吗?多个线程在判断时误进入,然后各自在内部做操作。"
版本二:加锁 ------ 安全,但每次都加锁
最直觉的修法是整个函数加锁。这样确实安全了,但有个性能毛病:GetInstance 是高频调用 的(每个要投任务的地方都得调一次),而真正需要保护的只有第一次。后面 99.99% 的调用都在为那一瞬间白白付锁的代价。
老师原话:"一旦来多线程,每次获取单例都要加锁,加锁失败我还要等,效率是不是太低了?"
版本三:双重判定 + volatile ------ 最终版
cpp
template <typename T>
class Singleton {
volatile static T* inst; // ★ volatile 防止被编译器优化
static std::mutex lock;
public:
static T* GetInstance() {
if (inst == NULL) { // 第一重:绝大多数情况直接返回,不加锁
lock.lock(); // 只有第一次(以及竞争落败时)才加锁
if (inst == NULL) { // 第二重:拿到锁后再确认一次
inst = new T();
}
lock.unlock();
}
return inst;
}
};
课件列了三条注意事项,逐条解释:
| 注意事项 | 为什么 |
|---|---|
| ① 加锁解锁的位置 | 锁要在第二重 if 的外面 、第一重 if 的里面。放外面等于每次调用都加锁(回到版本二);放里面等于没锁 |
② 双重 if 判定 |
外层的 if 让已经创建之后 的所有调用都不必加锁 ------ 降低锁竞争,提高性能 |
③ volatile 防止过度优化 |
防止编译器把 inst 缓存到寄存器里,导致别的线程改了它、而本线程还在读旧值 |
💡 第二重 if 不是多余的。设想 A、B 两个线程同时通过第一重判定(都是 NULL),A 先拿到锁创建了对象,B 接着拿到锁 ------ 如果没有第二重判定,B 会再 new 一次 ,把 A 创建的对象覆盖掉(还漏了内存)。第二重判定才是真正保证"只 new 一次"的那道闸。
7.6 落到线程池上
线程池的单例版本,把上面这套直接搬进 GetInstance,并且顺手把初始化也放进去:
cpp
static ThreadPool<T>* GetInstance()
{
if (nullptr == _instance) // 第一重:二次之后所有线程直接返回,不加锁
{
LockGuard lockguard(_lock);
if (nullptr == _instance) // 第二重:真正保证只创建一次
{
_instance = new ThreadPool<T>();
_instance->InitThreadPool(); // ★ 创建即初始化
_instance->Start(); // ★ 创建即启动
LOG(LogLevel::DEBUG) << "创建线程池单例";
return _instance;
}
}
LOG(LogLevel::DEBUG) << "获取线程池单例";
return _instance;
}
配套的类内部改动:
cpp
private:
ThreadPool(int threadnum = gdefaultthreadnum); // ① 构造私有
ThreadPool<T> &operator=(const ThreadPool<T> &) = delete; // ② 禁赋值
ThreadPool(const ThreadPool<T> &) = delete; // ③ 禁拷贝
static ThreadPool<T> *_instance; // ④ 唯一实例指针
static Mutex _lock; // ⑤ 保护实例的静态锁
template <typename T>
ThreadPool<T> *ThreadPool<T>::_instance = nullptr;
template <typename T>
Mutex ThreadPool<T>::_lock;
✔ 这五条(私有构造 + 禁拷贝 + 禁赋值 + 静态实例 + 静态锁)是单例的标准四件套(锁算加餐),缺一条就能被绕过。
改完之后,外面的写法彻底变了 ------ 老师强调这才是"逼着你走向正确用法":
cpp
// ✖ 改之前:想怎么建就怎么建
ThreadPool<task_t> tp;
// ✖ 改之后:以下全部编译不过
ThreadPool<task_t> tp; // 构造函数私有 → 编不过
ThreadPool<task_t> *p = new ThreadPool<task_t>(); // 同样编不过
ThreadPool<task_t> tp2(tp); // 拷贝构造 delete → 编不过
tp2 = tp; // 赋值 delete → 编不过
// ✔ 唯一合法的用法
ThreadPool<task_t>::GetInstance()->Enqueue(DownLoad);
ThreadPool<task_t>::GetInstance()->Stop();
ThreadPool<task_t>::GetInstance()->Wait();
💡这叫做 header only 的使用方式:连对象都不用建,#include 进来,GetInstance() 直接用。
从日志能清楚看到"只创建一次":
[DEBUG] [ThreadPool.hpp] [98] - 创建线程池单例 ← 第一次(首次使用)
[DEBUG] [ThreadPool.hpp] [133] - 任务入队列成功
...
[DEBUG] [ThreadPool.hpp] [102] - 获取线程池单例 ← 第二次及以后
[DEBUG] [ThreadPool.hpp] [102] - 获取线程池单例
✔ "创建线程池单例"只出现一次,"获取线程池单例"出现 N 次 ------ 这两条日志就是这个设计正确性的直接证据。
八、★ 线程安全 vs 可重入:这对概念必须分清
这是本节课后半段的理论重点,也是最容易背混的一段。
不要被上面绕口令式的话语唬住,你仔细看就会发现,其实对应概念说的都是一回事。
8.1 先说「重入」到底有几种
这个概念之所以绕,是因为"重入"其实包含两种完全不同的场景:
| 类型 | 场景 |
|---|---|
| ① 多线程重入 | 线程 A 正在执行函数 f,线程 B 也进来执行 f |
| ② 信号导致重入 | 单线程执行 f 到一半,来了个信号,信号处理函数里又调用了 f ------ 同一个执行流重复进入 |
✔ 第二种是很多人漏掉的。它不需要多线程,单线程程序里也会发生 ------ 这正是"线程安全"和"可重入"不能划等号的根本原因。
8.2 两个定义对照
| 线程安全 | 可重入 | |
|---|---|---|
| 定义 | 多个线程访问共享资源时能正确执行,不会相互干扰或破坏彼此的执行结果 | 同一个函数被不同执行流调用,前一个还没执行完,另一个又进来了;若结果不出任何问题,则称可重入 |
| 关注对象 | 并发线程的特点 ------ 线程访问公共资源的安全情况 | 函数的特点 ------ 这个函数能不能被重复进入 |
| 变量范围 | 全局变量 / 静态变量 + 有没有锁保护 | 函数内部是否依赖共享状态 |
还给了两个"常见情况"清单,对照看很有意思:
| 常见的线程不安全 | 常见的不可重入 |
|---|---|
| 不保护共享变量的函数 | 调用了 malloc / free(用全局链表管理堆) |
| 函数状态随调用而变化的函数 | 调用了标准 I/O 库函数(很多实现以不可重入方式使用全局数据结构) |
| 返回指向静态变量指针的函数 | 函数体内使用了静态数据结构 |
| 调用线程不安全函数的函数 | 调用不可重入函数 |
| 常见的线程安全 | 常见的可重入 |
|---|---|
| 不使用全局/静态变量 | 不使用全局/静态变量 |
| 每个线程对全局/静态变量只读不写 | 不使用 malloc / new 开辟的空间 |
| 类或接口对线程是原子操作 | 不调用不可重入函数 |
| 线程切换不会导致接口结果有二义性 | 不返回静态/全局数据,数据由调用者提供 |
| 使用本地数据,或对全局数据做本地拷贝 |
✔ 两边高度重合,但可重入那边的门槛更高 (还多要求了"不用 malloc")。这就是"可重入 ⇒ 线程安全"这条单向箭头的来源。
8.3 ★ 就在眼前的例子:GetInstance 本身就是"状态随调用变化"的函数
上面那张表里有一行看着抽象 ------ "函数状态随被调用而变化的函数" 属于线程不安全。第一个简直是"顺手一刀":
"举个例子,比如说我们刚刚写的线程池里的
GetInstance。它用了静态指针,第一次调的时候为空,第二次就不为空了 。也就是说这个函数在调的时候,它会随着你调用次数的变化,函数本身的状态会发生变化。"
✔ 一下子就把抽象概念钉死了:
GetInstance() 第 1 次调用 → _instance == nullptr → 走创建分支 → 改状态
GetInstance() 第 2 次调用 → _instance != nullptr → 走返回分支
↑ 同一个输入,不同的行为 ------ 因为"状态"变了
💡 这就是为什么它必须用双重 if + 静态锁来保护 ------ 第七节讲的是"怎么做",这里补上了"为什么它天然线程不安全 ":因为它有跨调用的可变状态,而这个状态是所有线程共享的。
第二个例子更朴素 ------ 一个统计自身被调用次数的函数:
cpp
void f() {
static int cnt = 0; // ✖ 函数内的静态变量 = 全局生命周期的共享状态
cnt++; // 每次都改,每次状态都不同 → 线程不安全
}
✔ 判据可以总结成一句话:函数里只要存在"会随调用改变、且能被多个执行流共享"的状态,它就是线程不安全的。
8.4 ★ 核心结论

联系:
- 函数是可重入的,那就是线程安全的 ------ 其实知道这一句话就够了
- 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题
- 如果一个函数中有全局变量,那么这个函数既不是线程安全的也不是可重入的
区别(原话):
- 可重入函数是线程安全函数的一种
- 线程安全不一定是可重入的,而可重入函数则一定是线程安全的
- 如果将对临界资源的访问加上锁,则这个函数是线程安全的;但如果这个重入函数若锁还未释放则会产生死锁,因此是不可重入的
第三条是整段最精妙的地方,这就是那个"反例"的核心,我们把它完整推一遍。
反例的完整推演:一个加锁的函数为什么不可重入
设定:单进程、单线程,但有信号 。函数 f() 内部加锁访问临界资源。
cpp
void f() {
lock(); // ← ① 加锁成功,进入临界区
... 处理 ...
LOG(INFO) << "..."; // ← ② 这里有系统调用,可能被切走
... 处理 ...
unlock(); // ← ④ 本该在这里解锁
}
① 线程执行 f(),lock() 成功 ------ 现在锁在【自己】手上
↓
② 执行到 LOG 时发生系统调用
↓
③ 内核在返回用户态前发现有待处理信号 → 转去执行【信号处理函数】
↓
信号处理函数里也调用了 f()
↓
再次 lock() ------ 这把锁【正是自己刚才加的那把】,还没释放
↓
✖ 自己把自己挂起:等待一把自己永远不可能释放的锁
↓
④ 永远回不到 unlock() → 死锁
✔ 所以:加锁把"线程安全"解决了,却把"可重入"破坏了。 锁能挡住别的线程,但挡不住"自己重入自己"。
这里补了一个很多人想不通的关键点:
"我们无数次强调过:加锁是原子的 。但在一个加锁的区间里面,线程可不可以被切换?可不可以被调度?可不可以做系统调用?可不可以处理信号?------ 都可以,因为它是多行代码。"
💡 "加锁是原子的"≠"临界区是原子的"。 原子的只是 lock() 这一条指令;lock() 到 unlock() 之间的那一大段完全可被抢占。这正是信号能插进来的缝隙。
"但这个函数在多线程访问时 不存在自己二次申请锁的情况,所以它是线程安全的 ,但它不可重入。"
✔ 这就是"线程安全 ⇏ 可重入"的唯一反例来源 ------ 不是因为多线程,而是因为信号。
顺带记一个"同源"的事实:
pthread_mutex的默认类型是PTHREAD_MUTEX_NORMAL(不可重入) ,重复加锁会死锁;改成PTHREAD_MUTEX_RECURSIVE(递归锁)才能重入 ------ 这就是"可重入锁"一词的由来。递归锁解决的就是上面这个"自己等自己"的场景。
8.5 一句话把这章收掉
课件最后一句注意:
- 如果不考虑「信号导致一个执行流重复进入函数」这种重入情况,线程安全和重入在安全角度不做区分
- 但线程安全 侧重说明线程访问公共资源的安全情况,表现的是并发线程的特点
- 可重入 描述的是一个函数是否能被重复进入,表示的是函数的特点
✔ 记忆锚点:线程安全说的是"线程",可重入说的是"函数"。一个讲环境,一个讲本体。
8.6 方法论:两个像的概念怎么分清楚
这一段不是知识点,但很值得记 ------ 老师专门讲了"面对两个相似概念该怎么想":
"我们在明确某些概念的区别时,首先要把每一个概念先抛清楚,理解清楚了,然后再把概念揉在一起,它才不会乱。"
落到这对概念上就是两步走:
第一步:先分开 ------ 它们站在不同角度
| 线程安全 | 可重入 | |
|---|---|---|
| 站在谁的角度 | 线程 | 函数 |
| 回答什么问题 | 并发访问公共资源会不会出错 | 这个函数能不能被重复进入 |
| 属于哪个体系 | 并发编程的理论体系 | 函数性质的理论体系 |
老师打的比方:"它俩是一个硬币的两面 ------ 你是花还是字,是从不同角度去看的。"
第二步:再揉在一起 ------ 那个单向箭头
可重入 ──⇒ 线程安全 (必然)
线程安全 ──⇏ 可重入 (只在"信号重入"时才破)
第三步:知道什么时候可以偷懒
老师说得很实在:"从今天开始,因为在多线程当中信号导致函数被重复进入 这种情况概率比较低,我们可以把它作为一个了解范畴 。今天只需要记住一句话:可重入描述的是函数的特点,跟线程没关系;如果函数是可重入的,那它一定是安全的 ------ 这句话就够了。"
✔ 也就是说:日常写多线程代码,把"线程安全"当唯一指标就行 ;只有在写信号处理函数、或写要给别人复用的库函数时,"可重入"才成为硬约束(信号处理函数里绝不能调用不可重入函数,比如 printf、malloc)。
九、死锁:四个条件,破一个就行
9.1 先破一个误区:单线程也能死锁
第一个问题就很反直觉:
"关于死锁,在我继续往后讲之前我先问一个问题:单线程有没有可能产生死锁?"
答案:可能。而且极其容易。
cpp
lock(); // 第一次加锁 ------ 成功
... // 干活
lock(); // ✖ 同一把锁又加了一次 ------ 这把锁已经是自己的了
// 默认类型(PTHREAD_MUTEX_NORMAL)下 → 永久阻塞
✔ 说白了:死锁本来就是你代码写错了。 同一把锁连续加两次,就是最典型的单线程死锁。
"这不就是最典型的单线程也同样会产生死锁的一个典型表现吗?"
顺带一提:这也是
PTHREAD_MUTEX_RECURSIVE(递归锁)存在的意义 ------ 它允许同一个线程重复加锁。
不过:我们讨论死锁,主要还是针对多线程:
死锁 是指在一组执行流中,各个执行流占有不会释放的资源 ,但因互相申请被其他执行流占用且不会释放的资源 ,而处于的一种永久等待状态。
9.2 死锁的四个必要条件
| 条件 | 含义 | 小胖小兽版 |
|---|---|---|
| ① 互斥条件 | 一个资源每次只能被一个执行流使用 | 一根棒棒糖只能卖给一个人 |
| ② 请求与保持条件 | 一个执行流因请求资源而阻塞时,对已获得的资源保持不放 | "把你的 5 毛给我,我的 5 毛我不释放" |
| ③ 不剥夺条件 | 已获得的资源在使用完之前,不能强行剥夺 | 老板说"别打架" → 系统不允许你 unlock 别人的锁 |
| ④ 循环等待条件 | 若干执行流之间形成头尾相接的循环等待关系 | 小胖等小兽的钱,小兽等小胖的钱 |
逐条再解释一下后两条,因为最容易含糊:
③ 不剥夺 ------ 技术上能不能抢?其实能:
cpp
// 理论上可以这么干,但系统不让你这么设计
pthread_mutex_unlock(&B的锁); // 直接解别人的锁 = 抢
✔ 所以"不剥夺"是一条约定 :加锁/解锁必须成对,且只能操作自己持有的锁。老师原话:"如果我允许你们两个小朋友打一架来决定钱归谁,那一定能分出胜负 ------ 小胖把小兽按在地上一顿,把钱拿过来,就不会死锁了。但我们不允许你这么做。"
④ 循环等待 ------ 这是最"巧合"的一条,也是最容易破坏 的一条。老师原话:"你怎么不去要别人的锁,非要我的锁?我又不给你,我还想要你的呢 ------ 这样耗着有意思吗?"
✔ 四个条件是与 关系 ------ 必须同时满足才会死锁。所以破掉任意一个,就不会死锁。
9.5 怎么避免:四种破坏方式
| 破坏哪个条件 | 具体做法 | 说明 |
|---|---|---|
| ① 破坏互斥 | 尽量不用锁(如 CAS、原子操作) | 往往做不到,资源本质就是互斥的 |
| ② 破坏请求与保持 | 一次性申请所有需要的锁;或"申请第二把失败就释放第一把" | std::lock(lock1, lock2);或用 trylock 循环 |
| ③ 破坏不剥夺 | 超时机制 :trylock 拿不到就放弃,把已持有的锁全部释放,等会儿重试 |
需要保证"被抢"的一方将来能重新拿到锁 |
| ④ 破坏循环等待 | ★ 加锁顺序一致 (所有线程都按 1 → 2 的顺序加锁) |
工程上最常用、最省事的一招 |
用 trylock 破坏"请求与保持"的具体写法(老师讲了这个思路):
线程要拿 锁1 + 锁2:
trylock(1) 失败 → 直接返回,什么也没拿到(干净)
trylock(1) 成功
trylock(2) 失败 → ★ 立刻 unlock(1),把自己已拿到的释放掉 → 重试
trylock(2) 成功 → 两把都在手,干活
✔ 关键就是那句 "申请第二把失败,就把第一把释放掉" ------ 这样永远不会出现"持有一把、等另一把"的中间状态,请求与保持条件被破坏。
RAII 保证不泄漏锁 :用 LockGuard 让异常路径也能解锁 ------ 对应课件里"避免锁未释放的场景"。
9.6 一个小拷问:为什么我们之前写的代码从来没死锁
"我们之前写的代码怎么没有出现死锁呢?"
同学追问:"蛋哥,你日志内部不是也有锁吗?"
区别讲得很清楚:
"那不一样。我们之前访问资源的时候并不需要同时持有两把以上的锁 ,之前所有的锁都是串行申请 的,而且彼此之间互不竞争 ------ 所有人申请锁的时候都是按相同的顺序 去申请的。
而造成死锁的情况是:线程 A 先申请锁 1,线程 B 先申请锁 2,最后彼此都持有一部分、又都在要对方的。"
✔ 两个关键差异:
| 之前的代码 | 死锁场景 | |
|---|---|---|
| 加锁方式 | 串行(一把一把拿,拿完用完再拿下一把) | 嵌套(持着锁 1 去申请锁 2) |
| 申请顺序 | 所有线程顺序一致 | 线程间顺序相反 |
💡 所以"嵌套加锁"才是危险信号。只要你的代码里出现"持着 A 去要 B",就要立刻检查顺序一致性。
9.7 一次申请 vs 分别申请:实测数据
课件里给了一段 C++ 示例(用 std::lock 一次锁两把,对比分别锁),结果非常直观:
cpp
// 两个线程各做 10000 次自增,期望结果 100000 × 2
$ ./a.out // 不一次申请
Shared Resource 1: 94416
Shared Resource 2: 94536 ← ✖ 结果不对,而且两个数还不一样
$ ./a.out // 一次申请(std::lock 同时锁住两把)
Shared Resource 1: 100000
Shared Resource 2: 100000 ← ✔ 完全正确
✔ 这个实验同时说明了两个问题:
- 分别申请锁不仅可能死锁,还可能根本没锁住 ------ 两次自增之间出现了间隙,别的线程插进来了,所以结果是错的
- 一次申请(
std::lock(lock1, lock2))才能真正保证"两个操作的原子性"
std::lock 内部的实现会用避免死锁的算法(尝试、回退、重试),所以它既保证原子性又不会把自己锁死。
💡 注意
std::lock是 C++ 标准库 提供的能力,Linux 原生 pthread 没有对应接口 ------ 要用就得自己封装。这也是为什么课件要专门演示这个例子:它展示的是"一次性分配"这个思路,而不是某个具体 API。
十、STL 和智能指针是线程安全的吗
面试常问,答案很干脆。
10.1 STL 容器:✖ 不是线程安全的
课件给的理由非常"工程":
STL 的设计初衷是将性能挖掘到极致 ,而一旦涉及到加锁保证线程安全,会对性能造成巨大的影响。
而且对于不同的容器,加锁方式的不同,性能可能也不同 (例如 hash 表的锁表 vs 锁桶)。
拆一下这个"锁表 vs 锁桶":
| 方案 | 做法 | 特点 |
|---|---|---|
| 锁整张表 | 一把大锁罩住整个 hash 表 | 简单,但并发度极低 |
| 锁桶(分段锁) | 每个桶(或每组桶)一把锁 | 并发度高,但一致性维护复杂 |
既然 STL 无法预知你会怎么用,它就只能不锁,把责任交给调用者:
因此 STL 默认不是线程安全的。如果需要在多线程环境下使用,往往需要调用者自行保证线程安全。
✔ 注意措辞:"默认不是" ------ 意味着你可以自己在外面加锁把它变安全,但库本身不提供。
10.2 智能指针:一半一半
| 类型 | 线程安全吗 | 原因 |
|---|---|---|
unique_ptr |
不涉及 | 独占所有权,只在当前代码块范围内生效,一般只和一个对象关联,根本不会被多个线程同时碰到 |
shared_ptr |
引用计数是安全的 | 多个对象共用一个引用计数变量 → 存在竞态;标准库用**原子操作(CAS)**保证引用计数的高效原子操作 |
⚠ 这里有一个必须区分的措辞,老师专门强调了:
"我们说的是
unique_ptr本身 不涉及线程安全问题,并不代表unique_ptr将来指向的那个对象不存在线程安全问题。"
✔ 拆成两句话:
| 智能指针本体 | 它指向的对象 | |
|---|---|---|
unique_ptr |
✔ 不涉及(独占,作用域内) | ✖ 照样可能有竞态 |
shared_ptr |
✔ 引用计数是原子的 | ✖ 照样可能有竞态 |
💡 所以那句流行的"shared_ptr 是线程安全的"只对了一半 ------ 安全的是引用计数 (多个线程同时拷贝/析构同一个 shared_ptr 不会崩),不是被指向的数据 。两个线程同时通过 *sp 改内容,一样要自己加锁。
实际考量:
"光智能指针本身不是重点 ------ 我们目前用到的智能指针,将来都要直接或间接地和特定的容器、或者某一种对象相关联。所以我们在加锁的时候,智能指针和对象之间是没法割裂的,一般都要对整体(STL + 智能指针)做加锁。"
✔ CAS(Compare-And-Swap) 是实现引用计数原子性的关键:它是一条 CPU 指令级的原子操作,"比较内存值与期望值,相等则写入新值",全程不需要锁。