You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

为何不可变性被视为线程安全优势?C# Record实例存疑

不可变性如何助力C#的线程安全?

我在学习C#的Records时了解到它们是不可变的,且线程安全被列为不可变性的一项优势。但我编写了一段声称使用Records能得到可预测结果的多线程代码,测试后结果却不可预测:

public record SharedData(int Value);

public static void Main(string[] args)
{
    for (int i = 0; i < 100; i++)
        ImmutableRecords();
        //MutableSharedVariable();
}

static void ImmutableRecords()
{
    SharedData sharedData = new SharedData(0);

    Task t1 = Task.Run(() =>
    {
        for (int i = 0; i < 1000; i++)
        {
            sharedData = sharedData with { Value = sharedData.Value + 1 };
        }
    });

    Task t2 = Task.Run(() =>
    {
        for (int i = 0; i < 1000; i++)
        {
            sharedData = sharedData with { Value = sharedData.Value - 1 };
        }
    });

    Task.WaitAll(t1, t2);

    Console.WriteLine(sharedData.Value);
}

我发现虽然sharedData是不可变实例,但引用变量可被替换,这和可变对象的多线程行为类似,导致结果不可预测。即便让引用变量也不可变,若仅做读取操作,普通可变变量也能实现。因此我疑惑:不可变性究竟如何助力线程安全?


核心解释:不可变性的线程安全优势在「实例状态不可修改」,而非「引用不可变」

  1. 不可变实例的本质
    Records的不可变性是指实例创建后内部状态完全不可修改。比如你用with表达式"修改"Value时,实际上是创建了一个全新的SharedData实例,原实例的Value始终保持不变。这意味着:只要一个线程拿到了某个不可变实例的引用,它就可以放心地使用这个实例的所有属性,永远不用担心其他线程会偷偷修改这个实例的状态——这才是不可变性带来线程安全的核心价值。

  2. 你的代码问题出在「引用变量的非原子更新」
    你代码中的问题不是不可变实例本身的线程安全问题,而是共享的sharedData引用变量的更新过程不是原子操作。两个线程都在执行「读取当前引用→创建新实例→赋值给引用」的三步操作,这三步之间没有同步:

  • 线程A读取到sharedData.Value=0,准备创建Value=1的新实例;
  • 同时线程B也读取到Value=0,创建Value=-1的新实例;
  • 最后哪个线程后执行赋值,就会覆盖另一个线程的结果,导致一次操作丢失。
    这种问题和可变对象的线程冲突本质完全一致:都是共享变量的非原子更新问题,和实例是否不可变无关。
  1. 不可变性如何正确发挥线程安全优势
  • 只读场景下无锁安全:当多个线程只需要读取共享状态时,使用不可变实例可以完全不需要加锁。因为实例状态永远不会变,所有线程拿到的都是稳定的状态,不会出现读取到半修改状态的情况。而如果是可变对象,哪怕所有线程都只读,你也要确保没有其他线程在修改它,否则可能读取到不一致的中间状态。
  • 更新场景配合原子操作:当需要更新共享状态时,不可变性可以配合原子引用更新(比如Interlocked.CompareExchange)来实现无锁的线程安全更新。修改你的代码如下,结果就会稳定为0:
    static void ImmutableRecordsWithAtomicUpdate()
    {
        SharedData sharedData = new SharedData(0);
    
        Task t1 = Task.Run(() =>
        {
            for (int i = 0; i < 1000; i++)
            {
                SharedData current, updated;
                do
                {
                    current = sharedData;
                    updated = current with { Value = current.Value + 1 };
                } while (Interlocked.CompareExchange(ref sharedData, updated, current) != current);
            }
        });
    
        Task t2 = Task.Run(() =>
        {
            for (int i = 0; i < 1000; i++)
            {
                SharedData current, updated;
                do
                {
                    current = sharedData;
                    updated = current with { Value = current.Value - 1 };
                } while (Interlocked.CompareExchange(ref sharedData, updated, current) != current);
            }
        });
    
        Task.WaitAll(t1, t2);
        Console.WriteLine(sharedData.Value); // 结果稳定为0
    }
    
    这里的关键是:旧的不可变实例不会被修改,即使更新失败(其他线程先修改了引用),我们也可以基于最新的实例重新创建新实例,全程不需要锁。而如果是可变对象,即使你用原子更新引用,也无法避免其他线程持有旧引用时修改对象内部状态的风险。
  1. 不可变引用 vs 不可变实例
    如果把sharedData声明为readonly,确实只能读取引用,但这只是限制了引用不能被修改。此时不可变实例的价值在于:你不用担心其他线程会修改实例的状态;而如果是可变对象,哪怕引用是readonly,其他线程仍然可以修改对象内部的字段,这时候必须加锁才能保证安全。

内容的提问来源于stack exchange,提问作者teenup

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.22 05:43:27