C# Using Task to Execute Parallel Tasks: Principles and Detailed Examples

C# Using Task to Execute Parallel Tasks: Principles and Detailed Examples

In C#, using Task makes it very convenient to execute parallel tasks.

Last updated 3/28/2023 8:17 PM
沙漠尽头的狼
15 min read
Category
.NET
Tags
.NET C#

This article is completed through continuous conversations with ChatGPT, and all code has been verified.

In C#, using Task makes it very convenient to execute parallel tasks. A Task is a class that represents an asynchronous operation, providing a simple, lightweight way to create multithreaded applications.

1. Principle of Executing Parallel Tasks with Task

The principle of using Task to execute parallel tasks is to divide the task into multiple smaller chunks, each of which can run on a different thread. Then, use the Task.Run method to submit these smaller chunks as individual tasks to the thread pool. The thread pool automatically manages the creation and destruction of threads and dynamically adjusts the number of threads based on the availability of system resources, thereby maximizing CPU resource utilization.

2. Five Examples

Example 1

Here is a simple example demonstrating how to use Task to execute parallel tasks:

void Task1()
{
    // Create an array of tasks
    var tasks = new Task[10];

    for (var i = 0; i < tasks.Length; i++)
    {
        var taskId = i + 1;

        // Submit tasks using Task.Run
        tasks[i] = Task.Run(() =>
        {
            Console.WriteLine("Task {0} running on thread {1}", taskId, Task.CurrentId);
            // Execute task logic
        });
    }

    // Wait for all tasks to complete
    Task.WaitAll(tasks);

    Console.WriteLine("All tasks completed.");
    Console.ReadKey();
}

In this example, we create an array of tasks with a length of 10, then use Task.Run to submit each task to the thread pool. During task execution, we use the Task.CurrentId property to get the ID of the current task and print it out for easy observation. Finally, we use Task.WaitAll to wait for all tasks to complete and print a completion message.

Output:

Task 3 running on thread 11
Task 4 running on thread 12
Task 8 running on thread 16
Task 1 running on thread 9
Task 2 running on thread 10
Task 5 running on thread 13
Task 6 running on thread 14
Task 7 running on thread 15
Task 9 running on thread 17
Task 10 running on thread 18
All tasks completed.

It is worth noting that in actual development, the size and number of tasks need to be evaluated based on the specific situation to ensure the efficiency and reliability of parallel tasks.

Example 2

Another example of using Task is calculating the Fibonacci sequence. We can treat each term of the Fibonacci sequence as a task and then use Task.WaitAll to wait for all tasks to complete.

void Task2()
{
    static long Fib(int n)
    {
        if (n is 0 or 1)
        {
            return n;
        }
        else
        {
            return Fib(n - 1) + Fib(n - 2);
        }
    }

    const int n = 10; // Calculate the first n terms of the Fibonacci sequence

    var tasks = new Task<long>[n];

    for (var i = 0; i < n; i++)
    {
        var index = i; // When using a loop variable in a closure, it needs to be assigned to another variable

        if (i < 2)
        {
            tasks[i] = Task.FromResult((long)i);
        }
        else
        {
            tasks[i] = Task.Run(() => Fib(index));
        }
    }

    // Wait for all tasks to complete
    Task.WaitAll(tasks);

    // Print results
    for (var i = 0; i < n; i++)
    {
        Console.Write("{0} ", tasks[i].Result);
    }

    Console.ReadKey();
}

In this example, we use an array of Task to store all tasks. For the first two terms, we directly use Task.FromResult to create a completed task; otherwise, we use Task.Run to create an asynchronous task and call the Fib method to compute the result. After waiting for all tasks to complete, we iterate through the task array and use the Task.Result property to get the result of each task and print it.

Output:

0 1 1 2 3 5 8 13 21 34

It is important to note that when creating asynchronous tasks, since the value of the loop variable inside the closure is indeterminate, it must be assigned to another variable and that variable must be used inside the closure. Otherwise, all tasks might use the same value of the loop variable, leading to incorrect results.

Example 3

Besides using an array of tasks to store all tasks, you can also use Task.Factory.StartNew to create parallel tasks. This method is similar to Task.Run; both can create asynchronous tasks and submit them to the thread pool.

void Task3()
{
    long Factorial(int n)
    {
        if (n == 0) return 1;
        return n * Factorial(n - 1);
    }

    const int n = 5; // Number to compute factorial

    var task = Task.Factory.StartNew(() => Factorial(n));

    Console.WriteLine("Computing factorial...");

    // Wait for the task to complete
    task.Wait();

    Console.WriteLine("{0}! = {1}", n, task.Result);
    Console.ReadKey();
}

In this example, we use Task.Factory.StartNew to create an asynchronous task to compute the factorial and wait for the task to complete before printing the result.

Output:

Computing factorial...
5! = 120

It is worth noting that although both Task.Run and Task.Factory.StartNew can create asynchronous tasks, their behavior differs slightly. In particular, Task.Run always uses TaskScheduler.Default as the task scheduler, while Task.Factory.StartNew allows specifying the task scheduler, task type, and other options. Therefore, when choosing which method to use, it should be evaluated based on the specific situation.

Example 4

Another example of using Task is asynchronous file reading. In this example, we use Task.FromResult to create a completed task and return the file content as the result.

void Task4()
{
    const string filePath = "test.txt";

    var task = Task.FromResult(File.ReadAllText(filePath)); // Just for example; better code would be: File.ReadAllTextAsync(filePath);

    Console.WriteLine("Reading file content...");

    // Wait for the task to complete
    task.Wait();

    Console.WriteLine("File content: {0}", task.Result);
    Console.ReadKey();
}

In this example, we use Task.FromResult to create a completed task and read the file content via File.ReadAllText, returning it as the result. After waiting for the task to complete, we can get the task result by calling the Task.Result property.

Feel free to create a text file as mentioned; here are the test results:

Reading file content...
File content: Dotnet9, focusing on ASP.NET Core website development, MAUI cross-platform application development, WPF client development, and sharing technical articles at https://Dotnet9.com. Welcome to exchange and learn.

It is important to note that in actual development, when dealing with large files or performing long I/O operations, asynchronous code should be used to avoid blocking the UI thread. For example, when reading a large file, we can use asynchronous code to avoid blocking the UI thread, thereby improving application performance and responsiveness.

Example 5

The last example is using Task and async/await to implement asynchronous tasks. In this example, we encapsulate a time-consuming operation as an asynchronous method and use the async/await keywords to wait for the operation to complete.

async Task Task5()
{
    async Task<string> LongOperationAsync()
    {
        // Simulate a time-consuming operation
        await Task.Delay(TimeSpan.FromSeconds(3));

        return "Completed";
    }

    Console.WriteLine($"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} Starting time-consuming operation...");

    // Wait for the asynchronous method to complete
    var result = await LongOperationAsync();

    Console.WriteLine($"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} Time-consuming operation completed: {result}");
    Console.ReadKey();
}

In this example, we declare the LongOperationAsync method as an asynchronous method using the async/await keywords and use the await keyword to wait for the Task.Delay operation to complete. In the main program, we can use the await keyword to wait for LongOperationAsync to complete and get its result.

2023-03-28 20:54:09.111 Starting time-consuming operation...
2023-03-28 20:54:12.143 Time-consuming operation completed: Completed

It is important to note that when using the async/await keywords, you should avoid using blocking thread operations inside asynchronous methods; otherwise, the UI thread may be blocked. If a blocking operation must be performed, it can be executed on a different thread or asynchronous I/O operations can be used to avoid blocking the thread.

3. Notes on Using async/await Keywords

When using the async/await keywords, there are some details to note. Two more examples are given below.

Example 1

The example code is as follows:

async Task Task6()
{
    async Task<string> LongOperationAsync(int id)
    {
        // Simulate a time-consuming operation
        await Task.Delay(TimeSpan.FromSeconds(1 + id));

        return $"{DateTime.Now:ss.fff} Completed {id}";
    }

    Console.WriteLine($"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} Starting time-consuming operation...");

    // Wait for multiple asynchronous tasks to complete
    var task1 = LongOperationAsync(1);
    var task2 = LongOperationAsync(2);
    var task3 = LongOperationAsync(3);

    var results = await Task.WhenAll(task1, task2, task3);
    var resultStr = string.Join(",", results);

    Console.WriteLine($"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} Time-consuming operation completed: {resultStr}");
    Console.ReadKey();
}

In this example, we use Task.WhenAll to wait for multiple asynchronous tasks to complete and use the Join method to concatenate all task results into the final result.

2023-03-28 21:15:42.855 Starting time-consuming operation...
2023-03-28 21:15:46.894 Time-consuming operation completed: 44.888 Completed 1,45.883 Completed 2,46.893 Completed 3

Example 2

Another point to note is that when using the async/await keywords, you should avoid using ConfigureAwait(false) as much as possible. This method allows the asynchronous operation not to resume on the original SynchronizationContext, thereby reducing thread switching overhead and improving performance.

However, in some cases, if you need to return to the original SynchronizationContext after the asynchronous operation completes, using ConfigureAwait(false) will cause the caller to be unable to correctly handle the result. Therefore, it is recommended to use ConfigureAwait(false) only when you are sure that returning to the original SynchronizationContext is not needed.

Example code: Suppose we have a console application with two asynchronous methods: MethodAAsync() and MethodBAsync(). MethodAAsync() waits for 1 second and returns a string. MethodBAsync() waits for 2 seconds and returns a string. The code is as follows:

async Task<string> MethodAAsync()
{
    await Task.Delay(1000);
    return $"{DateTime.Now:ss.fff}>Hello";
}

async Task<string> MethodBAsync()
{
    await Task.Delay(2000);
    return $"{DateTime.Now:ss.fff}>World";
}

Now, we want to call both methods simultaneously and combine their results into a single string. We can write code like this:

async Task<string> CombineResultsAAsync()
{
    var resultA = await MethodAAsync();
    var resultB = await MethodBAsync();
    return $"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}: {resultA} | {resultB}";
}

This code looks simple and clear, but it has a performance issue. When we call the CombineResultsAAsync() method, the first await operation will cause the execution context to switch back to the original SynchronizationContext (i.e., the main thread), so our asynchronous operation will run on the UI thread. Since we have to wait 1 second for the result from MethodAAsync(), the UI thread will be blocked until the asynchronous operation completes and the result is available.

In this case, we can use the ConfigureAwait(false) method to specify that the current context's thread execution state does not need to be preserved, allowing the asynchronous operation to run on a thread pool thread. This can be achieved with the following code:

async Task<string> CombineResultsBAsync()
{
    var resultA = await MethodAAsync().ConfigureAwait(false);
    var resultB = await MethodBAsync().ConfigureAwait(false);
    return $"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}: {resultA} | {resultB}";
}

By using the ConfigureAwait(false) method, we tell the asynchronous operation not to preserve the current context's thread execution state, so the asynchronous operation will run on a thread pool thread instead of the UI thread. This helps avoid potential performance issues because the UI thread is not blocked, and the asynchronous operation can run on a new thread pool thread.

4. Summary

When using the async/await keywords, some best practices should be followed to improve code readability, maintainability, and performance. Below are some common best practices:

  1. Declare asynchronous methods as Task or Task<TResult> types as much as possible so that the await keyword can be used to wait for their completion. If the asynchronous method does not return anything, it should be declared as Task.

  2. Inside asynchronous methods, avoid using blocking thread operations as much as possible; instead, use non-blocking operations to simulate delays. If a blocking operation must be performed, it can be executed on a different thread, or asynchronous I/O operations can be used to avoid blocking the thread.

  3. Do not catch exceptions inside asynchronous methods and handle them immediately, as this makes the code complex and difficult to maintain. Let the caller handle exceptions themselves. If it is necessary to catch an exception inside an asynchronous method, it should be wrapped as an AggregateException and passed to the caller.

  4. Be careful when using the ConfigureAwait(false) method; use it only when you are sure that returning to the original SynchronizationContext is not needed; otherwise, the caller may not be able to correctly handle the result.

  5. Avoid using unsafe thread APIs such as Thread.Sleep or Thread.Join inside asynchronous methods to ensure code portability and stability. Use non-blocking asynchronous methods to simulate delays.

  6. When using the async/await keywords, follow some naming conventions, such as ending asynchronous method names with Async to distinguish between synchronous and asynchronous methods.

  7. When waiting for multiple asynchronous tasks to complete simultaneously, use the Task.WhenAll method to wait for all tasks to complete. If you only need to wait for any one task to complete, use the Task.WhenAny method.

  8. Inside asynchronous methods, encapsulate time-consuming operations as separate asynchronous methods and call them using async/await where needed to improve code readability and maintainability.

  9. When using the async/await keywords, avoid using thread synchronization mechanisms such as the lock keyword or Monitor class, as they can block the UI thread. Instead, use asynchronous locks or other non-blocking thread synchronization mechanisms.

In summary, using Task and async/await can greatly simplify asynchronous programming, improving code readability, maintainability, and performance. However, attention must be paid to some details and best practices to ensure code correctness and stability.

Keep Exploring

Related Reading

More Articles