Уявіть систему, що динамічно розширюється: IDE завантажує тисячі розширень, кожне з яких виконує код у тому ж процесі. Або систему звітів, де кожен новий звіт — це окремо скомпільований скрипт. Або засіб тестування, що завантажує тест-збірки і має їх гарантовано вивантажити після виконання тестів.
Усі ці сценарії об'єднує одна потреба: завантажити сторонній код у runtime, ізолювати його від основного застосунку і мати можливість цей код вивантажити. У .NET Framework цю потребу задовольняв AppDomain. У сучасному .NET (Core/.NET 5+) архітектура ізоляції кардинально змінилась на користь AssemblyLoadContext.
Без розуміння цих механізмів неможливо будувати extensible системи, плагін-архітектури або системи з hot-reload. Ця тема — поглиблений розбір з поясненням "чому" на кожному кроці.
У .NET Framework (2002-2019) AppDomain слугував механізмом логічної ізоляції всередині одного OS-процесу. Ідея полягала у наступному: замість запуску окремого OS-процесу для кожного плагіна (дорого: ~10-100ms на запуск, ~MB RAM), можна створити легкий "контейнер" у межах наявного процесу.
AppDomain надавав:
AppDomain.Unload() звільняв всі збірки доменуApp.config, базова директорія, probe pathsКласичний use case у ASP.NET (Classic) / IIS: кожен веб-сайт (*.ashx, *.asmx) — окремий AppDomain у процесі w3wp.exe. Коли ви оновлювали bin\ директорію, IIS перезавантажував AppDomain без перезапуску w3wp.exe.
// .NET Framework — створення ізольованого AppDomain
AppDomain sandboxDomain = AppDomain.CreateDomain(
friendlyName: "Plugin Sandbox",
securityInfo: null,
info: new AppDomainSetup
{
ApplicationBase = pluginDirectory,
DisallowCodeDownload = true
}
);
// Виконання коду в іншому AppDomain через Proxy
// (через межі AppDomain працював Remoting, а не пряме посилання!)
var plugin = (IPlugin)sandboxDomain.CreateInstanceAndUnwrap(
assemblyName: "MyPlugin",
typeName: "MyPlugin.PluginClass"
);
// Вивантаження AppDomain — звільняє всі його збірки
AppDomain.Unload(sandboxDomain);
Ключова складність AppDomain: об'єкти не можуть напряму перетинати межі між доменами. Коли ви отримували IPlugin з іншого AppDomain, насправді отримували transparent proxy — об'єкт-посередник. Кожен виклик методу через proxy серіалізувався, перетинав межу домену, десеріалізувався і виконувався у цільовому домені.
Це означало:
Serializable або успадковувати MarshalByRefObjectasync/await через межі AppDomain (Remoting не підтримував TAP)Команда .NET Core вирішила: вартість підтримки AppDomain занадто висока, а ізоляція через OS-процеси у більшості реальних сценаріїв кращий вибір. Тому в .NET Core/.NET 5+:
AppDomain.CreateDomain()викидає PlatformNotSupportedException у .NET 5+. Весь .NET-процес тепер має рівно один AppDomain — AppDomain.CurrentDomain. Ніякої ізоляції між доменами немає.Незважаючи на втрату ізоляційних можливостей, клас AppDomain залишається в .NET 5+ і деякі його члени досі корисні:
AppDomain domain = AppDomain.CurrentDomain;
// 1. Базова директорія — де знаходяться збірки застосунку
Console.WriteLine($"BaseDirectory: {domain.BaseDirectory}");
// → C:\Projects\MyApp\
// 2. Ім'я домену — корисно для logging у бібліотеках
Console.WriteLine($"FriendlyName: {domain.FriendlyName}");
// → MyApp (або ім'я .exe файлу)
// 3. Глобальна обробка непійманих виключень
domain.UnhandledException += (sender, e) =>
{
var ex = (Exception)e.ExceptionObject;
bool isTerminating = e.IsTerminating;
// Останній шанс зафіксувати fatal error перед завершенням процесу
File.AppendAllText("crash.log",
$"[{DateTime.UtcNow:O}] FATAL: {ex.GetType().Name}: {ex.Message}\n{ex.StackTrace}\n");
};
// 4. Спостереження за завантаженими збірками
domain.AssemblyLoad += (sender, e) =>
{
Console.WriteLine($"Завантажено збірку: {e.LoadedAssembly.GetName().Name}");
};
// 5. Перелік завантажених збірок
foreach (var assembly in domain.GetAssemblies())
{
var name = assembly.GetName();
Console.WriteLine($" {name.Name} v{name.Version} ({(assembly.IsDynamic ? "dynamic" : assembly.Location)})");
}
// 6. Кастомний Assembly Resolver (якщо стандартний не знаходить збірку)
domain.AssemblyResolve += (sender, e) =>
{
Console.WriteLine($"Не знайдено збірки: {e.Name}");
// Можна повернути Assembly або null (продовжить стандартний пошук)
var customPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "plugins",
new AssemblyName(e.Name!).Name + ".dll");
return File.Exists(customPath) ? Assembly.LoadFrom(customPath) : null;
};
AssemblyLoadContext — це відповідь .NET Core на питання "як ізолювати збірки без AppDomain". Ключова ідея: замість ізоляції з'єднанням об'єктів через Remoting, ALC надає простір імен для збірок: один і той самий Newtonsoft.Json v12 і v13 можуть співіснувати в одному процесі, але в різних ALC, без конфліктів.
На відміну від AppDomain, об'єкти можуть вільно перетинати межі між ALC. Немає жодного Remoting, proxy чи серіалізації. Якщо interface IPlugin визначено в збірці, що завантажена в Default ALC, а плагін реалізує цей інтерфейс і завантажений у власний ALC, ви можете просто викликати plugin.Execute() напряму.
PluginA.dll використовує Newtonsoft.Json v12, PluginB.dll — v13. Конфлікту немає: кожен "бачить" свою версію в межах свого ALC. При цьому обидва реалізують IPlugin з Default ALC і взаємодіють з основним додатком без серіалізації.
Клас знаходиться у System.Runtime.Loader. Для використання потрібна збірка System.Runtime.Loader або просто using System.Runtime.Loader;:
Assembly.Load(), Assembly.LoadFrom(), та збірки, завантажені .NET Runtime автоматично, потрапляють сюди. Default ALC не може бути вивантажений — існує протягом всього часу process lifetime.true — ALC і всі його збірки можуть бути вивантажені з пам'яті після звільнення всіх посилань. Якщо false (за замовчуванням при new AssemblyLoadContext("name")) — збірки живуть до завершення процесу. Для plug-in систем з hot-reload — завжди true.AssemblyLoadContext.All enumeration.WeakReference, щоб не перешкоджати GC). Корисно для діагностики: скільки ALC існує, чи вивантажились очікувані.Assembly якщо можете завантажити, або null щоб делегувати в Default ALC.Load() для native DLL (P/Invoke). Дозволяє контролювати де шукати нативні бібліотеки плагіна.Unload() лише маркує ALC як "потрібно вивантажити"..NET надає кілька готових спеціалізованих ALC:
using System.Reflection;
using System.Runtime.Loader;
// 1. Default — головний контекст застосунку
var defaultAlc = AssemblyLoadContext.Default;
Console.WriteLine($"Default ALC: {defaultAlc.Name}");
// → Default
// 2. GetLoadContext — в якому ALC знаходиться конкретна збірка?
var alc = AssemblyLoadContext.GetLoadContext(typeof(string).Assembly);
Console.WriteLine(alc?.Name); // → "Default" (System.Runtime у Default)
// 3. Прямий non-collectible контекст (для довгоживучих плагінів)
var permanentAlc = new AssemblyLoadContext("PermanentPlugin", isCollectible: false);
var assembly1 = permanentAlc.LoadFromAssemblyPath("/path/to/StablePlugin.dll");
// Цей ALC не можна вивантажити — збірки живуть вічно
// 4. Collectible контекст (для hot-reload плагінів)
var hotReloadAlc = new AssemblyLoadContext("HotReloadPlugin", isCollectible: true);
var assembly2 = hotReloadAlc.LoadFromAssemblyPath("/path/to/Plugin.dll");
// Пізніше можна вивантажити:
hotReloadAlc.Unload(); // позначаємо для вивантаження
// Фактичне вивантаження — після GC.Collect() і відсутності посилань
У ALC є три основних методи завантаження:
using System.Reflection;
using System.Runtime.Loader;
var alc = new AssemblyLoadContext("demo", isCollectible: true);
// 1. За шляхом до файлу (найпоширеніший для плагінів)
Assembly byPath = alc.LoadFromAssemblyPath(
@"C:\Plugins\MyPlugin.dll");
// 2. За AssemblyName (з GAC або runtime paths)
Assembly byName = alc.LoadFromAssemblyName(
new AssemblyName("System.Text.Json, Version=8.0.0.0"));
// 3. Зі Stream (наприклад, з вбудованого ресурсу або blob storage)
using var stream = File.OpenRead(@"C:\Plugins\AnotherPlugin.dll");
// Опціонально: другий stream для PDB (символи для debugging)
using var pdbStream = File.OpenRead(@"C:\Plugins\AnotherPlugin.pdb");
Assembly fromStream = alc.LoadFromStream(stream, pdbStream);
Якщо плагін має залежності (NuGet пакети), їх потрібно завантажувати в той самий ALC (не з Default). AssemblyDependencyResolver читає .deps.json файл поруч із DLL і автоматично знаходить шляхи до всіх залежностей:
using System.Runtime.Loader;
using System.Reflection;
class PluginLoadContext : AssemblyLoadContext
{
private readonly AssemblyDependencyResolver _resolver;
public PluginLoadContext(string pluginMainAssemblyPath)
: base(name: $"Plugin:{Path.GetFileNameWithoutExtension(pluginMainAssemblyPath)}",
isCollectible: true)
{
// Resolver вичитує pluginMainAssemblyPath.deps.json
// і знає де лежать всі NuGet-залежності плагіна
_resolver = new AssemblyDependencyResolver(pluginMainAssemblyPath);
}
protected override Assembly? Load(AssemblyName assemblyName)
{
// Запитуємо resolver: чи є ця збірка серед залежностей плагіна?
string? assemblyPath = _resolver.ResolveAssemblyToPath(assemblyName);
if (assemblyPath is not null)
{
// Завантажуємо з диску в ІЗОЛЬОВАНий контекст плагіна
return LoadFromAssemblyPath(assemblyPath);
}
// Не знайшли у залежностях плагіна → повертаємо null
// ALC pipeline піде до Default ALC (там: System.*, Microsoft.*, PluginContracts)
return null;
}
protected override IntPtr LoadUnmanagedDll(string unmanagedDllName)
{
// Аналогічно для native DLL (libsqlite.so, sqlite3.dll тощо)
string? libraryPath = _resolver.ResolveUnmanagedDllToPath(unmanagedDllName);
return libraryPath is not null
? LoadUnmanagedDllFromPath(libraryPath)
: IntPtr.Zero;
}
}
AssemblyDependencyResolver є ключовим компонентом будь-якої production plug-in системи. Без нього ви будете отримувати FileNotFoundException для залежностей плагіна, або, що гірше, тихо підвантажувати несумісну версію з Default ALC.Вивантаження collectible ALC — не миттєва операція. Послідовність:
alc.Unload() — ALC маркується як "collectible, pending unload"WeakReference.IsAlive == false — вивантаження відбулосьusing System.Runtime.Loader;
WeakReference? alcRef = null;
// Завантаження у окремому методі — важливо!
// Щоб local змінна 'alc' точно вийшла зі scope перед GC.Collect()
[MethodImpl(MethodImplOptions.NoInlining)] // запобігаємо inlining — інакше JIT може залишити ref
void LoadAndUse()
{
var alc = new AssemblyLoadContext("temp", isCollectible: true);
alcRef = new WeakReference(alc, trackResurrection: true);
var assembly = alc.LoadFromAssemblyPath("/path/to/plugin.dll");
var type = assembly.GetType("PluginClass");
var instance = Activator.CreateInstance(type!);
// Викликаємо через reflection
type!.GetMethod("DoWork")?.Invoke(instance, null);
// По виходу з методу: alc, assembly, type, instance виходять зі scope
alc.Unload();
}
LoadAndUse();
// Форсуємо GC кілька разів (може знадобитися 2-3 покоління)
for (int i = 0; i < 10 && alcRef.IsAlive; i++)
{
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect(); // другий збір — для фіналізаторів
}
Console.WriteLine($"ALC вивантажено: {!alcRef.IsAlive}");
Найпоширеніші причини, чому ALC не вивантажується після Unload():
// ❌ НЕБЕЗПЕЧНО: static поле у Default ALC
// Тримає тип 'PluginClass' з collectible ALC → ALC НІКОЛИ не вивантажиться
static Type? _savedPluginType = null;
void LoadPlugin()
{
var alc = new AssemblyLoadContext("plugin", isCollectible: true);
var asm = alc.LoadFromAssemblyPath("Plugin.dll");
_savedPluginType = asm.GetType("PluginClass"); // 🚫 збережений у static!
alc.Unload();
// alcRef.IsAlive == true через 10 хвилин, через годину — назавжди
}
// ❌ Делегат закрито над методом плагіна → посилання утримується events
AppDomain.CurrentDomain.ProcessExit += pluginInstance.OnProcessExit;
// Плагін завантажений у collectible ALC, але event тримає його живим
// ✅ Завжди відписуйтесь перед Unload()
AppDomain.CurrentDomain.ProcessExit -= pluginInstance.OnProcessExit;
alc.Unload();
// ❌ Якщо потік ще виконує код плагіна при Unload() — ALC не вивантажиться
// Потрібно спочатку зупинити всі потоки, які виконують код плагіна
// Зазвичай: CancellationToken + await для завершення Task-ів плагіна
await pluginTask; // дочекатись завершення
alc.Unload();
// ❌ GCHandle.Alloc утримує об'єкт і його тип
GCHandle handle = GCHandle.Alloc(pluginObject, GCHandleType.Pinned);
// ... забули викликати handle.Free()
// плагін "прилип" — ALC не може вивантажитись
WeakReference не перешкоджає GC збирати об'єкт — ідеальний інструмент для моніторингу стану вивантаження:
using System.Runtime.Loader;
// trackResurrection: true — відстежуємо навіть після фіналізації
var alcWeakRef = new WeakReference<AssemblyLoadContext>(alc, trackResurrection: true);
alc.Unload();
alc = null!; // очищаємо strong reference
// Перевірка після GC
for (int attempt = 0; attempt < 10; attempt++)
{
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
if (!alcWeakRef.TryGetTarget(out _))
{
Console.WriteLine($"✅ ALC вивантажено після {attempt + 1} спроб GC");
break;
}
await Task.Delay(100); // невелика пауза між спробами
}
if (alcWeakRef.TryGetTarget(out _))
{
Console.WriteLine("⚠️ ALC не вивантажено — є активні посилання!");
// Можливі причини: static поля, events, active threads, GCHandles
}
Кожна .dll або .exe збірка .NET — це не просто набір скомпільованого коду. Це цілісний пакет, що включає кілька компонентів:
Маніфест (Manifest) — заголовок збірки. Містить:
Name, Version, Culture, PublicKeyTokenIL-код (MSIL) — скомпільований Intermediate Language. Платформонезалежний байткод, аналог Java bytecode, JIT-оване у нативний x64/ARM при першому виклику методу.
Метадані (Metadata) — таблиці типів, методів, полів, параметрів, атрибутів. Все, з чим працює Reflection API. Зберігаються у PE (Portable Executable) форматі паралельно з IL.
Embedded Resources — вбудовані файли: .resx локалізації, зображення, JSON-конфігурації, JSON-схеми та ін.
using System.Reflection;
var assembly = Assembly.GetExecutingAssembly();
// --- Ідентичність ---
AssemblyName name = assembly.GetName();
Console.WriteLine($"Name: {name.Name}");
Console.WriteLine($"Version: {name.Version}");
Console.WriteLine($"Culture: {(name.CultureName == "" ? "neutral" : name.CultureName)}");
// PublicKeyToken — для Strong Named збірок (підписаних)
byte[]? publicKeyToken = name.GetPublicKeyToken();
if (publicKeyToken?.Length > 0)
{
string token = string.Concat(publicKeyToken.Select(b => b.ToString("x2")));
Console.WriteLine($"PublicKeyToken: {token}");
Console.WriteLine("Збірка: STRONG NAMED ✅");
}
else
{
Console.WriteLine("Збірка: НЕ strong-named (development mode)");
}
// --- Атрибути збірки ---
string? title = assembly.GetCustomAttribute<AssemblyTitleAttribute>()?.Title;
string? description = assembly.GetCustomAttribute<AssemblyDescriptionAttribute>()?.Description;
string? company = assembly.GetCustomAttribute<AssemblyCompanyAttribute>()?.Company;
string? copyright = assembly.GetCustomAttribute<AssemblyCopyrightAttribute>()?.Copyright;
string? product = assembly.GetCustomAttribute<AssemblyProductAttribute>()?.Product;
// InformationalVersion: семантична версія типу "1.2.3-preview.4+abc1234"
string? infoVersion = assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion;
Console.WriteLine($"\nTitle: {title}");
Console.WriteLine($"Description: {description}");
Console.WriteLine($"Company: {company}");
Console.WriteLine($"Version: {name.Version}");
Console.WriteLine($"InfoVersion: {infoVersion}");
Console.WriteLine($"Copyright: {copyright}");
Console.WriteLine($"Location: {assembly.Location}");
Console.WriteLine($"Dynamic: {assembly.IsDynamic}"); // true для Emit або Source Generator
// --- Залежності ---
Console.WriteLine("\nЗалежності:");
foreach (var dep in assembly.GetReferencedAssemblies().OrderBy(a => a.Name))
Console.WriteLine($" {dep.Name} v{dep.Version}");
using System.Reflection;
Assembly assembly = Assembly.LoadFrom("MyLibrary.dll");
// GetExportedTypes() — лише public типи (без internal)
// GetTypes() — всі, включаючи internal та вкладені
foreach (Type type in assembly.GetExportedTypes().OrderBy(t => t.FullName))
{
string typeKind = type switch
{
{ IsInterface: true } => "interface",
{ IsAbstract: true } => "abstract class",
{ IsEnum: true } => "enum",
{ IsValueType: true } => "struct",
_ => "class"
};
// Базовий клас (якщо не object)
string baseInfo = type.BaseType is not null && type.BaseType != typeof(object)
? $" : {type.BaseType.Name}"
: "";
// Реалізовані інтерфейси
string interfaces = type.GetInterfaces().Length > 0
? $" [{string.Join(", ", type.GetInterfaces().Select(i => i.Name))}]"
: "";
Console.WriteLine($"{typeKind} {type.FullName}{baseInfo}{interfaces}");
// Публічні методи (без успадкованих від object)
foreach (var method in type.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly))
{
var parameters = string.Join(", ",
method.GetParameters().Select(p => $"{p.ParameterType.Name} {p.Name}"));
Console.WriteLine($" {method.ReturnType.Name} {method.Name}({parameters})");
}
}
AppDomain (Спадщина)
CreateDomain() → PlatformNotSupportedExceptionUnhandledException, AssemblyResolve, GetAssemblies()AssemblyLoadContext
isCollectible: true — вивантаження через GC після Unload()Load() для контролю пошуку залежностейAssemblyDependencyResolver — автоматичний пошук по .deps.jsonLifetime та Вивантаження
WeakReference — перевірка що ALC справді вивантажився[MethodImpl(NoInlining)] для методів що завантажують ALCGC.Collect() + WaitForPendingFinalizers() для форсуванняНапишіть консольну утиліту AssemblyInspector <path-to-dll> що:
Процеси в .NET — IPC та Міжпроцесна Комунікація
Поглиблений розбір механізмів міжпроцесної комунікації в .NET — Named Pipes, Memory-Mapped Files, архітектурні патерни та наскрізний приклад IPC чат-сервера від A до Я.
Application Domains та Збірки — Plug-in Система з Hot-Reload
Практична побудова production-ready plug-in архітектури на AssemblyLoadContext. Від контракту до hot-reload з FileSystemWatcher — наскрізний приклад від А до Я.