Search code examples
c#.net-coreassembly-loading

Why does AppDomain still have a reference to a dynamically loaded assembly after the unload event fires?


This is a netcore question, not a NETFX question.

This workup demonstrates the behaviour. You will need to supply an assembly for it to load and make the name in the code match the name in the file system.

using System;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Runtime.Loader;
using System.Threading;

namespace unload
{
  public class ALC : AssemblyLoadContext
  {
    public ALC() : base(isCollectible: true) { }
    protected override Assembly Load(AssemblyName name) => null;
  }
  class Program
  {
    static void Main(string[] args)
    {
      var alc1 = new ALC();
      var alc2 = new ALC();
      Assembly assy1, assy2;
      using (var stream = new FileStream("./assy.dll", FileMode.Open))
        assy1 = alc1.LoadFromStream(stream);
      using (var stream = new FileStream("./assy.dll", FileMode.Open))
        assy2 = alc2.LoadFromStream(stream);
      var currentDomain = AppDomain.CurrentDomain;
      alc1.Unloading += alc =>
      {
        Console.WriteLine("alc1 HAS UNLOADED");
      };
      alc2.Unloading += alc =>
      {
        Console.WriteLine("alc2 HAS UNLOADED");
      };
      alc1.Unload();
      assy1 = null;
      alc1 = null;
      alc2.Unload();
      assy2 = null;
      alc2 = null;
      GC.Collect(); //no refs and force GC
      Thread.Sleep(100); // let other things complete
      var duplicates = from a in currentDomain.GetAssemblies() group a by a.FullName into g where g.Count() > 1 select g;
      while (duplicates.Any())
      {
        GC.Collect();
        Thread.Sleep(500);
        duplicates = from a in currentDomain.GetAssemblies() group a by a.FullName into g where g.Count() > 1 select g;
        Console.WriteLine(string.Join(", ", duplicates.Select(g => $"{g.Count()} {g.Key}")));
      }
    }
  }
}

If you run this you will see that unload events fire but both instances of the assembly continue to be found by currentDomain.GetAssemblies().

Documentation for AssemblyLoadContext.Unload() says

  • An AssemblyLoadContext can only be unloaded if it is collectible.
  • Unloading will occur asynchronously.
  • Unloading will not occur while there are references to the AssemblyLoadContext

I believe this example meets all these requirements. What else might be interfering? Are there any known bugs with this?

Is it possible the assemblies actually unloaded but currentDomain has failed to update its bookkeeping? How would you tell?


Solution

  • In a moment of inspiration I tried a release build and the problem vanished. But why? A commenter Charlieface provides information explaining why and a link to another question. He's right but I don't think the relationship between the two is obvious, so I'll spell it out here.

    The problem is actually with the design of my test, specifically that the entire test is in a single method body. In a debug build local variables are not optimized. The references won't be released until the method exits, and the method won't exit while it can still see duplicate assemblies.

    Refactoring like this

    using System;
    using System.IO;
    using System.Linq;
    using System.Reflection;
    using System.Runtime.Loader;
    using System.Threading;
    
    namespace unload
    {
      class Program
      {
        static void Main(string[] args)
        {
          LoadAndUnloadTest();
          GC.Collect(2);
          var currentDomain = AppDomain.CurrentDomain;
          var duplicates = from a in currentDomain.GetAssemblies() group a by a.FullName into g where g.Count() > 1 select g;
          while (duplicates.Any())
          {
            Thread.Sleep(500);
            duplicates = from a in currentDomain.GetAssemblies() group a by a.FullName into g where g.Count() > 1 select g;
            Console.WriteLine(string.Join(", ", duplicates.Select(g => $"{g.Count()} {g.Key}")));
          }
        }
        static void LoadAndUnloadTest()
        {
          var alc1 = new ALC();
          var alc2 = new ALC();
          Assembly assy1, assy2;
          using (var stream = new FileStream("./assy.dll", FileMode.Open))
            assy1 = alc1.LoadFromStream(stream);
          using (var stream = new FileStream("./assy.dll", FileMode.Open))
            assy2 = alc2.LoadFromStream(stream);
          alc1.Unloading += alc =>
          {
            Console.WriteLine("AFTER UNLOAD alc1");
          };
          alc2.Unloading += alc =>
          {
            Console.WriteLine("AFTER UNLOAD alc2");
          };
          alc1.Unload();
          alc2.Unload();
        }
      }
      public class ALC : AssemblyLoadContext
      {
        public ALC() : base(isCollectible: true) { }
        protected override Assembly Load(AssemblyName name) => null;
    
      }
    }
    

    We have perfect behaviour in a Debug build.

    Note that it is necessary to explicitly perform garbage collection. Why GC doesn't GC eventually happen on its own? In a larger app with more going on, it would, but in the cloistered confines of this test no more objects are created, so the triggering thresholds of memory pressure and so forth aren't reached.

    What normally triggers it? The answer lies here: When does garbage collection get triggered in C#?