2

My team and I support several background worker console applications that synchronously process messages from a queue.

Currently, we spin up new instances of the app using docker to handle multiple messages when the number of messages in the queue exceeds a certain threshold.

We were discussing alternative ways we could process these messages in parallel and one teammate proposed using a top level, async void method in a while loop to process the messages.

It looks like it will work, since it would be similar to a UI-based application using async void as an event handler.

However, I've personally never written a background worker to handle multiple messages in parallel this way, and wanted to know if anyone had experience doing so, and if so, if there are any "gotchas" that we aren't thinking of.

Here is a simplified version of the proposed solution:

static async void Main(string[] args)
{
    while (true)
    {
        TopLevelHandler(await ReceiveMessage());
    }
}

static async void TopLevelHandler(object message)
{
    await DoAsyncWork(message);
}

static async Task<object> ReceiveMessage()
{
    //fetch message from queue
    return new object();
}

static async Task DoAsyncWork(object message)
{
    //processing here
}
Evan Payne
  • 131
  • 1
  • 9
  • Are you intent to handle exceptions which may be thrown inside `TopLevelHandler` in any way? With `async void` it will be difficult to work out such an exceptions, both during a developing/debugging and also in production. – Serg Sep 17 '22 at 13:41
  • In general, I would avoid writing an async void method. The behavior of this code also depends on the nature of the DoAsyncWork method. Is it mostly CPU bound or I/O bound? – Singularity222 Sep 17 '22 at 13:41
  • @serg yes, we would want to handle exceptions. That was my initial concern, but it looks like exceptions are indeed caught if we wrap the code inside of DoWork in a try catch – Evan Payne Sep 17 '22 at 13:45
  • @Singularity222 the work would be almost entirely IO bound – Evan Payne Sep 17 '22 at 13:46
  • Google "async void is evil" to get ahead. – Hans Passant Sep 17 '22 at 14:10
  • @HansPassant yes I am familiar with the sentiment. However, it appears there is an exception for event handlers. The solution is logically behaving like an event handler, so I am wondering specifically what the downsides are here in this specific scenario. – Evan Payne Sep 17 '22 at 14:16

3 Answers3

3

Can I safely use async void...

It depends on what you mean be "safely". The async void methods have specific behaviors, so if you like these behaviors then the async void is OK.

  1. The async void operations cannot be awaited, so you don't know when they have completed. If you have a requirement to wait for the completion of all pending asynchronous operations before terminating the program, then the async void is not good for you.
  2. Unhandled exceptions thrown in async void operations are rethrown on the SynchronizationContext that was captured when the async void method started. Since you have a Console application there is no SynchronizationContext, so the error will be thrown on the ThreadPool. This means that your application will raise the AppDomain.UnhandledException event, and then will crash. If you have a requirement to not have your application crashing at random moments, then the async void is not good for you.

The async void methods were originally invented to make async event handlers possible. In modern practice this is still their main usage.

One niche scenario that makes async void an interesting choice is the case that you have a workflow that when it completes it must kick off another workflow. If the kicking is critical for the continuous flow of your application, and a failure to do the kick will leave your app in a hanging state, then it makes sense to escalate this failure to a process-crashing event. Arguably a program that crashes is comparatively better than a program that hangs. You can see two examples of using async void for this scenario here and here. In the second example the async void is the lambda passed to the ThreadPool.QueueUserWorkItem method, ensuring that the async void will not capture any unknown ambient SynchronizationContext.

Theodor Zoulias
  • 34,835
  • 7
  • 69
  • 104
  • Thank you for the detailed response. It was difficult to find the right word to describe my concern concisely in the title. By "safely", I mean the program would have the same results as a program processing messages synchronously. The program would not have to know when the processing completed, and there is no requirement for kicking off another process. We could say that the processing is "fire and forget". If the entirety of that work is wrapped in a try/catch (so assuming all exceptions are handled), do you see any issue with this approach? – Evan Payne Sep 17 '22 at 14:54
  • 1
    @EvanPayne I wouldn't call `async void` methods "fire and forget". Stephen Cleary calls them ["fire and crash"](https://stackoverflow.com/questions/12803012/fire-and-forget-with-async-vs-old-async-delegate/12804036#12804036), which seems more accurate to me. Taking into account that you don't care about the completion of the async operations, and also that you don't allow any exception to escape unhandled, I can't think of any other issue. It seems that `async void` is good for your case. – Theodor Zoulias Sep 17 '22 at 15:14
2

With newest NET we have by default async Main method.

Moreover, if you don't have that feature, you could mark Main as async.

The main risk here that you could miss exceptions and have to be very carefull with created tasks. If you'll be creating tasks in while (true) loop you most probably could end up with thread pool starvation at some point (when someone mistakenly would use some blocking call).

Here is below sample code that shows what shuold be thought about, but i am most certain that there will be more intricacies:

using System.Collections.Concurrent;
using System.Security.Cryptography.X509Certificates;

namespace ConsoleApp2;

public static class Program
{
    /// <summary>
    /// Property to track all running tasks, should be dealt with carefully.
    /// </summary>
    private static ConcurrentBag<Task> _runningTasks = new();

    static Program()
    {
        // Subscribe to an event.
        TaskScheduler.UnobservedTaskException += TaskScheduler_UnobservedTaskException;
    }

    static async Task Main(string[] args)
    {
        var messageNo = 1;

        while (true)
        {
            // Just schedule the work
            // Here you should most probably limit number of
            // tasks created.
            var task = ReceiveMessage(messageNo)
                .ContinueWith(t => TopLevelHandler(t.Result));

            _runningTasks.Add(task);
            messageNo++;
        }
    }

    static async Task TopLevelHandler(object message)
    {
        await DoAsyncWork(message);
    }

    static async Task<object> ReceiveMessage(int messageNumber)
    {
        //fetch message from queue
        await Task.Delay(5000);
        return await Task.FromResult(messageNumber);
    }

    static async Task DoAsyncWork(object message)
    {
        //processing here
        Console.WriteLine($"start processing message {message}");
        await Task.Delay(5000);
        
        // Only when you want to test catching exception
        // throw new Exception("some malicious code");

        Console.WriteLine($"end processing message {message}");
    }

    /// <summary>
    /// Here you handle any unosberved exception thrown in a task.
    /// Preferably you should handle somehow all running work in other tasks.
    /// </summary>
    /// <param name="sender"></param>
    /// <param name="e"></param>
    static async void TaskScheduler_UnobservedTaskException(object sender, UnobservedTaskExceptionEventArgs e)
    {
        // Potentially this method could be enter by multiple tasks having exception.
        Console.WriteLine($"Exception caught: {e.Exception.InnerException.Message}");
        await Task.WhenAll(_runningTasks.Where(x => !x.IsCompleted));
    }
}

Michał Turczyn
  • 32,028
  • 14
  • 47
  • 69
  • @Singularity222 To use result of async method represented by Task, that i used ContinueWith method with. It could be done in several ways :) – Michał Turczyn Sep 17 '22 at 13:49
  • Michal, I agree with limiting the number of messages we dequeue. Also agree with the thread pool starvation point caused by a blocking call. Do you see anything wrong with wrapping everything inside of DoAsyncWork inside of a try catch, and handling the exception in the catch? – Evan Payne Sep 17 '22 at 14:17
  • @EvanPayne I think that should suffice to wrap "top level method" in try catch when we can await method and normally observe exception and handle it gracefully. – Michał Turczyn Sep 17 '22 at 14:19
  • @EvanPayne But I also would leave this event handler just in case, such as global net for exceptions, so if ever anything goes unexpectedly wrong you will have at least some info. – Michał Turczyn Sep 17 '22 at 14:19
  • _if ever anything goes unexpectedly wrong you will have at least some info_ Michal, Thank you for the response. But I do not understand, fully. If we log the exception, which contains the stack trace and error message, is there any other info we would be missing? – Evan Payne Sep 17 '22 at 14:26
  • 1
    @EvanPayne Maybe for now you are sure that anything won't be missed. But in the future you or other dev could add code that would throws exception not handled by your try-catch. – Michał Turczyn Sep 17 '22 at 14:36
-2

Try using IHostedService with Timer to invoke background work. Please ever this link https://learn.microsoft.com/en-us/aspnet/core/fundamentals/host/hosted-services?view=aspnetcore-6.0&tabs=visual-studio for more details.

Poul Bak
  • 10,450
  • 5
  • 32
  • 57
Amit Patil
  • 47
  • 3