文章目录
-
- 竟态条件 (Race Condition)
-
- [1. 原理:原子性与可见性](#1. 原理:原子性与可见性)
- [2. 概念](#2. 概念)
- [3. 本质:不确定性](#3. 本质:不确定性)
- 4. C#示例
-
- 错误示例(存在竞态条件)
- [正确做法 A:使用 `lock`](#正确做法 A:使用
lock) - [正确做法 B:使用原子操作 (Interlocked)](#正确做法 B:使用原子操作 (Interlocked))
- [5. 常见的竞态条件类型](#5. 常见的竞态条件类型)
-
- [5.1. 先检查后执行 (Check-Then-Act):](#5.1. 先检查后执行 (Check-Then-Act):)
- [5.2. 读取-修改-写入 (Read-Modify-Write):](#5.2. 读取-修改-写入 (Read-Modify-Write):)
- [5.3. 单例模式初始化 (Singleton Initialization):](#5.3. 单例模式初始化 (Singleton Initialization):)
- [6. 如何检测与规避](#6. 如何检测与规避)
- [7. 处理竞态条件的流程 (Workflow)](#7. 处理竞态条件的流程 (Workflow))
Race Condition(竞态条件) ,通俗点说就是**"赛跑现象"**。
当两个或多个线程(或异步任务)同时访问同一个资源,并且最终的结果取决于这些任务执行的先后顺序时,就会发生竞态条件。
一个最直观的例子:缩略图覆盖
假设用户快速点击了两个按钮:先点"查看北京照片",紧接着点"查看上海照片"。
- **任务 A(北京)**启动:去服务器下载北京的缩略图。
- **任务 B(上海)**启动:去服务器下载上海的缩略图。
- 网络波动:北京的图很大,下载慢;上海的图很小,下载极快。
- 结果发生 :
- 上海的任务先跑完,UI 显示了上海的图。
- 过了半秒,北京的任务才跑完,它冲过来把上海的图给覆盖了。
- 结局:用户明明点的是上海,最后屏幕上显示的却是北京。这就是典型的 Race Condition。
竟态条件 (Race Condition)
竞态条件 (Race Condition) 是多线程或并发编程中的一种异常现象。它的核心在于:系统的输出依赖于不受控制的事件执行顺序(序列)或时间点。
当多个线程同时访问同一个共享资源,并且至少有一个线程在执行写入操作时,如果最终的结果取决于线程执行的具体顺序,那么就发生了竞态条件。
1. 原理:原子性与可见性
从底层逻辑看,竞态条件的产生通常是因为操作缺乏原子性 (Atomicity)。
在 C# 或大多数高级语言中,一个简单的自增语句 count++ 在 CPU 层面并不是一个动作,而是分为三个阶段:
- 读取 (Read):将变量值从内存加载到寄存器。
- 修改 (Modify):在寄存器中进行加法运算。
- 写入 (Write):将新值写回内存。
如果两个线程同时执行 count++,可能会出现以下时序:

2. 概念
- 临界区 (Critical Section):指访问共享资源(如全局变量、文件、数据库连接)的代码块。这些代码在同一时刻只允许一个线程执行。
- 同步机制 (Synchronization Mechanisms) :为了防止竞态条件而采用的手段,如
lock关键字、信号量 (Semaphore)、互斥锁 (Mutex) 等。 - 线程安全 (Thread-Safety):如果一段代码在多线程环境下执行时,能够始终产生正确的结果且不发生非预期的侧面影响,则称该代码是线程安全的。
3. 本质:不确定性
竞态条件最显著的行为就是不可预测性。 由于线程调度是由操作系统内核(OS Kernel)控制的,开发者无法预知 CPU 在微秒级别上的切换时机。这导致程序表现出:
- 偶发性故障:程序在开发环境运行 1000 次可能都正常,但在用户高负载环境下突然崩溃。
- 时序依赖:结果取决于哪个线程"跑得快"。
4. C#示例
以下是一个经典的竞态条件案例。我们尝试启动 100 个线程,每个线程对同一个变量加 1000 次。理论上结果应为 100,000。
错误示例(存在竞态条件)
csharp
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
public class RaceConditionDemo
{
private int _counter = 0;
public async Task RunTest()
{
List<Task> tasks = new List<Task>();
for (int i = 0; i < 100; i++)
{
tasks.Add(Task.Run(() =>
{
for (int j = 0; j < 1000; j++)
{
// 这里的 ++ 操作不是原子的
_counter++;
}
}));
}
await Task.WhenAll(tasks);
Console.WriteLine($"最终结果: {_counter}"); // 结果通常小于 100,000
}
}
正确做法 A:使用 lock
通过强制串行化访问临界区来解决。
csharp
private readonly object _locker = new object();
// ... 循环内部
lock (_locker)
{
_counter++;
}
正确做法 B:使用原子操作 (Interlocked)
利用 CPU 指令集的原子加法,性能优于 lock。
csharp
// System.Threading 命名空间
Interlocked.Increment(ref _counter);
5. 常见的竞态条件类型
5.1. 先检查后执行 (Check-Then-Act):
这是最常见的陷阱。程序先观察一个状态,然后根据这个状态采取行动,但在观察和行动之间,状态已经失效。
- 逻辑:
if (x == 5) { do something with x; } - 风险:在判断完
x == 5之后、执行动作之前,另一个线程可能已经把x修改了。 - 逻辑拆解 :
- 线程 A 检查文件是否存在:
if (!File.Exists(path))。 - 线程 B 抢占 CPU,创建了该文件。
- 线程 A 恢复执行,尝试创建文件,导致报错。
- 线程 A 检查文件是否存在:
5.2. 读取-修改-写入 (Read-Modify-Write):
如上文所述的 count++。这是数据一致性破坏的根源。
- 逻辑拆解 :
- 线程 A 读取变量
balance = 100。 - 线程 B 读取变量
balance = 100。 - 线程 A 写入
100 + 10 = 110。 - 线程 B 写入
100 - 50 = 50。 - 最终结果是
50,线程 A 的增加操作被完全覆盖(丢失更新)。
- 线程 A 读取变量
5.3. 单例模式初始化 (Singleton Initialization):
如果没有正确加锁,两个线程可能同时判断 instance == null 为真,从而创建两个单例对象。
6. 如何检测与规避
- 避免共享状态:尽可能使用局部变量或不可变对象 (Immutable Objects)。如果数据不共享,就不存在竞争。
- 锁粒度控制:锁的范围越小越好,但要覆盖完整的逻辑原子块。
- 使用并发集合 :在 .NET 中优先使用
System.Collections.Concurrent命名空间下的集合,如ConcurrentDictionary,它们内部处理了竞态逻辑。 - 静态分析工具:利用 IDE 的并发可视化工具或 Thread Sanitizer 检测潜在的冲突。
在并发编程和分布式系统中,竞态条件 (Race Condition) 的行为表现通常被称为"海森堡 Bug (Heisenbug)",即当你试图去观察或调试它时,它往往会消失或发生改变。
从软件工程的视角来看,竞态条件的行为特征可以概括为以下三个维度:
7. 处理竞态条件的流程 (Workflow)
