Re-entrant Async Lock #157
Replies: 5 comments 9 replies
-
One thing I missed is, what if you await with cancelation? await childPromise.WaitAsync(cancelationToken); If the token is canceled while the lock is still held by a child, what happens? Usually, this would immediately throw and execution could continue in the parent. But that would break mutual exclusion, allowing concurrent calls to happen in 2 places. I think this is another place where the promise has to cooperate with the lock, and wait until the lock is exited from any children before throwing/continuing. |
Beta Was this translation helpful? Give feedback.
-
Is there anything special about the child task/promise/future being created inside the guarded section of the locks in your examples? To put it a different way, what would you expect to happen with this code? ReentrantAsyncLock _asyncLock = new ReentrantAsyncLock();
async Promise MainAsync()
{
var child = ChildAsync();
using (await _asyncLock.LockAsync())
{
await child;
}
}
async Promise ChildAsync()
{
await Task.Delay(TimeSpan.FromSeconds(1));
using (await _asyncLock.LockAsync())
{}
} |
Beta Was this translation helpful? Give feedback.
-
I think your idea of evaluating reentrance when The downside that I'm seeing with that is the same downside that you pointed out: if you need that superpower, you only get it with the one magic custom async method builder... you don't get it when the method returns a But... what if the async lock's API was a little different. What if the guarded section was a delegate instead of a ... _asyncLock = ...;
async Task MainAsync()
{
await _asyncLock.UseAsync(async () =>
{
DoNonThreadSafeStuff();
await ChildAsync();
});
}
async Task ChildAsync()
{
await _asyncLock.UseAsync(async () =>
{
DoNonThreadSafeStuff();
});
} ...and here's the signature of the public Task UseAsync(Func<Promise> guardedSection);
// ^^^^^^^ This is the secret sauce
// ^^^^ I suppose any awaitable type will do here I haven't tried turning this into code, but perhaps that would make it a little more natural for people to use your magic async method builder. They'll just create a delegate with the async keyword as I've shown above, which will feel very familiar to anyone who has used |
Beta Was this translation helpful? Give feedback.
-
One final thought for my evening: I think no matter how the async lock is implemented, you're not going to be able to entirely escape the switching context problem. For example: async Promise MainAsync()
{
using (await _asyncLock.LockAsync())
{
await Task.Run(async () =>
{
using (await _asyncLock.LockAsync())
{
// #1
}
await Task.Delay(1).ConfigureAwait(false);
using (await _asyncLock.LockAsync())
{
// #2
}
});
}
} I can imagine how reentrance into guarded section # 1 would be allowed. Of course this example is feeding a But what about guarded section # 2? That flowing context will have been lost by the Assuming that's the case, I would like to ask why it's important to be able to use |
Beta Was this translation helpful? Give feedback.
-
After some more thought, I think it's too easy to break, so it's not worth the effort. Even a method as simple as this would break the tree: public Promise Func()
{
var deferred = Promise.NewDeferred();
Func2()
.ContinueWith(resultContainer =>
{
if (resultContainer.State == Promise.State.Resolved)
deferred.Resolve();
else if (resultContainer.State == Promise.State.Rejected)
deferred.Reject(resultContainer.RejectReason);
else
deferred.Cancel();
})
.Forget();
return deferred.Promise;
} |
Beta Was this translation helpful? Give feedback.
-
I'm contemplating adding a
ReentrantAsyncLock
, which is much more complex than a non-re-entrantAsyncLock
. An article discussing the difficulties of implementing a general-purpose re-entrant async lock: https://itnext.io/reentrant-recursive-async-lock-is-impossible-in-c-e9593f4aa38a.An example of a re-entrant async lock: https://github.com/matthew-a-thomas/cs-reentrant-async-lock. This one takes advantage of the fact that Tasks capture the
SynchronizationContext
for each await (unless overridden with.ConfigureAwait(false)
). That means it won't work with ProtoPromise because we explicitly do not capture theSynchronizationContext
by default, for performance reasons (though, doing so can be forced via the.WaitAsync()
API). Also, the author explicitly mentions that it does not support switching context inside the lock body, as that will break the mutual exclusion.I think that ProtoPromise can have a re-entrant async lock with Promise cooperation. The way this will work is logically straight-forward: the lock can be "owned" by a single executing promise at a time, and when that executing promise awaits another promise, the ownership can transfer to the awaited promise to unlock the re-entrant aspect of it, then ownership transfers back when the lock is exited.
Re-entrant children promises are determined when they are awaited, not when they are called.
Example:
In this example, when
ChildAsync
tries to enter the lock, it will block, even though it was called by the owner of the lock. TheMainAsync
will complete theNonThreadSafe()
method, then when it awaits thechildPromise
, theChildAsync
promise will become the new owner of the lock, and the lock will be re-entered. ThenNonThreadSafe()
will run inChildAsync
(notice how it does not race with the call inMainAsync
). WhenChildAsync
exits the lock, the ownership transfers back to the parent. TheChildAsync
may attempt to re-enter the lock again after it exited, and that will work just fine, it will transfer lock ownership back to itself. (To see why it doesn't retain ownership until it is complete, see the next example).Example 2:
This time we have 10 parallel promises running concurrently, all attempting to take the lock, as well as the parent promise that has the lock. Just as the previous case, all child locks will block until the
childPromise
is awaited. But instead of all of the child parallel promises gaining ownership of the lock, causing a race between each other, only one promise will gain ownership of the lock at a time. When the promise that entered the lock releases it, ownership goes to one of the other parallel promises. If the same promise tries to re-enter the lock again, it will enter the lock queue and have to wait until the other promises release the lock. This way the lock is more concurrent than if the ownership is held until the parallel child promise is complete (it doesn't hold the lock for too long). When all parallel child promises have exited the lock, then the ownership transfers back to the parent. And the process repeats if they try to re-enter the lock again.Pros:
Cons:
@matthew-a-thomas I'm curious if you have any thoughts on this. What do you think of this approach, and are there any edge cases I missed?
Beta Was this translation helpful? Give feedback.
All reactions