0

I have read an answer to a similar question https://stackoverflow.com/a/43841624/11478903 but it doesn't explain the yielding of execution.

I have a thread that consumes events using GetConsumingEnumerable from a BlockingCollection<Event> _eventQueue property.

public async Task HadleEventsBlocking()
{
    foreach (var event in _eventsQueue.GetConsumingEnumerable())
    {
        switch (event)
        {
            case Event.BtnAddClicked:
                HandleBtnAddClickedAsync(_cts.Token);
                break;
            case Event.BtnRemoveClicked:
                HandleBtnRemoveClickedAsync(_cts.Token);
                break;
            case Event.BtnDisableClicked:
                _cts.Cancel();
                break;
            case Event.BtnEnableClicked:
                _cts.Dispose();
                _cts = new CancellationTokenSource();
                break;
        }
        Console.WriteLine("Event loop execution complete.");
    }
}

public async Task HandleBtnAddClickedAsync(CancellationToken token)
{
    try
    {
        await Task.Run(async () =>
        {
            token.ThrowIfCancellationRequested();
            await Task.Delay(2000);
            token.ThrowIfCancellationRequested();
            Console.WriteLine("BtnAddClicked event complete");
        });
    }
    catch (OperationCanceledException)
    {
        Console.WriteLine("HandleBtnAddClicked Cancelled");
    }
}
    
public async Task HandleBtnRemoveClickedAsync(CancellationToken token)
{
    try
    {
        await Task.Run(async () =>
        {
            token.ThrowIfCancellationRequested();
            await Task.Delay(2000);
            token.ThrowIfCancellationRequested();
            Console.WriteLine("BtnRemoveClicked event complete");
        });
    }
    catch (OperationCanceledException)
    {
        Console.WriteLine("HandleBtnRemoveClicked Cancelled");
    }
}

And this does exactly what I want, the foreach loop executes each Event as fast as possible and does not get blocked. The methods that correspond to each Event also get the convenience of try/catch with the await Task.Run but why does this work? Because if I simply rearrange it won't work as I want it to.

public async Task HadleEventsBlocking()
{
    foreach (var event in _eventsQueue.GetConsumingEnumerable())
    {
        switch (event)
        {
            case Event.BtnAddClicked:
                try
                {
                    await Task.Run(async () =>
                    {
                        _cts.Token.ThrowIfCancellationRequested();
                        await Task.Delay(2000);
                        _cts.Token.ThrowIfCancellationRequested();
                        Console.WriteLine("BtnAddClicked event complete");
                    });
                }
                catch (OperationCanceledException)
                {
                    Console.WriteLine("HandleBtnAddClicked Cancelled");
                }
                break;
            case Event.BtnRemoveClicked:
                try
                {
                    await Task.Run(async () =>
                    {
                        _cts.Token.ThrowIfCancellationRequested();
                        await Task.Delay(2000);
                        _cts.Token.ThrowIfCancellationRequested();
                        Console.WriteLine("BtnRemoveClicked event complete");
                    });
                }
                catch (OperationCanceledException)
                {
                    Console.WriteLine("HandleBtnRemoveClicked Cancelled");
                }
    
                break;
            case Event.BtnDisableClicked:
                _cts.Cancel();
                break;
            case Event.BtnEnableClicked:
                _cts.Dispose();
                _cts = new CancellationTokenSource();
                break;
        }
        Console.WriteLine("Event loop execution complete.");
    }
}

Now each time an event is executed the foreach loop is blocked by the await inside the try/catch and I understand why, because of the await on the Task.Run.

However I don't understand why I get desired behavior when I pack it into a method that I don't await. Is it because the await inside yields execution back to HandleEventsBlocking and it resumes the foreach loop? I'd also appreciate a comment on whether this is good practice, it got me far but I just don't understand the tool I'm using it and it makes me worried.

The_Matrix
  • 85
  • 7
  • `However I don't understand why I get desired behavior when I pack it into a method that I don't await.` Because you don't await. You'd have the same result if you didn't await the `Task.Run`. – tkausl Aug 19 '23 at 20:28
  • @tkausl But to me what is strange is that I can use a `try/catch` on the `await Task.Run` inside of my work methods with working error handling. I can't do that if I don't wrap it inside of a method. – The_Matrix Aug 19 '23 at 20:30
  • Try to add `await` to method calls in your first implementation - you will get the same result. First approach is called `async loop` pattern (mine naming), second one - just simple sequential loop. – Ryan Aug 19 '23 at 20:31
  • @The_Matrix; `await` means `asynchronous wait` : you will not get next work item until complete current one. – Ryan Aug 19 '23 at 20:33
  • The first version of `HadleEventsBlocking` should give you a compiler warning about an `async` method that lacks `await`. The `BlockingCollection` is intended for blocking threads. If instead of threads you prefer asynchronous flows (async methods) that lack thread affinity and identity, the correct queue to use is the [`Channel`](https://learn.microsoft.com/en-us/dotnet/api/system.threading.channels.channel-1). For more details see this question: [Is there anything like asynchronous `BlockingCollection`?](https://stackoverflow.com/questions/21225361/) – Theodor Zoulias Aug 19 '23 at 22:58

1 Answers1

4

However I don't understand why I get desired behavior when I pack it into a method that I don't await.

Because it's not awaited. You can think of await as "asynchronous wait". It pauses the method until the returned task completes. More info on my blog: https://blog.stephencleary.com/2012/02/async-and-await.html

I'd also appreciate a comment on whether this is good practice, it got me far but I just don't understand the tool I'm using it and it makes me worried.

Absolutely not. Ignoring tasks instead of awaiting them is dangerous: it means any exceptions are silently swallowed and your code cannot know when the processing is complete.

A better approach would be something like TPL Dataflow. Alternatively, you could create multiple consumers for your queue of work.

Stephen Cleary
  • 437,863
  • 77
  • 675
  • 810
  • `Absolutely not.` - i believe we can use this approach, it is `async loop` design pattern, the only thing to care - to handle all its outcomes (complete/cancelled/failured) via `Task.ContinueWith`. – Ryan Aug 19 '23 at 20:37
  • @Ryan Isn't that handled by the `try/catch` block? Anytime I cancel the `await Task.Run` it is handled by the `try/catch` and I can do clean up inside of it. Only issue is if it's potentially handled on a different thread. – The_Matrix Aug 19 '23 at 20:39
  • await bubbles up exception thrown - yes, it works. But the other side - you handle your work items one by one, sequentially. – Ryan Aug 19 '23 at 20:40
  • @Ryan But the first example I provide does not execute sequentially. What I get is desired behavior of the `foreach` iterating and calling methods and not waiting on them. But each method uses `try/catch` with `await` inside and does get the benefit of easy error handling without any setback. That is what I don't understand. – The_Matrix Aug 19 '23 at 20:42
  • 2
    You got the answer - because it's not awaited. Look for details in the article attached. – Ryan Aug 19 '23 at 20:44
  • Essentially I'm spawning a separate thread for each Task with the errors handled on random thread that they occur, but not in my consuming thread, where I'd actually want to handle the errors. – The_Matrix Aug 19 '23 at 20:58
  • @The_Matrix, the answer is about _any_ exceptions not caught within an async method. In synchronous code, an uncaught exception leads to the call stack being unwound until a matching exception handler is being found -- the exception bubbles up. But in async methods, an exception not handled within the async method itself cannot do that, simply because the async continuation might run in a very different thread context with a different call stack -- it cannot unwind the call stack reverting to the calling method because it's not the call stack of the caller anymore (1/2) – EyesShriveledToRaisins Aug 19 '23 at 21:05
  • 1
    @The_Matrix (2/2) Therefore, an async method (the state machine created by the compiler) basically captures any such unhandled exceptions in the returned task object. If you don't await such a task object (or otherwise handle the task object to a similar effect), the task object disappears with any such possible captured exception. Your code catches only `OperationCanceledException`. Think about what will happen when the code within the try-catch block throws some other exception type... – EyesShriveledToRaisins Aug 19 '23 at 21:08
  • @EyesShriveledToRaisins I would then add more robust error handling. But from further experimentation and reading the article, I also have to worry about the context that the Task is working with. Adding log statements of current thread ID I notice that the `OperationCanceledException` is caught on a different thread then my consuming thread and there is different contexts of my application state. To not distract from my original question, should I make this a separate question? – The_Matrix Aug 19 '23 at 21:11
  • 2
    @The_Matrix it is expected and normal that the asynchronous part of a method (the continuation of a method that happens after awaiting a task) to run in a thread different from the calling thread. In async operations, it doesn't really make sense to use thread id's for tracking control flow or stages of concurrent data processing. If you are looking for alternative approaches, i guess it's best to ask a new question in which you could elaborate the usage scenario and logging requirements... – EyesShriveledToRaisins Aug 19 '23 at 21:20
  • @The_Matrix if you want to handle the errors in the consuming thread, then you should transfer the errors through the same `BlockingCollection` that is consumed be the consuming thread. Of course you should modify the `T` so that it includes an `Exception` is addition to the `Event`. Doing so would make it very difficult and convoluted to determine when it is safe to `CompleteAdding` the `BlockingCollection`, so I wouldn't recommend doing it. – Theodor Zoulias Aug 19 '23 at 23:25