0

I have an Async processing pipeline. I'm implementing a constraint such that I need to limit the number of submissions to the next stage. For my component, I have:

  • a single input source (items are tagged with a source id)
  • a single destination that I need to propagate the inputs to in a round-robin fashion

If capacity is available for multiple clients, I'll forward a message for each (i.e. if I wake because client 3's semaphore has finally become available, I may first send a message for client 2, then 3, etc)

The processing loop is thus waiting on one or more of the following conditions to continue processing:

  • more input has arrived (it might be for a client that is not at its limit)
  • capacity has been released for a client that we are holding data for

Ideally, I'd thus use Task.WhenAny with

  • a task representing the input c.Reader.WaitToReadAsync(ct).AsTask()
  • N tasks representing the clients for which we are holding data, but it's not yet valid for submission (the Wait for the SemaphoreSlim would fail)

SemaphoreSlim's AvailableWaitHandle would be ideal - I want to know when it's available but I don't want to reserve it yet as I have a chain of work to process - I just want to know if one of my trigger conditions has arisen

Is there a way to await the AvailableWaitHandle ?

My current approach is a hack derived from this answer to a similar question by @usr - posting for reference

My actual code is here - there's also some more detail about the whole problem in my self-answer below

Ruben Bartelink
  • 59,778
  • 26
  • 187
  • 249
  • What code do you have so far? Sounds like you actually need a `BlockingCollection` or similar – Charlieface Aug 04 '22 at 10:10
  • 1
    *"I want to know when it's available but I don't want to reserve it yet as I have a chain of work to process"* -- What bad might happen in case you reserve the semaphore immediately? If the semaphore can be reserved independently by another worker in the meantime, are you sure that your setup is not prone to race conditions? – Theodor Zoulias Aug 04 '22 at 12:36
  • 1
    @Charlieface the [`Channel`](https://learn.microsoft.com/en-us/dotnet/api/system.threading.channels.channel-1) is basically an asynchronous `BlockingCollection`. It's a consumable queue with signaling functionality that doesn't block. – Theodor Zoulias Aug 04 '22 at 12:39
  • @Charlieface edited in a link to the code. Yes, N blocking collections, and an input non-blocking collection. Then I want to wait on an event arriving on the input, or a blocked collection that triggered me to go to sleep unblocking. But I own and control the collections, so it does not have to be that heavy (see the code, to which I added a link. Also would ideally not add a lib dependency and/or set of concepts beyond standard .NET concurrency primitives and/or Channels) – Ruben Bartelink Aug 04 '22 at 13:52
  • Re channels for others: [canonical article by Stephen Toub](https://devblogs.microsoft.com/dotnet/an-introduction-to-system-threading-channels/). Taking Charlieface's suggestion: A channel per client would achieve the ask - can then `WaitToWrite` on the blocked ones and `WaitToRead` on the input. It feels slightly heavy though given I own the channels and am the only reader/writer, which makes a Semaphore a more direct encoding of the problem (which takes me onwards to Stephen's answer, which argues that even that's too heavy...) – Ruben Bartelink Aug 04 '22 at 13:57
  • Stephen Toub in general does a great job at introducing concepts, but their [introductory article on channels](https://devblogs.microsoft.com/dotnet/an-introduction-to-system-threading-channels/) leaves much to be desired IMHO. Instead of focusing in what a `Channel` does, when it can be useful, talk a little bit about its historical origins (the Go language) and its [limitations](https://github.com/dotnet/runtime/issues/761 "Possible CancellationTokenRegistration leak in AsyncOperation"), they focus on how you can implement one inefficiently with `SemaphoreSlim`s... – Theodor Zoulias Aug 04 '22 at 14:24
  • 1
    Thanks for the share of the issue, noteworthy. The SemaphoreSlim talked to me as a way of conveying the functionality - am aware of the golang context but have not used so it would not be an ideal explanation tool in my context. but I do agree it would be nice if the corporate attitude of not referencing predecessors could have been paused for this article. – Ruben Bartelink Aug 04 '22 at 14:48

2 Answers2

3

I want to know when it's available but I don't want to reserve it yet as I have a chain of work to process

This is very strange and it seems like SemaphoreSlim may not be what you want to use. SemaphoreSlim is a kind of mutual exclusion object that can allow multiple takers. It is sometimes used for throttling. But I would not want to use it as a signal.

It seems like something more like an asynchronous manual-reset event would be what you really want. Or, if you wanted to maintain a locking/concurrent-collection kind of concept, an asynchronous monitor or condition variable.

That said, it is possible to use a SemaphoreSlim as a signal. I just strongly hesitate suggesting this as a solution, since it seems like this requirement is highlighting a mistake in the choice of synchronization primitive.

Is there a way to await the AvailableWaitHandle?

Yes. You can await anything by using TaskCompletionSource. For WaitHandles in particular, ThreadPool.RegisterWaitForSingleObject gives you an efficient wait.

So, what you want to do is create a TCS, register the handle with the thread pool, and complete the TCS in the callback for that handle. Keep in mind that you want to be sure that the TCS is eventually completed and that everything is disposed properly.

I have support for this in my AsyncEx library (WaitHandleAsyncFactory.FromWaitHandle); code is here.

My AsyncEx library also has support for asynchronous manual-reset events, monitors, and condition variables.

Stephen Cleary
  • 437,863
  • 77
  • 675
  • 810
  • `SemaphoreSlim` is often used as an `async` lock, given the lack of any other primitives that offer async blocking in the standard library, without using external libraries. – Charlieface Aug 04 '22 at 14:11
  • There's concurrency at play in my impl - I get an async callback when a submitted item is declared complete. I guess I could do an Interlocked.Decrement and set an event on it moving from full to non-full state. But then I have the questions of how to combine waiting for that with N of such internal buffers transitioning out of full state without a race condition. So I'll stick with the assumption of a `SemaphoreSlim` being a reasonable choice .... for now (see also my comments on the OP). The `RWFSO` seems like a good solution - In m case, 1 TCS between N items would work well - `RWFMO` ;P – Ruben Bartelink Aug 04 '22 at 14:19
  • @Charlieface: Yes, but using "the lock is available" as a signal without taking the lock is what indicates it's probably not the best choice. – Stephen Cleary Aug 04 '22 at 22:18
  • @RubenBartelink: `Interlocked.Decrement and set an event... race condition` - I don't have the full picture but it sounds like a condition variable scenario. They're complex and not familiar to most .net devs but there's good general-purpose docs around for them. – Stephen Cleary Aug 04 '22 at 22:22
  • I definitely need to read up on that so I grok it, but my immediate reaction is that I don't see how I'm going to combine waiting for it with the wait on the input channel that I mentioned in the OP (though as it happens in this very specific case, my component also manages both ends of that input channel, so I can revert it back to simply being a ConcurrentQueue as it was at the start https://github.com/jet/propulsion/compare/jet:fb71668...jet:f438e52#diff-9d65d2a4035a873ee555e5e04de6dc8a497be055267e127d9590c9283c0332a2R56) – Ruben Bartelink Aug 05 '22 at 11:13
0

Variation of @usr's answer that solved my problem

    class SemaphoreSlimExtensions
    
        public static Task AwaitButReleaseAsync(this SemaphoreSlim s) => 
            s.WaitAsync().ContinueWith(_t -> s.Release(), ct,
                                      TaskContinuationOptions.ExecuteSynchronously | TaskContinuationOptions.OnlyOnRanToCompletion,
                                      TaskScheduler.Default);
        public static bool TryTake(this SemaphoreSlim s) => 
            s.Wait(0);

In my use case, the await is just a trigger for synchronous logic that then walks the full set - the TryTake helper is in my case a natural way to handle the conditional acquisition of the semaphore and the processing that's contingent on that. My wait looks like this:

SemaphoreSlim[] throttled = Enumerable.Empty();
while (!ct.IsCancellationRequested)
{
    var throttledClients = from s in throttled select s.AwaitButReleaseAsync();
    var timeout = 3000;
    var otherConditions = new[] { input.Reader.WaitToReadAsync().ToTask(), Task.Delay(ct, timeout) };
    
    await Task.WhenAny(throttledClients.Append(otherConditions));
    throttled = propagateStuff();
}

The actual code is here - I have other cases that follow the same general pattern. The bottom line is that I want to separate the concern of waiting for the availability of capacity on a SemaphoreSlim from actually reserving that capacity.

Ruben Bartelink
  • 59,778
  • 26
  • 187
  • 249
  • As a side note, the `while (!ct.IsCancellationRequested)` might result in inconsistent completion of the loop, in case you have any `Task` that observes the `ct` and this task is awaited. Most of the time the loop will complete as canceled, but rarely will complete successfully. – Theodor Zoulias Aug 04 '22 at 12:31
  • Not sure what you mean, so will try to explain the aim of the loop in more detail. This is one of a series of loops that are expected to live until either the input Channel Completes or I tear down the stack via the `ct` - I actually want the loop to yield a RanToCompletion state unless there was an unexpected catastrophic error e.g. a bug in the logic of the loop that caused some other exception (in which case I want the whole pipeline/chain to tear down and stop) – Ruben Bartelink Aug 04 '22 at 13:46
  • Yes, it is possible to use a `CancellationToken` with stopping semantics instead of the usual cancelling semantics, but it's easy to get it wrong and get inconsistent behavior as a result. I don't know the details of your program, so I'll assume that you've handled all the edge cases correctly. – Theodor Zoulias Aug 04 '22 at 13:57
  • 1
    Well, the code is there as linked (though in an F# obfuscation layer!). I agree abusing cancellation as a stop signal definitely makes things more hairy, but it's not something that's exposed to consumers so it seems like a reasonable tradeoff for now from where I'm sitting. – Ruben Bartelink Aug 04 '22 at 14:22
  • In the unlikely event that someone that took the code before now sees this, there was a critical missing `TaskContinuationOptions.OnlyOnRanToCompletion ` in an earlier version of the `AwaitButReleaseAsync` helper https://github.com/jet/propulsion/pull/230 – Ruben Bartelink Aug 20 '23 at 07:27