Skip to main content

Task Class

In general, it's preferable not to share data unless absolutely necessary. For most applications, the shared data model is not used extensively. Instead, there is a preference for an asynchronous and functional programming style where each unit of work (task) is performed with its own data within a thread. In many applications, shared data mostly originates from other applications that are optimized for parallel and concurrent processing, as are databases, which ensure data consistency (even in the end). For this reason, many multi-threaded applications are designed to use the shared data model as little as possible, especially to simplify application logic, improve code quality, and reduce the likelihood of errors.

In C#, there is another abstraction over threads that is more useful in many situations. This abstraction implements the idea of a promise or future (although these terms are generally used interchangeably, they represent different concepts). Essentially, a promise is an object that can be assigned a value at some point, which can then be awaited and read. On the other hand, a future is just an object that will return a value at some point.

The simplest way to create a new task is using Task.Run(Action) for a Task or Task.Run(Func<T>) for a Task<T>. This starts the processing on a separate thread. The task can be awaited using the Wait() method, and if it's a Task<T>, the result can be read from the Result property.

Another possibility is to use an async function. An async function is a function that has the async keyword and returns a Task (it can also be void, but it's not recommended). The difference between an async function and a regular one is that only within an async function can the await keyword be used to wait for the completion of another task and potentially retrieve its values. Additionally, when returning from an async function, it doesn't return the Task or Task<T> type as in the signature; instead, it either returns nothing or an instance of T.

public class TaskWork
{
public int WorkLoad { get; }

public TaskWork(int workLoad)
{
WorkLoad = workLoad;
}

public int Execute()
{
var result = 0;

for (var i = 0; i < WorkLoad; ++i)
{
++result;

// Simulate intensive computation with sleep.
Thread.Sleep(10);
}

Console.WriteLine("Thread {0} finished work!", Thread.CurrentThread.Name);

return result;
}

public async Task<int> ExecuteAsync(CancellationToken cancellation = default)
{
var result = 0;

for (var i = 0; i < WorkLoad; ++i)
{
// Return or throw exception if cancellation is requested.
if (cancellation.IsCancellationRequested)
{
Console.WriteLine("Task was canceled!");

return result;
}

++result;

// Call another asynchronous function and await a task within an async function, passing the token.
await Task.Delay(10, cancellation);
}

Console.WriteLine("Thread {0} finished work!", Thread.CurrentThread.Name);

return result;
}
}

private static void RunTaskExample()
{
var work = new TaskWork(Workload);
Console.WriteLine("Starting the worker task!");
var sw = Stopwatch.StartNew();
var task = Task.Run(() => work.Execute());
Console.WriteLine("Waiting for the worker task!");
task.Wait();
sw.Stop();
Console.WriteLine("Work executed async in {0} milliseconds with result: {1}", sw.ElapsedMilliseconds, task.Result);
Console.WriteLine("---------------");
}

private static void RunTaskAsyncExample()
{
var work = new TaskWork(Workload);
Console.WriteLine("Starting the worker task async!");
var sw = Stopwatch.StartNew();
var task = Task.Run(() => work.ExecuteAsync());
Console.WriteLine("Waiting for the worker task!");
task.Wait();
sw.Stop();
Console.WriteLine("Work executed in {0} milliseconds with result: {1}", sw.ElapsedMilliseconds, task.Result);
Console.WriteLine("---------------");
}

private static void RunTaskAsyncWithCancelExample()
{
var work = new TaskWork(Workload);
Console.WriteLine("Starting the worker task async!");
var cancellationTokenSource = new CancellationTokenSource();
var sw = Stopwatch.StartNew();
var task = Task.Run(() => work.ExecuteAsync(cancellationTokenSource.Token), cancellationTokenSource.Token);
Console.WriteLine("Waiting for the worker task!");
cancellationTokenSource.CancelAfter(2000);

try
{
task.Wait(cancellationTokenSource.Token);
}
catch (OperationCanceledException)
{
Console.Error.WriteLine("The task was canceled!");
}

sw.Stop();

Console.WriteLine("Work canceled after {0} milliseconds with status: {1}", sw.ElapsedMilliseconds, task.Status);
Console.WriteLine("---------------");
}

The Task class is easy to use, and applications, whether they are web or desktop applications, prefer to use this abstraction over threads because it already encapsulates operations that need to be executed on separate threads, providing methods to return results.

Moreover, using the async and await keywords makes it very easy to create tasks and facilitates writing asynchronous code in a transparent, easy-to-follow, and efficient manner. The task is set to run in a thread pool by the framework to be picked up by a free thread. When the task encounters an await, it suspends, and the thread it was running on will pick up another task (depending on how the framework's scheduler works). The new task will be executed at some point, and upon completion, the execution will return to the initially suspended task, not necessarily on the same thread it started from.

Even though asynchronous programming is not a core aspect of object-oriented programming, it is facilitated by it. Many other languages like JavaScript/Typescript or Kotlin have support for this paradigm and it's very useful to know.

Cancellation Token

One useful aspect of using tasks is that they can be stopped/canceled using a cancellation token. Typically, you'll notice that in most C# libraries where there are async methods, you have an optional parameter of type CancellationToken. It's a structure through which multiple tasks can be canceled if they need to be forcefully stopped or if someone wants to signal the termination of processing by sharing and using methods like Cancel and CancelAfter.

The token is created through a CancellationTokenSource which has the Token property. Then, when tasks are canceled, you can check the IsCancellationRequested property of the token to determine whether processing should stop or not. When canceled, in many situations, the OperationCanceledException exception is thrown, indicating that someone requested the cancellation of the processing.

As a best practice, whenever you implement an async method, always add a CancellationToken as the last parameter with the default value, and in the method, either check if cancellation has been requested (for example, in a loop) or pass it along to other called async methods.