Open alekw911 opened 1 year ago
Additional information: Trying with values of 1 and 2 in the for loop for(int i=0;i<3;++i) works fine But when the iteration count is 3 or higher it deadlocks
Same is observed running with .net Framework 4.8 and .net Core 6.0
This is an interesting bug report, thank you for filing it. Did you run into this in the real world and then reproduce it w/ a modification to the example code?
I imagine .NET is starting new background threads to service the requests because too many tasks are being spun up, but this will need a lot of digging (and might not be solvable).
Yes it was first discovered when looking into a bug in our application Then a simpler console app was made to try to determine if it could be reproduced in a context unrelated to the application
I have looked into this issue. The simplified reproduction scenario from @alekw911:
using NeoSmart.AsyncLock;
namespace LockTest;
internal class Program
{
static async Task Main(string[] args)
{
var _lock = new AsyncLock();
Log($"Obtain the lock before entering the for loop");
using (await _lock.LockAsync())
{
for (int i = 0; i < 100; ++i)
{
Log(i, "Try to obtain the lock");
using (await _lock.LockAsync()) // <--------- The deadlock happens here at some iteration
{
Log(i, "Obtained the lock. Start waiting for 1ms");
await Task.Delay(TimeSpan.FromMilliseconds(1));
Log(i, "Waiting finished.");
}
Log(i, "Lock released");
}
Log("The loop finished successfully. Release the lock");
}
Log("The lock is completely released. Exit the main method");
}
static void Log(string msg)
{
Console.WriteLine($"[ThreadId={Thread.CurrentThread.ManagedThreadId,-2}] {msg}");
}
static void Log(int iteration, string msg)
{
Console.WriteLine($"[i={iteration,-2},ThreadId={Thread.CurrentThread.ManagedThreadId,-2}] {msg}");
}
}
It indeed deadlocks in 100% of cases:
[ThreadId=1 ] Obtain the lock before entering the for loop
[i=0 ,ThreadId=1 ] Try to obtain the lock
[i=0 ,ThreadId=1 ] Obtained the lock. Start waiting for 1ms
[i=0 ,ThreadId=6 ] Waiting finished.
[i=0 ,ThreadId=6 ] Lock released
[i=1 ,ThreadId=6 ] Try to obtain the lock
[i=1 ,ThreadId=6 ] Obtained the lock. Start waiting for 1ms
[i=1 ,ThreadId=10] Waiting finished.
[i=1 ,ThreadId=10] Lock released
[i=2 ,ThreadId=10] Try to obtain the lock
The code locks the mutex in one thread and unlocks it in another.
This happens because using await
doesn't guarantee that the subsequent code will execute on the same thread.
Therefore, in such case NeoSmart.AsyncLock
starts working as a non-reentrant lock - basically a functional equivalent of SemaphoreSlim.
This behavior explains why SemaphoreSlim doesn't support reentrancy. It's also the reason C# doesn't allow you to use the async keyword inside the lock statement, as shown below:
lock (obj)
{
await Task.Delay(TimeSpan.FromMilliseconds(1));
}
There is a very nice discussion on StackOverflow that provides a lot of details why such deadlocks happen.
What's more concerning is that even this basic code is expected to fail:
using (await _lock.LockAsync())
{
using (await _lock.LockAsync()) // <--- Deadlock is supposed to happen here,
// because it is not guranteed that this line
// will be executed in the same thread that
// holds the lock.
{
}
}
NeoSmart.AsyncLock
primitive shouldn't promise reentrancy when a user uses it with the async
keyword.I'm not relying on the task being dispatched on the same thread to enable reentrance, though. I'm using the extremely poorly documented AsyncLocal
(combined with traditional same-thread detection, but that's mostly for when using AsyncLock in non-async contexts), which should be able to detect this, but it's extremely tricky.
The biggest issue is that AsyncLocal<T>
doesn't really kick in when an async operation is dispatched, only when it is awaited.
@mqudsi,
I'm not relying on the task being dispatched on the same thread to enable reentrance
Sorry, my bad.
However, as far as I understood, the article Reentrant Async Lock is Impossible in C# paragraph ExecutionContext describes why using AsyncLocal
primitive doesn't solve the problem of reentrancy.
@alekw911 how did you fix this for your app?
@alekw911 how did you fix this for your app?
Hello @mitja-p, I'm not the author of this issue, but it seems that there is no fix, as I described in my previous message
What is your use case? Why do you need an async reentrant lock?
Hello! What about this async reentrant lock implementation? .NET Fiddle link
@IvanGit,
Ok, so you are asking about the usage of ReentrantAsyncLock. I have rewritten my reproduction scenario using it. This issue isn't reproduced using this class.
internal class Program
{
static async Task Main(string[] args)
{
var asyncLock = new ReentrantAsyncLock();
Log($"Obtain the lock before entering the for loop");
await asyncLock.AcquireAsync(async () =>
{
Log($"Enter the for loop");
for (int i = 0; i < 100; ++i)
{
await asyncLock.AcquireAsync(async () =>
{
Log(i, "Obtained the lock. Start waiting for 1ms");
await Task.Delay(TimeSpan.FromMilliseconds(1));
Log(i, "Waiting finished.");
});
}
});
}
static void Log(string msg)
{
Console.WriteLine($"[ThreadId={Thread.CurrentThread.ManagedThreadId,-2}] {msg}");
}
static void Log(int iteration, string msg)
{
Console.WriteLine($"[i={iteration,-2},ThreadId={Thread.CurrentThread.ManagedThreadId,-2}] {msg}");
}
}
@IvanGit,
Oh, by the way, I noticed you are a creator of NuExt.System Respect for making such a complex library work.
Oh cool a different implementation, I had ended up using: https://github.com/microsoft/vs-threading/blob/main/src/Microsoft.VisualStudio.Threading/ReentrantSemaphore.cs
I have taken the sample from the "AsyncLock usage example" section of the documentation and added a for loop around the inner reentrant call and observed a deadlock occur
This is the sample code I used