多线程安全的三大条件

总结来说,多线程安全主要要满足 三大条件:

原子性(Atomicity)

操作不可分割,要么全部成功,要么全部失败。

1
2
3
4
5
6
7
8
9
int x = 0;

// 原子性保证
Interlocked.Increment(ref x); // 原子 +1,不会丢失更新

// 非原子性
// 其实是 load → add → store,可能被打断,导致多个线程覆盖
// 例如线程a正在++,这个过程中线程b也++,本来x会+2结果会变成只+1
x++;

可见性 (Visibility)

一个线程对共享变量的修改,另一个线程能够立即看到。

CPU 会做缓存[1]和指令重排(跟有序性也有很大关系),导致线程 A 改了变量,线程 B 可能“看不到”。

需要内存屏障(memory barrier)[2]、volatile 或锁来保证。

1
2
3
4
5
6
7
8
9
10
11
12
volatile bool flag = false;

void ThreadA()
{
flag = true; // 修改对其他线程立即可见
}

void ThreadB()
{
while (!flag) { } // 否则可能无限循环
}

有序性 (Ordering)

程序执行顺序符合预期。

编译器,JIT和CPU,可能会重排指令,导致代码执行顺序和书写顺序不一致。

内存屏障、锁等可以限制重排。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Singleton {
private static Singleton instance;
private static readonly object lockObj = new object();

public static Singleton Instance {
get {
if (instance == null) {
lock (lockObj) {
if (instance == null) {
instance = new Singleton();
// 可能被重排为:
// 1. 分配内存
// 2. 把 instance 指向内存
// 3. 调用构造函数
// 线程 B 可能看到一个“未初始化完成”的对象
}
}
}
return instance;
}
}
}

这个时候可以给 instance 添加 volatile,或者是用 Lazy<T>(线程安全的初始化对象)

总结

在多线程编程里,原子性(Atomicity)、有序性(Ordering)、可见性(Visibility) 经常是相辅相成的——它们不是独立孤立的,而是互相依赖来保证线程安全。

脚注

[1] 现代的 cpu 大多有多级缓存,比如 L1,L2和L3,RAM,这么做是为了减少访存的时间。

缓存层级 特点
L1 Cache CPU 核心私有,最小、最快
L2 Cache CPU 核心私有,稍大,速度略慢
L3 Cache 共享缓存,多核心共享,速度比 L1/L2 慢,但比内存快
主存 (RAM) 所有核心共享,访问最慢

一般在cpu核心读操作时先从最近的缓存找,如果能命中缓存就直接读,不会再访问其他缓存。写操作时先写到缓存再延迟写回主存(根据cpu决定,通常也会写入 L2/L3,写主存很慢,所以这个操作是延迟的)。

[2] 内存屏障是编译器和CPU提供的用来限制指令的重排序和控制缓存可见性的指令/机制。

编译器为了性能可能会做指令重排,寄存器缓存。

为了避免这种优化导致问题,编译器会提供屏障语义:

比如编译器会在volatile变量前后插入内存屏障,保证可见和有序。

比如 lock,编译器会在周围插入 full fence(禁止编译器和cpu的重排序,同时保证读写可见性)。

CPU同样可能会为了性能去乱序执行,写缓冲区延迟写回等。

为了避免这种优化导致问题,cpu有专门的指令:

x86 有 mfencelfencesfence

ARM 有 dmbdsb

这些指令就是内存屏障。