Harnessing Tasks in C#: A Comprehensive Overview of TPL
Written on
Understanding Tasks and the Task Parallel Library
In a previous article, we delved into concepts such as Concurrency, Parallelism, and Asynchronous Execution, along with an exploration of Threads and related ideas. Today, we will discuss Tasks and provide a brief introduction to the Task Parallel Library (TPL). Additionally, we'll offer a sneak peek into Async & Task-Based Asynchronous Patterns, further elaborated in our dedicated four-part series on "Asynchronous Programming with async and await in C#."
What Are Tasks?
To clarify the concept of Tasks, let's first address a fundamental question often posed by developers: What distinguishes threads from tasks?
Threads are the foundational elements of multithreading. They represent basic units of execution allocated processor time by the operating system (OS) and encompass a sequence of instructions that can be managed independently by a thread scheduler. However, directly managing threads can become complex. For instance, returning a value from a worker thread can pose significant challenges.
Conversely, Tasks are a higher-level abstraction in .NET that symbolize a commitment to perform work that will be completed in the future. A Task essentially denotes a promise to return a value of type T upon completion. Tasks are inherently compositional, allowing for value returns and chaining through task continuations. They also leverage the thread pool, making them particularly useful for I/O-bound operations.
Important Note: Utilizing a Task in .NET does not inherently indicate the creation of new threads. Typically, when employing Task.Run() or similar methods, a task executes on a separate thread, often from a managed thread pool, as overseen by the .NET Common Language Runtime (CLR). However, this depends on the specific task implementation.
Threads vs. Tasks: Which Should You Use?
A Thread offers maximum control over execution and resource management. While work on a new thread starts immediately, creating threads in code can lead to significant resource consumption and potential complications. Starting, stopping, and managing threads can be resource-intensive, especially if the number of threads exceeds the available CPU cores, leading to frequent context switching.
In modern .NET development, it is advisable to opt for Tasks. This recommendation does not necessarily imply the creation of new threads. For instance, invoking new Thread(…).Start() creates a new thread, while Task.Run(…) merely queues work on the managed ThreadPool. The ThreadPool efficiently manages the workload by assigning tasks to available threads.
Key Points:
- Task and ThreadPool implementations are designed to be multi-core aware, efficiently utilizing multiple CPUs if available.
Three Ways to Start a Task
There are three primary methods to initiate a Task in your code:
new Task(Action).Start()
This command creates a new Task and starts it immediately. It is generally advisable to avoid this option due to the potential for synchronization issues when multiple threads attempt to start the same task.
Task.Factory.StartNew(Action)
This method begins the task and returns a reference to it. It is safer and more efficient than the first method, as it eliminates synchronization overhead.
Task.Run(Action)
This command queues the specified Action delegate on the ThreadPool. A thread is then allocated from the pool to execute the code as per availability. If a Task is designated as LongRunning, a new thread will be used instead.
Choosing the Right Method
For offloading a task to a background thread, it is advisable to use Task.Run(), which is a shorthand for Task.Factory.StartNew() with default parameters suitable for most scenarios. If customization is required, such as for a LongRunning task, opt for Task.Factory.StartNew().
Sample Code for Task Creation
Console.WriteLine($"Main starts execution on Thread {Environment.CurrentManagedThreadId}.");
// Option 1: new Task(Action).Start();
var task = new Task(SimpleMethod);
task.Start();
Console.WriteLine($"Main continues execution on Thread {Environment.CurrentManagedThreadId} after starting {nameof(SimpleMethod)} task.");
// Task that returns a value.
var taskThatReturnsValue = new Task(MethodThatReturnsValue);
taskThatReturnsValue.Start();
Console.WriteLine($"Main continues execution on Thread {Environment.CurrentManagedThreadId} after starting {nameof(MethodThatReturnsValue)} task - Option 1.");
// Block the current thread until the Task is completed.
taskThatReturnsValue.Wait();
// Get the result from the Task operation.
Console.WriteLine(taskThatReturnsValue.Result);
Example Outputs:
Hello from SimpleMethod on Thread 7.
Hello from MethodThatReturnsValue on Thread 11.
Handling I/O-Bound Operations
In addition to CPU-bound operations, Tasks can also facilitate I/O-bound operations. Here’s how you can do this effectively:
SomethingElse();
try
{
Console.WriteLine(task.Result);
}
catch (AggregateException ex)
{
Console.Error.WriteLine(ex.Message);
}
Task Cancellation
To cancel a task, you can utilize a cancellation token generated by a CancellationTokenSource object. It is crucial to note that requesting cancellation does not guarantee immediate cessation of the task; it depends on the task’s code checking for cancellation requests.
Sample Code for Cancellation:
Console.WriteLine("Starting application.");
var source = new CancellationTokenSource();
var task = CancellableTaskTest.CreateCancellableTask(source.Token);
Console.WriteLine("Heavy process invoked.");
Console.WriteLine("Press C to cancel.");
char ch = Console.ReadKey().KeyChar;
if (ch == 'c' || ch == 'C')
{
source.Cancel();
Console.WriteLine("nTask cancellation requested.");
}
try
{
task.Wait();
}
catch (AggregateException ex)
{
if (ex.InnerExceptions.Any(e => e is TaskCanceledException))
{
Console.WriteLine("Task cancelled exception detected.");}
}
finally
{
source.Dispose();
}
Introduction to Task Parallel Library (TPL)
The Task Parallel Library (TPL) is a collection of public types and APIs that enhance the simplicity of adding parallelism and concurrency to applications. It dynamically adjusts the degree of concurrency to optimize processor utilization, handles work partitioning, schedules threads on the ThreadPool, and facilitates task cancellation.
For a deeper understanding, keep in mind:
- Not all tasks are suitable for parallelization.
- Overhead is associated with threading, and using multiple threads for short tasks may reduce overall performance.
Summary
In conclusion, Tasks offer a sophisticated abstraction over threads, providing a compositional nature, ease of chaining, and effective resource management. Whether dealing with CPU-bound calculations or asynchronous I/O tasks, Tasks present a flexible and intuitive approach to managing concurrent workloads. The Task Parallel Library further streamlines the addition of parallelism to your applications, managing work partitioning, scheduling, and exceptions efficiently. With the Task-Based Asynchronous Pattern (TAP), asynchronous programming has become more straightforward and accessible, empowering developers to create responsive and scalable applications.