.NET 动态编译与可回收 AssemblyLoadContext 卸载方案

1. 适用场景与问题

当应用需要运行期编译并加载代码,例如规则脚本、在线评测、服务编排或插件执行时,常见的写法会依赖默认 AssemblyLoadContext:

var result = await CSharpScript.RunAsync(source, options, globals);

这种写法在语义和快速原型开发中很方便,但动态生成程序集会被加载到默认 AssemblyLoadContext。默认上下文在应用启动后创建,且不可回收,其中加载的程序集会随进程持续存在,重复编译会持续积累元数据、JIT 代码、类型对象和静态状态。

问题通常表现为:

  • 每次重编译都会生成新的动态程序集;
  • 元数据、类型对象、JIT 代码及静态状态不会随编译对象释放;
  • 临时脚本即使只执行一次,也会长期留在进程内存中;
  • 缓存键越多、执行频率越高,内存增长越明显;
  • GC.Collect 只能回收没有根的普通对象,无法回收默认 ALC 中的程序集。

因此,凡是“运行后可能丢弃”的动态代码,都应使用可回收 AssemblyLoadContext,而不能仅通过清空缓存或置空编译对象来解决。


2. AssemblyLoadContext 的核心机制

2.1 默认 ALC 与可回收 ALC

AssemblyLoadContext(ALC)是 .NET 中程序集加载、依赖解析和类型身份的边界。默认 ALC 承载主程序及其静态依赖,且不支持卸载;自定义 ALC 则可以隔离私有依赖、处理同名程序集,并在可回收模式下实现程序集卸载。

创建可回收 ALC:

var alc = new AssemblyLoadContext(
    name: $"DynamicScript_{Guid.NewGuid():N}",
    isCollectible: true);

CSharpCompilation 编译成功后,将 PE 写入 MemoryStream,再从内存加载:

compilation.Emit(peStream);

peStream.Seek(0, SeekOrigin.Begin);

var assembly = alc.LoadFromStream(peStream);

这种方式不会像 LoadFromAssemblyPath 一样持续锁定原始 DLL 文件,也能将动态程序集置于可回收上下文中。

需要注意,Assembly.Load(byte[]) 或加载到默认 ALC 的路径并不会自动获得可卸载能力;真正能够卸载的是可回收 ALC 及其内部加载的程序集。

2.2 卸载是协作式的

调用 alc.Unload() 只是发起卸载请求,并不会立即回收程序集。卸载能否完成,取决于以下条件:

  • 没有线程调用栈仍停留在 ALC 内程序集的方法;
  • 没有 Assembly、Type、MethodInfo 或成员对象的外部强引用;
  • 没有委托捕获 ALC 内方法或实例;
  • 没有尚未完成的异步状态机、事件订阅或后台任务;
  • 没有通过静态字段、缓存或全局字典保存 ALC 内对象;
  • ALC 本身及其加载程序集不再被根引用。

可以用 WeakReference 验证回收:

var weakAlc = new WeakReference(alc, trackResurrection: true);

alc = null;

for (var i = 0; i < 10 && weakAlc.IsAlive; i++)
{
    GC.Collect();
    GC.WaitForPendingFinalizers();
}

if (weakAlc.IsAlive)
{
    throw new InvalidOperationException(
        "可回收上下文仍然存在,可能仍有外部强引用。");
}

测试代码中的强制 GC 只能用于诊断,不应被当作生产环境的卸载步骤。

2.3 可卸载不等于完全隔离

可回收 ALC 仍然依赖宿主上下文中的程序集,尤其是运行时框架和基础库。跨 ALC 的两个同名类型,即使程序集内容完全一致,也不属于同一个 Type 实例。

因此,宿主和动态代码应使用共享契约程序集定义接口与数据契约,契约程序集通常应在默认 ALC 中统一加载;私有依赖再进入自定义 ALC。返回 null 可让依赖回退到默认 ALC,但要避免私有副本与共享契约发生类型分裂。


3. 推荐实现

3.1 动态编译宿主

宿主至少应同时持有执行委托与可回收 ALC。委托负责跨边界执行,ALC 负责限定生命周期。

public sealed class DynamicScriptHost : IDisposable
{
    private readonly AssemblyLoadContext _alc;
    private readonly object _entryPoint;
    private Func<object, CancellationToken, Task<object?>>? _executor;

    public DynamicScriptHost(
        AssemblyLoadContext alc,
        object entryPoint)
    {
        _alc = alc;
        _entryPoint = entryPoint;
    }

    public Task<object?> ExecuteAsync(
        object globals,
        CancellationToken cancellationToken = default)
    {
        if (_executor is null)
        {
            throw new ObjectDisposedException(nameof(DynamicScriptHost));
        }

        return _executor(globals, cancellationToken);
    }

    public void Dispose()
    {
        _executor = null;
        _alc.Unload();
    }
}

这里的关键顺序是:先释放执行委托,再卸载 ALC。委托可能捕获 ALC 内的 MethodInfo、实例或闭包,如果直接释放 ALC,这些引用仍会阻止回收。

3.2 用委托代替重复反射

动态程序集执行入口应尽早转换成稳定委托。常见流程如下:

var entryType = assembly.GetType("Script.EntryPoint")
    ?? throw new InvalidOperationException("未找到入口类型。");

var method = entryType.GetMethod(
        "Run",
        new[] { typeof(Globals), typeof(CancellationToken) })
    ?? throw new InvalidOperationException("未找到入口方法。");

var executor =
    (Func<object, CancellationToken, Task<object?>>)Delegate.CreateDelegate(
        typeof(Func<object, CancellationToken, Task<object?>>),
        method);

如果入口方法是实例方法,必须显式构造实例并创建开放委托,或者使用明确包含目标对象的闭包。避免将 MethodInfo、Type 和实例长期暴露到宿主缓存中。

入口签名建议统一:

public static Task<object?> Run(Globals globals, CancellationToken cancellationToken);

宿主只依赖固定的共享契约。即使动态程序集后续重新编译,宿主也无需再次反射完整调用链。

3.3 Globals 的边界传递

脚本 globals 模式会将宿主状态映射为脚本中的顶层变量。迁移到普通编译模型后,应通过共享 DTO 显式传参:

public sealed class Globals
{
    public IServiceProvider ServiceProvider { get; init; } = null!;
    public IReadOnlyDictionary<string, object> Arguments { get; init; } = null!;
    public StringBuilder DebugLog { get; } = new();
}

动态脚本示例:

public static class EntryPoint
{
    public static async Task<object?> Run(
        Globals g,
        CancellationToken cancellationToken)
    {
        var serviceProvider = g.ServiceProvider;
        var arguments = g.Arguments;

        g.DebugLog.AppendLine("开始执行");

        return await Task.FromResult(arguments["id"]);
    }
}

如果宿主需要读取动态代码中的诊断结果,应使用基本类型、StringBuilder 或固定 DTO。不要通过 object、Type 或动态类型把私有类型泄漏到宿主。


4. 元数据引用

CSharpCompilation 需要明确、可解析的 MetadataReference。使用 ScriptOptions.AddReferences(Assembly[]) 可能产生 UnresolvedMetadataReference,后续不会自动满足 CSharpCompilation 的引用需求。

推荐将所有依赖转换为文件路径引用并复用:

private static readonly ImmutableArray<MetadataReference> SharedReferences =
    BuildReferences();

private static ImmutableArray<MetadataReference> BuildReferences()
{
    var builder = ImmutableArray.CreateBuilder<MetadataReference>();

    AddFromAssembly(typeof(object).Assembly);
    AddFromAssembly(typeof(Task).Assembly);
    AddFromAssembly(Assembly.GetExecutingAssembly());

    foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
    {
        AddFromAssembly(assembly);
    }

    return builder.ToImmutable();

    void AddFromAssembly(Assembly assembly)
    {
        var path = assembly.Location;

        if (string.IsNullOrEmpty(path)
            || !File.Exists(path)
            || builder.Any(r => r.Display is not null
                && r.Display.StartsWith(Path.GetFileNameWithoutExtension(path), StringComparison.OrdinalIgnoreCase)))
        {
            return;
        }

        builder.Add(
            MetadataReference.CreateFromFile(
                path,
                MetadataReferenceProperties.Assembly));
    }
}

MetadataReference.CreateFromFile 会读取文件内容,并为编译提供确定引用。

需要注意:

  • 全局扫描会包含尚未触发的程序集,可能增加编译时间和引用数量;
  • 生产环境应基于脚本实际依赖生成最小引用集合;
  • 扫描逻辑应去重,避免同名程序集的多个版本同时进入编译;
  • 动态程序集文件可能缺少稳定路径,Assembly.Location 为空时应跳过;
  • 编译期引用不会自动成为运行期 ALC 中的程序集,两者需要分别管理。

5. 生命周期与释放顺序

5.1 常见泄漏链

泄漏来源 结果
缓存 MethodInfo、Type、Assembly 动态程序集始终存在外部根
保存完整执行委托 委托闭包可能保留方法和实例
正在执行的异步方法 异步状态机延长对象生命周期
事件订阅、定时器、后台任务 宿主通过回调持续持有 ALC 对象
静态字段和全局字典 形成进程生命周期内的强引用
缓存 Delegate.Target 实例及其 ALC 无法回收
异常堆栈保留 ALC 内对象 未及时释放时形成临时根

5.2 先释放调用能力,再卸载 ALC

public void Dispose()
{
    _executor = null;

    if (_alc is not null)
    {
        _alc.Unload();
    }

    _alc = null;
}

如果宿主同时缓存多个版本,应先移除版本缓存,再释放执行委托,最后卸载旧 ALC。不能仅调用 Unload(),却仍保留入口类型或执行委托。

5.3 一次执行的生命周期

public async Task<object?> ExecuteOnceAsync(
    string source,
    Globals globals,
    CancellationToken cancellationToken = default)
{
    var alc = new AssemblyLoadContext(
        $"DynamicScript_{Guid.NewGuid():N}",
        isCollectible: true);

    try
    {
        using var peStream = new MemoryStream();

        var compilation = CreateCompilation(source);

        var emitResult = compilation.Emit(peStream);

        if (!emitResult.Success)
        {
            throw new InvalidOperationException(
                string.Join(Environment.NewLine,
                    emitResult.Diagnostics.Select(d => d.ToString())));
        }

        peStream.Seek(0, SeekOrigin.Begin);

        var assembly = alc.LoadFromStream(peStream);

        var executor = CreateExecutor(assembly);

        return await executor(globals, cancellationToken);
    }
    finally
    {
        executor = null;
        alc.Unload();
    }
}

using 负责释放 PE 内存流,finally 负责切断执行引用并发起 ALC 卸载。

5.4 编译成功后再切换缓存

升级脚本时,应先编译新版本,成功后再卸载旧版本:

var oldHost = TryGetCachedHost(key);

var newHost = CompileAndLoad(source);

if (Interlocked.CompareExchange(ref _cache[key], newHost, oldHost) == oldHost)
{
    oldHost?.Dispose();
}
else
{
    newHost.Dispose();
}

这样可以保证编译失败时,旧版本仍可用于执行。


6. 端到端调用链

源代码
  ↓ CSharpSyntaxTree.ParseText
语法树
  ↓ CSharpCompilation + MetadataReference
EmitResult
  ↓ Emit 到 MemoryStream
PE 字节流
  ↓ AssemblyLoadContext.LoadFromStream
可回收 ALC 中的 Assembly
  ↓ GetType / GetMethod
入口方法
  ↓ Delegate.CreateDelegate
强类型执行委托
  ↓ 传入共享 Globals
执行动态代码
  ↓ 返回基本类型或 DTO
宿主结果
  ↓ 释放委托、清空缓存、ALC.Unload
等待 GC 回收

整条链中,只有入口委托、共享 DTO 和明确需要的返回值可以跨出 ALC。动态程序集中的 Type、MethodInfo、实例及异常对象不应进入长期缓存。


7. 编译与执行配置

生产编译应明确输出类型、优化级别和诊断处理:

var compilation = CSharpCompilation.Create(
    assemblyName: $"DynamicScript_{Guid.NewGuid():N}",
    syntaxTrees: new[] { CSharpSyntaxTree.ParseText(source) },
    references: SharedReferences,
    options: new CSharpCompilationOptions(
        outputKind: OutputKind.DynamicallyLinkedLibrary,
        optimizationLevel: OptimizationLevel.Release,
        allowUnsafe: false));

如果不需要调试,不应生成 PDB。否则,PE 和 PDB 都会占用可回收 ALC 生命周期内的内存。

执行入口统一为 Task 或 Task<T>,可以避免脚本同步方法和异步状态机在宿主中出现不同的生命周期路径。异步执行期间,异步状态机会持有入口方法和相关实例,因此必须等待 Task 完成后再释放宿主引用。

动态编译不会自动提供安全边界。执行代码具有宿主进程权限,仍可能访问文件系统、网络或外部资源。在线场景应增加超时、资源限制、代码静态检查和权限控制,而不能仅依赖可回收 ALC。


8. 回收验证与排查

8.1 使用弱引用观察

private static readonly ConditionalWeakTable<object, object> _diagnostics = new();

更有效的诊断方式是保存 WeakReference:

public sealed class TrackedHost : IDisposable
{
    public WeakReference ContextReference { get; }

    public TrackedHost(DynamicScriptHost host, AssemblyLoadContext alc)
    {
        Host = host;
        ContextReference = new WeakReference(alc, trackResurrection: true);
    }

    public DynamicScriptHost Host { get; }
    public bool IsAlive => ContextReference.IsAlive;

    public void Dispose()
    {
        Host.Dispose();
    }
}

IsAlive 为 true 只能证明根尚未回收,不能证明 ALC 已被完全卸载;为 false 则说明 GC 已经无法从根访问该对象。

8.2 排查清单

  1. 确认程序集加载到可回收 ALC,而非默认 ALC。
  2. 检查全局缓存、静态字段和并发字典是否保存动态类型。
  3. 检查事件订阅是否已在执行前或释放前取消。
  4. 检查异步方法是否确实等待完成。
  5. 检查执行委托是否仍被字段、闭包或日志保存。
  6. 检查动态代码是否启动线程、定时器或后台任务。
  7. 检查异常对象是否被长期保存,且保留 ALC 内堆栈。
  8. 检查 AssemblyLoadContext.Unloading 是否已触发。
  9. 检查是否误将可回收程序集加载到不可回收 ALC。
  10. 确认共享契约来自同一 Assembly 实例。

从 .NET 7 开始,不可回收 ALC 的 Load 或 Resolving 返回可回收程序集会抛出 FileLoadException,而不是像早期版本一样可能继续运行并在回收后产生 NullReferenceException 或访问冲突。


9. 设计约束与效果

9.1 应满足的边界

  • 共享契约放在稳定、默认 ALC 统一加载的程序集中;
  • 动态程序集仅返回基本类型、接口或固定 DTO;
  • 不把动态 Type、异常对象或实例放入全局缓存;
  • 不依赖 Assembly.LoadFrom 或默认 ALC 实现卸载;
  • 每个可回收 ALC 只负责一组需要共同卸载的程序集;
  • 执行对象显式实现 IDisposable,先释放委托,再卸载 ALC。

9.2 方案效果

采用上述结构后:

  • 动态程序集、PDB 和元数据可在外部根全部释放后被 GC 回收;
  • 一次性脚本不再把临时程序集遗留到进程生命周期;
  • 多次重编译仅保留当前有效版本,旧版本在释放后进入可回收状态;
  • 调用入口由 CreateDelegate 固定,减少重复反射;
  • 先编译成功、再切换缓存,可避免编译失败时失去可用脚本;
  • 共享契约固定,避免同名类型在不同 ALC 中发生转换失败。

该方案的核心不是“调用 Unload() 后程序集立即消失”,而是建立一条没有外部强引用的生命周期边界:MemoryStream 及时释放,委托先失效,缓存版本安全替换,旧 ALC 才具备被 GC 回收的条件。


10. Bug 修复:跨 ALC 匿名类型与 dynamic 绑定失败

10.1 现象

服务编排生成的业务代码常见写法:

var result = (dynamic)await cache.ScriptRunner.Invoke(engineInstance);
BarcodeType = ((dynamic)result).Data.Type;
UPNS = ((dynamic)result).Data.ListUPN;

在改为 CSharpCompilation + 可回收 ALC(RoslynScriptHost)后,子服务若返回 TheaResponse<匿名类型>,宿主侧访问会抛出:

'object' does not contain a definition for 'Type'

序列化后可以看到 Data 实际有值,例如:

{
  "Data": {
    "Type": "Unknow",
    "OutBarcode": "R2026072300008",
    "LabelId": "",
    "EncodeRuleId": "",
    "LabelDesignID": ""
  },
  "IsSuccess": true,
  "Code": 0
}

说明不是业务数据缺失,而是 跨程序集 dynamic 绑定失败。

10.2 根因

  1. 脚本编译产物落在独立可回收 ALC(如 HarperAdminScript_xxx)中。
  2. 脚本返回 TheaResponse<<>f__AnonymousType0\5<...>>。C# 匿名类型为 **internal`**,属性对外部程序集不可见。
  3. 宿主(默认 ALC)对结果做 (dynamic)result.Data.Type 时,RuntimeBinder 只能看到 object,无法绑定 Type。
  4. 这与“共享契约应来自同一程序集 / 可见类型”的约束一致:匿名类型不是合法的跨 ALC 返回契约。

补充:若用 JSON 把匿名对象反序列化成 ExpandoObject,数组字段会变成 List<object>,再赋给业务侧的 List<string>(如 UPNS = result.Data.ListUPN)时,会二次报错:

Cannot implicitly convert type 'System.Collections.Generic.List<object>'
to 'System.Collections.Generic.List<string>'

10.3 修复策略(不动业务生成代码)

在 RoslynScriptHost 的 ScriptRunner 出口统一做结果规范化,业务侧 ((dynamic)r).Data.Xxx 无需修改:

  1. 将泛型 TheaResponse<T> 转为非泛型 TheaResponse,使 dynamic 绑定到公开的 object Data。
  2. 对不可见 / 编译器生成的匿名类型,用反射拷贝到 ExpandoObject(属性 getter 仍可跨程序集读取)。
  3. 不要用 JSON 往返转换整个对象,以免 List<string> 退化为 List<object>。
  4. 仅当集合元素本身是匿名类型时,才逐元素转成 ExpandoObject;List<string> 等可见类型原样保留。

示意:

ScriptRunner<TheaResponse> runner = async (globals, ct) =>
{
    var result = await innerRunner(globals, ct);
    return MakeDynamicFriendly(result);
};

// MakeDynamicFriendly:
// - TheaResponse<T> → TheaResponse
// - 匿名 Data → ExpandoObject(反射拷贝,保留属性运行时类型)

实现位置:src/HarperAdmin/Domain/Services/RoslynScriptHost.cs(MakeDynamicFriendly / ToDynamicFriendlyData / AnonymousToExpando)。

10.4 约束补充

在 9.1 的边界上再明确:

  • 动态脚本返回值优先使用公开 DTO、ExpandoObject 或基本类型,避免依赖匿名类型跨 ALC 传递;
  • 若历史脚本已大量返回匿名类型,应在宿主出口做规范化,而不是逐个改业务生成代码;
  • 规范化时保留集合元素的运行时类型,避免破坏 List<string> 等强类型赋值。

10.5 验证要点

  1. 子服务返回含匿名 Data 时,宿主 ((dynamic)r).Data.Type 可正常读取。
  2. Data.ListUPN 仍为 List<string>,可赋给业务变量 List<string> UPNS。
  3. 出口包装委托在 Dispose 时随 _runner = null 一起断开,不额外阻塞可回收 ALC 卸载。
posted @ 2026-10-08 11:09  Hey,Coder!  阅读(16)  评论(0)    收藏  举报