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.
ThreadLocal
object has already been used a bit).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.:
MyAsyncMethod
) that references a 'shared' AsyncLocal
field (myAsyncLocal
) from its class, on either side of an await
call.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);
}
}
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).