1. 介绍
在多线程应用里,出现 race condition 不是“如果”,而是“什么时候”的问题。即使你觉得你的代码很可靠,而且只有“两三个小线程”,看起来很明显和简单,竞态也可能潜伏在最不起眼的逻辑里。
竞态条件到底是什么,为什么它这么可怕?想像两个人同时编辑同一张纸——一个在写,一个在擦。有时候一切正常,有时候变得一塌糊涂。在编程里后果更有趣:错误不会总是出现,只在特定、几乎随机的条件下才会呈现。
Race condition(竞态条件)——当程序的执行结果依赖于哪个线程先访问资源或先完成某个动作时出现的情况。这个问题只在并发(多线程)访问时产生,当两个或更多线程访问共享数据或资源时就会出现。
在竞态发生时发生了什么?
这是一个简单的示意。想像我们有两个线程和一个共享资源(比如变量 X):
+---------+ +---------+
| 线程1 | | 线程2 |
+----+----+ +----+----+
| |
| 读取 X |
| <-------------------|
| |
| 增加 X |
|-------------------> |
| |
| 写入 X |
| <-------------------|
如果两个线程同时读取变量 X,都把它增加然后写回去,就会出现某个线程覆盖另一个线程的修改,导致总的增加次数和预期不一致。
2. 经典的 Race Condition 示例
我们来看个例子。假设我们想统计按钮的点击次数,或者统计不同线程处理的任务数量。
取一个简单变量和几个对它做自增的线程:
using System;
using System.Threading;
class Program
{
static int counter = 0; // 共享资源
static void Main()
{
Thread t1 = new Thread(IncrementCounter);
Thread t2 = new Thread(IncrementCounter);
t1.Start();
t2.Start();
t1.Join();
t2.Join();
Console.WriteLine("期望的值:200000");
Console.WriteLine("实际值:" + counter);
}
static void IncrementCounter()
{
for (int i = 0; i < 100_000; i++)
{
counter++; // 就在这里可能出现问题!
}
}
}
我们期待什么?
因为每个线程把 counter 自增 100000 次,我们期望最终值是 200000。
实际会得到什么?
有时候是的,200000。但很多时候结果会更小——有时明显更小。多次运行,结果会波动!
为什么会这样?
操作 counter++ 不是原子性的。它实际上大致这样执行(简化):
- 读取当前的 counter(比如 0)
- 加上 1(得到 1)
- 写回去(counter = 1)
如果两个线程同时读到旧值,它们都可能写回新的相同值,实际上只增加了一次。
用两个线程的可视化说明:
假设 counter = 0。
- 线程1:读到 0
- 线程2:读到 0
- 线程1:算出 0 + 1 = 1
- 线程2:算出 0 + 1 = 1
- 线程1:写入 1
- 线程2:写入 1(覆盖了线程1的增加)
恭喜,你刚刚丢失了一次增加!在成千上万次操作下,结果会非常不稳定。
3. 还有别的例子:不只是自增!
厨房的忙乱
图更有趣一点,想像一家小咖啡馆。两个厨师在同一个煎锅上做煎蛋,但没有协调动作:
- 第一个放了一个煎蛋,第二个立刻把他的也放上去——它们互相干扰;
- 一个觉得“我已经放了两个煎蛋”,另一个也这么想,但实际锅里可能是三个,他们以为是四个;
- 混乱开始了……
在编程里,race condition 同样会导致“混乱”:结果依赖于一连串快速且不受控制的操作顺序。
当线程互相干扰:对数据的并发访问
假设你实现了一个银行应用,客户同时用两个线程对同一个账户进行存取款(比如一个是在线转账,另一个是柜台):
account.Balance += 500; // 线程1:存款
account.Balance -= 300; // 线程2:取款
如果这些操作没有保护,最终余额可能不正确:一部分操作会被“丢失”,如果线程同时工作就会这样。
4. 有用的细节
为什么 race condition 是个问题?
很难捕捉和复现。 错误可能只在负载很高的机器或罕见条件下出现。
难以调试。 在调试时线程的调度可能改变,错误就消失了。
数据完整性受损。 会得到不正确、损坏的数据,有时完全不被注意到。
安全性。 在关键应用中竞态可能导致数据泄露、数据破坏甚至安全漏洞。
竞态的“时序图”
+-----------------------+ +-----------------------+
| 线程1 | | 线程2 |
+-----------------------+ +-----------------------+
| 1. 读取 counter | | |
| 2. 增加 counter | | |
| (但不写入) | | |
| | | 1. 读取 counter |
| | | 2. 增加 counter |
| | | 3. 写入 counter |
| | | (counter = 1) |
| 3. 写入 counter | | |
| (counter = 1) | | |
+-----------------------+ +-----------------------+
两个线程都做了自增,但最终只写入了一个自增!
竞态常出现在哪些地方
- 任何全局或静态变量,被多个线程访问的地方。
- 从不同线程填充的列表、队列、集合。
- 事件和委托,如果订阅/取消订阅是同时发生的(比如 UI + 后台任务)。
- 缓存、字典、连接管理。
- 任何对文件、日志、数据库的交互,如果没有事务或锁定。
如何避免 race condition:小入门
- 同步!(更详细的内容在后面的讲座)
- 使用语言和库提供的专门结构: lock, Monitor, mutex, semaphore 等。
- 对于简单操作使用原子方法(Interlocked.Increment 等)。
- 使用线程安全的集合(ConcurrentBag, ConcurrentDictionary)。
- 总是想:“如果我的两个函数同时被调用会怎样?”
5. 有用的建议
查找和诊断竞态的建议
- 不要相信最简单的操作(自增 ++、赋值),当你使用多个线程时。
- 尽量避免共享变量访问。
- 如果你看到那些“漂移”的 bug,难以复现的错误——想想是不是竞态!
- 使用线程分析工具(dotTrace, Concurrency Visualizer, Thread Sanitizer)。
- 做压力测试——线程和操作越多,越容易发现问题。
什么可以在没有同步的情况下做,什么不行
| 操作 | 在多线程环境中安全吗? | 说明 |
|---|---|---|
| 赋值 int | 🟩 有时* | 只有一个线程写入,其他线程只读取时安全,否则会有竞态 |
| 自增(++/--) | 🟥 不安全 | 不是原子操作!会有 Race Condition |
| 读取 string | 🟩 有时* | 如果字符串在创建后不再改变 |
| 对象赋值 | 🟩 有时* | 前提是没有同时写入 |
| 向 List<T> 添加元素 | 🟥 不安全 | List<T> 不是线程安全的 |
|
🟩 安全 | 专门的原子方法 |
— “有时” 的意思是,如果只有一个线程写入,其他线程只读取,那是安全的;如果多个线程可能同时写入——总会有竞态。
6. 典型错误与陷阱
在上面的演示代码里我们看到 counter++ 是个问题。另一个陷阱是基于值的检查然后做操作。
示例:关于“首次运行”的有趣错误
if (!alreadyStarted)
{
alreadyStarted = true;
// 进行初始化...
}
如果多个线程同时执行这个条件判断,每个线程都可能看到 alreadyStarted == false 并进入内部!结果——初始化会被执行多次,可能导致故障。
GO TO FULL VERSION