← ALL ARTICLES

CSharp运行模型

全库目录221
本文目录12

编辑器能通过反射创建类型,AOT构建却可能缺少实例或被剥离。C#运行模型先看IL如何变成本机代码,再看值类型复制、装箱、委托可达性与异步状态机。托管回收和原生资源释放有不同职责。

编译与程序集

C# 的运行模型

C# 编译为 .NET 程序集与中间语言,随后由运行时 JIT 或 AOT 成本机代码。具体路径取决于平台和引擎。

Unity 中需要区分 Mono、IL2CPP 和平台 AOT:

  • 编辑器/部分平台可使用 JIT;
  • IL2CPP 把 IL 转换为 C++ 再编译;
  • AOT 对运行时泛型实例、反射和动态代码生成有额外限制;
  • 托管代码剥离可能删除只通过反射访问的类型。

因此只在编辑器运行成功的反射/动态加载代码,不一定能在目标平台工作。需要链接器配置、显式引用和设备构建测试。

反射可达与代码剥离是构建问题。测试时为只经反射创建的类型写保留配置或显式注册,并在实际AOT构建里验证构造、泛型与调用路径。编辑器中的可用类型不能证明目标产物也包含它。

程序集与模块边界

Unity程序集定义(Assembly Definition)固定一组源码的编译与依赖边界。若运行时程序集引用编辑器类型,目标平台没有对应编辑器环境,打包就会失败;把适配器放进仅编辑器编译的程序集,再让运行时只依赖稳定接口,才能消除这条不合法依赖。测试程序集按需引用领域核心,UI修改也不必触发所有核心源码重编译。

拆成更多文件夹不会自动改变依赖。程序集之间形成环时,先识别共同接口或职责归属,再改引用关系;把所有源码重新并到一个程序集只是隐藏了环,仍让修改互相影响。

值、引用与可达关系

class 是引用类型,变量保存对象引用;struct 是值类型,赋值和传参可能复制。

大结构体频繁复制会产生 CPU 成本;可变结构体也容易出现“修改了副本”的错误。小型、不可变、表达单值的数据更适合结构体。

装箱会把值类型包装为托管对象,例如通过非泛型接口或 object 使用。热循环中的装箱会产生分配和 GC 压力。

拆箱要求对象实际装箱的值类型与目标类型匹配。它先检查类型,再从包装对象中取出值;不能把装箱的 int 直接拆成 long。泛型集合和泛型接口让值类型使用专门实例,通常可以避免装箱。

值类型作为接口调用、传给 object、使用非泛型集合或某些格式化路径时可能发生隐式装箱。判断时应查看 IL、性能分析器分配或生成代码,不能只凭源码表面推测。

把可变struct从列表索引器取出时得到一个值。修改这个局部副本不会自动写回列表,需要显式赋回,或选择提供引用语义的访问接口。设列表中第0项结构体的生命值为10,var copy = list[0]后执行copy.hp = 0,列表仍为10;执行list[0] = copy才变成0。反之列表存类实例时,两变量指向同一实例,修改字段可见于另一引用。判断错误先画出两份值还是两个指向同一对象的引用。装箱则把值复制进一个独立托管对象,随后修改原值不会改变已装箱副本。检查这两类错误时先追踪“哪一份数据被写入”,再讨论分配成本。

委托与订阅关系

委托是类型安全的函数引用,可以组合多个调用目标。事件在委托外增加发布边界,订阅方只能订阅和取消,只有声明事件的类型能够触发。长生命周期发布者持有短生命周期订阅者时,未取消订阅会让对象保持可达。

泛型、延迟枚举与相等性

C# 泛型在编译和运行时保留类型信息,能提供类型安全,并减少值类型通过 object 传递产生的装箱。约束用于声明类型参数必须提供的能力,例如接口、引用类型、值类型或无参构造。

设查询q从列表筛选hp>0,但尚未枚举;创建q后把某个生命值由10改为0,第一次枚举看到的是改后的数据,重新枚举还会再次执行条件。调用ToList则在当时生成结果快照,但元素若是类引用,列表本身复制不等于深复制对象。

LINQ提高表达力,但许多操作使用延迟执行、迭代器、委托和闭包。重复枚举会重复计算,捕获变量可能分配闭包对象,ToList 会立即分配结果。编辑器与离线工具可以优先可读性,实时热路径需要用性能分析器核对。

引用类型默认按引用身份比较,string 和实现了值相等的类型除外。自定义键若重写 Equals,必须提供一致的 GetHashCode;参与哈希的字段在放入 Dictionary 后不应变化。

异步状态与继续执行

async/await 把异步操作的后续过程编译成状态机。它不会自动创建线程。IO 等待可以在不占用工作线程时挂起,CPU 密集工作仍需要明确调度。Task 表示未来结果,异常会保存在 Task 中,调用方应 await 或显式观察。

以读取配置为例,方法先打开流,调用异步读取;若读取尚未完成,编译后的状态机保存流、缓冲与恢复位置,把未完成Task返回给调用方。读取完成以后,继续执行部分取得实际结果,验证字节,再返回配置。调用方await的是这个结果,而不是某条固定线程。

text
开始 → 打开流并发起读取
若读取未完成:保存局部状态与恢复位置 → 返回未完成Task
读取完成:恢复 → 取得结果或异常 → 验证数据
finally路径释放流 → Task完成或失败

这是语义示意,不代表具体编译器生成字段。await遇到已经完成的操作时可以直接继续,不必挂起。继续执行的调度受await对象和同步上下文约定影响,不能假定恢复到任意工作线程或自动回到引擎主线程。CPU密集循环若在首次await之前执行,调用线程仍会被它阻塞。

如果调用者没有等待Task就销毁流,后台读取可能仍借用这个句柄;如果用同步Wait阻塞一个需要处理继续执行的单线程上下文,又可能让完成路径无法运行。把资源作用域覆盖整个异步读取,并让调用链观察完成和异常,才能同时解释这两类故障。取消仍是请求,其终态与借用寿命见 文件IO。

读取与确定性释放

IDisposable 用于确定性释放文件句柄、原生缓冲、锁等非托管资源。using/using var 会在作用域结束时调用 Dispose。终结器只提供兜底,执行时间不确定,并增加 GC 成本;安全封装原生句柄时优先使用 SafeHandle。

C#机制片段使用IDisposable宿主,读取失败或正常退出都由using释放流。

csharp
using var stream = File.OpenRead(path);
var bytes = new byte[checked((int)stream.Length)];
stream.ReadExactly(bytes);

上面的读取片段面向.NET7及以上提供的 Stream.ReadExactly,不能假定所有Unity运行时都支持。checked阻止长度转换溢出,但仍要先限制业务允许的最大文件大小;文件长度读出后若文件被截断,ReadExactly会因未读满抛出异常,using仍负责释放流。大文件使用固定大小缓冲分块读,避免一次分配整个长度。

验证方法

比较IL与性能分析器分配,测试struct副本、装箱、订阅释放和异常路径。Unity接入同时检查Mono/IL2CPP与目标AOT,尚未执行实际引擎运行。

实现与证据

正文C#读取片段要求.NET7+,未编译该片段或构建Unity IL2CPP。值复制、装箱与AOT分别给出验证目标,不能由编辑器运行替代设备构建。

相关主题

参考资料