1. 介绍
在多线程应用里,共享资源就是两个或更多线程可能同时访问的任何东西。它可以是:
- 变量(例如,全局计数器或列表)。
- 对象(例如,用户集合)。
- 文件或网络 socket。
- 任何被不同线程修改的数据结构。
在我们的控制台程序里,通常会遇到在多个线程之间“共享”的变量和对象。
类比
想象两个人同时往同一本笔记本写东西,但没有约定好顺序。最好情况是字写得歪歪扭扭,最糟就是把别人的内容改掉。在编程里情况完全一样,只不过这些“人”是线程。
关于常见会导致竞态的资源,简要说明
下面表格列出了最常见的、在多线程共享访问时容易出问题的资源:
| 资源 | 问题类型 | 示例 |
|---|---|---|
| int 类型的变量 | 不正确的增加/减少 | 计数器、索引 |
| 共享集合 | 元素丢失/损坏、抛出异常 | 共享的订单列表 |
| 对象 | 状态更改不一致 | 标志、属性 |
| 文件 | 数据损坏、读写不正确 | 日志文件、配置 |
2. 竞态条件:它怎么表现?
示例:访问计数器
假设我们想统计用户点击按钮的次数(或在这个例子中,不同线程对变量进行多少次自增)。简单版本代码:
int counter = 0;
void Increment() {
counter++;
}
现在我们创建两个线程,每个线程调用 Increment() 100_000 次:
using System;
using System.Threading;
class Program
{
static int counter = 0;
static void Increment()
{
for (int i = 0; i < 100_000; i++)
{
counter++;
}
}
static void Main()
{
Thread t1 = new Thread(Increment);
Thread t2 = new Thread(Increment);
t1.Start();
t2.Start();
t1.Join();
t2.Join();
Console.WriteLine($"预期: 200000, 实际: {counter}");
}
}
逻辑上 counter 应该被增加多少次?200000!但如果你多次运行这个代码,几乎肯定会看到不同的数字:185000、192500、198765……为什么会这样?
3. 为什么 counter++ 不是原子操作?
counter++ 实际上是怎么工作的
在 C# 和其他高级语言里,程序会被翻译成一系列机器指令。可惜的是,操作符 counter++ 并不会变成一条神奇的“对变量加 1”的指令。实际发生的事是:
- 线程从内存中读取值(counter)。
- 在处理器寄存器里把这个值加 1。
- 把新值写回到内存(counter)。
如果两个线程几乎同时做这三步,它们可能都会读到相同的旧值,各自加一,然后都把结果写回去,这样就丢失了一个自增。
竞态的场景
假设 counter 是 1000。两个线程都读到了这个值(步骤 1),各自把它加到 1001(步骤 2),然后都把 1001 写回内存(步骤 3)。结果:一个自增被丢掉了!
竞态可视化
| 时间点 | 线程 1 | 线程 2 | counter 的值 |
|---|---|---|---|
| 1 | 读取 1000 | 1000 | |
| 2 | 读取 1000 | 1000 | |
| 3 | 在寄存器中递增到 1001 | 在寄存器中递增到 1001 | 1000(还没写回) |
| 4 | 写回 1001 | 1001 | |
| 5 | 写回 1001 | 1001 |
结果就是,两次自增只把值增加了 1!
4. 还有一些例子:那些“看不见的 bug”
如果竞态不是发生在数字上呢?
现在假设多个线程往同一个列表添加元素:
using System;
using System.Collections.Generic;
using System.Threading;
class Program
{
static List<int> numbers = new List<int>();
static void AddNumbers()
{
for (int i = 0; i < 10000; i++)
{
numbers.Add(i);
}
}
static void Main()
{
Thread t1 = new Thread(AddNumbers);
Thread t2 = new Thread(AddNumbers);
t1.Start();
t2.Start();
t1.Join();
t2.Join();
Console.WriteLine($"预期: 20000, 实际: {numbers.Count}");
}
}
这个程序每次运行也可能得到不同的结果:有时程序会崩溃(抛出异常),有时你会看到比预期更少的元素。
为什么会这样?因为默认情况下 List<T> 不是线程安全的。也就是说,当两个线程同时调用 Add 时,列表的内部结构可能会被破坏。
5. 原子操作
什么是原子操作?
原子操作指的是整个操作要么完整执行,要么不执行,中间不能被其他线程打断。可以把它想成一个“事务”:要么全部成功,要么看起来什么也没发生。
- 大多数平台上,像 int 这种类型的赋值 myVar = 42; 通常是原子性的(除非是非常大的对象)。
- 但 counter++ 不是原子性的——它由三步组成。
专门的原子操作
在 .NET 里有专门用来做原子操作的类,例如 Interlocked。我们会在后面的讲座里详细看这个东西。
用 Interlocked.Increment 做原子自增的例子:
using System.Threading;
int counter = 0;
Interlocked.Increment(ref counter); // 原子操作!
6. 为什么抓住 race condition 很难?
竞态条件危险在于:
- 可能只在高负载下才会出现。
- 不一定每次都会触发,可能只有 5%,甚至 0.01% 的概率。
- 随机崩溃,发生在没人预料到的地方。
怎么识别问题?
如果每次运行程序你都得到不同(且错误)的结果,就应该怀疑是竞态条件。
程序员的笑话
“如果一个 bug 很少出现,并且通过加上 Thread.Sleep(50) 就能暂时解决——那说明你的问题比你想的要严重得多。”
7. 有用的细节
同步
要保护关键区(即访问共享资源的代码片段),需要对它们做同步。但这是后面几节课的内容。现在最重要的是学会发现问题并能解释它是怎么发生的。
初学者常犯的错误
很多初学者会想:“我就一个 counter++,能出啥问题?” 可惜事实是,一旦有多个线程参与,一切都有可能出错!哪怕看起来很简单的操作:读写变量、向列表添加元素、修改对象状态等等,都可能出问题。
竞态在真实开发中的地位
在现代多线程应用(比如服务器 API、处理 web 请求、游戏和移动应用)中,几乎总会有共享资源存在。如果不做同步,竞态会导致订单处理错误、崩溃、内存泄露,以及非常难调试的问题。
在面试中,面向 middle/senior 的岗位一定会问:“什么是竞态条件?怎么避免?” 如果你能举出上面这些例子并解释其机制,面试官会很满意!
GO TO FULL VERSION