线程池&&线程安全与可重入

一、为什么会有线程池:先讲「池化」这个思想

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 里 ofstream
  • Logger 内部只持有基类指针,运行时换实现

课件里的用法是一个宏:

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 那到底新在哪

新在两点,都不在同步机制上:

  1. 生命周期管理 :worker 线程是常驻 的,要能优雅启动(Start)和优雅退出(Stop + Wait)
  2. 任务抽象 :队列里存什么?存 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     ← ✔ 完全正确

✔ 这个实验同时说明了两个问题:

  1. 分别申请锁不仅可能死锁,还可能根本没锁住 ------ 两次自增之间出现了间隙,别的线程插进来了,所以结果是错的
  2. 一次申请(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 指令级的原子操作,"比较内存值与期望值,相等则写入新值",全程不需要锁。

相关推荐
龙孚信息1 小时前
跨境电商独立站的安全盲区:一次 PHP 核心漏洞的影响评估
开发语言·安全·php
程序员-Benothing1 小时前
Linux端口与连接排查:ss、lsof、nc、telnet、curl
linux·运维·服务器
志尊宝1 小时前
AI原生前端开发(019):端侧AI安全与隐私:数据保护、权限控制
人工智能·安全·ai-native
邪修king1 小时前
Re:Linux系统篇(三十七):进程间通信Chapter4:System V 共享内存深度解析:为什么它是最快的 IPC?
android·linux·运维
德迅云安全-上官1 小时前
新一代智能分布式云接入:应用加速守护 TCP 业务安全与体验
分布式·tcp/ip·安全
zxanz11 小时前
免费 SSL 证书的隐性成本:续期压力、稳定性与商业风险评估
网络·网络协议·ssl
weixin_417396831 小时前
酒店IPTV怎么和PMS对接?能实现哪些联动
网络·技术·网络运维·酒店iptv·组播排查
zzu123zsw1 小时前
面对多 RPC 扇入超时崩溃:深入 asyncTool 核心 DAG 编排与线程池隔离防护
java·网络·spring boot·网络协议·rpc·并发编程
桌管小李2 小时前
终端安全一体化实战:AI 审计、行为分析、零信任、防拍照与远程运维全解析
运维·服务器·网络·人工智能·windows·安全·macos