Vinicius Quinafelex Alves

🌐Ler em português

[C#] How tasks and threads work together

Threads are small working units that execute parts of a process. A process can make use of only one or multiple threads. Running under multiple threads can be beneficial on multi-core environments, because workload can be distributed to the multiple cores of the processor and executed in parallel, speeding up the processing time.

Task is a class that represents an asynchronous operation, or operations that can be executed independently of the current thread. Tasks can contain a return value and are a key component of the async programming feature in .NET.

Tasks have a status based on which step of the execution it currently is. Awaiting an incomplete task will halt the algorithm execution, the thread will be deallocated from the current execution and will be freed to execute other activities.

When the operation is completed, another thread will be allocated to continue the execution from where it stopped.

Awaiting a completed task will not rerun it, and the currently running thread will not be released. If the task has a result value, awaiting it will return the produced value synchronously.

Awaiting a task that threw an exception internally will throw the exception into the main execution.

Relationship with ThreadPool

.NET applications have a ThreadPool feature, which contains pre-created threads for fast thread allocation.

Those threads are generally used by the .NET process and its pool size is limited, so its recommended to not use them for operations that take too much time to execute, otherwise the pool might eventually become empty and the system will suffer with slowness or errors.

While there is no hard rule on how much time constitutes a slow or a fast operation, but it is commonly accepted that any operation under 500ms is considered short enough to be resolved by a ThreadPool thread.

Under most circunstances, tasks will use the ThreadPool threads to finish the workload. Be mindful of what kind of operation is being executed by them to avoid long running operations.

Note that a single task can have multiple internal asynchronous operations. In such cases, a thread will be allocated when an async operation is completed and released when it is required to await the next asyncronous operation.

Even if the task itself might take a while to complete, each allocation time can be short enough to safely use the ThreadPool.

public async Task RunOperationAsync()
{
    // Handled by Thread 1
    var firstResult = await FirstFunctionAsync();

    // Handled by Thread 2
    var secondResult = await SecondFunctionAsync();

    // Handled by Thread 3
    var thirdResult = await ThirdFunctionAsync();
}

The way tasks interact with threads can change depending on how they are created, as specified below.

Calling the constructor

Creating tasks by calling it's constructor is not recommended. Instead, it is better to use asynchronous functions or the Task static methods, as described below.

Task.FromResult and other presets

Static methods like Task.FromResult() and properties like Task.CompletedTask return instances of Tasks without executing any asynchonous code.

This can be useful for situations when the method signature cannot be changed to a synchronous method, but the implementation do not have any asynchronous function, or the value was already generated synchronously.

When a task is generated this way, it comes with a RanToCompletion state. Note that awaiting completed tasks is run synchronously.

public async Task<int> GetValueAsync()
{
    return await Task.FromResult(1);
}

public async Task ExecuteAsync()
{
    // Run synchronously
    var value = await GetValueAsync();
}

Task.Run()

This method will allocate a thread from the ThreadPool to run an operation, and as such, is not recommended for long running scenarios.

Task<int> task = Task.Run(() => 1 + 1);
var value = await task;

Be very careful when using an asynchonous method or lambda as a parameter, because it might match with the synchronous overload and generate a task that is not related to the execution of the parameter.

For such cases, it is safer to directly call the asynchronous method or lambda instead of running Task.Run().

Func<Task<int>> asyncFunction = async () => Task.FromResult(1+1);
var value = await asyncFunction();

Task.Factory.StartNew()

Enables the customization of the task through the use of TaskCreationOptions.

By using TaskCreationOptions.LongRunning, instead of using a thread from the ThreadPool, a brand new thread will be created to execute the task. This configuration is recommended for long running operations, because it avoids overusing the threads of the ThreadPool.

Creating new threads are not recommended for short running operations, because the effort of creating a new thread might be more expensive than executing the operation itself. For such scenarios, using the ThreadPool might be a better option.

var task = Task.Factory.StartNew
(
    () => LongRunningMethod(),
    TaskCreationOptions.LongRunning
);

var value = await task;

Asynchronous functions

When an asynchronous function is called, the currently running thread will run the algorithm synchronously until it is forced to await an operation that cannot run synchronously, such as an I/O operation or a task already running.

When the thread has to await the asynchronous task, the execution is halted and the thread is released. After the asynchronous task is completed, a thread from the ThreadPool is allocated to continue running from where the task stopped.

Note that asynchronous functions never create brand new threads by itself, and always consume threads from the ThreadPool.

public async Task ExampleAsync()
{
    // Run synchronously by the main thread
    var value = 1 + 1;

    // Release the thread until the task is completed
    await File.WriteLineAsync("path", value.ToString());

    // Executed by a thread from the ThreadPool
    Console.WriteLine(value.ToString());
}

It is also possible to call an asynchronous function without imediatelly awaiting it. In this case, instead of being released, the running thread will skip the non-synchronous operation, and move to the next line after the asynchronous method call.

In the meantime, when the underlying task is completed, a thread from the ThreadPool is allocated to finish executing the task.

If the process was awaiting the result, the allocated thread becomes the new main thread and continues executing the process. Otherwise it is released and returns to the thread pool.

public async Task<int> ExampleAsync()
{
    /*
    Since the task was not awaited yet, 
    the main thread exits this method
    to continue running the 
    next lines of code
    */

    await Task.Sleep(1_000);
    
    /*
    After the asynchronous operation 
    completes, the task will be handled
    by a thread from the ThreadPool
    */

    return value;

    /*
    At this point, is the program is 
    halted awaiting this result,
    the thread allocated before becomes
    the main thread. Otherwise, the 
    thread returns to the pool.
    */
}

When calling multiple asynchronous functions without awaiting, all the operations after the awaited line are executed by threads of the ThreadPool, and multiple threads may end up being used at the same time.

public async Task ExecuteAsync()
{
    var task1 = ExampleAsync();
    var task2 = ExampleAsync();
    var task3 = ExampleAsync();

    await Task.WhenAll(task1, task2, task3);
}

public async Task<int> ExampleAsync()
{
    await Task.Sleep(1_000);
    
    // Each task will receive an available task from the ThreadPool
    Console.WriteLine("Completed");
}