
写C#的朋友应该都经历过这样的时刻代码逻辑一点问题没有数据量也就几十万条可CPU占用就是降不下来或者界面一刷新就卡顿。我之前在做一个上位机点云数据处理模块时就撞上过类似的事。模块里定义了一个PointData结构体包含坐标和颜色字段用来承载扫描设备回传的点云快照。业务逻辑不复杂无非是采集、缓存、遍历、算距离、绘点。但一跑起来CPU占用居高不下UI线程只要一读取这批数据帧率立刻掉下来。排查了大半天最后定位到问题不在算法而在结构体本身。我把PointData从普通结构体改成了readonly struct又把几个高频函数改成in参数传递整体耗时肉眼可见地降了一截最直观的感受是GC分配少了UI也不卡了。这篇就从这次改造出发把readonly struct到底解决了什么问题、为什么能带来性能收益、以及哪些场景值得用哪些场景别硬改一次性讲清楚。1. 值类型性能问题的根源看似不起眼的拷贝1.1 值类型的一切操作本质上都在“复印”理解readonly struct之前得先把值类型的拷贝成本讲透。C#里结构体属于值类型值类型在赋值、传参、从一个集合里取出元素等操作时走的都是“值拷贝”语义。换句话说每次你执行一次var p2 p1或者调用一个void Process(PointData data)的方法运行时都会把结构体里的所有字段逐字节复制一份。这个拷贝成本取决于结构体的大小。一个只有一个int字段的结构体拷贝4字节几乎可以忽略但一个带有多个double、DateTime、嵌套结构体字段的“胖结构体”一次拷贝可能就是几十甚至上百字节。我那个点云模块里每个点包含X、Y、Z坐标、法向量、RGB颜色算下来一个点结构体足足有64字节。几十万个点做一次遍历如果每次访问字段都触发拷贝那光数据搬运就是几十MB级别的内存操作CPU自然下不来。这里要澄清一个常见误区很多新手以为“结构体在栈上分配所以很快”实际上结构体数组是分配在托管堆上的数组元素连成一片内存。结构体真正的问题不是分配位置而是拷贝语义。你可以把普通结构体想象成一张实体文件每次传递都是在复印店复印一份交出去引用类型则是传一个文件袋的挂钩位置谁要看内容谁自己去柜子里取。复印一张两张没什么感觉复印几万张就会又慢又费纸。1.2 防御性拷贝隐藏在编译器背后的隐形开销比显式赋值传参更隐蔽的是那个经常被忽略的“防御性拷贝”defensive copy。这个词从C# 7.2引入readonly struct之后才开始被广泛提及但这问题从C# 1.0就存在了。举个最简单的例子假设你定义了一个普通结构体public struct PointData { public double X { get; set; } public double Y { get; set; } public double Z { get; set; } }然后你有一个ListPointData在循环里访问元素的属性double sum 0; for (int i 0; i list.Count; i) { sum list[i].X list[i].Y; }表面上看list[i]只是读取一下元素不涉及拷贝。但在IL层面编译器会先通过ldelem指令把list[i]这个元素复制到一个临时变量里再从临时变量上读取X和Y。为什么必须这样因为编译器无法保证你在拿到list[i]的引用后不会有人修改这个元素。如果直接返回内部引用外部一旦写入就会破坏ListT内部的连续性假设。于是每次list[i]都会触发一次内存拷贝哪怕你只是读了一个字段。这就是防御性拷贝编译器为了确保安全主动为普通可变结构体的成员访问生成一个临时副本。这个副本在大多数情况下不痛不痒但在高频循环里它会把结构体的体积乘上循环次数变成一笔巨大的隐形成本。1.3 哪些场景最容易踩中拷贝陷阱结合我自己和身边同事的经验以下三类场景是结构体拷贝的重灾区第一类是高频遍历结构体数组或列表。像点云处理、图像像素遍历、从传感器读取大批量数据每次循环访问元素都带着一次拷贝累积效应非常明显。第二类是UI线程定时读取快照数据尤其是上位机界面那个定时器每隔几十毫秒就刷新一次曲线或点位如果刷新时要反复读取和拷贝一批结构体很容易造成界面掉帧。第三类是把大结构体反复传参给计算方法比如距离计算、坐标转换一次调用传两三个结构体每个64字节一秒钟几百万次调用就是几百MB的数据搬运。如果你用性能分析器观察这类问题的典型特征是CPU时间大量消耗在System.Copy相关指令上但代码热区看不出明显的算法复杂度问题。我当初调试点云模块时就发现明明只是遍历求和火焰图却显示有大量时间花在字段访问上这才顺藤摸瓜找到了防御性拷贝这个幕后黑手。2. readonly struct 的用法与约束2.1 从普通 struct 改成 readonly struct总共分几步readonly struct是C# 7.2引入的语法特性。它的核心约束很简单结构体的所有实例字段必须是只读的。什么意思你不能在结构体里声明一个可以被外部写入的字段或属性所有数据只能在构造函数里赋值一次之后整个结构体就不可变了。改造起来非常直接。拿我那个点云数据结构来说原本是这样的public struct PointData { public double X { get; set; } public double Y { get; set; } public double Z { get; set; } public byte R { get; set; } public byte G { get; set; } public byte B { get; set; } }改成readonly struct后变成了这样public readonly struct PointData { public PointData(double x, double y, double z, byte r, byte g, byte b) { X x; Y y; Z z; R r; G g; B b; } public double X { get; } public double Y { get; } public double Z { get; } public byte R { get; } public byte G { get; } public byte B { get; } }注意几个细节一是自动属性去掉了set只保留get二是字段或属性只能在构造函数中赋值三是所有需要初始化的数据都要通过构造函数参数传入。如果你的代码里有“先创建结构体再给某个字段单独赋值”的写法改成readonly struct后编译器会直接报错逼着你去调整设计。如果你用的C#版本比较新10.0以上还可以写成readonly record struct。像点云坐标、RGB颜色、配置快照这类“一次性生成、到处读取”的数据用readonly record struct最省事因为编译器会自动帮你生成构造函数、相等性比较和ToString减少样板代码。2.2 readonly struct 到底禁止了什么搞清楚readonly struct的限制比记住它允许什么更重要因为绝大部分坑都出在这里。首先你不能定义任何实例级可变状态。可写的自动属性、可变的公共字段、实例事件事件的add/remove本质上也是写操作在readonly struct里都不允许。其次你不能直接修改它内部的数据来“更新”某几个字段。以前结构体可以这么做从列表里取一个元素改X再放回去。现在只能创建一个新的结构体实例整体替换。看起来有点麻烦但换来的是编译器可以安全地按引用传递、按引用读取不需要担心任何一方偷偷改数据。这里有个很容易踩的坑如果构造函数里某个参数没赋给任何字段编译器会警告如果构造函数里给字段赋了两次值也会报错。另外从C# 10开始结构体可以有显式的无参构造函数readonly struct同样可以但约束不变构造完所有字段依然是只读的。默认值default(PointData)则是一个全零状态字段值分别为0、0、0和0业务上要注意区分“有效数据”和“空数据”。2.3 本次改造最关键的两个配合语法in 与 ref readonlyreadonly struct单独用性能收益有限真正让它发挥威力的是和C# 7.2引入的in参数、ref readonly返回以及C# 7.3的ref foreach迭代变量配合。in参数的意思是“按只读引用传递”。调用方法时不再把整个结构体复制一份而是把结构体所在的内存地址传进去同时保证方法内部无法修改这个值。这一点对大体结构是最直接的红利比如public double Distance(in PointData a, in PointData b) { double dx a.X - b.X; double dy a.Y - b.Y; double dz a.Z - b.Z; return Math.Sqrt(dx * dx dy * dy dz * dz); }以前这个方法的参数如果是PointData a每次调用都要拷贝64字节现在是用in传引用调用时零拷贝。但注意如果参数类型是普通可变结构体编译器会因为担心被调用方与调用方之间存在“别名修改”问题反而在方法内部偷偷做一次防御性拷贝导致in完全失效。只有参数类型是readonly struct时编译器才敢放心地直接用引用读取字段真正省掉拷贝。这也是我为什么强调in参数和readonly struct必须成对使用。ref readonly返回解决的是另一种场景从集合里取一个大结构体但不希望调用方复制一份。比如你缓存了大量快照数据别人只读不写可以这样暴露private readonly ListPointData _history new(); public ref readonly PointData GetLatestSnapshot() { return ref _history[^1]; }调用方拿到的是一个只读引用读取字段就跟访问数组元素一样直接不存在临时副本。还有一个实用场景是遍历整个数组做聚合计算C# 7.3之后可以这样写double total 0; foreach (ref readonly var point in _points) { total point.X point.Y point.Z; }这样每一轮循环都不会产生防御性拷贝直接按引用读取字段。3. 性能优势的本质为什么编译器敢优化3.1 不可变性给编译器吃了一颗定心丸很多人以为readonly struct的性能优势来自“不可变”本身其实不完全对。不可变性本身并不能让代码跑得更快真正的关键在于不可变性让编译器可以做更多优化尤其是消除防御性拷贝。回到前面的例子。普通结构体PointData有一个可变属性X当编译器看到list[i].X时它不能假设这个表达式只用一次。因为list[i]返回的是一个临时副本读取副本上的X是安全的但如果你告诉编译器“我要直接读取数组元素上的X”那么一旦有别的代码同时修改了这个元素的X读取结果就会不一致。为了防止这类问题普通结构体在访问成员时编译器倾向于先做一份防御性拷贝再对副本操作。readonly struct把这个顾虑直接消除了。既然结构体创建后所有字段都不能再改变那么任何代码都不可能通过写入来干扰读取结果。编译器不需要防御性拷贝可以直接在原始内存位置上读取字段。这一点在高频循环中的收益非常可观省掉了每次迭代的内存复制指令也就省掉了对应的CPU周期和内存带宽。用生活化的例子理解普通结构体访问成员像是有人递给你一份文件复印件你只能看复印件readonly struct则像给你一个密封文件袋你确认里面内容不会变就可以直接在袋子上看摘要。前者安全但费纸后者同样安全但省了一道工序。3.2 真正的大头按引用传递而不是按值传递除了消除防御性拷贝readonly struct带来的第二大收益是支持安全的按引用传递。前面提到结构体传参默认是拷贝。一个64字节的结构体传一次参就是64字节的memcpy一秒钟调用一百万次就是6400万字节的数据搬运。改用in参数后传递的是引用一次调用只需要传8字节的指针64位平台开销直接降了一个数量级。这里要反复强调一个新手容易搞反的点并不是任何结构体加上in就万事大吉。如果结构体是可变的编译器为了保证“不能通过in参数修改调用方的变量”这一语义会在方法内部创建一个副本再把副本的引用传给你。结果就是你以为省了拷贝实际上拷贝照样发生了甚至因为多了一层间接引用可能更慢。只有结构体本身是readonly struct编译器才能确认传入的引用不会被修改从而真正省掉拷贝。所以readonly struct和in参数是一对黄金搭档少了一个另一个的优化价值就大打折扣。这也是为什么我建议要么别用in要用就配合readonly struct一起用。3.3 一次简单的基准测试看看差距有多大为了更有说服力我针对点云数据做了一个简化版的基准测试。环境是64位Release、.NET 8数据是100万个PointData结构体每个含X/Y/Z坐标和RGB三个字节字段按64字节计算。测试逻辑很简单遍历数组对每个点的坐标求和。四种写法的对比结果如下数值为相对趋势具体取决于硬件但方向稳定写法是否产生防御性拷贝相对耗时普通struct 属性访问是每轮都拷贝基准值设为1.00普通struct 字段直接访问是但仍然有拷贝约0.85readonly struct 属性访问否约0.55readonly struct ref readonly遍历否约0.38这个趋势和我体感一致单纯把结构体改成readonly能砍掉接近一半耗时再配合ref readonly遍历能把总耗时压到原来的三分之一左右。如果你再用in参数传递来计算两点之间的距离收益会继续放大因为调用方传入的是引用方法内部也不需要拷贝。需要说明的是这组数据不是精确基准实际数字会随着结构体大小、访问模式、CPU缓存容量变化。但方向是明确的结构体越大、访问越频繁readonly struct的收益越明显。如果你要精确评估建议用BenchmarkDotNet跑一遍自己业务的基准别拿我这张表当标准答案。4. 实战案例上位机数据快照如何从卡顿到丝滑4.1 场景原貌为什么UI刷新会卡回到我开头说的上位机项目。设备通过串口和网口持续上报坐标数据采集线程把数据整理成结构体数组UI线程用一个Timer每隔50毫秒读取一次刷新点位显示。一开始的设计是这样的采集线程收到一批点生成PointData[]存入一个公共字段。UI线程的定时器把整个数组取出来遍历计算边界、生成绘制列表。绘制列表本身也是一个结构体数组封装了屏幕坐标和颜色。第一次优化前问题非常明显UI线程每次刷新要遍历几万个点而PointData是可变结构体访问属性时不断触发防御性拷贝。加上从采集线程拿数组时为了“安全”我又习惯性地写了个ToArray()等于每次刷新又整体复制了一遍。双重拷贝叠加UI线程负载接近饱和帧率自然上不去。这里要给一个通用判断方法如果你的UI刷新卡顿先别怀疑框架用性能分析器看一眼是不是有大量结构体拷贝。如果火焰图里Copy或get_Item相关的CPU占比很高那大概率就是结构体访问方式的问题不是Timer频率的问题。4.2 改造步骤从结构定义到读取方式一起动我按以下步骤完成了改造每一步都有明确目的第一步把PointData改成readonly record struct。因为这份数据本身就不应该被修改每次设备上报都是生成一个全新快照UI和算法只读不写。public readonly record struct PointData( double X, double Y, double Z, byte R, byte G, byte B);record struct还免费帮我把相等性比较和调试输出都处理好了省了不少样板代码。第二步把所有只读范围的遍历改成foreach (ref readonly var p in points)。UI刷新的核心遍历从每轮拷贝64字节变成每轮只读取CPU占用立刻掉了一截。第三步把计算方法改成in参数。比如坐标系转换、距离计算、包围盒计算凡是传入PointData或另一个大体结构体的方法一律用in。这里要特别注意方法签名要保持一致别一半用in一半用值传递不然编译器一会儿生成拷贝路径一会儿生成引用路径逻辑没问题但优化会打折扣。第四步把公共数据暴露改成ref readonly。以前UI线程要拿最新快照直接传数组引用然后ToArray()复制一份。现在直接用ref readonly返回最新一条数据或者直接返回内部数组引用因为readonly保证接收方无法修改原始数据省掉了复制保镖。4.3 改造后的实际效果与其他注意点改完以后UI刷新的CPU占用大概降了一半多帧率稳定在目标值清晰度也没有牺牲。更重要的是GC压力明显减少因为之前频繁的ToArray()和防御性拷贝会产生大量临时对象和内存压力改成只读引用后这些临时分配基本消失。这个过程中我踩过一个坑有一个模块需要根据用户操作修改某个点的颜色标注不能直接写points[i] points[i] with { R 255 }吗record struct支持with表达式但要注意with也是生成一个新实例再赋值不是原地修改。在循环里如果疯狂用with依然会有不小的分配成本。正确的做法是只在真正需要修改的地方创建新实例别把with写在热循环里。另一个注意点是readonly struct数组依然能修改数组元素本身只是不能修改元素内部的字段。也就是说你可以执行points[i] new PointData(...)但不能执行points[i].X 1。如果你需要一个“可变集合里装着不可变元素”的模式直接这样用就行安全性和性能都能兼顾。5. 误用与边界别为了性能盲目加 readonly5.1 小结构体不要硬改拷贝可能比引用更便宜readonly struct并非万能尤其是在结构体很小的情况下。一个只有四五个int或float字段的结构体大小在16字节以内拷贝一次也就是几条指令的事而按引用传递需要解引用反而多了一次间接寻址甚至可能因为CPU缓存行不友好而更慢。业界一个粗略的经验是结构体大小在16字节以上考虑按引用传递16字节以下按值传递往往更高效。readonly struct的优势也是在这个尺度上才真正显现。所以别一听说性能优化就到处加readonly先量一下结构体大小。怎么量用Unsafe.SizeOfPointData()输出一下不到16字节就没必要折腾。我用过一个反例同事把一个只有3个float的颜色结构体改成readonly struct然后所有方法都换成in参数结果性能反而有轻微回退。后来分析发现3个float一共12字节拷贝就是一条movups指令的事而in引用传递需要检查别名、加只读约束编译器生成代码的指令数没减少反而多了几步。最终我们保持原样只对真正大的结构体上readonly。5.2 可变性往往是刚需别硬削足适履并不是所有结构体都适合设计成不可变。如果你的业务逻辑经常需要对数据做局部修改比如交互式调整坐标、逐帧改变速度把结构体设计成不可变会让代码变得很别扭。每次修改都要重新构造整个结构体不仅啰嗦还可能因为频繁构造产生额外开销。这时可以考虑替代方案把“核心数据”做成readonly struct把“修改操作”放到一个独立的静态方法或者处理器类里由它读取只读数据生成新的只读结果。这样既保持了不可变性带来的安全性和免拷贝优势又避免把业务逻辑硬塞进构造函数。如果你需要的是一个高频读写的临时工作区ref struct或许是更好的选择。ref struct只能活在栈上不能装箱、不能被捕获到lambda或异步方法里但它允许可变字段且因为栈上分配局部使用成本很低。需要注意的是ref struct不能用于集合或异步上下文适用范围比较窄。选择的关键还是回到业务数据要长期保存就用readonly struct只是临时计算就看看ref struct。5.3 readonly struct 容易踩的坑第一长构造函数灾难。如果一个结构体有十几个字段构造函数就会有十几个参数调用和调试都不方便。这个问题在点云数据上还没那么明显如果是更复杂的业务快照就会很难受。解决办法是用嵌套结构体管理相关字段或者采用readonly record struct让代码更紧凑也可用工厂方法来集中初始化。第二default值的业务陷阱。readonly struct同样存在default(T)全零状态。如果你的业务里“全零”恰好是一个合法状态比如原点坐标就是0,0,0那么在使用时必须额外用一个IsValid字段来区分有效数据和空数据。这个字段本身也要只读初始化时一并赋值。第三集合操作效率反直觉。ListT.Remove、Add这类操作本质是在数组里搬移元素如果元素是大结构体搬移成本依然不低。readonly struct能帮你省掉的是“访问元素时的拷贝”但集合自身结构调整时的复制并不会减少。所以大结构体集合如果频繁增删性能仍然会差这时可能需要考虑用LinkedListT或索引数组来优化。第四接口实现的装箱风险。readonly struct可以实现接口但当你把它当作接口类型使用时仍可能发生装箱。尤其在调用object.Equals、GetHashCode这类方法时readonly record struct生成的实现能减少一部分装箱但如果你自己定义了接口方法还是要留意热路径上是否会发生从值类型到接口类型的转换。能用泛型约束就用泛型约束避免直接拿结构体去当IInterface用。5.4 常见问题速查表现象可能原因解决方向循环遍历数组很慢CPU占用高普通结构体成员访问触发防御性拷贝改成readonly struct用ref readonly遍历加了in参数后性能反而没提升参数类型是可变结构体编译器内部在做拷贝确保in参数配合readonly struct使用结构体明明很小用了readonly却变慢结构体在16字节以下按值传递更高效取消readonly恢复值传递需要修改结构体某个字段但编译器报错readonly struct不允许字段写入重新构造新实例用with或构造函数生成新值改成readonly后出现默认值误判default(T)是有效的全零状态增加IsValid等只读标记字段大量使用with后GC又上去了with每次生成新实例热循环里开销积累只在真正需要更新时使用with避免放在热路径中6. 实际使用中的几个补充技巧关于readonly struct还有一个细节容易被忽略它可以显著改善并发安全体验。因为结构体不可变多个线程同时读取同一份数据时不需要加锁也不需要担心读到一半被另一个线程改写。这个特性在上位机、工业控制这类多线程场景里非常值钱——往往省掉的不仅仅是拷贝开销还有一整套同步成本。另外推荐一个小习惯在代码里让数据流向一目了然。既然readonly struct创建后不可变那么“构造数据”和“消费数据”两个阶段就非常清晰。项目里可以约定采集、计算、转换阶段统一生成新的只读结构体UI、算法、日志阶段统一按只读引用消费。这个约定不需要额外框架支持靠编译器就能强制执行一旦有人想硬改数据编译直接报错比Code Review靠谱得多。我在实际项目里的体会是readonly struct最适合三类场景高频遍历的大结构体、作为缓存快照在多线程间共享的只读数据、以及频繁传参计算又不需要修改入参的业务模型。其他场景别盲目套用尤其小结构体和频繁局部修改的结构体硬改成readonly反而增加代码量、降低可读性、甚至略微拖慢性能。如果你现在正被“结构体访问慢”“UI刷新卡顿”“GC压力大”这类问题困扰不妨先检查一下自己的数据结构定义用性能分析器看看有没有防御性拷贝的影子。把适宜的普通结构体改成readonly struct再让in和ref readonly配合上有时候一行关键字的改动就能换来明显的性能改善这大概是我做C#开发以来性价比最高的一次性能优化。