1. 更复杂的 Race Condition 示例
Race Condition — 是最阴险的一类 bug 之一。为啥?因为它们通常很少发生,依赖 CPU 速度、操作系统调度延迟、线程切换的随机性,而且往往在你早已下班回家之后才复现。这类错误静态分析器抓不住,单元测试也通常捕捉不到(除非你有超能力),但一旦出现……就是生产环境的大麻烦。
为了避免这种讨厌的惊喜,你得弄清楚 Race Conditions 怎么产生以及怎么应对。
示例 1. 没有银行伦理的银行
假设我们有一个非常简单的银行账户类:
public class Account
{
public int Balance = 0;
public void Deposit(int amount)
{
Balance += amount;
}
public void Withdraw(int amount)
{
Balance -= amount;
}
}
想象一下,两个线程同时试图分别往账户存入 100 和 200 欧元,然后每个线程依次取出 50 欧元。如果这是单线程世界,余额总是:(0 + 100 + 200 - 50 - 50) = 200。
多线程现实中会怎样?做个实验:
var acc = new Account();
var t1 = new Thread(() => {
acc.Deposit(100);
acc.Withdraw(50);
});
var t2 = new Thread(() => {
acc.Deposit(200);
acc.Withdraw(50);
});
t1.Start(); t2.Start();
t1.Join(); t2.Join();
Console.WriteLine(acc.Balance); // 每次运行可能输出不同的值!
说明:
如果两个线程都把 Balance 读成了 0,然后一个写回 100,另一个写回 200,但中间发生了线程切换——余额可能会“丢失”或“多出来”钱。一个没有锁的银行是骗子的梦想!
示例 2. 作为标志的变量
很常见的错误是用布尔标志作为状态指示器:
bool isReady = false;
void Worker()
{
while (!isReady)
{
// 等待...
}
// 做点事情
}
另一个线程可能会写 isReady = true。乍一看似乎安全:能出啥问题?事实上,即使是读写布尔变量也可能不安全!原因是:编译器优化、CPU 缓存、以及多处理器系统里的指令重排序。
会发生什么?
一个线程可能永远看不到该变量的变化,继续在无限循环里卡着,即便另一个线程早已把 isReady 设置成了 true。要在线程间传递“标志”,总是用专门的原语(volatile、Interlocked、事件等 — 详见 官方文档)。
示例 3. 空检查和对象创建
想象一个单例类(Singleton):
public class Singleton
{
private static Singleton _instance;
public static Singleton Instance
{
get
{
if (_instance == null)
{
_instance = new Singleton();
}
return _instance;
}
}
}
代码看起来简单?但如果很多线程同时调用 Instance,可能会创建多个实例!这是没有同步的 double-check locking 的经典例子:字段 _instance 在没有 lock 保护时会发生竞态。
2. Race Condition 如何出现在代码里
“读-改-写”操作 — 恶的解剖
再拿我们的自增操作: counter++
从 CPU 的角度看,这包括:
- 从内存读: register = counter;
- 增加: register = register + 1;
- 写回: counter = register;
如果两个线程几乎同时执行这段代码,会怎样?
- 两个线程都读到 counter = 0。
- 两者都把寄存器加到 1。
- 两者都写回 1。
结果:两次自增之后值只增加了 1!其中一次增长被“吞掉”了 — 没人注意到。
可视化两个线程的“碰撞”
线程 A | 线程 B | counter-------------------------------------------
读取 (0) | | 0
| 读取 (0) | 0
自增 (1) | | 0
| 自增 (1) | 0
写入 (1) | | 1
| 写入 (1) | 1 <-- 哎呀!本来希望是 2,却是 1
为什么竞态条件这么难捕捉?
- 竞态会被“掩盖”。在代码里加几句 Console.WriteLine — 错误可能就消失了。因为线程走了不同路径。
- 错误依赖核数、负载、操作系统和 .NET 版本。昨天还好,今天就崩了。
- 结果不稳定:错误不是每次都出现,只在“特殊情况下”。
- 调试这种问题是一种折磨。
3. Race Condition 在集合和 .NET 类中
竞态不仅发生在变量上。集合、队列,甚至一些标准的 .NET 类并不总是线程安全的。
示例:List<T> 不是线程安全的
如果两个线程同时对普通的 List<T> 调用 .Add(),后果可能很严重:
var list = new List<int>();
var tasks = new List<Task>();
for (int t = 0; t < 10; t++)
{
int threadNum = t;
tasks.Add(Task.Run(() => {
for (int i = 0; i < 1000; i++)
list.Add(threadNum * 1000 + i);
}));
}
Task.WaitAll(tasks.ToArray());
Console.WriteLine(list.Count); // 很可能小于 10000
这里的竞态发生在 Add() 内部,因为集合可能会扩容内部数组并复制元素,在这个过程中另一个线程也在添加……结果就是丢数据、抛异常或集合损坏。
示例:多个线程使用同一个索引器
var array = new int[10];
void Worker(int index, int value)
{
array[index] = value;
}
Parallel.Invoke(
() => Worker(5, 1),
() => Worker(5, 2)
);
结果是 array[5] 可能是 1,也可能是 2 — 看哪个线程最后写进去。有时候这就是期望的行为,但更多时候这是细微且难以捕获的 bug 源。
4. 有用的细节
竞态条件和非原子操作
如果某个操作看起来很“短”,但不保证原子性,就要特别小心。
- i++, i--
- a = b
- myObject.Property = value
- “检查标志 — 然后修改值”
- “获取对象 — 修改它的内部状态”
这些单独看都不是原子操作!别的线程可以在这些微步骤之间“插入”自己的操作。
竞态的迷思和陷阱
迷思 1:“如果变量是 int,就安全了,因为它是原始类型”。
现实:即便是对 int 的操作也不保证原子性,除非它被标记为 volatile 或通过专门 API 使用。
迷思 2:“如果一个线程写,另一个只读 — 没事”。
现实:不!在 .NET 里同时读写集合并不保证数据完整性,可能得到“坏”对象或者奇怪的异常。
迷思 3:“竞态只在高负载服务里出现”。
现实:即便是小工具、游戏、桌面程序也可能遇到竞态。只是它们出现得更少,或者不会马上被发现。
如何防护 Race Condition?
- 使用同步原语(lock、Mutex、Monitor 等)。
- 对基本的增减/修改数字操作,使用 Interlocked(文档)。
- 对集合,使用专门的线程安全集合(ConcurrentBag、ConcurrentDictionary 等 — 参见 文档)。
- 不要在没有保护的情况下使用标志变量和状态(用 volatile、事件或同步原语)。
什么时候会出现 Race Condition
| 场景 | 可能有 Race Condition? | 如何保护 |
|---|---|---|
| 多个线程只读数据 | 否(如果没有写操作) | - |
| 一个线程写,另一个线程读 | 是 | |
| 多个线程写 | 是 | |
| 多个线程修改集合 | 是 | 线程安全的集合 |
| 多个线程使用标志 | 是 | volatile, lock, 事件 |
原子性不是万能药
有时候即便是原子操作也救不了逻辑上的“竞态”。
示例:
if (!cache.ContainsKey(key))
{
cache[key] = GetData(key);
}
如果两个线程同时进入这个 if,都会看到键不存在,都会创建新值 —— 后面的会覆盖前面的!这里原子性的 int 操作或 Interlocked 并不能解决问题 —— 需要完整的锁保护,或用专门的函数(在 ConcurrentDictionary 里用 GetOrAdd)。
4. 学生在处理 Race Condition 时常犯的错误
错误 №1:认为 lock 只在“大事情”时才需要。
实际上即便是简单变量在没有保护时也会坏掉。
错误 №2:锁错对象。
比如锁字符串或一个从类外部可访问的对象。结果同步不起作用,或行为不是预期的。
错误 №3:相信“结果几乎总是正确”。
如果 bug 很少出现 —— 不代表可以不保护资源。
错误 №4:在异步方法里不做同步。
抱着侥幸心理,结果在最意想不到的地方出现混乱错误。
错误 №5:写了双重检查的 Singleton,却没用 lock。
内存里出现多个对象副本,调试变成噩梦。
GO TO FULL VERSION