CodeGym /课程 /C# SELF /共享资源的问题

共享资源的问题

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

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”的指令。实际发生的事是:

  1. 线程从内存中读取值(counter)。
  2. 在处理器寄存器里把这个值加 1。
  3. 把新值写回到内存(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 的岗位一定会问:“什么是竞态条件?怎么避免?” 如果你能举出上面这些例子并解释其机制,面试官会很满意!

2
任务
C# SELF, 第 56 级, 课程 0
已锁定
通过整数计数器演示竞态条件的检测
通过整数计数器演示竞态条件的检测
评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION