Search code examples
.nettask-parallel-libraryappdomainparallel-extensions

QueuedTaskScheduler: How to deal with AppDomain unload?


Using QueuedTaskScheduler from the ParallelExtensionsExtras I face the following issue: When the AppDomain that the scheduler threads are running in is unloaded (in my case due to deploying a new code version to an ASP.NET site) the threads go into an infinite spinning loop. The relevant code is this:

// If a thread abort occurs, we'll try to reset it and continue running.
while (true)
{
    try
    {
        // For each task queued to the scheduler, try to execute it.
        foreach (var task in _blockingTaskQueue.GetConsumingEnumerable(_disposeCancellation.Token))
        {
            //[...] run task
        }
    }
    catch (ThreadAbortException)
    {
        // If we received a thread abort, and that thread abort was due to shutting down
        // or unloading, let it pass through.  Otherwise, reset the abort so we can
        // continue processing work items.
        if (!Environment.HasShutdownStarted && !AppDomain.CurrentDomain.IsFinalizingForUnload())
        {
            Thread.ResetAbort();
        }
    }
}

The scheduler tries to detect the case that its AppDomain is being unloaded but unfortunately the condition as written turns out to be false at runtime. This causes the abort to be reset and the loop to continue.

I understand that a pending abort is not raised immediately. Only sometimes the jitted code polls for a pending abort and raises the TAE. According to the call stacks that I observe this seems to be inside GetConsumingEnumerable here.

So the threads never exit the loop and continue spinning. (Even if this interpretation is wrong, the threads provably do end up in GetConsumingEnumerable and consume lots of CPU there).

What is an appropriate fix for this code? It seems not to be possible to detect that the current AppDomain is being unloaded (AppDomain.CurrentDomain.IsFinalizingForUnload is probably false because we are not finalizing). I considered to modify the code to just never reset the abort (which fixes the issue). But what would have been the appropriate fix?

(I'm less interested interested in workarounds because I already have one.)


Solution

  • Have you tried something like this (untested)?

    var domain = Thread.GetDomain();
    var unloading = false;
    domain.DomainUnload += (s, e) =>
    {
        unloading = true;
        _blockingTaskQueue.CompleteAdding();
    };
    
    while (true)
    {
        try
        {
            // For each task queued to the scheduler, try to execute it.
            foreach (var task in _blockingTaskQueue.GetConsumingEnumerable(_disposeCancellation.Token))
            {
                //[...] run task
            }
        }
        catch (ThreadAbortException)
        {
            if (!unloading)
            {
                Thread.ResetAbort();
            }
        }
    }
    

    Updated, I'm not sure about ASP.NET, but the following produces the correct sequence of events in a console app. I don't see "End of DoCallBack" even though I call Thread.ResetAbort(). I guess it makes sense because the code inside DoCallBack is supposed to be executed only on the domain which no longer exists:

    using System;
    using System.Runtime.Remoting.Contexts;
    using System.Threading;
    
    namespace ConsoleApplication
    {
        public class Program
        {
            public static void Main(string[] args)
            {
                var newDomain = System.AppDomain.CreateDomain("New domain");
                var thread = new Thread(() =>
                {
                    try
                    {
                        newDomain.DoCallBack(() =>
                        {
                            var unloading = false;
                            try
                            {
                                var domain = Thread.GetDomain();
                                domain.DomainUnload += (s, e) =>
                                {
                                    unloading = true;
                                    // call CompleteAdding here
                                    Console.WriteLine("domain.DomainUnload");
                                };
    
                                Thread.Sleep(2000);
                                Console.WriteLine("End of sleep");
                            }
                            catch (ThreadAbortException)
                            {
                                Console.WriteLine("ThreadAbortException");
                                Thread.ResetAbort();
                            }
    
                            Console.WriteLine("End of DoCallBack");
                        });
                    }
                    catch (AppDomainUnloadedException)
                    {
                        Console.WriteLine("AppDomainUnloadedException");
                    }
                    Console.WriteLine("End of thread");
                });
    
                thread.Start();
                Thread.Sleep(1000);
                AppDomain.Unload(newDomain);
    
                Console.ReadLine();
            }
        }
    }
    

    Output:

    domain.DomainUnload
    ThreadAbortException
    AppDomainUnloadedException
    End of thread