在工控、低代码平台或微服务网关的开发中,我们常常需要动态加载业务 DLL。然而,绝大多数开发者都遭遇过“Dll 内存泄露”的尴尬:插件虽然不再使用,但占用的内存就是降不下来。统计显示,因插件卸载不彻底导致的内存累积泄露,平均贡献了后台系统崩溃诱因的 30% 以上。
今天咱们就来掰扯掰扯这玩意儿。这篇文章将结合实际生产项目,一步步解构如何使用 .NET 8 中的 AssemblyLoadContext(简称 ALC)实现无损、无泄露的插件热拔插。
💡 问题深度剖析:为什么你的 DLL 卸不掉?
很多老铁在动态加载程序集时,习惯直接调用 Assembly.LoadFrom。
对,这很省事。但问题是:它是个单行道,一旦载入就无法回头。
1. 致命的默认上下文绑定
默认情况下,所有被加载的 DLL 都会塞入系统默认的应用程序域上下文。在这里,程序集被强引用。即使你手动将插件对象置为 null,垃圾回收器(GC)依然会无视你的请求,直接跳过它们。
2. 元数据引用的“藕断丝连”
在 C# 里,不仅“代码对象”占用内存,程序集的“元数据”(类定义、方法表等)也会常驻非托管堆。任何外部订阅的事件(Event)、静态字段、甚至反射出的元数据缓存,只要有一个地方没处理干净,整个程序集和它依赖的所有库就全卡在内存里了。这就是常说的“死锁引用”。
🛠️ 核心要点提炼
想要构建一套能够“挥一挥衣袖不带走一片云彩”的插件机制,必须牢记三个法宝:
• 隔离程序集上下文(ALC):将每个插件塞进一个专有的、可回收的 AssemblyLoadContext容器中。• 契约程序集共享原则:公共的接口定义(比如 IPlugin契约)千万不能在插件专属 ALC 中重复加载,否则宿主和插件拿到的类型不一样,直接报InvalidCastException。• GC 强制回收链:卸载插件后,需要主动调用 GC.Collect()并等待终结器执行完毕。
🚀 解决方案设计与落地

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);
}
}2. 隔离加载:定制 PluginAssemblyLoadContext
这是最核心的部分。我们要重写 AssemblyLoadContext 并将其属性设置为 isCollectible: true(可回收)。同时,我们要拦截契约程序集,不让它被二次加载:
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. 插件管理:动态生命周期的调度者
这个管理器负责加载 DLL,扫描实现了 IPlugin 的类并执行实例化。更关键的是,它还能执行一键完美卸载:
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);
// 排除契约以及管理器自身的 DLL
if (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>
publicIEnumerable<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}");
}
}
}⚠️ 踩坑预警:卸载失效的排查清单
即使你在 ALC 上加了 isCollectible: true,偶尔还是会发现内存纹丝不动。这就得开启“抓鬼”模式了。请检查你的插件代码,确保它没有犯下以下罪行:
1. 事件订阅没有双向解绑:
宿主类订阅了插件里的某个事件,或者插件订阅了宿主的全局事件。在OnUnload时如果没有执行-=,引用链条就还在!2. 开启了常驻后台的异步 Task 或 Timer:
插件在OnLoad里启动了一个Task.Run(async () => { while(true)... })。如果卸载时不主动通过CancellationToken取消它,这个后台线程将无限存活并牢牢锁死它所在的程序集。3. 被宿主本地变量或全局容器(静态 List)捕获:
宿主程序在调用GetPlugins<IPlugin>()之后,千万不要把返回的插件对象挂载到宿主的常驻静态字段上,否则 GC 一定无法回收。
🎯 互动传播设计
在用 C# 开发各类插件或者热更新系统时,各位老铁最常踩中的“泄露坑”是哪一个?大家目前在生产环境中使用的是古老的 AppDomain 还是最新的 AssemblyLoadContext 方案呢?
💡 实战挑战:大家可以尝试在插件代码里添加一个静态的大型
byte[]数组,分别在卸载前后通过Process.GetCurrentProcess().PrivateMemorySize64抓取宿主进程内存,看看本套方案的实际释放效果!
🏁 结尾呼应
通过这套基于 AssemblyLoadContext 的隔离加载与热卸载系统,我们不仅规避了古老 AppDomain 那低效且繁琐的跨域封送(MarshalByRefObject)成本,更实现了在同一个进程内对不合规插件的快速清洗与更新,极大提升了系统的健壮性。
为了让大家少走弯路,以下是一份简易的后续学习路线图:
1. 基础:掌握 .NET进程内存布局(托管堆 vs 非托管堆)2. 进阶:使用 dotMemory或dotnet-dump抓取并分析被锁程序集的引用根链3. 架构:结合 FileSystemWatcher实现自动化检测 DLL 变更、秒级无缝自动热替换。
觉得有用的话,欢迎微信打赏鼓励一下,让我有动力继续输出这类实战内容。完整源码结构已在各节逐一呈现,可结合项目实际直接落地;如需完整源码,可在公众号聊天窗口获取。
夜雨聆风