Racing Tasks in C♯

šŸ”– c-sharp ā²ļø 2 minutes to read

In general, when running Tasks in Cā™Æ, you want to run one, or multiple in parallel, and continue executing code when they all complete (to process the result for example). The normal thing to do there is use Task.WhenAll.

Occasionally you might have a case where you want to run multiple tasks at once, and continue when one has completed (either by succeeding or failing). For that, you can use Task.WhenAny.

However: what if you wanted to execute a set of tasks, and not take the first completed one, but the first successful one? In other words, race the tasks, and if one of them falls over, they lose the race.

Hypothetical Application

For an example application of this, imagine two API calls which return the same information, however sometimes one is faster than the other. You find yourself in the luxurious position of not needing to worry about rate limiting or load on said APIs, so you call both in parallel.

One of the additional benefits is resiliency - what if one of the APIs errors, and the other is successful? Ignoring that failed task would still allow you to get a successful result, even if the successful call completed later.

Task Racer Implementation

The below method takes a set of tasks and returns the first successfully completed one. If all tasks fail, the last one to fail is returned. The usage of this is similar to Task.WhenAny, where the result is a Task that you must await rather than the result object.

internal static class TaskRacer
{
    public static async Task<Task<TResult>> RaceTasks<TResult>(IEnumerable<Task<TResult>> tasks)
    {
        var queue = tasks.ToList();

        Task<TResult> task;
        do
        {
            task = await Task.WhenAny(queue);
            queue.Remove(task);
        }
        while (task.IsFaulted && queue.Count > 0);

        return task;
    }
}

This class is actually from my DNS library, and for example usage you can look at the unit tests (which cover the majority of interesting cases).

Practical Application

As above I was interested in this functionality for my DNS library. There are multiple public DNS resolvers which you can use, for example 1.1.1.1, Google Public DNS and OpenDNS, and since they're hosted on completely different infrastructure it makes sense to use two at once, and "race" for the best result.

If you're curious how this works, the DnsRacerClient accepts a set of clients to perform name resolution with, and will select two at random to run the incoming query against. The fastest successful result wins, and the slowest or failed is discarded.

And for a real exercise of this code, it allowed my home network to weather the storm of the 1.1.1.1 lookup failures on October 4th, 2023 without a single failed DNS query!

šŸ·ļø task successful dns completed multiple race application api failed c♯ parallel continue code whenany library

ā¬…ļø Previous post: Using Material Layers in UE5

āž”ļø Next post: Raspberry Pi Pico Home Assistant Motion & Temperature Sensor

šŸŽ² Random post: Automating macOS Notarization for UE4

Comments

Please click here to load comments.