尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

.NET AssemblyLoadContext 可卸载性(Unloadability)设计解析:插件隔离与动态代码回收的运行时原理

.NET AssemblyLoadContext 可卸载性(Unloadability)设计解析:插件隔离与动态代码回收的运行时原理 .NET AssemblyLoadContext 可卸载性Unloadability设计解析插件隔离与动态代码回收的运行时原理【免费下载链接】runtime.NET is a cross-platform runtime for cloud, mobile, desktop, and IoT apps.项目地址: https://gitcode.com/GitHub_Trending/runtime6/runtime本篇文章以 .NET runtime 仓库中 AssemblyLoadContext 可卸载性设计文档 为核心主线深入讲解AssemblyLoadContext如何成为可卸载插件的构建基石从 API 形态Unload、IsCollectible、运行时支撑AssemblyLoaderAllocator、LoaderAllocatorScout到两阶段卸载流程与各类功能的解锁interop、线程静态、COM、FixedAddressValueType。读完本文你将理解可卸载上下文与普通上下文的本质差异掌握如何在自己的应用中安全地加载、运行并回收动态代码并知晓哪些场景如 Roslyn 编译器服务、dotnet watch、LINQPad 式驱动隔离最适合受益于这一机制。一、设计目标与边界什么是可卸载的 AssemblyLoadContext1.1 Goals设计目标根据 unloadability.md 的原始定义本特性的目标非常聚焦提供可卸载插件的构建基石用户可以将一个程序集及其依赖加载进可卸载的AssemblyLoadContext中。允许多个可卸载上下文共存进程中可以同时存在多个互不干扰的可卸载AssemblyLoadContext实例。引用消失即回收当外部对上下文内程序集的类型、类型实例及程序集本身不再有强引用时用户即可卸载该上下文。支持显式与隐式两种卸载触发既可主动调用Unload方法立即启动卸载也可在该上下文及其内部所有程序集的外部引用全部消失后由终结器finalizer自动触发卸载。1.2 Non-goals明确不做的事设计文档明确划定了边界不支持通过线程中止thread abort之类的强制手段强行停止正在执行上下文内代码帧的线程。也就是说可卸载性是协作式的——代码应当自行退出上下文而不是被运行时暴力打断。1.3 Unsupported scenarios暂不支持的场景在调研细节后设计方决定在未收到强烈反馈之前不在可卸载上下文中支持以下场景场景处理方式加载 IJWItanium 中间语言/本地代码混合程序集不支持使用 R2RReadyToRun预生成代码不支持R2R 程序集将按纯 IL 方式加载FixedAddressValueTypeAttribute固定地址值类型字段原为不支持已在 .NET 9 中补充支持详见后文其中 R2R 程序集以纯 IL 加载这一点意味着需要预编译性能的场景不应依赖可卸载上下文来承载。二、典型应用场景谁需要可卸载的 ALC2.1 通用场景文档归纳了社区反馈中反复出现的三类需求插件系统需要动态加载与卸载插件的场景动态编译-运行-刷新例如 Web 站点、脚本引擎等需要编译代码 → 执行 → 丢弃的场景一次性内省仅为反射检查而临时加载程序集用完后希望其被回收。2.2 现实世界案例的可行性评估设计文档还分析了几个真实项目用以评估AssemblyLoadContext卸载能否替代老的AppDomain卸载Roslyn历史上用AppDomain隔离同名异构测试程序集每个测试一个AppDomain太慢后改为复用在 .NET Core 上它们直接使用不卸载的AssemblyLoadContext每测试一个上下文即可。未来在**编译器服务compiler server**中分析器analyzers执行用户代码需要隔离理想做法是每次带分析器的编译在独立AssemblyLoadContext中运行、编译结束后卸载该上下文。ASP.NET Core经典 ASP.NET 曾依赖AppDomain支持页面动态编译但AppDomain既没带来性能收益也给出虚假的安全感ASP.NET Core 因缺乏卸载能力转向静态模型。但两个工具仍可能受益dotnet watch源文件变更即重编译重部署进程重启开销大若能只卸载/重载变更程序集将显著提升体验以及 Razor 页面按需编译的编译服务器。LINQPad大量使用AppDomain卸载——驱动隔离数据源访问组件可动态加载/卸载、查询服务器的轻量隔离、为内省而隔离加载程序集、按每驱动-每 schema隔离构建数据上下文。Prism其目录目录directory catalog实现用ReflectionOnlyLoad加载程序集、发现IModule后销毁域AssemblyLoadContext完全可胜任这一用途。这些案例共同指向一个结论可卸载 ALC 是 AppDomain 卸载在 .NET Core/.NET 5 时代的继承者尤其适合短暂、隔离、可丢弃的执行单元。三、API 设计新增的公共接口面从公共 API 角度看特性只增加了几处新成员新的AssemblyLoadContext构造函数允许创建可卸载类型的上下文AssemblyLoadContext.Unload()方法显式发起卸载AssemblyLoadContext.IsCollectible属性查询该上下文是否可卸载MemberInfo.IsCollectible与Assembly.IsCollectible属性帮助开发者对可收集程序集采取正确做法例如避免长时间缓存其中类型。3.1 源码中的 API 形态在 AssemblyLoadContext.cs 中可以看到完整的构造函数体系与卸载入口protected AssemblyLoadContext() : this(false, false, null) { } protected AssemblyLoadContext(bool isCollectible) : this(false, isCollectible, null) { } public AssemblyLoadContext(string? name, bool isCollectible false) : this(false, isCollectible, name) { }isCollectible参数直接决定该上下文是否可卸载并被保存在_isCollectible字段中通过public bool IsCollectible _isCollectible;第 267 行暴露。Unload()的实现第 499-508 行则严格把关public void Unload() { if (!IsCollectible) { throw new InvalidOperationException(SR.AssemblyLoadContext_Unload_CannotUnloadIfNotCollectible); } GC.SuppressFinalize(this); InitiateUnload(); }即对不可收集的上下文调用Unload会抛出InvalidOperationException卸载前先抑制终结器因为下面会走显式卸载路径随后进入InitiateUnload。3.2 可收集性的反射面MemberInfo.IsCollectible见 MemberInfo.cs与Assembly.IsCollectible见 Assembly.cs的默认实现均返回true由各类型在运行时按实际情况覆写。这一对属性是可收集性感知编程的基础——例如缓存MemberInfo时应当意识到其背后的程序集可能随时被卸载缓存键不应使上下文长期存活。3.3 构造函数中的生命周期布线从源码第 83-113 行可以看到构造可收集 ALC 时有一套精心的生命周期安排非可收集上下文GC.SuppressFinalize(this)终结器永远不会运行可收集上下文用GCHandleType.WeakTrackResurrection创建跟踪复活的弱句柄并传给原生侧InitializeAssemblyLoadContext而非可收集上下文用强句柄GCHandleType.Normal所有存活上下文登记在进程级AllContexts字典中以WeakReferenceAssemblyLoadContext(this, true)形式保存——即全局字典不会阻止可收集上下文被回收。另外值得注意的是终结器路径第 115-127 行未显式调用Unload的可收集上下文在 GC 回收其托管对象时会自动走InitiateUnload这正对应设计文档中所有引用消失后自动卸载的目标。四、运行时支撑AssemblyLoaderAllocator 与可收集类型体系4.1 建立在可收集类型之上本特性的设计直接构建在 CLR 已有的可收集类型collectible types之上。隔离容器由AssemblyLoadContext提供所有加载进可卸载上下文中的程序集都被标记为可收集。对于每个可卸载的AssemblyLoadContext运行时都会创建一个AssemblyLoaderAllocator用于分配与上下文内程序集相关的全部上下文专属内存。可收集类型既有的支持机制保证了这一行为。4.2 AssemblyLoaderAllocator 的三重职责AssemblyLoaderAllocator除了作为内存分配器还承担了维护DomainAssembly清单记录加载进对应AssemblyLoadContext的全部DomainAssembly实例以便卸载时定位并销毁为绑定缓存分配条目AssemblySpecBindingCache中与可卸载上下文内程序集相关的条目也由该分配器分配承载机器码 thunk 的生命周期尾调用参数拷贝 thunktail call arg copy thunk、shuffle thunk 缓存、JIT helper 日志 thunk 及其 unwind 信息其生命周期都必须绑定到AssemblyLoadContext。设计文档特别指出这些 thunk 在既有可收集类型实现中并非从正确的AssemblyLoaderAllocator分配因此本次特性更新了 thunk 相关代码以修复该问题。4.3 卸载时的联动销毁卸载发生时以下组件以协调一致的方式被销毁AssemblyLoaderAllocator原生侧其托管侧辅助对象LoaderAllocator与LoaderAllocatorScoutCustomAssemblyBinder相关的全部DomainAssembly实例AssemblyLoadContext本身五、为可卸载解锁的既有能力可收集类型在 CLR 中引入时为避免复杂度和满足当时的用例显式禁用了一批功能。本特性为可卸载上下文逐一解锁5.1 Interop 支持移除禁止加载含 interop 函数的程序集的检查即可——文档指出这几乎是一行改动的难度。5.2 线程静态成员Thread Static这是最具技术挑战的一环。引用类型或含引用的类型的线程静态值存储在托管object[]数组中而该数组此前由非托管线程对象上的强句柄持有生命周期与线程一致。若用于可收集类线程静态实例将存活到线程结束AssemblyLoadContext将永远无法卸载。解决方案借鉴普通静态regular statics的做法保存可收集类线程局部引用的托管object[]数组改为由对应LoaderAllocator分配出的句柄持有而非全局强句柄——使它们能在卸载时被回收线程终止时释放所有这些句柄。这要求改造LoaderAllocator中的句柄分配器因为既有实现无法释放单个句柄若不支持在线程持续创建/销毁的场景下句柄表会爆炸LoaderAllocator被销毁时与之关联的程序集在每个现存线程的ThreadLocalBlock中存储的所有ThreadLocalModule实例一并销毁运行时中所有禁止在可收集类型上使用线程静态的检查全部移除。5.3 可收集程序集内类型的委托封送Delegate Marshaling只需移除运行时中阻止相关委托封送的检查。非托管到托管的 thunk 从全局LoaderAllocator分配并在对应托管委托被回收时释放——因此该行为对可卸载性无需任何改变。5.4 可收集类型的 COM Interop既有 COM interop 实现本就支持AppDomain卸载每个使用 COM interop 的域会创建ComCallableWrapperCache实例负责处理托管 COM 服务对象消失但 COM 客户端仍持有接口引用的情况——此时 CCWCOM 可调用包装器对所有调用返回失败码但保持存活直到接口引用全部释放。对AssemblyLoadContext卸载的改造很小将ComCallableWrapperCache实例从AppDomain迁移到LoaderAllocator移除COM 接口寿命超过托管对象的兼容逻辑——这在 ALC 卸载场景下不可能发生托管 COM 对象的实例会持有AssemblyLoadContext因此只有 COM 引用全部释放后卸载才能推进到LoaderAllocator销毁阶段AssemblyLoadContext卸载启动、托管LoaderAllocator被收集后ComCallableWrapperCache在LoaderAllocator::Destroy中被销毁。5.5 FixedAddressValueTypeAttribute.NET 9 的补全带FixedAddressValueTypeAttribute的字段始终被固定pinned内存地址永不改变。历史上对不可收集类型这类字段由 pinnedGCHandle固定但对可收集类型不能用此法——因为指向值类型装箱实例中MethodTable的指针会阻止托管LoaderAllocator被收集。自 .NET 9 起这类字段总是分配在 Pinned Object Heap固定对象堆中既无需句柄即可保持固定又能够被回收。对应特性声明见 FixedAddressValueTypeAttribute.cs。六、AssemblyLoadContext 卸载流程两阶段详解6.1 参与组件的引用关系理解卸载流程前需先厘清各组件间的生命周期关系原文档附有unloadability.svg架构图绿色标记为本次新增的引用关系黑色为既有关系虚线表示对象MethodTable到LoaderAllocator的间接链接。核心关系可概括为托管AssemblyLoadContext↔ 原生CustomAssemblyBinder自定义绑定器CustomAssemblyBinder↔AssemblyLoaderAllocator原生原生AssemblyLoaderAllocator↔ 托管LoaderAllocator/LoaderAllocatorScout对象实例 → 其类型的MethodTable→ 对应LoaderAllocator6.2 第一阶段启动卸载卸载由用户代码调用AssemblyLoadContext.Unload或AssemblyLoadContext终结器执行而启动步骤如下触发Unloading事件给用户代码机会做清理例如停止上下文内运行的线程、移除引用、销毁句柄等。托管侧RaiseUnloadEvent通过Interlocked.Exchange(ref _unloading, null!)?.Invoke(this)AssemblyLoadContext.cs保证事件只触发一次InitiateUnload调用创建一个指向AssemblyLoadContext的强 GC 句柄使上下文在卸载完成前保持存活例如上下文内类型的终结器可能仍需访问AssemblyLoadContext以该强句柄为参数调用AssemblyNative::PrepareForAssemblyLoadContextRelease进而调用CustomAssemblyBinder::PrepareForLoadContextRelease。原生实现见 assemblynative.cpp该方法将传入的强 GC 句柄存入CustomAssemblyBinder::m_ptrManagedStrongAssemblyLoadContext递减AssemblyLoaderAllocator的引用计数CustomAssemblyBinder所指向的那个最后销毁指向托管LoaderAllocator的强句柄——这使托管LoaderAllocator可以被 GC 收集。从托管源码AssemblyLoadContext.cs可以印证该序列InitiateUnload在锁内检查状态为Alive后先GCHandle.Alloc(this, GCHandleType.Normal)创建强句柄再调用PrepareForAssemblyLoadContextRelease随后将状态置为Unloading并把自身从全局AllContexts字典移除。6.3 第二阶段实际销毁该阶段在上下文内程序集类型的全部实例都已消失后启动GC 收集托管LoaderAllocator已无强引用随之LoaderAllocatorScout也可被收集LoaderAllocator持有其唯一引用。LoaderAllocatorScout带终结器终结器执行时调用原生LoaderAllocator::DestroyDestroy递减所有引用本LoaderAllocator的LoaderAllocator的引用计数再递减自身计数若并非最后一个引用原生AssemblyLoaderAllocator必须存活到引用清零——它将由LoaderAllocator::GCLoaderAllocators在最后引用消失后销毁若已释放最后一个引用执行LoaderAllocator::GCLoaderAllocators实现见 loaderallocator.cpp找出所有已不再存活的 collectibleLoaderAllocator清理其中全部 domain assemblies——将每个DomainAssembly从AppDomain与绑定缓存中移除、通知调试器、最后销毁该DomainAssembly销毁这些LoaderAllocator中指向托管AssemblyLoadContext的强句柄与长弱句柄使相关AssemblyLoadContext可被 GC 回收这些LoaderAllocator被登记到AppDomain等待清理最终在终结器线程的Thread::DoExtraWorkForFinalizer中实际销毁销毁LoaderAllocator时关联的CustomAssemblyBinder一并销毁。6.4 两阶段设计的本质整个流程的精髓在于**引用计数 GC 协作**第一阶段只做断链事件通知、强句柄转移、引用计数递减、释放托管句柄第二阶段等待 GC 自然回收托管对象再由LoaderAllocatorScout的终结器驱动原生资源的级联销毁。这保证了只要还有对象实例无论托管侧还是 COM 侧引用上下文原生AssemblyLoaderAllocator就通过引用计数保持存活一旦引用归零整个分配器链连同绑定器、程序集与上下文被彻底回收。七、实践要点与注意事项结合设计文档与源码使用可卸载AssemblyLoadContext时应当遵循以下实践必须显式选择可收集创建上下文时传入isCollectible: true或使用protected AssemblyLoadContext(bool isCollectible)派生构造否则Unload()抛InvalidOperationException卸载是协作式的Unloading事件中应停止运行于上下文内的线程、释放句柄与外部引用不要依赖强杀警惕静态与缓存引用可收集程序集中的静态字段、线程静态、长期缓存的MemberInfo/Assembly引用都可能阻止卸载——这正是MemberInfo.IsCollectible/Assembly.IsCollectible存在的意义缓存逻辑应据此避免长期持有理解性能取舍可卸载上下文内的程序集按纯 IL 加载R2R 降级为 IL且每个上下文配有专属AssemblyLoaderAllocator创建与卸载都有成本适合加载-运行-丢弃的短生命周期场景而非高频热路径验证卸载完成可通过等待LoaderAllocatorScout终结器运行的方式如GC.CollectGC.WaitForPendingFinalizers循环确认上下文真正被回收。八、测试与验证参考仓库中针对可卸载上下文的托管测试可帮助理解行为契约。例如 AssemblyLoadContextTest.cs 中通过断言alc.IsCollectible的值第 184-228 行验证不同构造方式下可收集性的预期。读者可结合 AssemblyLoadContext 源码 与原生实现 loaderallocator.cpp、assemblynative.cpp 深入追踪卸载全流程将本文所述的两阶段机制与实际代码一一对应。【免费下载链接】runtime.NET is a cross-platform runtime for cloud, mobile, desktop, and IoT apps.项目地址: https://gitcode.com/GitHub_Trending/runtime6/runtime创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表