DotNetIsolator序列化原理深挖:MessagePack如何跨越宿主与沙箱传递任意对象
【免费下载链接】DotNetIsolatorA library for running isolated .NET runtimes inside .NET项目地址: https://gitcode.com/gh_mirrors/do/DotNetIsolator
DotNetIsolator 是一个能在 .NET 进程内部启动并运行隔离 .NET 运行时的开源库,它把 .NET 运行时编译成 WebAssembly 模块,用 Wasmtime 当作"沙箱"。可两个运行时内存完全隔离,宿主与沙箱之间到底怎么传递任意对象?答案就是MessagePack 序列化协议。本文带你深挖 DotNetIsolator 的序列化原理,看它如何在宿主与沙箱之间安全、高效地搬运任意 .NET 对象。
为什么宿主与沙箱之间必须有一套序列化协议?
先说背景:DotNetIsolator 的架构是"宿主进程(普通 .NET 程序)+ 沙箱(编译为 WASI 模块的 Mono 运行时)",两者之间唯一的通信通道是wasm 线性内存和几个导出/导入函数。托管堆中的对象指针、GC 句柄在两边完全不可通用,所以:
- 对象没法直接"搬过去",只能编码成字节流再解码
- 字节流必须能表达任意类型,因为调用方传入的参数可能是任何对象
- 字节流还要够紧凑,毕竟要走 wasm 内存拷贝
MessagePack 恰好满足这三条:它是二进制格式、支持无类型(Typeless)序列化、体积小速度快,于是成为这个项目的事实标准。
第一站:宿主如何把参数"打包"进沙箱?
当你调用IsolatedMethod.Invoke(...)时,参数的旅程是这样的:
- 宿主侧先用
MessagePackSerializer.Typeless.Serialize(param0)把参数序列化成字节数组(见 IsolatedMethod.cs) - 再通过
CopyValueLengthPrefixed把这串字节连同 4 字节长度前缀一起复制进 wasm 内存(见 IsolatedRuntime.cs) - 沙箱侧 C 代码
deserialize_param拿到缓冲区后,调用托管方法Serialization.Deserialize,用MessagePackSerializer.Typeless.Deserialize还原出真实的 .NET 对象(见 dotnetisolate.c 和 Serialization.cs)
这里有个容易忽略的细节:如果参数是值类型,沙箱侧还会做一次mono_object_unbox拆箱,返回指向值类型内存的裸指针,因此该对象必须用 GCHandle 钉住(pinned),防止 GC 移动内存导致指针失效。
第二站:沙箱如何把返回值"递回"宿主?
调用完成后,返回值要走相反的路线:
- 沙箱侧
serialize_return_value调用Serialization.Serialize,使用ContractlessStandardResolverAllowPrivate.Options序列化结果 - 序列化后的字节数组同样被 GCHandle 钉住,宿主才能安全读取
- 宿主侧
InvokeDotNetMethod用MessagePackSerializer.Deserialize<TRes>(...)还原返回值
关键点在于:返回值的反序列化刻意没有用 Typeless。代码注释写得很明白——宿主不希望沙箱里的代码通过反序列化让宿主进程实例化任意类型,宿主只会实例化TRes类型图中静态定义的类型,这是一道重要的安全边界。
第三站:沙箱主动呼叫宿主——GuestToHostCall 回调协议
前面讲的是"宿主调沙箱",那沙箱里的代码想调用宿主注册的回调(比如RegisterCallback)怎么办?DotNetIsolator 定义了一个跨边界的调用信封:
GuestToHostCall结构体带有[MessagePackObject]和[Key(0)]等特性,包含回调名、参数数组和原始模式标记(见 GuestToHostCall.cs)- 沙箱侧
DotNetIsolatorHost.Invoke先把每个参数按参数自身类型分别序列化,再把整个信封序列化 - 通过
Interop.CallHost(一个 InternalCall 内部调用)把字节交给宿主(见 Interop.cs) - 宿主侧
AcceptCallFromGuest反序列化信封,再按回调方法声明的参数类型逐个还原参数,最后DynamicInvoke执行回调(见 IsolatedRuntime.cs)
这条链路上还有个安全细节:当回调抛异常时,宿主只把"调用失败,请看宿主日志"这种话术返回给沙箱,绝不暴露宿主内部异常堆栈,因为沙箱被视为不可信代码。
第四站:整个对象的"空投"——CopyObject
如果你想把宿主里的一个完整对象(比如某个类的实例)送进沙箱长期使用,就用IsolatedRuntime.CopyObject<T>(value):
- 宿主用
MessagePackSerializer.Typeless.Serialize序列化整个对象 - 沙箱侧
dotnetisolator_deserialize_object把它反序列化成 MonoObject,返回一个 GCHandle - 宿主拿到的
IsolatedObject就持有这个句柄,之后通过它查找方法、调用方法,用完再ReleaseGCHandle(见 IsolatedObject.cs)
这样,宿主对象就在沙箱里"活"了下来,实现了任意对象的跨边界传递与生命周期管理。
安全与性能的权衡:为什么去程回程区别对待?
把四个方向放在一起看,DotNetIsolator 的序列化设计非常讲究:
| 方向 | 序列化方式 | 信任模型 | 目的 |
|---|---|---|---|
| 宿主→沙箱 参数 | Typeless | 信任宿主 | 灵活传递任意类型 |
| 沙箱→宿主 返回值 | 强类型 Deserialize<T> | 不信任沙箱 | 防止任意类型实例化 |
| 沙箱→宿主 回调 | 按声明类型还原 | 不信任沙箱 | 限定回调参数边界 |
| 宿主→沙箱 整个对象 | Typeless | 信任宿主 | 完整对象空投 |
性能方面也有不少功夫:Program.cs里专门在启动时预热序列化代码路径,避免首次调用时的 JIT 冷启动;宿主侧还使用GeneratedResolver(见 MessagePackGenerated.cs)预生成解析器提升序列化速度。代码注释里还留下了不少优化 TODO,比如用缓冲池避免两侧分配、让宿主直接序列化进沙箱内存实现真正的零拷贝。
总结:一条字节流,串起两个 .NET 世界
DotNetIsolator 用一条清晰的 MessagePack 字节流协议,解决了"隔离的 .NET 运行时之间如何传递任意对象"这个核心难题:参数编码、返回值解码、回调信封、整对象空投四条路径各有分工,同时在安全性和性能上做了精细的取舍——对宿主充分信任,对沙箱处处设防。理解了这套序列化原理,你就能明白为什么它敢说"在 .NET 内运行隔离的 .NET",也更能体会沙箱安全边界设计的巧妙之处。
【免费下载链接】DotNetIsolatorA library for running isolated .NET runtimes inside .NET项目地址: https://gitcode.com/gh_mirrors/do/DotNetIsolator
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考