夜雨聆风学习资料网

ARTICLE · 1160542

C# 动态加载 DLL:解决插件内存泄露问题

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 等工具纳入日常性能排查流程,以进一步保障系统的长期稳定运行。

推荐阅读:
C# 生产者消费者模式实战:从并发瓶颈到高吞吐量系统
Google 官方下场安利:Go 是 AI 时代的"理想语言"?其实 C# 更有资格坐这个位置
JVMGuard 来了,.NET 开发者别慌:你的 dotnet-monitor 早就准备好了
C# 复刻了一个PLC,我才搞懂扫描周期到底是怎么回事
用 C# 构建多租户应用:共享库、独立库与租户上下文边界
MCP 第五版 × OpenClaw.NET:从协议升级到生态编排

点击下方卡片关注DotNet NB

一起交流学习

▲点击上方卡片关注DotNet NB,一起交流学习

请在公众号后台

回复【路线图】获取.NET 2026开发者路线
回复【原创内容】获取公众号原创内容
回复【峰会视频】获取.NET Conf大会视频
回复【个人简介】获取作者个人简介
回复【年终总结】获取作者年终回顾
回复【加群】加入DotNet NB 交流学习群
长按识别下方二维码,或点击阅读原文。和我一起,交流学习,分享心得。

相关学习资料