☰
C# 可变引用类型和不可变引用类型:多线程并发下的数据一致性方案
2026/9/30 4:02:09 网站建设 项目流程

C#里干了这么多年,见过不少因为"对象莫名其妙的变了"引发的血案。更早散落在内存里的变量在另一个线程里被悄悄改掉,排查到最后发现是引用类型天然的可变性在作祟。这也是标题里那两个词——可变引用类型和不可变引用类型——背后真正要解决的东西。

简单说,可变引用类型指对象创建后内部状态可以继续被修改(List 、StringBuilder、自定义class都算);不可变引用类型则是一旦实例化,内部状态在整个生命周期内保持不变(string就是最典型的例子)。做上位机、做通信协议解析、做多线程缓存时,这两个概念决定你用哪种姿势去设计类、控并发、保一致。这篇文章不拉理论框架,直接用实操视角拆一遍这俩东西的底层逻辑、设计约束和踩坑场景,适合刚入门C#的应届生,也适合作了三五年还在被并发修改bug追着跑的业务开发。

1. 先搞懂"可变"和"不可变"到底解决什么问题

1.1 从一次多线程bug讲起

前几年写一个工业数据采集模块,用BackgroundService轮询抓设备数据,抓到之后塞进一个全局static的DeviceState对象里,UI线程再定时去读这个对象刷新界面。单个设备测试一切正常,接上第三台设备后界面上的数据就开始跳变,有时候一台设备的值直接覆盖了另一台的,看日志却又找不出业务逻辑错误。

问题就出在DeviceState是个标准的可变引用类型:Writer线程在改对象的某个属性时,Reader线程也在读同一个属性,你没有锁还指望它一致,这从一开始就是站不住的。当时的表现就是"数据偶尔串"——让排查难度飙升的那种偶发。

把DeviceState改成不可变引用类型之后,数据不一致的问题从根上消失了。原理不复杂:对象一旦构造完成,它的字段就不再允许改写。你要更新数据?那就构造一个新实例替换旧引用,而不是在旧实例上原地修改。这样读的线程拿到的永远是一个自洽的快照,不会存在"读了一半的时候被另一个线程改掉"的状态。

这段经历基本就是C#类型系统里可变与不可变这两条设计路线的一个缩影。可变类型的优势是灵活、写起来省事、性能开销低;不可变类型则牺牲了部分灵活性,换来的是跨线程安全、可缓存、可安全共享。搞清楚它们各自的适用场景,绝大多数并发和一致性bug都能在架构设计阶段就消除掉。

1.2 C#里这两个概念的定义边界

C#里谈可变性,首先要分清类型本身(Type)和变量(Variable)。引用类型的变量存的是对象的引用,class的引用指向堆上的对象;值类型的变量本身就是数据。常见的混淆点在于"readonly只能让引用不可改变,不能保证对象内部不可改变"这层边界。

对于引用类型来说:

  • class Person { public string Name { get; set; } }是可变引用类型,因为Person实例创建后,Name可以被重新赋值。
  • class Person { public string Name { get; private set; } }依然是可变引用类型,虽然外部不能直接set,但类内部方法仍然可以改。
  • sealed class Person { public string Name { get; } }需要看构造后是否还有办法改Name,如果没有,就是不可变引用类型。

对于值类型来说,结构体默认就带可变性,但通过readonly修饰字段或readonly struct可以把结构体声明为不可变。值类型和引用类型在可变性上的处理逻辑有本质差别,这一点在3.1里再展开。

还有一个容易被忽略的边界:可变性不等于安全性。List<T>是可变的,如果你在对它进行Foreach遍历的同时另一个线程在Add,会直接抛"集合已修改"异常;但你用一个不可变的ImmutableList<T>,遍历时内部结构是不允许篡改的,这个问题天然就不存在。由此可见不可变类型最大的意义在于模式上的安全,而非运行时魔法。

1.3 锁和不可变怎么选:一个判断框架

很多人第一反应是"多线程就加锁",却忽略了加锁本身是有成本的:锁竞争、死锁、中断线程调度的不可控性。不可变引用类型在并发场景下可以用"Copy-on-Write(写时复制)"策略替代加锁,核心逻辑是:

  • 读取:永远直接读当前引用,无锁。
  • 写入:基于当前状态深拷贝出一份新对象,在新对象上修改,修改完成后再把引用指向新对象。

这套路子的开销主要来自拷贝本身,但在"读多写少"场景下比加锁高效得多,因为读操作完全不存在竞争。判断框架很简单:

  • 状态变化频繁且并发的读写量都大,选可变类型加并发集合(如ConcurrentDictionary)或锁。
  • 状态变化不频繁,读操作是主场景,选不可变类型,配合volatile或Interlocked做引用替换。

真实项目里很少有一刀切的,往往是"外部可变、内部不可变"混搭:一个可变的状态容器,内部装的每一条数据都是不可变对象。这样既要了灵活,又保住了单条数据的一致性。

2. 不可变引用类型的实现细节

2.1 从字段到构造函数:一个标准写法的演进

一个真正不可变class在C#里的标准形状大约是这样:

public sealed class SensorReading { // 只读字段,且只能由构造函数赋值 private readonly double _value; private readonly DateTime _timestamp; private readonly string _unit; public double Value => _value; public DateTime Timestamp => _timestamp; public string Unit => _unit; public SensorReading(double value, DateTime timestamp, string unit) { _value = value; _timestamp = timestamp; _unit = unit ?? throw new ArgumentNullException(nameof(unit)); } }

关键点就三个:sealed(防止被继承后篡改语义)、readonly字段(编译器层面不允许改写)、构造函数中完成所有初始化。这套写法也被称为"手写不可变类",优点是零依赖、性能最好、问题最容易排查。

但这种写法有个体验问题:属性一多,构造函数参数就会变得特别长。比如你对接PLC采集数据,一个点位可能同时携带信号值、状态、质量戳、采集时间、设备编号,五六个参数往构造函数里塞,调用方直接看晕。这就引出了Builder模式或者带with语义的写法:

public sealed class SensorReading { public double Value { get; } public DateTime Timestamp { get; } public string Unit { get; } private SensorReading(Builder builder) { Value = builder.Value; Timestamp = builder.Timestamp; Unit = builder.Unit; } public sealed class Builder { public double Value { get; set; } public DateTime Timestamp { get; set; } public string Unit { get; set; } public SensorReading Build() => new SensorReading(this); } public SensorReading With(double? value = null, DateTime? timestamp = null, string? unit = null) { return new SensorReading(value ?? Value, timestamp ?? Timestamp, unit ?? Unit); } }

With方法是仿照函数式语言里的record语义做的,调用时只传想改的字段:

var updated = original.With(value: 12.5);

这个在C# 9.0支持record类型之后其实已经被内置了——with表达式就是干这个的。但对于还在.NET Core 3.1或者.NET Framework项目里待着的同学,手写With方法依然是常规操作。

2.2 readonly、init和record:现代C#的三种姿势

C# 9.0以后有了init访问器和record类型,不可变类型的写法又简化了一大截。

public sealed class SensorReading { public double Value { get; init; } public DateTime Timestamp { get; init; } public string Unit { get; init; } }

init访问器特殊的地方在于:它允许"对象初始化期间"赋值——包括对象初始化器、构造函数、with表达式——但初始化完成之后就不能再赋值。相比传统的"私有set+构造函数"组合,init让语法友好度提升了一个档次。

record则更进一步,它在编译期自动生成基于值的Equals、GetHashCode、ToString和with表达式支持:

public sealed record SensorReading(double Value, DateTime Timestamp, string Unit);

这一行代码做的事情远超表面:编译器生成位置记录(Positional Record)的属性,且默认只读(get; init;)。两个内容相同的record用==比较会返回true,这在调试和断言场景下非常有用。

不过record也有坑——它默认已经是引用类型,但编译器生成的Equals是按"属性值"来比较的。如果你不小心在record里塞了一个可变引用类型的属性(比如List ),Equals依然还会比较引用,导致"看似内容相同,结果不相等"。**用record做不可变类型的前提是:所有属性都必须是不可变的元素,嵌套也一样。**这也是2.3节要讲的深度不可变问题。

2.3 深度不可变:集合和嵌套对象怎么处理

浅不可变只保证最外层字段不被替换,不保证内部对象不变。考虑这个设计:

public sealed class DeviceSnapshot { public List<double> RawValues { get; init; } public DeviceInfo Info { get; init; } }

RawValues是List ,外部拿到的引用依然可以snapshot.RawValues.Add(99),Info如果是可变class同理。这不算真正的不可变类型。

深度不可变有三个常用解法:

  • 用不可变集合(System.Collections.Immutable提供的ImmutableList、ImmutableArray、ImmutableDictionary)。这些集合内部是树或结构共享的结构,每次"添加"返回一个新集合实例,原来的引用不受影响。
public ImmutableArray<double> RawValues { get; init; }

构造函数里用rawValues.ToImmutableArray()转换一次就行。

  • 对外暴露只读视图但不保证原对象不变:
private readonly List<double> _rawValues = new(); public IReadOnlyList<double> RawValues => _rawValues;

这只挡了"从外部直接改集合"的路径,内部方法要克制自己不要改。严格说只算"半不可变"。

  • 对嵌套对象同样采用不可变设计:DeviceInfo里所有字段readonly或init,构造函数入参拷贝。

经验之谈:深拷贝方法(如BinaryFormatter、JsonSerializer序列化反序列化)能实现深度不可变复制,但性能开销大、还容易踩序列化兼容的坑。能用不可变集合解决的问题就不要走深拷贝,性能差距是数量级的(不可变集合的Add大约O(log n),深拷贝是O(n)起,保底还有一个序列化开销)。

3. 可变引用类型的高效使用

3.1 值类型与引用类型在可变性上的本质差异

C#值类型(struct)的变量直接持有数据,赋值时是逐字段拷贝。引用类型(class)的变量只是指针,赋值时拷贝的是引用而不是数据。这个差异直接决定了可变性带来的后果:

  • 对可变引用类型:var a = obj; var b = a;之后,a和b指向同一个对象,改a的属性,b看到的是同一份变化。
  • 对可变值类型:var a = structObj; var b = a;之后,a和b是完全两份数据,改a的字段不影响b。

所以从"共享可变状态"这个角度来看,可变引用类型才是危险的,可变值类型相对安全但会有性能代价(赋值、装箱、传参都在拷贝)。C#里很多"面试题"喜欢考ref、out和in,本质上就是在控制这个拷贝成本。

.NET 7+还支持了ref readonly返回和readonly struct。给struct加上readonly修饰符后,编译器会强制你所有字段都是readonly,所有实例方法都不能修改this的状态。这样既保留了值类型的高效(无堆分配),又拿到了不可变语义。这在解析协议帧时非常实用——一帧数据解析完直接以readonly struct形式传递给多个消费方,不需要担心谁偷偷改了它。

3.2 暴露可变成员的常见设计失误

处理可变引用类型时,最容易出的问题不是"自己改了它",而是"自己以为别人不会改它"。我在代码评审里见过三类高频问题。

第一类是暴露了内部集合的真实引用:

public List<ChannelState> Channels { get; private set; } // 调用方可以 var list = obj.Channels; list.Clear();

这类问题在UI绑定时尤其恶心——UI线程可以肆无忌惮地改动业务线程的数据。修复方式就是2.3里提到的只读视图,或者返回副本Channels.ToList(),或者在框架允许时用ReadOnlyCollection包装。

第二类是事件订阅导致的对象生命周期拉长。可变引用类型的属性可以在运行时被不断修改,但事件委托(delegate)本身也是引用类型,订阅了就不再释放。一个常见事故是:静态事件反复订阅同一个处理函数,导致对象一直挂在事件源上,内存只增不减。这和我做上位机框架时遇到的窗口卡顿问题如出一辙——事件链上挂了太多不该存在的实例。

第三类是可变深嵌套导致的事务性破坏。业务上"要么全部更新、要么什么也别动"的需求,用可变引用类型很难做。每个属性单独set,中间过程一旦异常退出,对象就处于一个半新半旧的坏状态。不可变类型 + 整体替换才能原生地保证原子性。

3.3 多线程下可变引用的防护套路

如果你的业务确实需要可变引用类型,多线程场景要做的就不是"不用它",而是"控制读写访问"。常规套路按强度从低到高排列:

  • 锁定读-改-写整段逻辑:lock语法糖包住所有会读取或修改共享状态的代码段,最简单但容易死锁,锁的范围越大性能越差。
  • 使用并发集合替代手工同步:ConcurrentDictionary、ConcurrentQueue、BlockingCollection可以让你在集合层面避免手工加锁。注意它们只保证单个操作的原子性,不保证"查询后再修改"这个复合操作原子性(比如先TryGet再TryUpdate)。
  • 用volatile/Interlocked做引用级别的原子替换:只替换一个引用,不死锁,但字段级的复合变更(多个字段一起改)依旧需要锁或不可变类配合。

说个我实际踩过的:用一个可变class承载UI配置项,线程A重新加载配置文件,直接去改这个对象的五个属性;线程B同时读取这五个属性渲染界面,结果UI上看到四个新值一个旧值——这就是"撕裂读"。改成不可变配置对象之后:

_config = ConfigLoader.LoadFromFile(path);

整个引用替换一行代码搞定,读写之间不再有中间状态,问题当场消失。代价只是每次配置更新多构造了一个对象,一百次更新里可能根本感知不到。

4. 可变与不可变类型的选择与权衡

4.1 什么时候必须用可变,什么时候优先选不可变

我的判断顺序大致是这样的:

  • 如果你的对象天生代表"一系列变化的状态",比如实时通信的会话、增量累加的计数器,选可变类型,用锁或并发原语控制访问。
  • 如果你的对象是"一个事件发生时的完整快照",比如一次采集的数据、一条日志、一帧解析结果,选不可变类型,一次构造终生使用。
  • 如果对象会被多个类共享且需要频繁读取,选不可变类型,省锁还能放心缓存。
  • 如果对象体量大(几百个属性、大集合成员)且更新非常频繁,不可变类型每次更新都要复制一整套字段,开销可能超过锁,此时可变类型加正确的锁定策略更划算。

套用到C#上位机开发里特别明显:UI的绑定模型(INotifyPropertyChanged)天然是可变引用类型——它的意义就在于属性被改动后通知界面刷新。但设备上报的数据包完全没有通知需求,用不可变类型反而有利于Canvas渲染和缓存。

4.2 性能维度:拷贝开销、GC压力与缓存友好性

不可变不是免费的。每次更新都产生一个新对象,意味着更多堆分配、更多GC压力、CPU缓存局部性变差。在.NET里有一个常见的计数器例子:

// 可变写法 var total = 0.0; for (int i = 0; i < 1_000_000; i++) total += values[i]; // 不可变写法每次聚合都要新建对象,显然不合适

聚合计算这种高频累积场景就不适合把中间状态设计成不可变类型。相对地,不可变类型在缓存安全方面有独特收益:"因为它不会被修改",所以它可以被安全地缓存、共享、放入字典的键、甚至跨线程传递,完全不需要因为"害怕外面改数据"而被迫做防御性拷贝。这个收益在大型系统里往往远超那点分配开销。

.net 7+的基本类型如DateOnly、TimeOnly也都是不可变struct,正是看中了缓存和并发友好。实际优化时先分清哪些数据是"热点中的热点",再决定要不要为了性能打破不可变设计,不要一上来就全局套不可变模式。

受益于record类型和init的普及,日常业务代码里不可变类型的使用成本已经大幅降低,但"性能敏感代码"里手写struct依然是最优解——它没有堆分配,完全绕开GC。

4.3 混合架构:外部可变、内部不可变

真实的工程里最实用的不是"全部不可变",而是分层设计——可变的状态容器管生命周期,不可变的值对象管数据内容。

可以把它类比成仓库里的一排货架(可变容器)和装在密封罐里的货物(不可变对象)。货架可以换成新的一排(引用替换),但罐子一旦封上就再也不打开。这样:

  • 容器层处理"什么时候更新"、"更新哪一批"、并发控制、失效通知。
  • 数据层只管"是什么",不用考虑"会不会变"。

比如通信系统中,每个设备连接状态用可变字典维护,每次解析出的数据帧用不可变record存储。多线程读写时,锁只保护字典的增删改查,数据帧本身无需任何同步保护。这个架构在我做的Modbus TCP客户端和USB摄像头回调处理里都用过,稳定性和排查复杂度完全是两回事。

5. 实操案例:一个上位机采样模块的重构

5.1 原实现的问题清单

选一个典型的上位机采样场景来说明:设备每100ms上报一组多通道电压值,UI需要实时绘制波形,同时后台还有一个分析模块在统计峰值。最初的实现大致是这样:

public class SampleBuffer { public List<double> Voltage = new(); public List<byte> Status = new(); public DateTime Time; }

这个SampleBuffer被采集线程、渲染线程、统计线程共享,三处各自往里面塞数据、读数据。跑起来以后症状特别典型:

  • 波形偶发出现"鬼影"(渲染线程读到半新半旧的列表)。
  • 统计结果偶尔和UI对不上(统计在取数据时刚好被采集线程Clear)。
  • 线程越多问题越隐蔽,加了锁之后反而出现渲染掉帧。

原设计的本质问题是:一个高度共享的缓冲区被当成"容器"来用,数据往里塞了又改,改了又删,根本没考虑"读者需要的是一个统一一致的数据视图"。

5.2 用不可变类型重构的步骤

重构目标很明确:让采样数据的每个版本都变成一个独立的不可变快照,UI和统计模块读取任何一个版本都是完整、自洽的。步骤大致如下。

第一步,把采样数据设计成record:

public sealed record ChannelSample(DateTime Timestamp, double Voltage, byte Status); public sealed record SampleFrame(ImmutableArray<ChannelSample> Channels);

第二步,把缓冲区改成"只保存最新引用"的容器:

public sealed class SampleBuffer { private SampleFrame _latest = new(ImmutableArray<ChannelSample>.Empty); public SampleFrame GetLatest() => Volatile.Read(ref _latest); public void Push(ImmutableArray<ChannelSample> channels) { var newFrame = new SampleFrame(channels); Volatile.Write(ref _latest, newFrame); } }

Volatile.Read和Volatile.Write保证上层读到的引用是最新的,且不会拿到半构造的对象——新对象构造完毕才发布出去,这个模式有时叫"发布不可变快照"。

第三步,把UI和统计模块全部改成"先GetLatest拿快照,然后只读这个快照":

var frame = buffer.GetLatest(); foreach (var channel in frame.Channels) { // 在本地做所有计算,不要回头再读buffer }

整个重构把锁从三个线程共享的热点路径上移除了,代价是每次Push多构造一个ImmutableArray,100ms一次,完全可接受。

5.3 重构前后的数据对比与心得

我拿一台四核工控机做了简单的对比实测。数据量是每帧32个通道,每100ms一帧,连续跑30分钟:

指标重构前(可变+lock)重构后(不可变快照)
渲染掉帧次数平均约8-12次/分钟0
统计与UI数据不一致次数偶发约3-5次/小时0
单帧读取耗时(平均)受锁竞争影响,波动大稳定在微秒级
30分钟新增GC对象中等(加锁、集合复制)略高(快照分配)

从数据看,"不可变"没有在某些指标上全面碾压,但"数据一致性"和"渲染稳定性"这两个最头疼的指标是彻底根治了。GC对象增加的量级在100ms频率下根本感知不到。

心得两点:一是不要试图把所有对象都改成不可变,改的是"跨线程共享且读者不需要写"的特定对象;二是快照发布时留意volatile或者直接用ImmutableInterlocked.Update这类辅助方法,否则只在理论上安全,实际可能拿到旧值或半发布引用。

6. 踩坑记录与排查思路

6.1 常见问题速查表

把实际项目中高频踩到的问题整理成一张速查表,覆盖声明、并发和滥用几个维度。

问题典型表现核心原因解决方向
record对象属性不生效record里用了可变List,Equals按引用比较"不相等"集合本身可变属性类型换成ImmutableArray
init只保护基本类型多层嵌套class,外层init了,内层还能改只做到浅不可变嵌套对象也全部用只读设计
readonly集合只挡外部类内部方法依然可以Clear只是封装不是约束配合代码规范和代码评审
不可变每次更新都分配新对象高频率更新时GC压力大对象复制不可避免改为"半不可变":只拷贝变化部分,共享未变部分
可变引用跨线程共享无保护偶发的数据错乱和撕裂读缺少volatile或锁用不可变快照替换可变引用

表格里提到的"半不可变"是一个比较实用的小技巧:结构共享式设计——new一个record去复用不可变的内部引用,只替换需要变的字段,这样只分配最少的对象。record的with表达式其实就是干的这件事,CPU缓存和GC都友好。

6.2 两个必须Mark的排查手法

第一个是"抓现行"。遇到怀疑是对象被并发修改的bug,先把所有setter打上断点并开启"条件断点:断在特定条件",或者临时把字段改成属性并在setter里加日志。这一步能把"谁在什么时候改的"直接定位,远比翻业务代码快。

第二个是"输出快照对比"。把可疑对象的序列化字符串(ToJson)在多个线程的关键位置dump下来比对,一旦发现内容不同就说明中间存在修改路径,然后顺着修改路径逐个排除。这套方法在.NET Framework没调试器远程时尤其好用,也多亏了我在做日志分析时养成的"随手给对象拍快照"的习惯。

6.3 最后的实用建议

如果你刚开始接触这个概念,我的建议是把record当成默认选项去对待。业务里大部分DTO、事件参数、状态快照用record天然合适;只有在明确需要"多个线程共同修改一个对象"或者"高频状态累积"时才回头用可变类型。这样设计出来的系统在数据一致性和调试体验上会好一个量级。

遇到性能瓶颈时也不要急着往可变类型迁移,先做profiling定位热点,确认"不可变确实是瓶颈"后,再把热点路径专门改成可变结构。我试过好几次,以为自己省了一口气去做可变优化,结果瓶颈根本不在这里——不可变设计的正确性和免调试价值,往往比那点微秒级性能宝贵得多。一个框架跑得好不好,说到底不是某个类型可变不可变决定的,而是你有没有把"状态在哪里变化、谁在看、什么时候需要一个一致视图"这件事想清楚。这个题想透之后,C#里绝大多数"奇怪的bug"都会变少很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询