CodeGym /课程 /C# SELF /深入解析 Race Conditions(竞态条件)

深入解析 Race Conditions(竞态条件)

C# SELF
第 57 级, 课程 0
可用

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 的角度看,这包括:

  1. 从内存读: register = counter;
  2. 增加: register = register + 1;
  3. 写回: 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? 如何保护
多个线程只读数据 否(如果没有写操作) -
一个线程写,另一个线程读 是
lock/volatile
多个线程写 是
lock/Interlocked
多个线程修改集合 是 线程安全的集合
多个线程使用标志 是 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。
内存里出现多个对象副本,调试变成噩梦。

2
任务
C# SELF, 第 57 级, 课程 0
已锁定
通过 `Monitor` 同步访问资源
通过 `Monitor` 同步访问资源
评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION