ARTICLE · 1160542
C# 动态加载 DLL:解决插件内存泄露问题
前言
在工业控制系统、低代码平台以及微服务网关等场景中,动态加载业务程序集(DLL)是一项基础且高频的需求。无论是为了实现插件化架构,还是为了支持运行时功能更新,开发者都绕不开动态程序集加载这一环节。然而,一个长期困扰着广大 .NET 开发者的痛点始终存在:插件在卸载后,其占用的内存无法被有效回收,最终导致进程内存持续膨胀,直至崩溃。
据统计,因插件卸载不彻底所导致的内存累积泄露,平均约占后台系统崩溃诱因的三成以上。这一数字足以引起我们对这一问题的重视。本文将结合 .NET 8 提供的 AssemblyLoadContext 机制,从原理剖析到代码落地,一步步构建一套支持无损、无泄露热拔插的插件加载系统。
一、问题根源:为什么 DLL 卸载如此困难
很多开发者习惯于直接使用 Assembly.LoadFrom 来加载外部程序集。这个方法使用简便,但其背后的加载机制存在一个致命缺陷:它将程序集绑定到系统的默认应用程序域上下文中,且不支持反向卸载操作。一旦程序集被加载,它就会一直驻留在内存中,直到整个进程结束。
更深层次的原因在于两个方面。第一,默认上下文对程序集持有强引用,即便开发者将插件实例置为 null,垃圾回收器也不会回收程序集本身。第二,程序集的元数据(包含类型定义、方法表、属性信息等)常驻于非托管堆中,任何未被正确解除的外部引用——例如事件订阅、静态字段持有、反射缓存等——都会导致整个程序集及其依赖项无法被卸载。这种元数据层面的“死锁引用”是内存泄露的主要元凶。
二、核心解决思路
要构建一个真正可卸载的插件机制,需要遵循三条核心原则:
隔离程序集上下文(ALC):为每个插件创建独立的
AssemblyLoadContext实例,并将该上下文设置为可回收(isCollectible: true)。契约程序集共享:公共的接口定义(如
IPlugin)必须由默认上下文加载,绝不能在每个插件的专属 ALC 中重复加载,否则宿主与插件拿到的类型将不匹配,引发InvalidCastException。强制垃圾回收:卸载插件后,主动触发
GC.Collect()并等待终结器执行,确保所有无根对象被彻底清理。
三、代码实现与落地
3.1 契约定义:宿主与插件的通信协议
以下代码位于共享库 PluginContracts 中,定义了插件必须实现的元数据接口、插件主接口以及上下文接口。
namespacePluginContracts{///<summary>/// 插件元数据,用于描述插件的基本信息///</summary>publicinterfaceIPluginMetadata {string Id { get; }string Name { get; }string Version { get; }string Author { get; }string Description { get; } }///<summary>/// 所有插件必须实现的基础接口///</summary>publicinterfaceIPlugin { IPluginMetadata Metadata { get; }///<summary>/// 插件加载时的初始化回调///</summary>voidOnLoad(IPluginContext context);///<summary>/// 插件卸载时的清理回调///</summary>voidOnUnload(); }///<summary>/// 宿主向插件提供的上下文信息,用于日志输出和获取宿主服务///</summary>publicinterfaceIPluginContext { IServiceProvider Services { get; }voidLog(string message); }}3.2 隔离加载上下文:自定义 AssemblyLoadContext
这里是整个方案的核心。我们继承 AssemblyLoadContext 并将构造函数参数 isCollectible 设为 true,同时重写 Load 方法,拦截契约程序集的加载请求,使其回退到默认上下文。
using System.Reflection;using System.Runtime.Loader;namespacePluginLoader{///<summary>/// 专为插件设计的隔离程序集加载上下文,支持插件的动态加载与卸载///</summary>publicclassPluginAssemblyLoadContext : AssemblyLoadContext {privatereadonly AssemblyDependencyResolver _resolver;// isCollectible: true 是实现“热卸载”的绝对前提!publicPluginAssemblyLoadContext(string pluginPath) : base(isCollectible: true) { _resolver = new AssemblyDependencyResolver(pluginPath); }protectedoverride Assembly? Load(AssemblyName assemblyName) {// 黄金关键点:防止重复加载契约程序集。// 契约必须在默认上下文中共享,否则宿主和插件会认为它们是不同的类型!if (assemblyName.Name == "PluginContracts") {returnnull; }string? assemblyPath = _resolver.ResolveAssemblyToPath(assemblyName);if (assemblyPath != null) {return LoadFromAssemblyPath(assemblyPath); }returnnull; } }}3.3 插件管理器:负责全生命周期调度
管理器负责扫描指定目录、加载 DLL、筛选实现了 IPlugin 的类型并实例化。更重要的是,它提供了 UnloadAll 方法,能够一键执行插件清理、上下文卸载和垃圾回收。
using System.Reflection;using System.Runtime.Loader;using PluginContracts;namespacePluginLoader{///<summary>/// 插件管理器:负责扫描目录、加载、实例化和卸载插件///</summary>publicclassPluginManager : IDisposable {privatereadonly List<(PluginAssemblyLoadContext Context, IPlugin Plugin)> _plugins = new();privatereadonly IServiceProvider _services;publicPluginManager(IServiceProvider services) { _services = services; }///<summary>/// 从指定目录加载所有的插件 DLL///</summary>publicvoidLoadPluginsFrom(string directory) {if (!Directory.Exists(directory)) { Directory.CreateDirectory(directory);return; }foreach (var dll in Directory.GetFiles(directory, "*.dll")) {var fileName = Path.GetFileName(dll);// 排除契约以及管理器自身的 DLLif (fileName.Equals("PluginContracts.dll", StringComparison.OrdinalIgnoreCase) || fileName.Equals("PluginLoader.dll", StringComparison.OrdinalIgnoreCase)) {continue; }try {// 为每一个插件程序集创建独立的 ALC 上下文var context = new PluginAssemblyLoadContext(dll);var assembly = context.LoadFromAssemblyPath(dll);var pluginTypes = assembly.GetTypes() .Where(t => typeof(IPlugin).IsAssignableFrom(t) && !t.IsAbstract);foreach (var type in pluginTypes) {var plugin = (IPlugin)Activator.CreateInstance(type)!;var pluginContext = new DefaultPluginContext(_services);// 执行插件自身的载入生命周期 plugin.OnLoad(pluginContext); _plugins.Add((context, plugin)); } }catch (Exception ex) { Console.WriteLine($"[PluginManager] 加载插件失败: {fileName}、错误: {ex.Message}"); } } }///<summary>/// 获取当前所有已加载的特定类型插件///</summary>public IEnumerable<T> GetPlugins<T>() where T : IPlugin {return _plugins.Select(p => p.Plugin).OfType<T>(); }///<summary>/// 卸载所有已加载的插件,释放内存///</summary>publicvoidUnloadAll() {foreach (var (context, plugin) in _plugins) {try {// 1、让插件主动释放自己的事件订阅或定时器 plugin.OnUnload();// 2、卸载上下文 context.Unload(); Console.WriteLine($"[PluginManager] 已成功请求卸载插件: {plugin.Metadata.Name}"); }catch (Exception ex) { Console.WriteLine($"[PluginManager] 卸载插件时发生错误: {ex.Message}"); } } _plugins.Clear();// 3、强行唤醒垃圾回收器,把不再被引用的 ALC 连根拔起 GC.Collect(); GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced, blocking: true); GC.WaitForPendingFinalizers(); Console.WriteLine("[PluginManager] 内存垃圾回收完成,程序集已从非托管堆深度清除。"); }publicvoidDispose() { UnloadAll(); } }internalclassDefaultPluginContext : IPluginContext {public IServiceProvider Services { get; }publicDefaultPluginContext(IServiceProvider services) { Services = services; }publicvoidLog(string message) { Console.WriteLine($"[PluginLog] {DateTime.Now:yyyy-MM-dd HH:mm:ss} - {message}"); } }}四、常见卸载失败原因排查
即便按照上述方案正确实现了 isCollectible: true,有时仍会发现内存未能如期释放。此时应重点检查插件代码中是否存在以下三类常见问题:
1、事件订阅未双向解除:宿主类型订阅了插件内部的事件,或插件订阅了宿主的全局事件。在 OnUnload 中必须显式执行 -= 操作,否则引用链将阻止程序集回收。
2、常驻后台任务未取消:插件在 OnLoad 中启动了无限循环的 Task 或 Timer。若未通过 CancellationToken 在卸载时主动终止这些任务,后台线程将持续存活并锁定程序集。
3、插件实例被宿主静态字段或全局容器持有:宿主在调用 GetPlugins 后,若将返回的插件对象存入静态字段或长期存活的数据结构中,垃圾回收器将无法回收该对象及其所属程序集。
总结
本文从动态加载 DLL 所面临的内存泄露问题出发,详细剖析了默认加载上下文的局限性,并基于 .NET 8 的 AssemblyLoadContext 机制,设计并实现了一套完整的插件隔离加载与热卸载方案。该方案通过为每个插件创建独立且可回收的 ALC、共享契约程序集、以及主动触发垃圾回收,有效解决了程序集无法卸载的顽疾。
相较于传统的 AppDomain 方案,本方法避免了繁琐的跨域封送开销,在同一进程内即可实现插件的快速清洗与更新,显著提升了系统的健壮性与可维护性。在实际生产环境中,建议结合 FileSystemWatcher 实现 DLL 变更的自动检测与无缝热替换,并将 dotMemory 或 dotnet-dump 等工具纳入日常性能排查流程,以进一步保障系统的长期稳定运行。
点击下方卡片关注DotNet NB
一起交流学习
▲点击上方卡片关注DotNet NB,一起交流学习
请在公众号后台
回复【路线图】获取.NET 2026开发者路线 回复【原创内容】获取公众号原创内容 回复【峰会视频】获取.NET Conf大会视频 回复【个人简介】获取作者个人简介 回复【年终总结】获取作者年终回顾 回复【加群】加入DotNet NB 交流学习群 长按识别下方二维码,或点击阅读原文。和我一起,交流学习,分享心得。
