044 深入线程安全:单例、智能指针与同步原语

044 深入线程安全:单例、智能指针与同步原语
小米里的大麦深入线程安全:单例、智能指针与同步原语
1. 线程安全的单例模式
1. 什么是设计模式?
设计模式 是一套经过总结、优化的 代码设计经验,它解决的是软件中 可复用性、可维护性、可扩展性 问题。需要注意的是:它不是具体代码,而是解决特定问题的通用模板。
设计模式分为三类:
分类 | 说明 | 举例 |
---|---|---|
创建型 | 处理对象创建 | 单例、工厂、建造者 |
结构型 | 处理类/对象组合 | 适配器、装饰器、组合、代理 |
行为型 | 处理对象交互 | 观察者、策略、状态机、职责链等 |
2. 什么是单例模式
定义: 单例模式是一种 创建型设计模式,它保证 某个类在整个程序运行过程中只有一个实例,并提供全局访问点。其核心思想是: 通过私有构造函数和静态实例控制对象创建。
例如:数据库连接池、配置管理类、线程池、日志管理器等,通常用单例实现。
3. 单例模式的特点
- 唯一性:类只能有一个实例。
- 全局访问:提供统一的访问接口,无需重复创建对象。
- 自行实例化:单例类自己负责创建唯一实例。
4. 饿汉 VS 懒汉(实现方式)
- 吃完饭,立刻洗碗,这种就是饿汉方式。因为下一顿吃的时候可以立刻拿着碗就能吃饭。
- 吃完饭,先把碗放下,然后下一顿饭用到这个碗了再洗碗,就是懒汉方式。
懒汉方式最核心的思想是 “延时加载”,从而能够优化服务器的启动速度。
1. 饿汉式
思路: 在程序启动时,就直接创建好唯一实例,不管后面用不用,先占好位置。
特点:
- 简单,线程安全(因为实例在程序启动时就构造完毕)
- 缺点:如果创建成本高但很少用,会造成浪费
伪代码示例:
1 | class Singleton |
2. 懒汉式
思路: 实例在 第一次调用 getInstance() 时才创建,实现“延迟加载”。
问题: 多线程下不安全,两个线程可能同时进入 if (instance == nullptr)
,最终创建两个对象。
线程不安全版本(仅演示):
1 | class Singleton |
3. 懒汉式(线程安全版本,重点)
方式一:使用 pthread_mutex_t
加锁(适合 C++11 以下版本)
1 |
|
方式二:C++11 之后的写法(推荐),使用局部静态变量(C++11 起线程安全):
1 | class Singleton |
推荐写法:这种方式最简单、性能好、安全,不需要手动加锁,是现代 C++ 推荐写法。
5. 小结
实现方式 | 是否线程安全 | 是否延迟加载 | 优点 | 缺点 |
---|---|---|---|---|
饿汉式 | ✅ 是 | ❌ 否 | 简单,天然线程安全 | 占内存,不灵活 |
懒汉式(非线程安全) | ❌ 否 | ✅ 是 | 按需创建,节省资源 | 多线程不安全 |
懒汉式 + 加锁 | ✅ 是 | ✅ 是 | 安全 + 延迟加载 | 加锁有开销 |
C++11 局部静态法 | ✅ 是 | ✅ 是 | 安全、性能好 | 编译器需支持 C++11 及以上 |
2. STL 容器、智能指针与线程安全
1. STL 容器是否是线程安全的?
结论:不是。
- 原因:
- STL 的设计目标是追求 高性能,而加锁保证线程安全会显著降低性能。
- 不同容器的加锁方式不同,可能导致性能差异(例如
hash
表的锁表和锁桶)。
- 解决方案:
- 如果需要在多线程环境中使用 STL 容器,我们调用者需要自行保证线程安全(如手动加锁)。
2. 智能指针是否是线程安全的?
- unique_ptr:
- 特点:
unique_ptr
是独占所有权的智能指针,仅在当前代码块范围内生效。 - 线程安全性:由于其独占性,不涉及线程安全问题。
- shared_ptr:
- 特点:
shared_ptr
支持多个对象共享同一资源,通过引用计数管理。 - 线程安全性:
- 存在线程安全问题,因为多个对象可能同时操作引用计数。
- C++ 标准库实现时考虑了这个问题,使用 原子操作 来高效地管理引用计数,确保线程安全。
3. 其他常见的锁
1. 悲观锁 —— “凡事往坏处想”
- 想法:总觉得别人会抢我东西,先锁住再说。
- 做法:操作数据前,先加锁(比如互斥锁
mutex
),别人想动?等我用完! - 现实比喻:去图书馆占座,一坐下就放书包:“别动,我占了!”
- 适用:写操作多、冲突频繁的场景(如银行转账)。
2. 乐观锁 —— “我相信世界很和平”
- 想法:大家不会乱改我的数据,我不锁,直接干。
- 关键:更新数据前,得 “检查” 下数据有没有被改动过(毕竟乐观只是假设,得验证 )。常用两种办法:
- 版本号:数据带个“版本”,你改一次+1。我更新时发现版本对得上才提交,否则重来。
- CAS:见下文。
- 现实比喻:编辑一个在线文档,提交时系统提示:“别人改过了,请刷新再试。”
- 适用:读多写少(如商品库存查询)。
3. CAS(Compare-and-Swap)—— 无锁的“原子校验”
本质:更新数据时,保证 “数据没被改” 才更新,是个 原子操作(简单说,操作过程不会被其他线程打断 )。
流程:假设要更新变量
value
,线程先记下旧值oldValue
,计算出新值newValue
。真正更新时,对比当前内存里value
和oldValue
:- 相等,说明没被改,用
newValue
替换value
,成功。 - 不相等,说明被其他线程改过,更新失败,一般会重新尝试(自旋 ),直到成功或者达到重试次数。
- 相等,说明没被改,用
问题:可能“ABA 问题”(值变回 A,看似没变,其实动过),可用带版本号的
AtomicStampedReference
解决。应用:
shared_ptr
引用计数、无锁队列。
4. 自旋锁(重点详解)—— “我不睡,我就等!”
一句话:自旋锁的效率和适用性取决于其他线程执行临界区的时长。拿不到锁,我不挂起,我循环检查——“转圈圈”。
工作方式: 线程尝试获取锁,如果锁被别人占着,它 不进入睡眠状态,而是:
1 | while (锁 != 空闲) |
一旦锁释放,立刻抢到手,继续执行。
生活例子: 你去银行办事,窗口写着“请等待叫号”。但你不想听广播,于是你:站在窗口前盯着工作人员,一直问:“好了没?好了没?”虽然烦人,但如果对方 3 秒就办完,你省了“坐下 → 等待 → 被叫 → 起身”的时间。这就是 自旋锁的思想:牺牲一点 CPU,换响应速度。
优点:
无上下文切换开销:线程不挂起、不唤醒,避免操作系统调度开销。
适合锁持有时间极短的场景:比如保护一个计数器的加减。
缺点:
浪费 CPU:如果锁被长时间占用,线程白白“空转”,消耗 CPU 资源。
可能引发性能下降:多核还好,单核上自旋线程会一直占着 CPU,其他线程无法运行。
适用场景:
- 锁的持有时间 非常短(微秒级)。
- 多核 CPU 环境(一个核自旋,其他核可以释放锁)。
- 高频、轻量级的同步操作(如原子变量、内核级同步)。
5. 公平锁 vs 非公平锁
类型 | 规则 | 优点 | 缺点 |
---|---|---|---|
公平锁 | 先到先得,排队领号 | 不会饿死,公平 | 效率低,频繁切换线程 |
非公平锁 | 不排队,谁抢到算谁的 | 效率高,减少等待 | 可能有线程一直抢不到(饿死) |
- 公平锁:医院挂号,叫号制。
- 非公平锁:菜市场抢特价菜,谁手快谁拿。
4. 读者写者问题(了解)
在编写多线程的时候,有一种情况是十分常见的。那就是,有些公共数据修改的机会比较少。相比较改写,它们读的机会反而高的多。通常而言,在读的过程中,往往伴随着查找的操作,中间耗时很长。给这种代码段加锁,会极大地降低我们程序的效率。那么有没有一种方法,可以专门处理这种 多读少写 的情况呢?有,那就是 读写锁。它具备以下特性:
- 多个读操作可以同时进行(并发),互不干扰。
- 写操作必须独占资源,不能同时有读或写操作。
- 读写互斥,读时不允许写,写时也不允许读。
相比普通互斥锁,读写锁可以在 多线程读操作场景下显著提升并发性能,非常适用于“读多写少”的情况。
2. 函数调用
同样的,这部分函数和之前的非常相似,类比使用即可。
接口 | 作用 | 使用场景 | 返回值 |
---|---|---|---|
pthread_rwlock_t |
定义读写锁变量 | 全局或局部声明读写锁 | 类型定义,无返回值 |
pthread_rwlock_init(&rwlock, &attr) |
动态初始化读写锁(可指定属性) | 初始化锁时,可通过 attr 设置共享属性等 | 成功返回 0;失败返回错误码 |
pthread_rwlock_rdlock(&rwlock) |
获取 读锁(读者加锁) | 线程需要读取共享资源时调用 | 成功返回 0;失败返回错误码 |
pthread_rwlock_wrlock(&rwlock) |
获取 写锁(写者加锁) | 线程需要修改共享资源时调用 | 成功返回 0;失败返回错误码 |
pthread_rwlock_unlock(&rwlock) |
释放锁(读锁 / 写锁通用) | 线程完成读写操作后,释放锁供其他线程使用 | 成功返回 0;失败返回错误码 |
pthread_rwlock_destroy(&rwlock) |
销毁读写锁,释放资源 | 不再使用锁时调用,避免资源泄漏 | 成功返回 0;失败返回错误码 |
2. 读写锁的两种策略对比:读者优先 vs 写者优先
项目 | 读者优先 | 写者优先 |
---|---|---|
核心目标 | 让读操作尽可能并发、不被阻塞 | 避免写操作长期等待(防止写饥饿) |
锁的策略 | 第一个读者锁住写锁,后续读者并发 | 新写者到来时,阻止新读者进入 |
读者行为 | 多个读者可并发访问,互不阻塞 | 如果已有写者等锁,新读者需等待 |
写者行为 | 写者需等所有读者读完才能进入 | 一旦有写者等锁,写者优先处理 |
潜在问题 | 写者可能“饿死”,一直等不到机会 | 读者可能“饿死”,持续被写者打断 |
实现难点 | 控制第一个读者加锁 / 最后一个解锁 | 控制写等待状态,禁止新读者抢占锁 |
常见适用场景 | 读操作频繁、实时性要求低的写操作 | 写操作频繁、写实时性要求较高 |
总的来说就是:
- 读者优先:有读我就先读,写操作慢慢等。
- 写者优先:只要有写要来,先暂停读,等我写完再说。
读者优先的伪代码:
1 | int reader_count = 0; |
写者优先的伪代码:
1 | int reader_count = 0, writer_wait = 0; |