Search code examples
c#multithreadingasync-awaitthread-local-storage

Does `AsyncLocal` also do the things that `ThreadLocal` does?


I'm struggling to find simple docs of what AsyncLocal<T> does.

I've written some tests which I think tell me that the answer is "yes", but it would great if someone could confirm that! (especially since I don't know how to write tests that would have definitive control of the threads and continuation contexts ... so it's possible that they only work coincidentally!)


  • As I understand it, ThreadLocal will guarantee that if you're on a different thread, then you'll get a different instance of an object.

    • If you're creating and ending threads, then you might end up re-using the thread again later (and thus arriving on a thread where "that thread's" ThreadLocal object has already been used a bit).
    • But the interaction with await is less pleasant. The thread that you continue on (even if .ConfigureAwait(true)) is not guaranteed to be the same thread you started on, thus you may not get the same object back out of ThreadLocal on the otherside.
  • Conversely, AsyncLocal does guarantee that you'll get the same object either side of an await call.

But I can't find anywhere that actually says that AsyncLocal will get a value that's specific to the initial thread, in the first place!

i.e.:

  • Suppose you have an instance method (MyAsyncMethod) that references a 'shared' AsyncLocal field (myAsyncLocal) from its class, on either side of an await call.
  • And suppose that you take an instance of that class and call that method in parallel a bunch of times. * And suppose finally that each invocation happens to end up scheduled on a distinct thread.

I know that for each separate invocation of MyAsyncMethod, myAsyncLocal.Value will return the same object before and after the await (assuming that nothing reassigns it)

But is it guaranteed that each of the invocations will be looking at different objects in the first place?


As mentioned at the start, I've created a test to try to determine this myself. The following test passes consistently

    public class AssessBehaviourOfAsyncLocal
    {
        private class StringHolder
        {
            public string HeldString { get; set; }
        }

        [Test, Repeat(10)]
        public void RunInParallel()
        {
            var reps = Enumerable.Range(1, 100).ToArray();
            Parallel.ForEach(reps, index =>
            {
                var val = "Value " + index;
                Assert.AreNotEqual(val, asyncLocalString.Value?.HeldString);
                if (asyncLocalString.Value == null)
                {
                    asyncLocalString.Value = new StringHolder();
                }
                asyncLocalString.Value.HeldString = val;
                ExamineValuesOfLocalObjectsEitherSideOfAwait(val).Wait();
            });
        }

        static readonly AsyncLocal<StringHolder> asyncLocalString = new AsyncLocal<StringHolder>();

        static async Task ExamineValuesOfLocalObjectsEitherSideOfAwait(string expectedValue)
        {
            Assert.AreEqual(expectedValue, asyncLocalString.Value.HeldString);
            await Task.Delay(100);
            Assert.AreEqual(expectedValue, asyncLocalString.Value.HeldString);
        }
    }


Solution

  • But is it guaranteed that each of the invocations will be looking at different objects in the first place?

    No. Think of it logically like a parameter (not ref or out) you pass to a function. Any changes (e.g. setting properties) to the object will be seen by the caller. But if you assign a new value - it won't be seen by the caller.

    So in your code sample there are:

    Context for the test
     -> Context for each of the parallel foreach invocations (some may be "shared" between invocations since parallel will likely reuse threads)
       -> Context for the ExamineValuesOfLocalObjectsEitherSideOfAwait invocation
    

    I am not sure if context is the right word - but hopefully you get the right idea.

    So the asynclocal will flow (just like a parameter to a function) from context for the test, down into context for each of the parallel foreach invocations etc etc. This is different to ThreadLocal (it won't flow it down like that).

    Building on top of your example, have a play with:

    using System.Linq;
    using System.Threading;
    using System.Threading.Tasks;
    using NUnit.Framework;
    
    namespace NUnitTestProject1
    {
        public class AssessBehaviourOfAsyncLocal
        {
            public class Tester
            {
                public int Value { get; set; }
            }
    
            [Test, Repeat(50)]
            public void RunInParallel()
            {
                var newObject = new object();
                var reps = Enumerable.Range(1, 5);
                Parallel.ForEach(reps, index =>
                {
                    //Thread.Sleep(index * 50); (with or without this line, 
                    Assert.AreEqual(null, asyncLocalString.Value);
                    asyncLocalObject.Value = newObject;
                    asyncLocalTester.Value = new Tester() { Value = 1 };
    
                    var backgroundTask = new Task(() => {
                        Assert.AreEqual(null, asyncLocalString.Value);
                        Assert.AreEqual(newObject, asyncLocalObject.Value);
                        asyncLocalString.Value = "Bobby";
                        asyncLocalObject.Value = "Hello";
                        asyncLocalTester.Value.Value = 4;
    
                        Assert.AreEqual("Bobby", asyncLocalString.Value);
                        Assert.AreNotEqual(newObject, asyncLocalObject.Value);
                    });
    
                    var val = "Value " + index;
                    asyncLocalString.Value = val;
                    Assert.AreEqual(newObject, asyncLocalObject.Value);
                    Assert.AreEqual(1, asyncLocalTester.Value.Value);
    
                    backgroundTask.Start();
                    backgroundTask.Wait();
                    // Note that the Bobby is not visible here
                    Assert.AreEqual(val, asyncLocalString.Value);
                    Assert.AreEqual(newObject, asyncLocalObject.Value);
                    Assert.AreEqual(4, asyncLocalTester.Value.Value);
    
                    ExamineValuesOfLocalObjectsEitherSideOfAwait(val).Wait();
                });
            }
    
            static readonly AsyncLocal<string> asyncLocalString = new AsyncLocal<string>();
            static readonly AsyncLocal<object> asyncLocalObject = new AsyncLocal<object>();
            static readonly AsyncLocal<Tester> asyncLocalTester = new AsyncLocal<Tester>();
    
            static async Task ExamineValuesOfLocalObjectsEitherSideOfAwait(string expectedValue)
            {
                Assert.AreEqual(expectedValue, asyncLocalString.Value);
                await Task.Delay(100);
                Assert.AreEqual(expectedValue, asyncLocalString.Value);
            }
        }
    }
    

    Notice how backgroundTask is able to see the same async local as the code that invoked it (even though it is from the other thread). It also doesn't impact the calling codes async local string or object - since it re-assigns to them. But the calling code can see its change to Tester (proving that the Task and its calling code share the same Tester instance).