Unity 中的 Span、foreach 与 LINQ:从"零分配"口号到可验证热路径
系列 :C# 与常用数据结构源码剖析 · Unity 实战篇
阅读时间 :约 85 分钟
版本口径 :示例以 Unity 2022.3 LTS、API Compatibility Level 为 .NET Standard 2.1 作为最低讨论基线,并与桌面
.NET 8区分。具体补丁版、平台、包与后端仍需用该项目验证。后端口径 :分别讨论编辑器/Player 的 Mono、IL2CPP,以及 Unity Burst + Jobs;不把任何一个的生成代码推广给另外两个。
代码约定:代码均为业务示例或实验代码,不冒充 Unity/运行时逐字源码;"可能分配"必须由目标 Player Profiler 证实。
一、先建立四层版本模型
Unity 项目里"支持某个 C# 功能"至少有四层:
- 语言/编译器:项目使用的 C# 编译器是否认识范围运算、ref struct、模式枚举等语法。
- reference assembly/API profile :编译时是否存在
Span<T>、某个 Parse 重载、CollectionsMarshal等类型和成员。 - 类库与运行时:目标 Player 实际带的 BCL 实现、GC、线程和互操作语义。
- backend/codegen:Mono JIT、IL2CPP AOT 或 Burst 如何把 IL/受支持子集生成机器码。
语法编译通过不证明桌面 .NET 8 的所有 API 都可用;API 可用也不证明 IL2CPP 与 CoreCLR 的优化相同;编辑器没有 GC Alloc 更不证明真机 Player 相同。本文将"Unity 2021.2+ 完整支持 Span、IL2CPP 下 Span 就是零开销指针"这类绝对表述替换为可检查边界。
升级 Unity、切换 API Compatibility Level、添加 NuGet DLL 或 asmdef 平台约束后,都应重新构建目标 Player。仅在 IDE 补全中看到成员,不算兼容性验证。
二、Span 的本质:带长度的短生命周期借用视图
2.1 ref struct 不是普通容器
Span<T>/ReadOnlySpan<T> 是 ref struct,概念上保存起始引用与长度。它不拥有内存,不负责释放,也不保证底层内容不变。编译器通过 ref-safety 规则限制它装箱、成为普通 class 字段、跨越不允许的 await/yield、被闭包捕获或逃逸到更长生命周期。
// 业务示例:切片不复制字符,生命周期不超过 raw。
static bool TryReadCommand(string raw, out int id)
{
ReadOnlySpan<char> input = raw.AsSpan();
int separator = input.IndexOf(':');
if (separator <= 0)
{
id = default;
return false;
}
return int.TryParse(input[..separator], out id);
}
这段是否做到该调用路径"零托管分配"还取决于所用 Unity 类库是否提供 Span 的 int.TryParse 重载、调用方是否分配 raw、日志与委托等周边。Span 切片自身不复制字符,但不能把整个方法概括成必然零分配。
ReadOnlySpan<T> 只禁止通过该视图写入,不让底层数组/原生存储深度不可变。其他别名仍可能修改内容;跨线程使用需要外部同步和生命周期证明。
2.2 数组与字符串边界
数组可隐式/显式转换为 Span,切片仍指向原数组。只要 Span 活着,编译器/运行时会维护相应托管引用生命周期;它不是裸指针。字符串产生 ReadOnlySpan<char>,不能通过它写字符串。
Span<T> 的索引器与 Slice 会检查边界。它可能让 JIT/AOT 消除可证明冗余的检查,但 Span 并不等于"关闭边界检查"。越界按托管契约抛异常。
2.3 stackalloc 边界
// 业务示例:固定小上限;阈值必须按目标线程栈验证。
Span<byte> scratch = stackalloc byte[128];
scratch.Clear();
EncodeHeader(scratch);
stackalloc 存储只在当前方法栈帧语义中有效,不经普通 GC 回收。长度来自网络或资产时必须先限制;大/循环 stackalloc 可能耗尽线程栈。不要写死某个平台"默认栈一定多少 MB"。内容也不应假定自动清零。
若大小可能较大,可用 ArrayPool<T>,但租到的数组可能更长、含旧数据且必须归还。Span 只是借用视图,不知道数组已归还;归还后继续使用是所有权错误。
三、托管、固定和 Native 内存的边界
3.1 Span 不能让 Native 内存自动安全
从 NativeArray<T>、插件地址、NativeMemory 或 Unity UnsafeUtility 构造 Span/指针时,调用方必须保证:地址有效、长度正确、T 布局合法、内存在视图结束前未释放、没有与 Job 冲突的并发访问。
// 教学边界:真实 Unity API/命名空间按包版本核验。
unsafe static Span<T> Borrow<T>(void* pointer, int length)
where T : unmanaged
{
if (pointer == null && length != 0)
throw new ArgumentNullException(nameof(pointer));
if (length < 0)
throw new ArgumentOutOfRangeException(nameof(length));
return new Span<T>(pointer, length);
}
这个函数无法证明 pointer 真的覆盖 length * sizeof(T) 字节,也无法延长 owner 生命周期。生产封装应绑定 owner 或让回调在 owner 生存期内消费,而不是返回可任意保存的视图。
3.2 fixed 与 GC 移动
将托管数组地址传给原生同步调用时,fixed 在块内 pin;原生方不得在返回后保留指针。IL2CPP 生成 C++ 不会自动把托管数组变成永久稳定的原生数组,仍应遵守该 Unity 后端的 pin/互操作契约。
长期异步原生操作应使用生命周期覆盖操作的固定句柄或非托管缓冲,并在完成时释放。哪种 GC 会移动、当前对象是否实际移动,都不能代替正确 pin 协议。
3.3 NativeArray 与 Jobs
Unity NativeArray<T> 是拥有/借用 Native 存储及安全句柄语义的容器。Allocator.Temp、TempJob、Persistent 的生命周期规则和 Job 依赖必须遵守。主线程在 Job 未完成时 Dispose,或 Job 写入时主线程同时读,都不是 Span 能解决的。
开发环境的 Collections Safety Checks 能帮助发现别名/生命周期问题;在 Release/Burst 中检查可能不同,契约并未消失。每个 Native 容器所有权要能回答由谁 Dispose、在哪个 JobHandle 完成后释放。
四、Memory:需要跨 async 时的拥有关系
Span 受栈生命周期约束,不能作为普通异步状态机长期保存。Memory<T>/ReadOnlyMemory<T> 是可存入对象和跨 await 的普通结构,通常包装数组、字符串或 MemoryManager<T>:
// 业务示例:Memory 可跨 await;每次使用时短暂取得 Span。
static async Task ParseLaterAsync(
ReadOnlyMemory<byte> payload,
CancellationToken token)
{
await WaitForTurnAsync(token);
Parse(payload.Span);
}
Memory 也不一定拥有底层资源。若它包装池数组,调用者必须保证 await 完成前不归还;若包装自定义 MemoryManager,Dispose/Pin 规则另行定义。跨异步最安全的公共契约通常需要清楚说明"复制进入方法"还是"调用期间借用"。
Unity 版本是否包含所需 Memory API、Socket/Stream 的 Memory 重载,应看 reference assembly,不用桌面 .NET 8 文档猜测。
五、CollectionsMarshal 等 API:先确认存在,再谈风险
桌面 .NET 的 CollectionsMarshal.AsSpan(list) 能暴露 List<T> 当前后备数组的有效区间,但它位于 System.Runtime.InteropServices,并不是 Unity 2022.3 .NET Standard 2.1 profile 必然提供的 API。复制 DLL 或包也可能因运行时内部契约不同而不能安全使用。
即使目标确实提供,返回 Span 只在 List 未发生可能改变后备数组/结构的修改期间有效:Add 触发扩容、Insert、Remove、Clear 等与视图并用会破坏语义;绕过 List API 写元素还可能绕开版本号,使已有枚举器无法检测内容变化。
// 桌面 .NET 8 示例,不作为 Unity 2022.3 可用性承诺。
Span<Entity> items = CollectionsMarshal.AsSpan(list);
for (int i = 0; i < items.Length; i++)
items[i].Tick();
Unity 项目优先使用其版本明确支持的公开 API、普通 List 索引、数组、NativeArray 或专用数据布局。不要为省一次属性访问反射获取 List 私有数组。
MemoryMarshal、Unsafe、GC.AllocateArray<byte>(length, pinned: true) 等同理:语法、泛型 API 签名、程序集引用和后端实现都要验证。这里的完整调用是桌面 .NET 示例,不代表 Unity 2022.3 profile 必然提供该 API。
六、foreach 是否分配,取决于静态类型与枚举器形态
6.1 数组
编译器通常把一维数组 foreach 降低为按索引循环,不需要创建接口枚举器:
int[] values = GetValues();
foreach (int value in values)
Consume(value);
这不代表循环体不分配;Consume、日志、闭包等仍可能分配。多维数组和不同编译器 lowering 需单独查看 IL。
6.2 List<T> 的具体静态类型
当表达式静态类型是 List<T>,编译器的 foreach pattern 可直接调用 List<T>.GetEnumerator(),得到结构体 Enumerator;正常调用不需要因为 IL2CPP 就强制把它装箱成 IEnumerator<T>。
List<int> list = GetList();
foreach (int value in list)
Consume(value);
旧稿声称这在 IL2CPP 下必然通过 IEnumerable 装箱,是错误的。是否有其他分配仍应在目标 Player 测量,且 Enumerator 会检查 List 版本,枚举中结构修改会失败。
6.3 接口擦除
IEnumerable<int> sequence = list;
foreach (int value in sequence)
Consume(value);
这里调用接口 GetEnumerator()。List 的结构体 Enumerator 作为 IEnumerator<int> 返回时通常需要装箱,或实现可能返回接口对象;具体分配由类库/backend 决定。把参数声明成 IEnumerable<T> 带来通用性,也丢失具体 pattern 的静态信息。
IList<T> 静态类型同样没有保证暴露 List 的具体 GetEnumerator pattern;foreach 绑定应看编译器选择的方法,不凭运行时对象实际是 List 推断。
6.4 自定义枚举器
C# foreach 支持模式:类型只要有可访问 GetEnumerator,返回类型有 Current 和 MoveNext,就不必实现 IEnumerable。自定义 struct enumerator 可以避免接口装箱,但若被转成 IEnumerator/IEnumerable 或捕获到接口字段仍可能装箱。
枚举器自身也可能是 class,或 GetEnumerator 内部分配。readonly struct、扩展 GetEnumerator、ref struct enumerator 与 ref foreach 还有版本规则,须看实际编译器 IL。
6.5 装箱之外的成本
即使零分配,List foreach 仍有 MoveNext/Current 和版本检查;for 有索引与 Count;backend 可能内联并消除部分成本。哪个更快取决于 T、循环体、backend 和平台,不应为"零 GC"机械把所有 foreach 改成 for。可读性和修改安全也是成本。
七、LINQ:区分迭代器、委托、闭包和终结缓冲
7.1 延迟迭代器
Where、Select 通常返回延迟查询对象。创建查询时可能创建 iterator 对象和委托;枚举时才拉取源并执行 predicate/selector。具体源为数组/List 时,.NET/Unity 类库可能选择专用 iterator,但不能假定所有链都融合成零对象。
// 业务示例:query 创建与枚举是两个测量区间。
IEnumerable<Enemy> query = enemies.Where(static e => e.IsAlive);
foreach (Enemy enemy in query)
enemy.Tick();
static lambda 明确不捕获,但委托实例是否缓存、查询对象是否分配仍取决于编译器和类库。普通无捕获 lambda 常被缓存,不等于 Where iterator 不存在。
7.2 闭包
float radius = CurrentRadius;
var query = enemies.Where(e => e.Distance < radius);
lambda 捕获 radius,编译器通常生成 closure/display class,把 radius 变成字段;委托引用该对象。若在 Update 中每次创建,可能每帧分配闭包和查询对象。也可能通过重构为显式循环、缓存不可变参数或使用支持 state 参数的专用 API避免捕获;标准 Enumerable.Where 没有 state 重载。
不要为消除闭包把可变半径放静态字段,造成线程安全与重入错误。先测量,再选择语义正确的替代。
7.3 终结与缓冲操作
ToList、ToArray、GroupBy、OrderBy 等会分配/缓冲不同结构。FirstOrDefault、Any、Count 是终结操作,但某些源有快速 Count 路径,某些必须枚举;Aggregate 可不建立集合,但用户累加器仍可能分配。
OrderBy 至少需要读入并排序索引/元素;GroupBy 在第一个组前缓冲全源;Distinct 建哈希集合;Join 缓冲 inner。不能把它们与 Where/Select 的逐项流式行为放在一个"LINQ 每帧三次分配"公式中。
7.4 重复枚举
var alive = enemies.Where(e => e.IsAlive);
int count = alive.Count();
foreach (Enemy enemy in alive) { ... }
源与 predicate 被执行两次。若源变化,两个结果甚至不是同一快照;selector 有副作用则重复发生。确需复用快照可 ToList 一次并承担分配,或在一次手写循环中同时计数和处理。选择是 CPU、内存与一致性权衡。
八、字符串与日志:最常被忽略的分配源
即使遍历本身不分配,下列每帧代码仍可能创建字符串、参数数组或装箱值:
Debug.Log("Enemies: " + enemies.Count);
Debug.LogFormat("Position: {0}, {1}", x, y);
日志 API、插值字符串处理器和 Unity 版本支持不同。关闭日志级别也不一定阻止调用前已经完成的字符串拼接。热路径用条件保护构造,或使用项目日志框架的延迟/结构化 API,并验证它是否真的避免装箱与参数数组。
Substring 创建新字符串;Span 切片不创建子字符串,但最终若 API 只接受 string,调用 .ToString() 仍会分配。解析 API 是否有 ReadOnlySpan 重载取决于 Unity reference assembly。
string.Split 返回字符串数组和子字符串;简单协议可用 Span/索引手写解析,但必须处理空字段、转义、Unicode、文化与错误输入,不能为了 GC 写一个不完整 CSV 解析器。复杂格式使用经过验证的解析库并在加载阶段完成。
不要在 Profiler marker 名、异常消息或测试日志中引入大量动态字符串后,把分配误归因于 foreach/LINQ。
九、Mono、IL2CPP、Burst 与 Jobs 的不同优化面
9.1 Mono
Unity 编辑器和部分 Player 配置使用 Unity 的 Mono 分支。其 JIT、类库版本、内联和逃逸能力不是桌面 .NET 8 RyuJIT。编辑器还包含 Console、Inspector、Deep Profile 等额外噪声。Mono 结果主要用于开发反馈,不替代目标 Player。
9.2 IL2CPP
IL2CPP AOT 生成 C++ 后由平台编译器优化。foreach lowering 已在 C# 编译阶段决定,接口装箱语义也由 IL 与类库路径决定;不能说"没有 JIT,所以所有 List foreach 装箱",也不能说"AOT 会消除所有迭代器"。检查生成 C++/符号与真机 Profiler。
泛型实例、异常、托管分配和 Unity GC 仍存在。Span 可能被高效降低,但它仍有边界和生命周期语义。
9.3 Burst + Jobs
Burst 针对受支持的 C# 子集和 Native 容器优化,不支持任意托管 List、LINQ、闭包和对象图。通常把热数据布局成 NativeArray/NativeSlice,在 IJob/IJobParallelFor 等中循环。Burst 的向量化、别名分析和安全检查取决于版本、属性及代码形态。
Job 只解决调度/并行的一部分。Native 容器分配器生命周期、依赖、竞争、批大小和主线程同步点仍需设计。不要为了避免 LINQ 分配把小循环盲目搬 Job,调度成本可能不合适;以真机 Timeline 和 Burst Inspector 为证据。
十、安全替代模式
10.1 一次循环完成筛选和处理
// 业务示例:不需要结果集合时直接消费。
for (int i = 0; i < enemies.Count; i++)
{
Enemy enemy = enemies[i];
if (enemy.IsAlive)
enemy.Tick();
}
若需要结果快照,复用一个明确所有者的 List,并在每帧 Clear。Clear 保留容量但清引用;不能把同一结果 List 暴露给跨帧持有者,否则下一帧重用会篡改观察结果。
10.2 缓存静态查询不是万能方案
缓存委托可减少闭包/委托创建,但 LINQ iterator 往往仍按查询创建;缓存完整 IEnumerable 又可能绑定旧源、延长对象生命周期或不是线程安全可重入。通常缓存数据索引或结果快照比缓存延迟查询对象更明确。
10.3 数据驱动索引
每帧 Where(e => e.Team == team) 可改为在实体加入/换队/离开时维护按队伍的集合;每帧 Join 可改为稳定 ID 的 Dictionary。把查询成本移到状态变化点,但要维护索引一致性与生命周期。
10.4 小缓冲分层
有上限的临时字节使用 stackalloc Span;可变大缓冲用 ArrayPool;跨 Job 使用 NativeArray;跨 async 使用有明确所有权的 Memory/数组;发布长期只读数据使用数组/不可变快照。不要让"零分配"迫使所有层共用一种危险缓冲。
十一、故障反例
- 声称 Unity 某版本"完整支持 Span",却未写 API profile 和目标平台。
- 认为 IL2CPP 把 Span 变指针,因此无边界、无生命周期成本。
- 返回 stackalloc Span/指针或让它跨 await。
- 归还 ArrayPool 后继续使用先前 Span。
- Job 未完成就 Dispose NativeArray,或主线程同时写。
- 在 Unity profile 不含 CollectionsMarshal 时照抄桌面 .NET 代码。
- 持有 List 的 AsSpan 视图时 Add/Remove 并继续使用旧视图。
- 断言具体
List<T>foreach 在 IL2CPP 必然装箱。 - 把 List 擦成 IEnumerable 后忽略接口枚举器装箱/对象路径。
- 认为 static lambda 让整条 LINQ 查询零分配。
- Count 后 foreach 重复枚举有副作用的延迟查询。
- 每帧 OrderBy/GroupBy/ToList,却只统计 iterator 对象而漏掉全量缓冲。
- 优化 foreach 后仍每项拼接 Debug.Log 字符串。
- 用手写 Span CSV 解析器处理转义数据却没有完整语法测试。
- 只在编辑器 Deep Profile 测量并推广到 IL2CPP Release 真机。
- 将热循环搬入 Job,却忽略调度、Complete 同步和 Native 分配。
十二、Profiler 与真机实验方法
12.1 分离创建和枚举
对 LINQ 使用两个 ProfilerMarker:一个只创建 query,一个完整枚举并消费结果。分别测无捕获/static lambda、捕获 lambda、数组/List/IEnumerable 静态类型、ToList/OrderBy/GroupBy。否则无法知道分配来自 closure、iterator、enumerator 还是终结缓冲。
12.2 foreach IL 矩阵
编译以下静态类型:T[]、List<T>、IEnumerable<T> 指向 List、IList<T>、自定义 struct/class enumerator。查看 C# 生成 IL 的 GetEnumerator 返回类型、box、constrained. 和 try/finally Dispose;再看 IL2CPP 生成 C++/符号。IL 解释语义,Profiler 证实可观察分配。
12.3 Span API 矩阵
在 Unity 2022.3 reference assembly 中编译 AsSpan、Span Parse 重载、Memory API、MemoryMarshal 和 CollectionsMarshal;记录哪些真正存在。对支持 API 分别构建 Editor Mono、Standalone Mono(若平台提供)和 IL2CPP。不要通过条件编译悄悄让 Player 走另一逻辑而未测试等价性。
12.4 真机采样
至少包含目标设备 Release/非 Development Player;若必须 Development 获取 Profiler 数据,另做 Release 性能验证。预热场景,固定输入与帧数,关闭无关日志。记录 GC.Alloc、总分配率、GC 事件、主线程/Job 时间、帧时间分位数和内存峰值,不只报告平均毫秒。
Profiler 自身、Deep Profile、Autoconnect 和 Development checks 会改变行为。把原始 Profiler capture、Unity 完整版本、Scripting Backend、API profile、Managed Stripping、Burst 版本与设备信息随结论保存。
12.5 生命周期和正确性
性能前先做属性/边界测试:Span 空/截断/超长输入;池缓冲 finally 归还;NativeArray Job 完成后才释放;LINQ 多次枚举结果与手写参考循环一致;foreach 修改集合按契约失败。错误但零分配没有价值。
十三、选型矩阵
| 需求 | 首选起点 | 必须验证 |
|---|---|---|
| 同步解析现有数组/字符串片段 | Span/ReadOnlySpan | Unity API 重载、边界、无跨 await |
| 跨 await 传递缓冲 | Memory/ReadOnlyMemory 或拥有型数组 | owner 在 await 期间存活、池归还时机 |
| 小型同步临时缓冲 | 有上限 stackalloc Span | 线程栈、长度上限、初始化 |
| 每帧遍历 List | 清晰的 foreach 或 for | 静态类型、循环体分配、目标后端 |
| 通用 IEnumerable API | 保持接口或泛型 pattern 设计 | 装箱权衡、可组合性、测量 |
| 查询初始化数据 | LINQ 通常可接受 | 缓冲量、重复枚举、加载阶段预算 |
| 高频筛选无快照 | 手写单循环/维护索引 | 一致性、可读性、结果所有权 |
| Burst 并行热数据 | NativeArray + Job/Burst | 支持子集、依赖、Allocator 生命周期 |
| 原生插件同步借用 | fixed/NativeArray 互操作 | pin 范围、ABI、插件不保存指针 |
"零分配"只是一项约束。API 可用性、正确生命周期、代码可维护性、CPU、缓存和帧尾延迟同样重要。一次加载阶段的短期 LINQ 分配可能比复杂手写索引更合理;每帧数万次缓冲查询则值得重构。
十四、总结:按调用形态测量,不按关键字定罪
Span 是带长度的短期借用,不是所有权容器,也不是"IL2CPP 原生指针零开销"同义词。数组、stackalloc、池和 Native 内存各有不同生命周期;跨 async 使用 Memory 仍要绑定 owner。桌面 CollectionsMarshal 等 API 不能默认出现在 Unity profile。
foreach 是否分配由表达式静态类型、GetEnumerator 返回类型、接口转换、枚举器实现与 backend 共同决定。数组和具体 List 的常见路径不应被统一判为分配;擦成 IEnumerable 或自定义 class iterator 又可能不同。
LINQ 成本要拆成查询 iterator、委托、捕获闭包、接口枚举,以及 ToList/OrderBy/GroupBy 等终结缓冲。延迟执行还会带来重复枚举与数据变化。字符串和日志经常比循环本身分配更多。
Unity 优化的可靠流程是:写清完整版本与四层边界,在正确性测试后查看 IL,分别对 Mono、IL2CPP 与 Burst 适用路径做真机 Profiler,最后选择最简单且满足帧预算的实现。不要优化关键字;优化已经被证据定位的调用路径。