ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

08-01-不可变-ImmutableArray-T-低包装开销与复制边界

08-01-不可变-ImmutableArray-T-低包装开销与复制边界 ImmutableArray结构体包装很轻不可变并非零开销系列C# 与常用数据结构源码剖析 · 不可变集合篇版本边界以项目实际引用的System.Collections.Immutable包/共享框架版本和目标 TFM 为准核心问题何时共享数组何时复制谁拥有 Builder 缓冲区一、先质疑标题“零开销”究竟指什么ImmutableArrayT是一个只读结构体核心状态是对 T 数组的引用。复制这个包装值通常只复制一个引用不会复制整组元素索引也能直接到达数组。这可以称为低包装开销。但它不代表整个集合没有成本创建快照需要数组Add/Remove/SetItem 通常要分配并复制Builder 可能扩容大数组影响 GC接口调用还可能装箱。更准确的成本表述是复制ImmutableArrayT值通常 O(1)多个包装值共享同一后备数组从已有内容创建独立快照通常 O(n) 并分配数组单次索引 O(1)顺序枚举 O(n)对既有快照做结构修改通常返回新数组成本 O(n)批量构建应使用 Builder把多次复制降为可增长缓冲管理加最终发布不可变只约束集合槽位不会递归冻结 T 指向的对象。本文不给所有 .NET 版本画一张伪时间线。System.Collections.Immutable经常以 NuGet 包交付即使两个程序都目标net8.0包版本也可能不同共享框架、包解析和 Unity 包兼容又有各自边界。具体 API如ItemRef、AsSpan和 Builder 的移动操作必须在目标 TFM 与实际解析版本下编译并查对应源码 tag。下文标为“结构化节选”的代码用于说明当前常见实现形态不是任何版本可逐字复制的 BCL 源码示例代码则只使用其注明的公共 API。二、一个字段带来的三种状态核心形态可抽象为// 结构化节选展示概念不是完整 System.Collections.Immutable 源码。 public readonly struct ImmutableArrayT { private readonly T[]? array; }包装值只读并不意味着数组本身具备 CLR 级不可变标记。安全性来自库不向普通调用者暴露可写数组并让所有“修改”返回新集合。内部或受信任的所有权转移路径可以复用数组这要求严格保证发布后没有可写别名。这个可空字段形成两个必须区分的零长度状态ImmutableArrayint uninitialized default; // 底层引用为 null ImmutableArrayint empty ImmutableArrayint.Empty; // 已初始化的空数组状态 Console.WriteLine(uninitialized.IsDefault); // true Console.WriteLine(empty.IsDefault); // false Console.WriteLine(empty.IsEmpty); // truedefault更像“尚未提供值”Empty才是“已提供但有零个元素”。二者在业务状态机中可以很有价值配置缺失与明确空列表不是同一含义。但默认值的某些成员可能抛异常或表现与 Empty 不同具体应按 API 契约测试不能写“null 就是空集合”。可用IsDefaultOrEmpty在确实不关心两者差异时合并判断static int CountOrZeroT(ImmutableArrayT items) items.IsDefaultOrEmpty ? 0 : items.Length;公共 API 边界最好显式决定策略参数是否允许 default序列化时 default 和 Empty 是否区分返回值是否始终规范化为 Empty。若一部分代码把 default 当错误另一部分静默当空问题会在远离来源的位置出现。三、创建、复制与共享不要只看方法名3.1 包装值复制共享存储ImmutableArrayint a ImmutableArray.Create(1, 2, 3); ImmutableArrayint b a;b a复制结构体字段a 与 b 逻辑上观察同一只读数组不发生元素复制。这是不可变值适合作为快照句柄的原因调用者不必为“把快照传给另一个方法”支付 O(n)。3.2 从可变数组创建时必须理解所有权若一个 API 接收调用者仍可写的 T[]安全的不可变快照必须复制否则调用者稍后修改原数组会穿透快照。不同ImmutableArray.Create/CreateRange重载可能走不同优化路径不能凭名字推断是否复制。int[] mutable { 1, 2, 3 }; ImmutableArrayint snapshot ImmutableArray.CreateRange(mutable); mutable[0] 99; // 对一个符合不可变快照契约的创建路径snapshot[0] 仍应为 1。若使用低层封装 API 把数组“接管”为 ImmutableArray调用者就必须永久放弃对原数组的写权限。此类 API 的名字、可用版本与安全备注应查目标文档它们不是普通业务代码的默认选择。所有权无法证明时复制比共享可靠。3.3 修改操作通常整段复制ImmutableArray 不是树式持久化集合。Add、Insert、RemoveAt、SetItem等通常创建目标长度数组并复制相应区间未变化元素值会被复制到新槽位。若 T 是引用类型复制的是引用不会克隆对象若 T 是大型结构体则复制实际结构体数据。旧快照[ A ][ B ][ C ] SetItem(1, X) 新快照[ A ][ X ][ C ] 两个数组独立但 A/C 若是对象引用仍指向同一对象。循环对不可变数组逐项 Add 会产生递增长度数组总复制量呈二次增长趋势。小集合或偶发单次更新可能完全可接受批量加载、解析或生成代码时应使用 Builder 或先在普通缓冲中构建。四、Builder可变阶段和不可变发布之间的所有权协议Builder 是可变的连续缓冲维护 Count 与 Capacity并提供 Add、索引赋值、排序等构建能力。它本身不是线程安全集合也不是不可变对象。典型流程是单一所有者构建然后发布 ImmutableArray// 公共 API 示例可用性以实际 System.Collections.Immutable 版本为准。 var builder ImmutableArray.CreateBuilderint(initialCapacity: 1024); for (int i 0; i 1024; i) builder.Add(i); ImmutableArrayint result builder.ToImmutable();4.1 ToImmutable保留 Builder 可继续使用的边界ToImmutable()产生不可变结果同时 Builder 仍是有效的可变对象。为避免 Builder 后续修改影响已发布快照实现必须确保二者不共享可被 Builder 写入的所有权常见结果是复制有效元素。不能假定“Builder 的 ToImmutable 永远 O(1)”。ImmutableArrayint first builder.ToImmutable(); builder[0] -1; // first 必须保持发布时内容否则不可变契约被破坏。4.2 MoveToImmutable满足条件时转移数组所有权目标包版本提供MoveToImmutable()时它可以把 Builder 的底层数组所有权移交给结果避免最终复制。关键前提通常是 Count 恰好等于 Capacity没有未使用尾部操作成功后 Builder 被重置不应把它当成仍持有原内容。// 公共 API 示例先在目标包版本编译核验。 var builder ImmutableArray.CreateBuilderint(3); builder.Add(10); builder.Add(20); builder.Add(30); ImmutableArrayint moved builder.MoveToImmutable(); // 原缓冲已被转移之后只能按 API 规定把 builder 当重置后的 Builder 使用。如果预估容量大于最终 CountMove 可能因“数组未填满”而失败而不是自动裁剪。调用者可选择 ToImmutable 支付复制或在构建协议中准确控制容量。绝不能用反射修改 Count/Capacity 绕过所有权检查。DrainToImmutable等其他版本相关 API 可能提供不同的转移/重置策略只有 reference assembly、官方文档和固定源码能证明它存在及语义。文章不应把最新包 API 当成所有 Unity/.NET 目标都可用。4.3 Builder 生命周期审查不在多个线程无锁共享同一个 Builder发布后不通过任何数组别名写入结果存储预估容量基于上界高估会提高构建期内存且妨碍移动条件Builder 被池化时清理引用并防止峰值容量长期驻留元素若实现 IDisposable清 Builder/丢弃快照不会自动 Dispose 元素异常中断构建时明确 Builder 和已创建资源的清理责任。五、读取路径索引、ItemRef、Span 与枚举普通索引器按值返回 TImmutableArrayLargeValue values LoadValues(); LargeValue copy values[0];当 T 是大型结构体时按值返回可能复制整个结构体。目标版本支持时ItemRef(index)可返回ref readonly T让调用者只读引用原槽位// 公共 API 示例ItemRef 是否可用须按包版本核验。 ref readonly LargeValue value ref values.ItemRef(0); Consume(in value);引用不能逃逸超过底层数组的安全生命周期也不能转成可写 ref。对小型值类型或引用类型ItemRef 未必更快JIT 也可能消除某些复制必须用反汇编和基准验证。AsSpan()可暴露只读连续视图便于切片和调用 Span APIReadOnlySpanint span result.AsSpan(); ReadOnlySpanint middle span.Slice(10, 20);Span 视图不复制元素但有ref struct生命周期限制不能跨await不能装箱也不能通常存入普通堆对象字段。default ImmutableArray 调用 AsSpan 的具体行为应按目标版本测试业务最好先规范化状态。5.1 枚举器何时可能装箱直接对静态类型为 ImmutableArray 的值 foreach编译器通常采用其模式枚举器枚举器为结构体可避免为枚举器分配对象foreach (int value in result) Consume(value);但若先转换为IEnumerableT、IReadOnlyListT或非泛型接口结构体集合或枚举器可能装箱并经接口分派IEnumerableint sequence result; // 可能装箱 ImmutableArray 包装值 foreach (int value in sequence) Consume(value);“可能”必须通过目标编译器、JIT 和分配诊断核验。LINQ 链还可能引入迭代器、委托或闭包分配这不是 ImmutableArray 本体必然产生也不能用“结构体所以绝无分配”掩盖。六、相等性同内容不一定相等ImmutableArray 的相等性主要体现包装的底层数组身份而不是逐元素结构相等。两个分别创建但内容相同的数组使用或Equals不应被当作内容相等判断var a ImmutableArray.Create(1, 2, 3); var b ImmutableArray.Create(1, 2, 3); var c a; Console.WriteLine(a c); // 共享同一存储身份 Console.WriteLine(a b); // 不要期待因内容相同而为 true内容比较应使用SequenceEqual并明确元素比较器bool sameContents a.AsSpan().SequenceEqual(b.AsSpan());对于引用类型元素默认 SequenceEqual 又取决于元素自己的相等性浮点、字符串文化和领域对象可能需要专用 comparer。内容相等是 O(n)把它放进频繁状态比较要衡量成本。default 与 Empty 的身份也不同。若它们在业务中都代表“无元素”内容比较函数需要先规范化若 default 代表“未加载”则应保留差异。不要让Equals偶然决定领域状态。七、线程安全安全发布不等于元素深度不可变一旦构造完成且没有可写数组别名多个线程可以并发读取同一 ImmutableArray无需为集合结构加锁。创建新版本也不会修改旧版本读者可以继续使用自己的快照。private ImmutableArrayRoute _routes ImmutableArrayRoute.Empty; public ImmutableArrayRoute Snapshot _routes; public void ReplaceRoutes(IEnumerableRoute routes) { ImmutableArrayRoute next routes.ToImmutableArray(); ImmutableInterlocked.Update(ref _routes, static (_, replacement) replacement, next); }示例使用System.Collections.Immutable8.0.0 提供的ImmutableInterlocked.Update原子转换 API把当前值替换成已构造的快照目标包若不同必须由 reference assembly 编译核验。不要想当然地认为“只有一个引用字段所以任意结构体读写必定符合所需内存顺序”。多个写者做读—改—写时应把完整转换放进ImmutableInterlocked.Update(ref location, transformer)、正确的 CAS 循环或锁中若先在外部读旧值再直接发布后写者仍可能覆盖先写者更新Writer A 读取旧快照 S生成 SA Writer B 读取旧快照 S生成 SB A 发布 B 发布A 的更新丢失更重要的是浅不可变性sealed class PlayerState { public int Score { get; set; } } var player new PlayerState { Score 10 }; var snapshot ImmutableArray.Create(player); player.Score 999; // snapshot[0].Score 也观察到变化ImmutableArray 只保证不能通过其 API 替换槽位并不冻结 PlayerState。真正的跨线程快照应让元素自身不可变、深复制元素或采用清楚的同步/所有权规则。数组中保存byte[]、List、Dictionary 时同样如此。八、GC、LOH 与大型结构体包装结构体自身可能内联在对象字段、栈上或寄存器中但后备 T[] 是托管对象。元素越多或 T 越大数组字节数越大超过目标运行时的大对象分配边界时会进入相应大对象管理路径。阈值与行为是运行时细节文章不应凭元素个数写死。应计算实际元素布局并用运行时诊断验证。每次 Add/Remove 产生的新数组在旧快照仍被读者引用时不能回收。快照模式的峰值内存取决于更新速率和最慢读者生命周期即使每次只改一个元素整数组复制也可能让多个大版本同时存活。若集合大且更新频繁树式持久化集合、分块快照、差量日志或加锁可变结构可能更合理。引用类型数组复制的是引用速度和内存字节数与大型结构体数组不同但数组仍让所有元素对象可达。Empty 不会持有业务元素default 没有后备数组。Builder 的多余容量包含默认槽位已移除引用的槽位应由实现清理但池化 Builder 或保留巨型容量仍会占空间。大型结构体 T 带来三类成本数组本身更大复制/插入移动更多字节索引按值返回可能复制。使用 ItemRef 只能改善最后一类并不能让结构更新避免复制。结构体内若含对象引用GC 还需扫描相应字段。内存分析应同时查看 allocation rate、retained size、快照代数、LOH 情况和 GC 暂停不要把“本次 foreach 零分配”扩大为“ImmutableArray 零内存成本”。九、Unity 中的包与 API 边界Unity 是否可用 ImmutableArray 取决于编辑器版本、API Compatibility Level、已安装的System.Collections.Immutable包或程序集、依赖解析、Mono/IL2CPP 和目标平台。桌面net8.0能编译的ItemRef、AsSpan 或 Builder API不代表 Unity 当前配置可用。使用前应做最小 Player 构建而不只在编辑器中看 IDE 补全固定 Unity 与包版本并提交 lock/manifest编译目标平台检查裁剪与 AOT对公开 API 做运行测试尤其是 Span、泛型值类型和原子更新用 Profiler 在 Player 中观察构建快照的分配和 GC不从工作线程访问只能主线程操作的 UnityEngine.Object。适合场景包括加载后发布的关卡配置、技能定义、路由表和系统间只读快照。每帧对大型实体列表 Add/Remove 再发布通常不是好模式可在帧内用 Builder/Native 容器构建在明确同步点发布或选择 ECS/Jobs 适配的数据结构。ImmutableArray 的线程安全不会使其中的 GameObject 可跨线程安全访问。十、反例与改进反例一循环 Add 构建大集合每次都创建更长数组总复制量二次增长。改用准确容量 Builder最后根据所有权条件选择 ToImmutable 或 MoveToImmutable。反例二把 default 当 Empty 到处传播某些成员在远处失败序列化语义含混。API 入口规范化或把 default 明确定义为“未初始化”并尽早拒绝。反例三公开数组别名后声称不可变底层数组仍被调用方写入。无法证明所有权转移时使用复制创建低层封装仅限封闭、可审计边界。反例四快照里放可变对象其他线程修改元素字段读者看到变化甚至数据竞争。用不可变 record、深复制或元素级同步。反例五通过 IEnumerable 传递所有热循环可能产生装箱和接口分派。热路径保留具体类型、Span 或专用重载并用测量证明必要性普通业务代码优先可读性。反例六多个写者直接赋值读—改—写丢失更新。使用 ImmutableInterlocked、锁或单写者架构并测试竞争。反例七以为 Clear/丢弃快照会 Dispose 元素不可变集合只管理引用不管理资源生命周期。拥有 IDisposable 元素的上层必须定义何时释放并避免仍在旧快照读取时提前 Dispose。十一、可复现实验实验应分开回答四个问题读取成本、接口装箱、构建成本和更新时的内存峰值。下面是 BenchmarkDotNet 骨架属于实验代码不是库源码using BenchmarkDotNet.Attributes; using System.Collections.Immutable; [MemoryDiagnoser] public class ImmutableArrayBenchmarks { [Params(16, 4096)] public int N { get; set; } private ImmutableArrayint _items; [GlobalSetup] public void Setup() _items Enumerable.Range(0, N).ToImmutableArray(); [Benchmark] public int DirectEnumeration() { int sum 0; foreach (int item in _items) sum item; return sum; } [Benchmark] public int InterfaceEnumeration() { IEnumerableint source _items; int sum 0; foreach (int item in source) sum item; return sum; } [Benchmark] public ImmutableArrayint BuilderBuild() { var builder ImmutableArray.CreateBuilderint(N); for (int i 0; i N; i) builder.Add(i); return builder.MoveToImmutable(); } }只有实际包支持 MoveToImmutable 且容量条件满足时该基准才可编译运行。应另建逐项 Immutable Add、Builder.ToImmutable、CreateRange、数组遍历和 List 的对照不能把语义不等价的操作放在同一排行榜。大型结构体测试要定义真实字段并使用返回校验防止工作被消除。报告记录 TFM、SDK/runtime、System.Collections.Immutable 解析版本、CPU、OS、JIT/AOT、GC 模式、N、T 形态、完整分配结果和原始统计。内存峰值实验需要让旧快照按真实读者时长存活单次方法结束后的 allocated bytes 无法反映多版本共存。正确性测试还应覆盖default 与 Empty数组来源修改不穿透安全创建路径Builder 发布后修改不影响 ToImmutable 结果Move 后 Builder 状态两个同内容独立数组的身份相等和内容相等区别多写者原子更新可变元素反例AsSpan/ItemRef 边界和空集合。十二、选型结论ImmutableArray 适合长度相对稳定、连续读取频繁、快照需要廉价传递的场景。它的结构体包装轻索引直接枚举可走具体类型快路这些优点来自“连续数组 受控可写别名”不是神奇的零成本不可变性。若大量逐项更新考虑 Builder 批量发布若大集合每次只改少量项且旧版本长期共存评估结构共享的 ImmutableList/ImmutableDictionary 或领域分块结构若只有单线程可变阶段普通数组/List 加清晰所有权可能更简单若需要跨线程写采用原子发布或锁而不是依赖 readonly struct 猜测安全。最终审查四件事default 与 Empty 是否有明确业务语义后备数组是否存在可写别名Builder 发布采用复制还是符合条件的所有权移动元素本身是否真正适合跨线程共享。把这四点讲清才能知道 ImmutableArray 的成本在哪里、不可变保证到哪里。下一篇ImmutableList持久化树与结构共享
返回列表