Привет, Хабр!

Сегодня рассмотрим как в.NET можно горячо подгружать, обновлять и выгружать сборки на лету. Речь пойдёт о AssemblyLoadContext, специальном механизме, благодаря которому мы можем создавать плагинные системы, изолировать зависимости и освобождать память, выгружая неиспользуемые сборки.

Начнём с предыстории. В.NET Framework для изоляции и динамической подгрузки кода использовались AppDomain, можно было создать новый домен приложения, загрузить туда сборки, а потом выгрузить весь домен целиком. В.NET Core (ныне просто.NET 5+ и далее) концепцию AppDomain упразднили, оставив только один домен. Но потребность‑то никуда не делась: как загрузить плагин или модуль в процессе, а потом выгрузить его, чтобы обновить?

Решение — AssemblyLoadContext (ALC).

Это специальный класс рантайма, представляющий контекст загрузки сборок. Проще говоря, ALC позволяет сгруппировать набор загруженных сборок отдельно от других, добиваясь изоляции. Зачем это нужно? Например, чтобы разные версии одной библиотеки не конфликтовали друг с другом или с основной программой. Можно загрузить плагин со своими зависимостями в отдельный контекст, и он не вмешается в зависимости хост‑приложения.

Кроме изоляции версий, AssemblyLoadContext решает главную боль прошлого, выгрузку сборок. Раньше, в.NET Framework, выгрузить отдельно загруженную DLL было нельзя — только весь домен целиком. Теперь же мы можем создать коллектable (сбороспособный) контекст и выгрузить его, когда он больше не нужен, освободив память. Однако выгрузка работает по‑другому: если AppDomain убивался принудительно, то AssemblyLoadContext выгружается кооперативно, лишь когда вы сами освободите все ссылки на загруженные объекты, и ни один поток не исполняет код из этой сборки. Мы ещё вернёмся к механизму выгрузки подробнее.

Горячая загрузка и перезагрузка плагинов

Представим ситуацию: у нас есть приложение, которое должно поддерживать подключаемые модули. Например, десктопное приложение или сервер, где хочется подгружать логики обработки из внешних DLL, не перезапуская основной процесс. С AssemblyLoadContext это довольно просто.

Создаём свой класс, наследуемый от AssemblyLoadContext, и при необходимости переопределяем метод Load для контроля загрузки зависимостей. Обычно пишут что‑то вроде PluginLoadContext, куда передают путь к плагину. В конструкторе можно инициализировать AssemblyDependencyResolver, вспомогательный класс, появившийся в.NET Core 3.0. Он умеет по имени запрашиваемой сборки найти путь к файлу DLL на основе файла депendenсий .deps.json плагина.

Проще говоря, AssemblyDependencyResolver знает, где лежат нужные DLLки плагина, и мы используем его результат, чтобы загрузить их в наш контекст.

Напишем минимальный пример своего AssemblyLoadContext для плагина:

using System;
using System.Reflection;
using System.Runtime.Loader;

class PluginLoadContext : AssemblyLoadContext
{
    private AssemblyDependencyResolver _resolver;

    public PluginLoadContext(string pluginPath) : base(isCollectible: true) // контекст собираемы (выгружаемый)
    {
        _resolver = new AssemblyDependencyResolver(pluginPath);
    }

    protected override Assembly Load(AssemblyName assemblyName)
    {
        string assemblyPath = _resolver.ResolveAssemblyToPath(assemblyName);
        if (assemblyPath != null)
        {
            // Если зависимость плагина найдена, загружаем её в контекст плагина:
            return LoadFromAssemblyPath(assemblyPath);
        }

        // Иначе возвращаем null – пусть runtime попытается загрузить в контекст по умолчанию
        return null;
    }
}

PluginLoadContext помечен как isCollectible: true, что означает возможность выгрузки. В переопределённом Load мы пытаемся резолвить зависимости плагина: если AssemblyDependencyResolver нашёл путь к нужной DLL, загружаем её в этот же контекст с помощью LoadFromAssemblyPath. Если же возвращаем null, то зависимость будет загружена обычным способом (то есть в контекст по умолчанию, либо вообще она уже загружена). Можно например разделить, какие библиотеки остаются общими, а какие изолируются.

В простом случае можно всегда возвращать null и тогда все зависимости плагина подтянутся из основного контекста (если там доступны), но тогда о независимости версий речи не идёт. Мы же хотим полной изоляции, поэтому используем _resolver с путём плагина, чтобы тянуть его копии библиотек.

Теперь, как загрузить плагин с помощью этого контекста. Предположим, у нас есть путь к сборке плагина (например, "MyPlugin\bin\Debug\net6.0\MyPlugin.dll"). Загрузим её:

string pluginPath = @"MyPlugin\bin\Debug\net6.0\MyPlugin.dll";
var loadContext = new PluginLoadContext(pluginPath);
Assembly pluginAssembly = loadContext.LoadFromAssemblyName(
    new AssemblyName(System.IO.Path.GetFileNameWithoutExtension(pluginPath))
);
Console.WriteLine($"Плагин {pluginAssembly.FullName} загружен!");

Создали новый контекст для каждого загружаемого плагина. Изолируя плагины по контекстам, мы позволяем им иметь даже конфликтующие версии библиотек без проблем. Каждый контекст живёт в своём пузыре, если два плагина зависят от разных версий Newtonsoft.Json, каждый получит свою копию, и конфликтов не будет.

После загрузки сборки нам, конечно, нужно как‑то вызывать код плагина. Обычно для этого определяют интерфейс в общем для приложения проекте. Например, в проекте хоста у нас есть интерфейс:

// Общая библиотека PluginBase.dll, загружена в Default контекст
public interface IPlugin 
{
    string Name { get; }
    void Execute();
}

Плагин, в свою очередь, реализует этот интерфейс в своей DLL:

public class MyPlugin : IPlugin
{
    public string Name => "My Awesome Plugin";
    public void Execute() 
    {
        Console.WriteLine("Hello from plugin!");
    }
}

PluginBase.dll с интерфейсом доступен в главном приложении и не копируется в папку плагина (MSBuild‑флаг <Private>false</Private> в ссылке на проект). Иначе получится, что плагин загрузит свой отдельный экземпляр интерфейсной сборки, и тогда типы IPlugin у хоста и плагина не совпадут и плагин будет реализовывать интерфейс из своей копии PluginBase.dll, и мы не сможем привести объект к нужному интерфейсу.

Это тонкий момент, который часто возникает: CLR считает типы разными, если они загружены из разных контекстов, даже если названия и версии совпадают.

В нашем случае мы намеренно хотим, чтобы интерфейс был один, поэтому избегаем дублирования этой DLL. Тогда плагин при загрузке обнаружит, что PluginBase.dll уже есть (в Default контексте) и использует его. Наш PluginLoadContext.Load для таких зависимостей вернёт null, и рантайм найдёт PluginBase.dll в контексте по умолчанию.

Теперь можем создать экземпляр класса плагина и вызвать метод:

Type? pluginType = pluginAssembly.GetTypes()
    .FirstOrDefault(t => typeof(IPlugin).IsAssignableFrom(t));
if (pluginType == null)
    throw new Exception("Класс, реализующий IPlugin, не найден в плагине.");

IPlugin pluginInstance = (IPlugin)Activator.CreateInstance(pluginType)!;
Console.WriteLine($"Выполняем плагин: {pluginInstance.Name}");
pluginInstance.Execute();

С помощью рефлексии находим первый тип, реализующий IPlugin, создаём его и вызываем метод Execute. Благодаря тому, что интерфейс у нас общий, приведение (IPlugin) успешно. Если бы мы попытались кастовать к самому классу плагина, объявленному в его сборке, прямо в хост‑приложении, то получили бы InvalidCastException, ведь у хоста такого типа нет или он считается другим (из другого контекста). Интерфейс решает проблему общением через абстракцию.

Таким образом, мы динамически загрузили DLL, нашли в ней нужный класс и исполнили код. Горячая перезагрузка плагина строится на этом же принципе: можно выгрузить контекст с старой версией плагина и загрузить новый. Например, представьте, что плагин это отдельный файл, который иногда обновляется. Вы следите за изменением файла (через FileSystemWatcher или по команде администратора) — и когда видите новую версию, выполняете следующие шаги:

  1. Выгружаете старый плагин (как это делать — чуть дальше).

  2. Загружаете новую версию DLL в новый AssemblyLoadContext.

  3. Переключаете выполнение на новый плагин.

Пользователь даже не заметит, что модуль перегрузился, никакого даунтайма всего приложения.

Конечно, важно спроектировать плагинную систему так, чтобы обновление было безопасным: например, дождаться, что старый плагин завершил все текущие задачи, прежде чем выгружать его контекст. Но сама платформа позволяет это сделать без рестарта процесса, что уже огромный плюс.

Изоляция зависимостей плагинов

Теперь подробнее о изоляции зависимостей, одной из главных причин использовать AssemblyLoadContext. Как уже упоминалось, плагин может принести с собой библиотеку, которая конфликтует по версии с другой в приложении. Классический пример — два плагина используют разную версию какой‑нибудь популярной библиотеки (тот же Newtonsoft.Json разных версий). В обычной ситуации.NET загрузил бы одну версию и попытался её использовать везде, или вообще отказался бы загружать вторую версию, выбросив FileLoadException или подобную ошибку конфликта.

Когда мы загружаем каждый плагин в свой контекст, у каждого своя картина мира. Один плагин может иметь Newtonsoft.Json, Version=12.0.0.0, другой Version=13.0.0.0 — и оба спокойно будут сосуществовать, потому что их AssemblyLoadContext подгрузит разные копии библиотеки. Версии сборок не конфликтуют, если они разнесены по разным контекстам. Для CLR это просто две независимые сборки (хотя и с одинаковым именем), каждая привязана к своему контексту.

А что если некоторые зависимости как раз нужно шарить между плагинами и хостом?

Например, логгер Microsoft.Extensions.Logging.Abstractions — хорошо бы, чтобы и плагин, и основное приложение использовали одну общую копию, иначе получится раздвоение сущностей (логгер из хоста не будет считаться тем же типом, что логгер внутри плагина). В таком случае можно целенаправленно заставить ALC грузить конкретные библиотеки не в себя, а пользоваться основным контекстом. Делается это двумя путями:

  • Не копировать общие DLL в плагин. Мы уже сделали так для интерфейсов (PluginBase.dll). Аналогично можно поступить с Logging.dll или другими общими зависимостями: убрать их из папки плагина. Тогда при попытке загрузить эту зависимость _resolver.ResolveAssemblyToPath вернёт null, и runtime найдёт библиотеку в Default контексте. По сути, мы таким образом расшарили библиотеку между контекстами. Главное убедиться, что версии совпадают, иначе можно нарваться на ту же проблему, но об этом мы узнаем по исключению.

  • Явно переопределить Load для выборочной загрузки. Мы могли бы в методе Load(AssemblyName name) прописать нечто вроде: if (name.Name == "Microsoft.Extensions.Logging.Abstractions") return null;. Тогда именно эту библиотеку контекст не будет грузить сам, а всегда отдавать решению дефолтного контекста (где она, допустим, уже загружена).

Часто комбинируют оба подхода. Например, плагин может тянуть кучу своих специфических библиотек — их мы изолируем, а какие‑то базовые штуки (logging, analytics SDK и тому подобное) можно оставить общими, чтобы плагин мог взаимодействовать с хостом через них без проблем. Но делать общими стоит только действительно необходимые компоненты, иначе теряется смысл изоляции.

Что насчет границ безопасности? Тут момент: AssemblyLoadContext не обеспечивает sandbox по безопасности. Код плагина, хоть и загружен изолированно в плане сборок, выполняется в общем процессе, может вызывать любые доступные ему API и в целом не ограничен. Если вам нужно изолировать выполнение недоверенного кода, то ALC конечно жене панацея. В таких случаях, как и раньше, лучше использовать отдельный процесс (или контейнер/виртуализацию) и общаться через IPC.

Разгрузка сборок (выгрузка контекста)

Перейдём к самому интересному — выгрузке уже загруженных сборок. В.NET Core это новшество долго ждали, и наконец можно сказать: сборку можно выгрузить без завершения процесса. Но сразу оговоримся: выгрузка работает не как выключатель, а скорее как сборщик мусора. Мы помечаем контекст на выгрузку, а CLR дальше делает всё ленино (то есть лениво, через GC).

Как инициировать выгрузку: у AssemblyLoadContext есть метод Unload(). Вызываем loadContext.Unload(). Runtime помечает все сборки в этом контексте как кандидатов на выгрузку и разрывает связи, но не будет выгружать, пока есть живые ссылки на объекты из этих сборок.

Поэтому, если вы где‑то сохранили объект плагина в статической переменной, или у вас поток всё ещё выполняет метод из плагина и не вышел, выгрузки не случится до тех пор, пока это не прекратится. Это и называется кооперативной выгрузкой: мы должны сами позаботиться, чтобы отпустить плагин.

В простом случае, достаточно перестать использовать объект из плагина (например, удалить его из списка активных модулей) и вызвать Unload(). Но чтобы убедиться, что CLR действительно всё освободил, приходится иногда даже насильно дергать сборщик мусора.

Шаблонный пример:

// 1. Инициируем выгрузку
pluginInstance = null;            // отпускаем ссылку на экземпляр
loadContext.Unload();             // помечаем контекст на выгрузку

// 2. Форсируем сборку мусора, т.к. без нее выгрузка может не завершиться быстро
for (int i = 0; i < 5; i++)
{
    GC.Collect();
    GC.WaitForPendingFinalizers();
    GC.Collect();
    if (loadContext.IsCollectible && loadContext.Assemblies.Count == 0)
    {
        // Если сборки в контексте уже все выгружены (Count == 0), выходим раньше
        break;
    }
}
Console.WriteLine("Контекст плагина выгружен.");

После Unload() несколько раз запускаем сборку мусора и ждём финализации, пытаясь ускорить процесс. Проверяем loadContext.Assemblies.Count, когда он стал 0, можно уверенно сказать, что все сборки оттуда выгружены. (Можно также использовать WeakReference на сам контекст или один из объектов внутри него, и следить за IsAlive).

В реальности, часто нет нужды так явно крутить GC, достаточно просто знать, что оно выгрузится когда‑нибудь потом. Но если вы, скажем, хотите тут же заменить файл DLL на диске, то без финализирования контекста файл может оставаться занятым. Поэтому можно применить описанный трюк: после Unload вызвать GC и убедиться, что все освободилось.

Выгрузка контекста убирает все сборки, которые были в нём загружены. Если тот же плагин был загружен дважды в разных контекстах, выгрузка одного не тронет другого. А вот частично выгрузить одну сборку из контекста нельзя, только все или ничего. Но мы можем дробить плагины на более мелкие контексты, если нужна более гибкая стратегия выгрузки.

А что если выгрузка не происходит, хотя мы всё сделали? Значит, где‑то остались ссылки.

Это может быть неочевидно: например, забыли отписаться от события, и делегат из плагина всё ещё торчит в основном приложении, вот вам сильная ссылка. Или поток, запущенный плагином, продолжает работу и удерживает ссылки на его код. В таких случаях стоит проверить все возможные зацепки..NET не выгрузит контекст, пока ни один объект из него не доступен вне его, и ни один поток не выполняет код, принадлежащий ему.

Если не удаётся выяснить причину, можно применить тяжёлую артиллерию: отладчик наподобие WinDbg с расширением SOS. С его помощью ищут, что держит LoaderAllocator данного контекста в памяти (через просмотр GC heap и корней).

Кстати, у AssemblyLoadContext есть событие Unloading. Его можно использовать внутри плагина, чтобы выполнить очистку ресурсов, когда контекст выгружается. Например, плагин может на это событие зарегистрировать обработчик, который остановит запущенные им таймеры, освободит неуправляемые ресурсы и так далее Это примерно аналог финализатора для всего контекста.

Археология текущего домена

Когда начинаешь играться с динамической загрузкой, невольно хочется посмотреть: а что вообще сейчас загружено в приложение? Какие AssemblyLoadContext существуют и какие сборки в них лежат?

В.NET есть несколько инструментов для этого. Самый простой — метод AppDomain.CurrentDomain.GetAssemblies(), возвращающий массив всех сборок, загруженных в текущий домен (а он у нас один). Однако учтите: там вы не узнаете, в каком контексте каждая сборка. Будет просто список Assembly, где у каждой можно посмотреть FullName, Location и прочие свойства. В этом списке вы увидите все сборки, включая загруженные в кастомные контексты, ведь технически домен‑то общий. Например, если один плагин загрузил Newtonsoft.Json, Version=12, а другой Version=13, в AppDomain.CurrentDomain.GetAssemblies() вы, скорее всего, увидите обе сборки (каждая имеет собственный кодбейс/контекст). Их FullName могут различаться версией, а могут и нет — но будут разные объекты Assembly.

Чтобы отличать, где что загружено, можно воспользоваться классом AssemblyLoadContext программно. У него есть статическое свойство AssemblyLoadContext.All, которое возвращает коллекцию всех активных контекстов.

Включая контекст по умолчанию. А у каждого контекста есть свойство Assemblies — список Assembly, загруженных в него. Таким образом, мы можем перебрать контексты и напечатать содержимое:

foreach (var alc in AssemblyLoadContext.All)
{
    Console.WriteLine($"Контекст: {alc.Name ?? "[Default]"} (Collectible={alc.IsCollectible})");
    foreach (var asm in alc.Assemblies)
        Console.WriteLine($"  - {asm.FullName}");
}

Вывод покажет, например, что в контексте по умолчанию у нас основные сборки приложения, в контексте PluginLoadContext (название можно задавать в конструкторе ALC) — соответствующие плагинные сборки и их зависимые библиотеки, и так далее Полезно, что даже после вызова Unload() контекст может висеть в AssemblyLoadContext.All до тех пор, пока полностью не соберётся. Так что, если мы видим, что контекст уже выгружен (например, его Assemblies.Count = 0, или alc.IsCollectible == true, но assemblies пусты), значит всё успешно освободилось. Если же висит контекст с какой‑то сборкой внутри, которая должна была уйти — это повод заняться расследованием.

К слову, если требуется прям серьёзный анализ, можно воспользоваться профилировщиками или тем же WinDbg, как упоминалось. Но для начала обычно хватает и перечисления через AssemblyLoadContext.All.

Напоследок, вспомним еще раз: AssemblyLoadContext не панацея, но мощный инструмент. Он не избавит вас от необходимости правильно проектировать плагины и следить за тем, что вы делаете с загруженными сборками. Но с ним у нас развязаны руки — можно строить действительно гибкие архитектуры плагинов, обновлять части приложения на лету и избегать ужасов конфликтов версий библиотек.

Спасибо за внимание и приятных случайностей!


Если вы хотите глубже разобраться в таких аспектах.NET, как работа AssemblyLoadContext и других механизмах платформы, приглашаем на курс C# Developer. Professional. В рамках курса рассматривается внутреннее устройство.NET, принципы управления памятью, продвинутые сценарии загрузки сборок и построения архитектуры приложений. На странице курса можно записаться на бесплатные уроки, а также пройти вступительный тест для оценки знаний.

А тем, кто настроен на серьезное системное обучение, рекомендуем рассмотреть Подписку — выбираете курсы под свои задачи, экономите на обучении, получаете профессиональный рост. Узнать подробнее

Комментарии (1)


  1. Wolfdp
    27.10.2025 09:40

    Дёргать в цикле GC -- это конечно сильно.

    А так может кому и понадобится, если первый раз будет писать код. Единственное что следовало указать, что в плагин через общую dll также можно инжектить обекты, что позволяет общаться плагину с общим хостом.