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

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

1. 线程安全的单例模式

1. 什么是设计模式?

设计模式详解:单例、线程安全、反模式 | B 站

设计模式 是一套经过总结、优化的 代码设计经验,它解决的是软件中 可复用性、可维护性、可扩展性 问题。需要注意的是:它不是具体代码,而是解决特定问题的通用模板。

设计模式分为三类:

分类 说明 举例
创建型 处理对象创建 单例、工厂、建造者
结构型 处理类/对象组合 适配器、装饰器、组合、代理
行为型 处理对象交互 观察者、策略、状态机、职责链等

2. 什么是单例模式

定义: 单例模式是一种 创建型设计模式,它保证 某个类在整个程序运行过程中只有一个实例,并提供全局访问点。其核心思想是: 通过私有构造函数和静态实例控制对象创建。

例如:数据库连接池、配置管理类、线程池、日志管理器等,通常用单例实现。

3. 单例模式的特点

  • 唯一性:类只能有一个实例。
  • 全局访问:提供统一的访问接口,无需重复创建对象。
  • 自行实例化:单例类自己负责创建唯一实例。

4. 饿汉 VS 懒汉(实现方式)

  • 吃完饭,立刻洗碗,这种就是饿汉方式。因为下一顿吃的时候可以立刻拿着碗就能吃饭。
  • 吃完饭,先把碗放下,然后下一顿饭用到这个碗了再洗碗,就是懒汉方式。

懒汉方式最核心的思想是 “延时加载”,从而能够优化服务器的启动速度。

1. 饿汉式

思路: 在程序启动时,就直接创建好唯一实例,不管后面用不用,先占好位置。

特点:

  • 简单,线程安全(因为实例在程序启动时就构造完毕)
  • 缺点:如果创建成本高但很少用,会造成浪费

伪代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Singleton
{
private:
Singleton() {} // 构造函数私有化
static Singleton instance; // 静态成员,程序启动就初始化

public:
static Singleton& getInstance()
{
return instance; // 直接返回静态对象
}
};

Singleton Singleton::instance; // 静态成员初始化(程序启动就创建)

2. 懒汉式

思路: 实例在 第一次调用 getInstance() 时才创建,实现“延迟加载”。

问题: 多线程下不安全,两个线程可能同时进入 if (instance == nullptr),最终创建两个对象。

线程不安全版本(仅演示):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Singleton
{
private:
Singleton() {}
static Singleton* instance;

public:
static Singleton* getInstance()
{
if (instance == nullptr)
{
instance = new Singleton(); // 线程不安全
}

return instance;
}
};

Singleton* Singleton::instance = nullptr; // 初始化为 nullptr

3. 懒汉式(线程安全版本,重点)

方式一:使用 pthread_mutex_t 加锁(适合 C++11 以下版本)

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
#include <pthread.h>

class Singleton
{
private:
Singleton() {} // 私有构造函数,禁止外部创建对象

static Singleton* instance; // 静态成员指针,用于保存唯一实例
static pthread_mutex_t lock; // 互斥锁,保证线程安全

public:
// 获取单例实例的静态方法
static Singleton* getInstance()
{
// 第一次判断,避免每次都加锁(提高性能)
if (instance == nullptr)
{
pthread_mutex_lock(&lock); // 加锁,防止多个线程同时进入创建逻辑

// 第二次判断,防止多线程创建多个实例
if (instance == nullptr)
{
instance = new Singleton(); // 只在第一次真正创建对象
}

pthread_mutex_unlock(&lock); // 解锁
}
return instance; // 返回唯一实例指针
}
};

// 静态成员初始化
Singleton* Singleton::instance = nullptr; // 初始为空指针
pthread_mutex_t Singleton::lock = PTHREAD_MUTEX_INITIALIZER; // 初始化互斥锁

方式二:C++11 之后的写法(推荐),使用局部静态变量(C++11 起线程安全):

1
2
3
4
5
6
7
8
9
10
11
12
class Singleton
{
private:
Singleton() {}

public:
static Singleton& getInstance()
{
static Singleton instance; // C++11 之后线程安全
return instance;
}
};

推荐写法:这种方式最简单、性能好、安全,不需要手动加锁,是现代 C++ 推荐写法。


5. 小结

实现方式 是否线程安全 是否延迟加载 优点 缺点
饿汉式 ✅ 是 ❌ 否 简单,天然线程安全 占内存,不灵活
懒汉式(非线程安全) ❌ 否 ✅ 是 按需创建,节省资源 多线程不安全
懒汉式 + 加锁 ✅ 是 ✅ 是 安全 + 延迟加载 加锁有开销
C++11 局部静态法 ✅ 是 ✅ 是 安全、性能好 编译器需支持 C++11 及以上

2. STL 容器、智能指针与线程安全

1. STL 容器是否是线程安全的?

结论:不是。

  • 原因
    • STL 的设计目标是追求 高性能,而加锁保证线程安全会显著降低性能。
    • 不同容器的加锁方式不同,可能导致性能差异(例如 hash 表的锁表和锁桶)。
  • 解决方案
    • 如果需要在多线程环境中使用 STL 容器,我们调用者需要自行保证线程安全(如手动加锁)。

2. 智能指针是否是线程安全的?

  1. unique_ptr
  • 特点unique_ptr 是独占所有权的智能指针,仅在当前代码块范围内生效。
  • 线程安全性:由于其独占性,不涉及线程安全问题。
  1. shared_ptr
  • 特点shared_ptr 支持多个对象共享同一资源,通过引用计数管理。
  • 线程安全性
    • 存在线程安全问题,因为多个对象可能同时操作引用计数。
    • C++ 标准库实现时考虑了这个问题,使用 原子操作 来高效地管理引用计数,确保线程安全。

3. 其他常见的锁

1. 悲观锁 —— “凡事往坏处想”

  • 想法:总觉得别人会抢我东西,先锁住再说。
  • 做法:操作数据前,先加锁(比如互斥锁 mutex),别人想动?等我用完!
  • 现实比喻:去图书馆占座,一坐下就放书包:“别动,我占了!”
  • 适用:写操作多、冲突频繁的场景(如银行转账)。

2. 乐观锁 —— “我相信世界很和平”

  • 想法:大家不会乱改我的数据,我不锁,直接干。
  • 关键:更新数据前,得 “检查” 下数据有没有被改动过(毕竟乐观只是假设,得验证 )。常用两种办法:
    • 版本号:数据带个“版本”,你改一次+1。我更新时发现版本对得上才提交,否则重来。
    • CAS:见下文。
  • 现实比喻:编辑一个在线文档,提交时系统提示:“别人改过了,请刷新再试。”
  • 适用:读多写少(如商品库存查询)。

3. CAS(Compare-and-Swap)—— 无锁的“原子校验”

  • 本质:更新数据时,保证 “数据没被改” 才更新,是个 原子操作(简单说,操作过程不会被其他线程打断 )。

  • 流程:假设要更新变量 value,线程先记下旧值 oldValue,计算出新值 newValue。真正更新时,对比当前内存里 valueoldValue

    • 相等,说明没被改,用 newValue 替换 value,成功。
    • 不相等,说明被其他线程改过,更新失败,一般会重新尝试(自旋 ),直到成功或者达到重试次数。
  • 问题:可能“ABA 问题”(值变回 A,看似没变,其实动过),可用带版本号的 AtomicStampedReference 解决。

  • 应用shared_ptr 引用计数、无锁队列。

4. 自旋锁(重点详解)—— “我不睡,我就等!”

一句话:自旋锁的效率和适用性取决于其他线程执行临界区的时长。拿不到锁,我不挂起,我循环检查——“转圈圈”。

工作方式: 线程尝试获取锁,如果锁被别人占着,它 不进入睡眠状态,而是:

1
2
3
4
while (锁 != 空闲)
{
// 空循环,持续检查锁是否释放
}

一旦锁释放,立刻抢到手,继续执行。

生活例子: 你去银行办事,窗口写着“请等待叫号”。但你不想听广播,于是你:站在窗口前盯着工作人员,一直问:“好了没?好了没?”虽然烦人,但如果对方 3 秒就办完,你省了“坐下 → 等待 → 被叫 → 起身”的时间。这就是 自旋锁的思想牺牲一点 CPU,换响应速度

  1. 优点:

    • 无上下文切换开销:线程不挂起、不唤醒,避免操作系统调度开销。

    • 适合锁持有时间极短的场景:比如保护一个计数器的加减。

  2. 缺点:

    • 浪费 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
int reader_count = 0;
mutex_t r_lock, w_lock;

// 读者加锁
lock(r_lock);
reader_count++;
if (reader_count == 1) lock(w_lock); // 首个读者阻写
unlock(r_lock);

// 读者解锁
lock(r_lock);
reader_count--;
if (reader_count == 0) unlock(w_lock); // 最后读者放写
unlock(r_lock);

// 写者加锁/解锁
lock(w_lock); // 被读者阻塞
// 写操作
unlock(w_lock);

写者优先的伪代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
int reader_count = 0, writer_wait = 0;
mutex_t r_lock, w_lock, q_lock;

// 读者加锁
lock(q_lock); // 检查写者等待
lock(r_lock);
reader_count++;
if (reader_count == 1) lock(w_lock);
unlock(r_lock);
unlock(q_lock);

// 读者解锁(同读者优先)

// 写者加锁
lock(q_lock); // 阻止新读者
writer_wait++;
lock(w_lock); // 等当前读者
writer_wait--;
unlock(q_lock);

// 写者解锁
unlock(w_lock);