-
Notifications
You must be signed in to change notification settings - Fork 754
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Feature Request: Make AsyncGate.LockAsync
cancellable
#2148
Comments
It's not AsyncRx.NET's goal to provide general-purpose asynchronous utilities, so I would only want to add features to In fact, my first thought here is: should This is not an area of AsyncRx.Net that I've explored yet. (We inherited this code when we took over maintenance of Rx, and most of our focus so far has been elsewhere.) So I need to attempt to understand the thinking behind the fact that As far as I can tell, it currently appears in just two public APIs: public static class Observable
{
public static IObservable<TSource> Synchronize<TSource>(this IObservable<TSource> source, object gate)
}
public static class Observer
{
public static IObserver<T> Synchronize<T>(IObserver<T> observer, object gate)
} Note that these take a plain So these Rx.NET methods offer two capabilities:
AsyncRx.NET faces a challenge in implementing the same service: the synchronization capability available for any object (i.e. what we use through So to enable the two capabilities described above we can't use What we would want to do is use the async equivalent of dotnet/runtime#34812 (comment) (The basic issue is that thread affinity is part of the design, which rules out async.) There is a very long-standing request to add an So the original AsyncRx.NET implementors' decision to define their own That shuts down my initial thought: no, this type should not be So, given that we need to provide However... There's one notable feature of the discussion linked to above in the proposal for adding I'm wondering if we should in fact take a completely different approach. I'm wondering if we should define an (AsyncRx.NET is still in preview, so we can make breaking changes like this.) In fact I'd be inclined to make all of So AsyncRx.NET would make no attempt to support cancellation, but it would become possible for application code to supply its own This approach would remove the block that prevents you from doing what you want. It's not quite what you asked for, because we wouldn't be providing the solution we want, but we would a) make it possible for you to write such a solution, and b) leave the door open for using a future .NET-supplied async lock primitive if they ever add such a thing. Also, if you're already using a library that provide such a primitive (and I gather project Orleans does) you could use that. I prefer that to an API design that forces you to use our type. So this would provide a way for you to do what you want. How does that sound? |
@idg10 thanks for the thorough response, using an interface sounds like an appropriate fix for Rx. It's easy enough for me to copy over I'll admit I'm using some (useful) exposed public classes outside of the scope of Rx. For example, I'm guessing I shouldn't rely on the implementation of |
I apologise for not noticing that Q&A early. I thought GitHub would notify me when people posted in there, but I guess not! You can totally rely on all our public |
I've prototyped an implementation of this I'll post again here once a usable preview is available. |
(I am still trying to solve the code signing! I was off sick for a bit, and then on vacation for a while, and I now think it's possible that the person at the .NET Foundation whose help I need to get code signing reinstated may be on vacation... But I haven't forgotten this.) |
OK, that took a lot longer than expected! (The .NET Foundation wanted to move people over to use of Managed Identities for code signing auth. This is a great idea, but since Rx.NET was the first project to attempt this, it took a while to work out how to get the config right.) Anyway, we are now able to sign packages again! 🎉 There is, finally, a preview build of To use the preview package feed, you'll need to configure NuGet to use this endpoint:
This is how a reference to the specific preview that has this feature looks in a <PackageReference Include="System.Reactive.Async" Version="6.0.0-alpha.44.g5ca1c61383" /> To test this out, I thought I'd see if I could write an adapter enabling us to use someone else's cancellable asynchronous lock with AsyncRx.NET's This is not an endorsement of that lock type. I have great admiration for Stephen Cleary's work, but I have done nothing to test this, or even to find out what state of maturity he considers this lock to be in. It was simply a readily usable async lock with cancellation support. It has revealed a possible shortcoming with my design, which I'd be interested in your (or anyone else's) view on. Here's my adapter: using System.Reactive.Threading;
namespace TestRxAsyncPluggableGate;
/// <summary>
/// An adapter implementating of AsyncRx.NET's <see cref="IAsyncGate"/> around Nito.AsyncEx's
/// <see cref="Nito.AsyncEx.AsyncLock"/>.
/// </summary>
internal class NitoAsyncLockAsRxAsyncGate : IAsyncGate
{
private IDisposable? _releaser;
public Nito.AsyncEx.AsyncLock Lock { get; } = new();
public async ValueTask<AsyncGateReleaser> LockAsync()
{
// The Nito AsyncLock returns a disposable that we must use to release the lock.
// Since this gate is by design one-at-a-time (it does not support re-entrancy,
// unlike AsyncRx.NET's own AsyncGate) we can be confident that once this
// returns, we will have the lock, and the _releaser will be null.
IDisposable releaser = await this.Lock.LockAsync().ConfigureAwait(false);
// Locking purely to avoid any out-of-order memory issues. A more clever implementation
// could use memory barriers, but it's really easy to introduce bugs that way.
lock (this)
{
if (this._releaser is not null)
{
throw new InvalidOperationException("NitoAsyncLockAsRxAsyncGate in invalid state - _releaser not null even though nothing else should own this lock");
}
this._releaser = releaser;
}
return new AsyncGateReleaser(this);
}
public void Release()
{
// As above, we are using lock just to avoid out-of-order memory access issues.
// We shouldn't ever actually see concurrent access to _releaser here, because LockAsync
// can't proceed until the underlying lock becomes available, and that won't happen
// until this method reaches the point where it calls releaser.Dispose(), which happens
// after we've finished checking and then updating the field.
// But without some sort of memory barrier, it's possible in theory that the memory
// write that sets _releaser to null could become visible to another thread only after
// the call to releaser.Dispose(). Similarly, without memory barriers, the write that
// LockAsync makes to set _releaser might not be visible to this thread even though
// the lock has been acquired.
// (This is all vanishingly unlikely stuff, but that just means problems caused by
// bugs in this area are nearly impossible to reproduce and debug, but can still happen
// in production, especially under heavily load. The simplest way to be safe is just to
// use lock, and since we expect no contention in most cases, this should be fine.)
IDisposable releaser;
lock (this)
{
if (this._releaser is null)
{
throw new InvalidOperationException("Release called when NitoAsyncLockAsRxAsyncGate not held");
}
releaser = this._releaser;
this._releaser = null;
}
releaser.Dispose();
}
} This was more complex than I had hoped. When I did the original design for Now there is a YOLO version: internal class NitoAsyncLockAsRxAsyncGate : IAsyncGate
{
private IDisposable? _releaser;
public Nito.AsyncEx.AsyncLock Lock { get; } = new();
public async ValueTask<AsyncGateReleaser> LockAsync()
{
IDisposable releaser = await this.Lock.LockAsync().ConfigureAwait(false);
this._releaser = releaser;
return new AsyncGateReleaser(this);
}
public void Release()
{
IDisposable releaser = this._releaser;
this._releaser = null;
releaser.Dispose();
}
} That's actually the essence of it. And perhaps we might think there's no need for any sort of locking around access to the Either way, this is more complex than I'd hoped. The design of So I'm now wondering whether I should make a small addition to internal class NitoAsyncLockAsRxAsyncGate : IAsyncGate
{
public Nito.AsyncEx.AsyncLock Lock { get; } = new();
public async ValueTask<AsyncGateReleaser> LockAsync()
{
IDisposable releaser = await this.Lock.LockAsync().ConfigureAwait(false);
return new AsyncGateReleaser(release);
}
public void Release()
{
// Nothing to do, because the IDisposable we return via LockAsync does all the work
}
} That seems simpler, at the expense of a potentially more confusing design. (There are now two ways you can choose to implement gate release in Any thoughts? |
Tagging @StephenCleary since this is based on his |
What about: public interface IAsyncGate
{
ValueTask<IAsyncGateReleaser> LockAsync();
}
public interface IAsyncGateReleaser
{
void Release();
} Then you can implement like this: internal class NitoAsyncGate : IAsyncGate
{
public Nito.AsyncEx.AsyncLock Lock { get; } = new();
public async ValueTask<IAsyncGateReleaser> LockAsync()
{
return new Releaser(await Lock.LockAsync().ConfigureAwait(false));
}
private class Releaser(IDisposable releaser) : IAsyncGateReleaser
{
public void Release() => releaser.Dispose();
}
} Or like this: internal class MyCustomAsyncGate : IAsyncGate, IAsyncGateReleaser
{
public MyCustomAsyncLock Lock { get; } = new();
public async ValueTask<IAsyncGateReleaser> LockAsync()
{
await Lock.EnterAsync();
return this;
}
public void Release() => Lock.Exit();
} P.S. I implemented a custom AsyncLock that returns a disposable struct to avoid allocations. It's very efficient, and it would make me want to use |
The underlying
SemaphoreSlim.WaitAsync
already supports cancellation. Can a parameter be exposed to pass a token down to this line?reactive/AsyncRx.NET/System.Reactive.Async/Threading/AsyncGate.cs
Line 35 in 5f831de
The text was updated successfully, but these errors were encountered: