尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

C#不可变引用类型实战:线程安全与record设计取舍

C#不可变引用类型实战:线程安全与record设计取舍 最近在排查一个上位机软件的线上问题时发现设备状态参数莫名其妙被改动最后定位到是一个共享的配置对象被某个线程顺手改了属性。这类问题在C#开发里太常见了根源就是可变引用类型被多个地方共享后谁都能改改了大家跟着变。今天把可变引用类型和不可变引用类型这件事从头到尾捋一遍内容偏实战涉及内存模型、线程安全、设计取舍也会给出一套工程上可以直接落地的方案。1. 可变引用类型和不可变引用类型是什么1.1 从“引用”的本质说起先明确一个基础概念引用类型在C#里分为值类型和引用类型而“可变/不可变”讨论的是引用类型比如class、string、数组、List这些。一个引用类型的变量存的不是数据本身而是指向堆内存中对象实例的“引用”。两个变量指向同一个实例时它们操作的是同一块内存。public class DeviceStatus { public string DeviceName { get; set; } public double Temperature { get; set; } public bool IsRunning { get; set; } } var statusA new DeviceStatus { DeviceName PLC-01 }; var statusB statusA; // 两个变量引用同一个对象 statusB.Temperature 85.5; Console.WriteLine(statusA.Temperature); // 输出 85.5这个例子中statusB修改了TemperaturestatusA看到的也变了。这是引用类型的天然行为也是可变引用类型的核心特征。这里的DeviceStatus就是一个可变引用类型它的属性有set访问器实例创建后内部状态随时可以被修改。1.2 不可变引用类型的定义和典型代表不可变引用类型指的是对象一旦创建完成其内部状态就再也不允许被修改。从头到尾这个对象对外呈现的数据都是固定的。最典型的例子是string。你可能写过这样的代码以为修改了字符串string s Hello; s s.ToUpper();这看起来是“改变了s”但实际过程是在堆中创建了一个新的字符串对象“HELLO”把s的引用指向了新对象原来的“Hello”对象并没有被修改只是没人引用它了等待被垃圾回收。这个过程中没有任何一个string对象被真实修改过。另一个典型例子是System.Version类它表示程序版本号创建之后就只读没有提供任何方式去修改Major、Minor等属性。System.DateTime在大多数平台实现上也是不可变的值类型返回新实例而不是修改原实例。1.3 可变性不是值类型与引用类型的专属属性这里要澄清一个常见误区值类型也可能是“可变”的引用类型也可能是“不可变”的。看这个例子struct MutablePoint { public int X; public int Y; } var p new MutablePoint { X 1, Y 2 }; p.X 100; // 直接修改字段值类型也可变反过来引用类型通过私有构造函数、只读字段、无公开修改方法等手段可以做成完全不可变的状态。可变性与类型类别没有必然关系只是引用类型的可变性影响范围更大——因为多个变量可以共享同一个实例。引入一个衡量可变性影响的关键分析维度一个对象“被多少变量共享”以及“修改它的频率”。值类型变量存储的是副本一个变量改了不会影响另一个引用类型因为共享引用一个对象被改动所有指向它的变量都受影响。不可变引用类型则同时兼顾了两者的优点可以共享但不存在被意外修改的风险。2. 为什么需要使用不可变引用类型2.1 多线程环境下的安全困境在实际的C#项目中尤其是上位机、工控、服务端后台这类并发场景可变对象共享带来的麻烦极其明显。假设有一个全局的设备状态对象多个线程同时访问public class DeviceManager { public static DeviceStatus CurrentStatus { get; } new DeviceStatus(); } // 线程A读取温度做显示 void ReadTemp() { var temp DeviceManager.CurrentStatus.Temperature; // 读取瞬间线程B可能在写 } // 线程B实时更新温度 void UpdateTemp(double newTemp) { DeviceManager.CurrentStatus.Temperature newTemp; }这个简单的读写场景就会出问题。线程A读的温度可能是线程B写入一半的中间状态。如果想保护它就得加锁而锁会造成性能开销和复杂的死锁排查。线程越多竞争越激烈排查难度越大。换成不可变类型情况完全不同。不可变对象一旦创建所有线程读到的都是同一份稳定的数据没有“写入一半”的状态。如果需要更新就创建一个新对象并整体替换引用这个替换操作本身可以做原子性处理。从根源上消灭了读-写竞争而不是靠锁去硬扛。2.2 缓存与共享数据的安全性缓存系统对不可变性的需求尤其强烈。很多后台服务会缓存配置、字典数据这类高频读取的信息。如果缓存的是可变对象任何一个拿到该对象引用的代码都可以修改缓存内容导致数据污染。public class AppConfig { public string ServerAddress { get; set; } public int Timeout { get; set; } } public static class ConfigCache { public static AppConfig Current { get; set; } public static void Refresh() { Current LoadFromFile(); } } // 某业务代码意外修改了缓存 ConfigCache.Current.Timeout 9999;这不是皮一下的问题。缓存对象一旦被改所有后续读取缓存的逻辑都会拿到脏数据而且很难追溯是谁在什么时候改的。如果AppConfig是不可变类型这种意外修改在编译期就不可能发生——你没有setter可以调编译器直接报错。做成不可变类型之后即使代码里把对象到处传来传去也不必担心被修改。安全性是设计层面保证的不需要每个调用方都小心翼翼。2.3 作为字典键和哈希集合元素的硬性要求字典的键、HashSet的元素其实对“不可变性”有极强的隐含要求。这一点容易被忽略出了问题还很隐蔽。原理是这样的字典用键的哈希码去决定存放位置。如果键对象在被加入字典后内容发生了变化导致哈希码变化字典就再也找不到这个键了。var dict new DictionaryListstring, string(); var key new Liststring { a }; dict[key] value; key.Add(b); // 哈希码变了 dict.TryGetValue(key, out var val); // 大概率找不到或抛异常List 就是典型的可变引用类型作为字典键极其危险。string适合当键除了因为它的Equals和GetHashCode实现可靠之外更本质的原因是它不可变哈希码可以保证稳定。自定义类型如果要当字典键强烈建议设计成不可变类型或者至少保证参与哈希码计算的字段在加入字典后不会被修改。2.4 纯函数式思维与可预测性真实项目中不可变类型带来的最大好处其实是“代码可预测性”。你拿到一个对象不需要去想它背后的状态会不会在某个角落被改动。读代码的时候看到一个方法接收了不可变对象你可以放心地认为方法内部的任何操作都不会影响调用方的数据。这一点在团队协作中的价值太大了。以前排查bug经常要顺着引用关系到处找谁改了共享对象。全链路都是不可变类型时这种可能性直接清零排查问题的思路清晰很多。3. 如何设计不可变引用类型3.1 最朴素的方案私有构造函数加只读字段不依赖任何高级语法纯手动实现不可变类型是理解原理的最佳路径public class TemperatureReading { private readonly double _celsius; private readonly DateTime _timestamp; public double Celsius _celsius; public DateTime Timestamp _timestamp; public TemperatureReading(double celsius, DateTime timestamp) { _celsius celsius; _timestamp timestamp; } }这个类的全部数据通过构造函数一次初始化之后只能通过只读属性访问没有任何修改途径。只要你的属性本身也是不可变的double、DateTime都是这个类就是完全不可变的。需要注意两点一是只读字段仅保证“引用不可变”不保证“被引用对象不可变”例如private readonly Listint _data只能保证_list这个引用不变List里面的元素依然可以被add、remove二是属性返回引用类型时也要小心直接返回内部的List调用方拿到引用后可以肆意修改其内容。3.2 用init语法简化不可变属性的写入C# 9.0之后init访问器可以让不可变属性的声明简洁许多public class TemperatureReading { public double Celsius { get; init; } public DateTime Timestamp { get; init; } } var reading new TemperatureReading { Celsius 23.5, Timestamp DateTime.Now };init和set的区别在于init只允许在对象初始化期间对象构造、对象初始化器、with表达式赋值初始化完成后属性变为只读。相比手动私有构造函数用init写起来清爽得多特别适合DTO、配置项、查询结果这类一次成型的数据载体。3.3 record类型语法级别的不可变参照C# 9.0引入的record类型是设计不可变数据的利器public record DeviceState(string DeviceName, double Temperature, bool IsRunning); var state1 new DeviceState(PLC-01, 23.5, true); var state2 state1 with { Temperature 25.0 };record默认提供基于值的相等性比较两个record只要字段值都相同Equals就认为它们相等。配合with表达式可以很方便地“基于现有实例创建新实例修改部分字段”完全不用手写构造函数和复制逻辑。实际项目里我经常用record来定义命令、事件、配置快照这些概念。比如上位机里的报警事件天然就不该被修改用record定义事件产生后全程稳定。3.4 恰到好处的不可变集合很多场景下我们需要暴露一组数据但不希望调用方修改这个集合。最朴素的错误是直接把List 暴露出去public class SensorGroup { public Liststring Sensors { get; set; } // 调用方可以随便add/remove }改成只读接口并不能真正阻止修改public IReadOnlyListstring Sensors { get; }IReadOnlyList只是“没有修改方法的接口”背后的List实例依然可以被内部修改但外部如果把它重新转换成List就能绕过限制操作。严格方案是把数据复制进ReadOnlyCollection或者System.Collections.Immutable里的ImmutableListpublic class SensorGroup { public IReadOnlyListstring Sensors { get; } public SensorGroup(IEnumerablestring sensors) { Sensors new ReadOnlyCollectionstring(sensors.ToList()); } }ReadOnlyCollection包装了内部List外部无法修改。但这个ReadOnlyCollection的工作原理是“只读包装”即内部List被修改ReadOnlyCollection读到的内容也会变。如果想彻底封死使用ImmutableList更稳妥它内部是持久化数据结构任何“修改”操作都返回新实例。不可变集合不一定性能差。ImmutableList的增删操作是O(log n)对于大多数业务场景完全够用。如果数据量极大且频繁修改集合内容才需要评估变成可变集合是否有必要。3.5 构建器模式让复杂不可变对象构造不痛苦不可变对象有个天然痛点字段多了构造函数会变得很长调用方传参容易传错顺序。构建器模式能很好地解决这个问题。public class MachineConfig { public string MachineName { get; } public string IpAddress { get; } public int Port { get; } public double MaxTemperature { get; } private MachineConfig(Builder builder) { MachineName builder.MachineName; IpAddress builder.IpAddress; Port builder.Port; MaxTemperature builder.MaxTemperature; } public class Builder { public string MachineName { get; set; } public string IpAddress { get; set; } public int Port { get; set; } public double MaxTemperature { get; set; } public MachineConfig Build() { // 构建之前做校验保证不可变对象创建后就是合法的 if (string.IsNullOrWhiteSpace(MachineName)) throw new InvalidOperationException(MachineName不能为空); if (Port 1 || Port 65535) throw new ArgumentOutOfRangeException(nameof(Port)); return new MachineConfig(this); } } } var config new MachineConfig.Builder { MachineName CNC-01, IpAddress 192.168.1.10, Port 502, MaxTemperature 85.0 }.Build();Builder负责收集参数和校验Build()返回一个构造完成且校验通过的不可变对象。创建之后所有属性都只读没有修改入口。这个模式在配置项多、必填项多的场景非常实用。需要注意Builder自身是可变的它只用于“构建过程”。构建完成后把不可变对象传递出去即可Builder可以丢弃。4. 实际开发中的选型与实践经验4.1 什么时候坚持不可变什么时候接受可变不可变类型虽好但并不是所有场景都适合。两类类型在工程中的定位截然不同。适合用不可变类型的场景包括DTO/请求响应模型、配置对象、缓存项、事件/命令消息、字典键、集合的只读快照、需要跨线程共享的公共数据。适合保留可变类型的场景包括频繁变化的实体比如游戏中的角色属性、实时采集的传感器状态、临时凑数据再一次性传给构造函数的中间过程、Entity Framework Core这类ORM的实体类、WinForms/WPF里作为绑定源的模型。举一个实际取舍的例子。上位机软件里设备实时温度值每秒钟变化好几十次。如果每次温度变化都创建一个新的不可变对象会带来大量的小对象分配增加GC压力。这种高频变化的数据用可变模型再加锁保护只会更简单或者直接用最新的值覆盖旧值不需要保留历史版本。而设备配置、参数配方、报警规则这类很少变化但会被多处读取的数据就非常适合设计成不可变。4.2 不可变对象的内存开销与性能思考不可变对象因为每次“修改”都要创建新对象在频繁更新的场景下会增加内存分配。但这不等于不可变类型慢。绝大多数业务系统的性能瓶颈在数据库IO、网络通讯、文件读写而不是在内存对象的分配和GC。.NET的GC对短命对象的回收非常高效创建小对象通常只影响几纳秒。从另一个角度看不可变类型在多线程场景下节省了锁的开销这个收益往往远大于新对象分配的代价。假设原来用一个锁保护共享数据现在改成不可变对象加原子替换Interlocked.Exchange性能提升非常明显。public class TempStore { private TemperatureReading _current; public TemperatureReading Current Volatile.Read(ref _current); public void Update(double celsius) { var newReading new TemperatureReading(celsius, DateTime.Now); Interlocked.Exchange(ref _current, newReading); } }这个设计里读操作完全无锁写操作是创建一个新对象原子替换引用。由于TemperatureReading是不可变的所有线程读到的数据要么是旧版本要么是新版本绝不会有中间状态。4.3 打破不可变性的隐藏后门设计不可变类型时要警惕三个常见后门。第一个是返回了内部可变引用。属性返回List 、数组、Dictionary等都会让外部得到修改入口。public class CachedReport { public Liststring Lines { get; init; } // 危险init只是限制属性赋值时机List内部依然可改 }确实有这种写法看起来用了init实际上List内容随时能被外部修改。要修复返回只读视图或一次性返回不可变集合。第二个是字段本身是可变对象。假设类中有一个readonly StringBuilder字段字段引用虽然不能换但StringBuilder本身的内容可以随便改。所以不可变类型要求包含的所有字段都必须是“递归不可变”的子对象也要是不可变的。第三个是修改数组元素。数组是引用类型即使是private readonly数组里面的元素也可以被修改。使用数组时要格外小心最好用不可变集合替代。4.4 序列化、数据库操作与不可变对象的兼容性不可变类型与很多框架的兼容性已经相当好。System.Text.Json支持反序列化带init属性和record类型的对象Newtonsoft.Json同样通过构造函数或属性赋值初始化不可变对象EF Core从C# 9开始也支持包含init属性、record类型的实体但仍建议实体保持可变因为EF的变更追踪依赖属性setter。如果你要把不可变对象作为WPF或WinForms的绑定源情况会复杂一点。UI绑定通常要求实现INotifyPropertyChanged属性更新时会通知界面刷新。不可变对象没有更新入口所以不适合直接作为UI绑定的“当前值”。更合理的做法是在ViewModel层维护可变状态用不可变模型做数据快照传给后台或保存。5. 常见问题与排查技巧实录5.1 string不可变背后的性能陷阱string不可变带来的一个坑是频繁拼接字符串时产生的垃圾对象比想象的多。很多初学者用 循环拼接上万次字符串时程序明显变慢。原因不难理解每次都创建一个新字符串对象旧对象成为垃圾。多次拼接后再看创建了大量临时字符串。正确做法是使用StringBuildervar sb new StringBuilder(); for (int i 0; i 10000; i) { sb.Append(i); } var result sb.ToString();StringBuilder是可变引用类型专门用来高效构建字符串。构建完成后调用ToString()拿到一个不可变的string对象。这印证了一个设计理念性能敏感的构建过程用可变类型构建完成对外暴露不可变类型。5.2 暴露List给外部导致数据被偷偷修改项目里常见到这样的“安全错觉”public class AlarmConfig { public Liststring AlarmRecipients { get; } new Liststring(); }这个类本身没有提供修改方法但外部仍然能这样调用var recipients alarmConfig.AlarmRecipients; recipients.Add(userexample.com); // 成功了问题在于AlarmRecipients属性返回的List引用本身就是可变的。解决方式可以返回只读集合视图使用ReadOnlyCollection包装或者用ImmutableList来存储真实数据。真实项目中采用ImmutableList的改动更小只需要把属性声明的类型换成IReadOnlyList内部字段换成ImmutableList。5.3 数组作为字段时意外改变元素值数组是引用类型即使声明为private readonly也不阻止元素修改public class DeviceData { private readonly int[] _values; public DeviceData(int[] values) { _values values; // 直接引用外部数组外部还能改 } }这里有两个问题构造函数直接引用了外部传入的数组外部代码后续对数组元素的修改会反映到DeviceData内部即使内部持有私有数组类的内部方法仍可能修改数组元素。正确做法是构造函数里做防御性复制public DeviceData(int[] values) { _values values.ToArray(); // 复制一份外部修改不影响内部 }如果想防止通过属性访问后修改数组元素最好用IReadOnlyList暴露而非直接暴露数组。5.4 可变对象做字典键导致的丢失问题这个坑我在项目里亲手踩过印象极深。用普通class定义了设备标识然后把它当字典键。程序运行一段时间后某些条目凭空消失TryGetValue永远返回false。排查了很久才发现设备标识对象的某个属性在别处被改了哈希码跟着变字典内部索引就错乱了。定位方法在Debug.WriteLine里打日志查看字典的Count和每个键的哈希码对比初始化时的哈希码很快就能发现对象被改。最好的解法是“直接把键定义为不可变类型”。C# 9的record类型就是为这种场景准备的。record默认重写了GetHashCode和Equals按值比较而且本身设计为不可变非常适合做字典键。5.5 深拷贝还是浅拷贝的抉择不可变对象的一大好处是不需要深拷贝既然对象不能变共享引用就完全安全。但如果数据是可变对象浅拷贝往往不够。public class MachineInfo { public string Name { get; set; } public ListSensorInfo Sensors { get; set; } } var copy new MachineInfo { Name original.Name, Sensors original.Sensors }; // copy和original共享同一个Sensors列表改一个另一个也跟着变这里即使Name是string不受影响Sensors共享引用就是隐患。想安全复制可变对象需要深拷贝整个对象树要么写递归克隆方法要么用序列化-反序列化方案。与其纠结深拷贝不如把MachineInfo改成不可变类型Sensors用ImmutableList保存后续数据变更都通过创建新对象完成。不需要拷来拷去代码简洁且不容易出错。6. 工程中值得长期坚持的几项规范设计团队级的C#项目时几个有关可变性的约定能显著降低维护成本。建议在项目规范里固定下来领域模型中的配置、规则、事件默认设计为不可变DTO和网络报文模型使用record类型所有公开属性如果是集合类型优先暴露IReadOnlyList或IReadOnlyDictionary共享缓存的入参和出参都要求是不可变类型字典键类型必须不可变。有一个实践原则值得特别强调越往外层传递的数据越倾向于不可变。内部实现可以用可变类型做计算但边界处接口、消息、配置、缓存入口换成不可变模型。这相当于给所有数据交换加了一层隔离网让跨模块的数据流动可控可预测。另外一个值得形成习惯的做法是构建对象时先在Builder或工厂方法里做完整校验确保不可变对象一旦创建就是合法状态。这样可以消灭一类很烦人的运行时错误——“对象创建的时候没问题但后来被改坏了”。不可变对象从创建到销毁状态完全一致这类错误在架构上被禁掉了。在真实的C#项目里我曾经用一晚上把一个共享配置类改成不可变类型删掉了三个锁改掉了两处深拷贝同时随手修掉了一个偶发的并发修改异常。第二天测试同学反馈并发压力下的稳定性明显提升。有些问题不是靠小心就能防住的而是要靠设计去消灭它。把可变引用类型的共享范围收紧把真正需要稳定传递的数据设计成不可变类型这套思路在多线程、批量任务、复杂业务系统中收益比想象中更大。
返回列表