Csharp Wait All Tasks to Finish Without Holding Pointers to Tasks

Oct 14, 2023  │  m. Apr 13, 2024 by khatibomar  │  #dotnet   #csharp   #concurrency   #parallelism   #threading  
Disclaimer: Views expressed in this software engineering blog are personal and do not represent my employer. Readers are encouraged to verify information independently.

Introduction

Let’s say you have an old code base, that doesn’t hold pointer to Tasks, and you need to wait all of them to be done on caller side, and you have nested calls so it’s hard to refactor, so you can’t do Task.WhenAll(tasks).Wait(), so we need a wait all tasks to finish before executing code without keeping track of which Tasks are executed.

Coding

C# Task.WhenAll

I will start by show casing how it’s done usually with Task.WhenAll, we will start with a code that does not wait tasks and refactor to wait tasks.

static void Main(string[] args)
{
    for(int i = 0; i < 5; i++)
        Work(i);

    Console.WriteLine("Done");
}

public static void Work(int i)
{
    Task.Run(() => {
        Console.WriteLine($"Entry index: {i}");
        Thread.Sleep(1000);
        Console.WriteLine($"Out index: {i}");
    });
}

As you can see in output down below it doesn’t wait to tasks to finish as we didn’t get any Out index message

Done
Entry index: 1
Entry index: 0
Entry index: 2
Entry index: 3
Entry index: 4

C:\Users\User\Documents\code\ConsoleApp3\bin\Debug\net6.0\ConsoleApp3.exe (process 21140) exited with code 0.
Kanna Kamui

Your output may vary and even the code will immediately exit, my hardware is so fast so I was able to get results on screen.

to fix this issue with Task.WhenAll we need to hold pointers of those tasks somewhere, I will store them in List<Task>

static void Main(string[] args)
{
    List<Task> tasks = new();

    for (int i = 0; i < 5; i++)
        tasks.Add(Work(i));

    Task.WhenAll(tasks).Wait();

    Console.WriteLine("Done");
}

public static Task Work(int i)
{
    return Task.Run(() =>
    {
        Console.WriteLine($"Entry index: {i}");
        Thread.Sleep(1000);
        Console.WriteLine($"Out index: {i}");
    });
}

As you can see in output down below it now waits the tasks to finish then print Done

Entry index: 0
Entry index: 1
Entry index: 2
Entry index: 3
Entry index: 4
Out index: 3
Out index: 2
Out index: 0
Out index: 4
Out index: 1
Done

C:\Users\User\Documents\code\ConsoleApp3\bin\Debug\net6.0\ConsoleApp3.exe (process 27356) exited with code 0.

C# CountdownEvent

Okay so method above works but the refactoring was so small,

What if you run into situation where there is deeply nested function calls? That will take time and huge refactoring.

So I wanted something very similar to sync.WaitGroup in Go

var wg sync.WaitGroup

for i := 1; i <= 5; i++ {
    wg.Add(1)

    go func(i int) {
        defer wg.Done()
        worker(i)
    }(i)
}

wg.Wait()

The pattern is as follows: Increment counter by number of jobs that are going to be executed, then after job completes it will decrement counter by one.

Kanna Kamui

This sounds very easy and you may say that I can create a simple variable that hold counter, but don't forget that we need it to be thread safe as many threads/routines accessing same variable

In C# there is equivalent class to sync.WaitGroup and it’s CountdownEvent class

I will reuse code from above but this time will fix it with CountdownEvent instead of Task.WhenAll

 1private static CountdownEvent _countdownEvent = new(1);
 2
 3static void Main(string[] args)
 4{
 5    
 6    for (int i = 0; i < 5; i++)
 7        Work(i);
 8
 9    _countdownEvent.Signal();
10    _countdownEvent.Wait();
11    Console.WriteLine("Done");
12}
13
14public static void Work(int i)
15{
16    _countdownEvent.AddCount(1);
17
18    Task.Run(() => {
19        Console.WriteLine($"Entry index: {i}");
20        Thread.Sleep(1000);
21        Console.WriteLine($"Out index: {i}");
22    }).ContinueWith(t => _countdownEvent.Signal());
23}

At line 1 I initialized CountdownEvent to 1 because setting it to 0 will throw an exception if we try to add counter.
then on line 9 I called _countdownEvent.Signal() which will decrement counter by 1. So the one that I added is decremented later.

then on line 16 each time I am running new task I am adding counter by 1.

then on line 22 after task is completed I am decrementing this one, to run a code after Task execution I am using ContinueWith which will execute code right after completion of task.

Then on line 10 I call wait _countdownEvent.Wait() which will wait the counter to become 0 again.

As we can see we were able to wait all tasks to finish without holding a pointer to any task.

Entry index: 0
Entry index: 3
Entry index: 2
Entry index: 4
Entry index: 1
Out index: 2
Out index: 4
Out index: 0
Out index: 3
Out index: 1
Done

C:\Users\User\Documents\code\ConsoleApp3\bin\Debug\net6.0\ConsoleApp3.exe (process 22704) exited with code 0.