为何不可变性被视为线程安全优势?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是不可变实例,但引用变量可被替换,这和可变对象的多线程行为类似,导致结果不可预测。即便让引用变量也不可变,若仅做读取操作,普通可变变量也能实现。因此我疑惑:不可变性究竟如何助力线程安全?
核心解释:不可变性的线程安全优势在「实例状态不可修改」,而非「引用不可变」
不可变实例的本质
Records的不可变性是指实例创建后内部状态完全不可修改。比如你用with表达式"修改"Value时,实际上是创建了一个全新的SharedData实例,原实例的Value始终保持不变。这意味着:只要一个线程拿到了某个不可变实例的引用,它就可以放心地使用这个实例的所有属性,永远不用担心其他线程会偷偷修改这个实例的状态——这才是不可变性带来线程安全的核心价值。你的代码问题出在「引用变量的非原子更新」
你代码中的问题不是不可变实例本身的线程安全问题,而是共享的sharedData引用变量的更新过程不是原子操作。两个线程都在执行「读取当前引用→创建新实例→赋值给引用」的三步操作,这三步之间没有同步:
- 线程A读取到
sharedData.Value=0,准备创建Value=1的新实例; - 同时线程B也读取到
Value=0,创建Value=-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 }
- 不可变引用 vs 不可变实例
如果把sharedData声明为readonly,确实只能读取引用,但这只是限制了引用不能被修改。此时不可变实例的价值在于:你不用担心其他线程会修改实例的状态;而如果是可变对象,哪怕引用是readonly,其他线程仍然可以修改对象内部的字段,这时候必须加锁才能保证安全。
内容的提问来源于stack exchange,提问作者teenup
相关产品推荐
相关产品推荐

