在C#中,运行时动态加载和卸载程序集的核心在于使用AssemblyLoadContext。与旧版Framework依赖AppDomain不同,.NET Core及后续版本通过AssemblyLoadContext提供隔离且可卸载的程序集加载环境,使插件化架构和热更新成为可能。借助反射,我们可以在不知道具体类型的情况下调用方法。

一、为什么需要AssemblyLoadContext
在早期.NET Framework中,开发者习惯用AppDomain来加载和卸载DLL,但这种方式开销大且跨域调用复杂。从.NET Core开始,AppDomain的卸载能力被削弱,官方引入了AssemblyLoadContext(简称ALC)作为更轻量的替代方案。ALC不仅能够将程序集加载到独立上下文中避免污染默认上下文,还支持通过Unload方法释放资源,从而解决DLL文件被占用无法替换的问题。
使用ALC时,每个上下文拥有自己的程序集解析逻辑。当你从文件夹动态读取一个插件DLL时,可以新建一个ALC实例,让它优先从该文件夹解析依赖,而不是去全局缓存找。这种隔离对插件系统尤为关键,因为不同插件可能引用同一库的不同版本,ALC能避免冲突。同时,只有将ALC标记为可卸载,并在其中加载的程序集没有外部强引用时,卸载才会成功。
二、动态加载并调用方法的基础实现
下面示例展示如何创建一个可卸载的自定义AssemblyLoadContext,加载指定路径的DLL,并通过反射调用其中某个类的静态方法。我们定义一个继承自AssemblyLoadContext的类,并覆写Load方法以控制依赖解析。
using System;
using System.Reflection;
using System.IO;
public class PluginLoadContext : AssemblyLoadContext
{
private AssemblyDependencyResolver _resolver;
public PluginLoadContext(string pluginPath) : base(isCollectible: true)
{
// 根据插件主DLL路径解析其依赖
_resolver = new AssemblyDependencyResolver(pluginPath);
}
protected override Assembly Load(AssemblyName name)
{
// 尝试从插件目录解析依赖
string path = _resolver.ResolveAssemblyToPath(name);
if (path != null)
{
return LoadFromAssemblyPath(path);
}
return null;
}
}
class Program
{
static void Main()
{
string dllPath = @"C:pluginsMyPlugin.dll";
var alc = new PluginLoadContext(dllPath);
Assembly asm = alc.LoadFromAssemblyPath(dllPath);
// 假设插件中有类 Demo.PluginRunner,包含静态方法 Run
Type type = asm.GetType("Demo.PluginRunner");
MethodInfo method = type.GetMethod("Run");
string result = (string)method.Invoke(null, new object[] { "hello" });
Console.WriteLine(result);
}
}
上述代码通过PluginLoadContext加载MyPlugin.dll,并利用AssemblyDependencyResolver自动处理同目录下的依赖项。调用Invoke执行方法时,如果方法是实例方法,则需要先创建对象实例再传入。注意,LoadFromAssemblyPath会将文件锁住,直到ALC卸载才会释放,因此不要频繁用同一路径重复加载。
在真实项目中,插件方法往往有返回值或抛出异常。建议将反射调用包在try-catch中,并将插件接口抽象为公共契约程序集,主程序引用该契约而插件实现它,这样能减少直接使用字符串类型名的脆弱性。不过即便如此,跨ALC传递类型时仍需小心,因为如果主程序直接引用了插件内部的具体类,会导致ALC无法卸载。
三、实现真正的卸载
仅仅丢弃ALC变量并不能卸载程序集,必须确保没有来自默认上下文的引用。.NET的垃圾回收器会在ALC无强引用且内部程序集类型无存活对象时,通过终结器触发卸载。我们通常使用WeakReference监听卸载状态。
using System;
using System.Reflection;
using System.Runtime.CompilerServices;
class UnloadDemo
{
static WeakReference StartAndUnload()
{
string dllPath = @"C:pluginsMyPlugin.dll";
var alc = new PluginLoadContext(dllPath);
Assembly asm = alc.LoadFromAssemblyPath(dllPath);
Type type = asm.GetType("Demo.PluginRunner");
type.GetMethod("Run").Invoke(null, new object[] { "unload test" });
// 保存弱引用用于后续检查
WeakReference weakRef = new WeakReference(alc, trackResurrection: true);
alc.Unload();
return weakRef;
}
static void Main()
{
var weak = StartAndUnload();
// 强制GC以帮助卸载
for (int i = 0; i < 3; i++)
{
GC.Collect();
GC.WaitForPendingFinalizers();
}
Console.WriteLine(weak.IsAlive ? "尚未卸载" : "已成功卸载");
}
}
代码中调用alc.Unload()只是发出卸载请求,实际释放依赖GC回收。循环调用GC.Collect能加速这一过程,但生产环境不应频繁强制GC。若weak.IsAlive仍为true,通常是因为主程序仍持有插件类型的对象、事件订阅未取消,或静态字段引用了插件类型。
为避免泄漏,插件内部不应在静态构造函数中向主程序上下文的类型注册回调;若必须通信,可使用弱事件模式或依赖注入容器在ALC卸载时清理。另外,文件锁的释放也依赖卸载完成,因此在热更新场景中,只有当WeakReference确认死亡后才能安全覆盖DLL。
四、常见陷阱与优化建议
动态加载中最容易出错的是类型透传。如果主程序直接引用了插件DLL中的具体类(哪怕只是作为返回类型),CLR会将该类加载到默认上下文,导致ALC永远无法卸载。正确做法是定义单独的契约程序集(如IPlugin接口),主程序和插件都引用它,但主程序绝不引用插件实现程序集。
| 问题 | 原因 | 对策 |
|---|---|---|
| DLL文件无法删除 | ALC未卸载或仍有引用 | 使用WeakReference确认卸载后再操作文件 |
| 找不到依赖项 | Load方法未正确解析 | 利用AssemblyDependencyResolver或手动LoadFromAssemblyPath |
| 类型转换异常 | 同名称类型分属不同上下文 | 通过接口或序列化传递数据 |
此外,对于需要频繁加载卸载的高性能场景,应避免在插件中分配大对象或开启非托管资源而未释放。可结合DynamicDependency特性或提前裁剪无用代码,减小插件体积。若方法调用极频繁,反射带来的开销可通过委托缓存(如CreateDelegate)缓解,将MethodInfo转为强类型委托后调用几乎无额外成本。
综合来看,C#在运行时动态加载卸载程序集已不再是难题,关键在于理解AssemblyLoadContext的隔离边界与垃圾回收机制。只要契约清晰、引用干净,就能构建稳定的插件化系统。
AssemblyLoadContext反射DynamicDependency修改时间:2026-08-05 23:48:36