Sean Rose Sean Rose - 2 months ago 7
C# Question

Nito.AsyncEx.AsyncLock stack overflow with lots of awaiters and synchronous fast path

I'm using

AsyncLock
from Stephen Cleary's
Nito.AsyncEx
NuGet package (v3.0.1) to protect the initialization of an expensive resource, so only the first caller will perform the time-consuming asynchronous initialization, and all subsequent callers will wait asynchronously until the initialization is done and then get the cached resource.

What I first noticed is that the code following the region protected by the
AsyncLock
was executing in the tasks in the exact reverse order the tasks were started in (i.e. the last task started got to continue past the locked region first, then the second to last task, and so on until the first task continued last).

Then in the process of investigating why that was happening I discovered I consistently get a stack overflow when there are a large number of the asynchronous tasks. Here's a simplified example:

object _foo;
readonly Nito.AsyncEx.AsyncLock _fooLock = new Nito.AsyncEx.AsyncLock();

async Task<object> GetFooAsync()
{
using (await _fooLock.LockAsync().ConfigureAwait(false))
{
if (_foo == null)
{
// Simulate time-consuming asynchronous initialization,
// during which all the subsequent tasks end up awaiting the AsyncLock.
await Task.Delay(5000).ConfigureAwait(false);
_foo = new object();
}
return _foo;
}
}

async Task DoStuffAsync()
{
object foo = await GetFooAsync().ConfigureAwait(false);
// Do stuff with foo...
}

void DoStuff()
{
var tasks = new List<Task>();

for (int i = 1; i <= 1000; i++)
{
tasks.Add(DoStuffAsync());
}

Task.WhenAll(tasks).Wait();
}


If the fast path through
GetFooAsync()
is not synchronous (e.g. if I add
await Task.Yield();
right before
return _foo;
) then not only does the stack overflow not occur, but the tasks continue past the locked region in the order they were started in.

I'll probably be changing my code to use
AsyncLazy<T>
from AsyncEx instead for this use case, which I've tested and does not seem to exhibit this problem.

However, I'd like to know if this problem is due to a mistake in my code, a bug in
AsyncLock
, or is it just expected behavior (more of a gotcha)?

Answer

It's a bug in AsyncLock; all the queue-based asynchronous coordination primitives have the same problem. A fix is in the works.

The new version of this library has a rewritten queue which does not suffer from this problem.

Comments