docs/design/features/unloadability.md
AssemblyLoadContext unloadabilityAssemblyLoadContext.AssemblyLoadContext instances coexistingAssemblyLoadContext when there are no outside hard references to the types and instance of types from the assemblies loaded into the context and the assemblies themselves.Unload method or it can happen after all references to the AssemblyLoadContext and all the assemblies in it are gone.After investigating all the details, it was decided that we won't support the following scenarios in unloadable AssemblyLoadContext unless we get strong feedback on a need to support those.
Based on various discussions and feedback on github, the following general scenarios were often mentioned as use cases for unloadability.
A couple of real-world scenarios were analyzed to assess feasibility of the AssemblyLoadContext unloading as a replacement of AppDomain unloading and also for possible new usages of the AssemblyLoadContext unloading.
Roslyn has historically used AppDomains for executing different tests that can have assemblies with the same names but different identity or even the same identity, but different contents. AppDomain per test was very slow. So, to speed it up, they made them reusable based on the assemblies loaded into them.
AssemblyLoadContext without unloading, one context per test works fine.In future, the ability to use unloadable plugins in the compiler server would be great. Analyzers execute user code in the server, which is not good without the isolation. Ideally, whenever the compilation is executed with analyzers, it would run inside AssemblyLoadContext. The context would get unloaded after the build.
ASP.NET (not Core) originally used AppDomains to support dynamic compiling and running code behind pages. AppDomains were originally meant as performance and security mean. It turned out that they didn't help with performance much and they provided a false sense of security. ASP.NET Core moved to a more static model because of the lack of ability to unload stuff. Many of their customers did the same thing too. So, there is no pressing need for unloadability there at the moment.
However, they use two tool that could potentially benefit from the unloadability
LINQPad (https://www.linqpad.net) is a very popular third-party tool for designing LINQ queries against various data sources. It uses AppDomains and their unloading mechanism heavily for the following purposes:
AppDomain per-driver-per-schema).Prism (https://prismlibrary.github.io/) is a framework for building loosely coupled, maintainable, and testable XAML applications in WPF, Windows 10 UWP, and Xamarin Forms. The current version 6 is a fully open source version of the Prism guidance originally produced by Microsoft patterns & practices.
It uses AppDomains for directory catalog implementation. The directory catalog scans the contents of a directory, locating classes that implement IModule and add them to the catalog based on contents in their associated ModuleAttribute. Assemblies are loaded into a new application domain with ReflectionOnlyLoad. The application domain is destroyed once the assemblies have been discovered.
So from the unloadability point of view, the AssemblyLoadContext can be used for this purpose here.
From the API surface perspective, there are only couple of new functions added:
AssemblyLoadContext to enable creation of unloadable kind of AssemblyLoadContextAssemblyLoadContext.Unload method to explicitly initiate unloadingAssemblyLoadContext.IsCollectible property to query whether the AssemblyLoadContext is unloadable or not.MemberInfo.IsCollectible and Assembly.IsCollectible properties to enable developers to do the right thing for collectible assemblies (e.g. avoid caching them for a long time, etc.)The design of the feature builds on top of the existing collectible types. The isolation container for this feature is provided by AssemblyLoadContext. All assemblies loaded into an unloadable AssemblyLoadContext are marked as collectible.
For each unloadable AssemblyLoadContext, an AssemblyLoaderAllocator is created. It is used to allocate all context specific memory related to the assemblies loaded into the context. The existing collectible types support ensures this behavior.
The AssemblyLoaderAllocator also keeps a list of DomainAssembly instances for assemblies loaded into the related AssemblyLoadContext so that they can be found and destroyed during the unload.
In addition to that, entries in the AssemblySpecBindingCache for assemblies loaded into unloadable AssemblyLoadContext are allocated by this AssemblyLoaderAllocator.
There are also machine code thunks (tail call arg copy thunk, shuffle thunk cache, JIT helper logging thunk) and unwind info for the thunks that have lifetime bound to the lifetime of the AssemblyLoadContext. These were not allocated from a proper AssemblyLoaderAllocator in the existing implementation of collectible types. So the thunk related code is updated to fix that.
At unload time, the AssemblyLoaderAllocator, its associated managed side helpers LoaderAllocator and LoaderAllocatorScout, the CustomAssemblyBinder, all the related DomainAssembly instances and the AssemblyLoadContext are destroyed in a coordinated manner. Details of this process are described in a paragraph below.
These are features that were explicitly disabled when collectible assemblies were added to CLR in the past. They were not supported because they were not needed for the use cases of the collectible assemblies and they seemed to have potential non-trivial complexity.
Enabling support for assemblies with classes containing interop functions is as easy as removing the check that prevented these assemblies from being loaded.
The thread statics that are of reference types or contain references are stored in managed object[] arrays. These arrays are being held alive by strong handles stored in the respective non-managed thread objects. That makes them alive for the whole lifetime of the thread. This is not suitable for thread statics on collectible classes since the thread static instances would be held alive for the whole life of the thread and the AssemblyLoadContext would not be able to unload.
To support thread statics on collectible types, their lifetimes need to be bound to both the thread and the LoaderAllocator lifetimes. The solution is to use a similar way to what regular statics do. The managed object[] arrays that store the references to thread locals of collectible classes are held alive by handles allocated from the respective LoaderAllocator instead of global strong handles. That enables them to be collected during the unload.
When a thread terminates, all these handles are freed. This requires a modification of handle allocator in the LoaderAllocator, since the existing implementation cannot free individual handles. We need to be able to do that, otherwise the handle table would explode e.g. in case when threads are created and destroyed continuously.
When a LoaderAllocator is destroyed, all ThreadLocalModule instances for Modules that were loaded into the AssemblyLoadContext related to this LoaderAllocator are destroyed. The ThreadLocalModule instances are stored in ThreadLocalBlock of each existing thread.
Also, all checks in the runtime that were disabling usage of thread statics on collectible types need to be removed.
Fortunately, we just need to disable the checks in runtime that prevent delegate marshaling for types within collectible assemblies. The unmanaged to managed thunks are allocated from a global LoaderAllocator and they are released when the corresponding managed delegates are collected. So for unloadability, this behavior doesn't need to change in any way.
The existing COM interop implementation has support for AppDomain unloading. For each domain that uses COM interop, an instance of ComCallableWrapperCache class is created to manage the interop. It has support for the case when an AppDomain is unloaded and managed objects implementing a COM server go away, but a COM client still holds references to the related interfaces. In such case, the COM callable wrapper that forward calls from the COM world to the managed world starts returning failure return codes for all calls, but it is kept alive until all references to the related interfaces are released.
We can reuse the ComCallableWrapperCache with only a very minor modifications for AssemblyLoadContext unloading. We can move the instance of this cache from AppDomain to LoaderAllocator. And we can get rid of the functionality that handled the case when COM interfaces outlived the managed objects, as it is not possible with the AssemblyLoadContext unloading. The reason is that the managed instances of the COM objects keep the AssemblyLoadContext alive and so the unloading can move to the phase when the LoaderAllocator is destroyed only after there are no COM references to the interfaces exposed by the managed COM objects.
The After the AssemblyLoadContext unload is initiated and the managed LoaderAllocator is collected, the ComCallableWrapperCache is destroyed in the LoaderAllocator::Destroy method.
The fields with FixedAddressValueTypeAttribute are always pinned, so their address in memory never changes. Historically for non-collectible types, these fields are held pinned by a pinned GCHandle. But we could not use that for collectible types, since the MethodTable whose pointer is stored in the respective boxed instance of the value type would prevent the managed LoaderAllocator from being collected.
Since .NET 9, we always allocate these fields in the Pinned Object Heap. That way, they are pinned without being held by a handle, and are able to be collected.
For better understanding of the unloading process, it is important to understand relations between several components that play role in the lifetime management. The picture below shows these components and the ways they reference each other.
The green marked relations and blocks are the new ones that were added to enable unloadable AssemblyLoadContext. The black ones were already present before.
The dashed lines represent indirect links from MethodTables of the objects to the LoaderAllocator.
Unloading is initialized by the user code calling AssemblyLoadContext.Unload method or by execution of the AssemblyLoadContext finalizer. The following steps are performed to start the unloading process.
AssemblyLoadContext fires the Unloading event to allow the user code to perform cleanup if required (e.g. stop threads running inside of the context, remove references and destroy handles, etc.)AssemblyLoadContext.InitiateUnload method is called. It creates a strong GC handle referring to the AssemblyLoadContext to keep it around until the unload is complete. For example, finalizers of types that are loaded into the AssemblyLoadContext may need access to the AssemblyLoadContext.AssemblyNative::PrepareForAssemblyLoadContextRelease method with that strong handle as an argument, which in turn calls CustomAssemblyBinder::PrepareForLoadContextReleaseCustomAssemblyBinder::m_ptrManagedStrongAssemblyLoadContext.AssemblyLoaderAllocator the CustomAssemblyBinder points to.LoaderAllocator. That allows the LoaderAllocator to be collected.This phase is initiated after all instances of types from assemblies loaded into the AssemblyLoadContext are gone.
LoaderAllocator, since there is no strong reference to it anymore.LoaderAllocatorScout too, as the LoaderAllocator was holding the only reference to it. The LoaderAllocatorScout has a finalizer and when it is executed, it calls the native LoaderAllocator::Destroy method.LoaderAllocators that reference this LoaderAllocator and then the reference count of this LoaderAllocator itself.AssemblyLoaderAllocator must stay alive until there are no references to it, so there is nothing else to be done now. It will be destroyed later in LoaderAllocator::GCLoaderAllocators after the last reference goes away.LoaderAllocator::GCLoaderAllocators is executed. This function finds all collectible LoaderAllocators that are not alive anymore and cleans up all domain assemblies in them. The cleanup removes each DomainAssembly from the AppDomain and also from the binding cache, it notifies debugger and finally destroys the DomainAssembly.AssemblyLoadContext in each of these LoaderAllocators are now destroyed. That enables these related AssemblyLoadContexts to be collected by GC.LoaderAllocators are registered for cleanup in the AppDomain. Their actual destruction happens on the finalizer thread in Thread::DoExtraWorkForFinalizer. When a LoaderAllocator is destroyed, the related CustomAssemblyBinder is destroyed too.