In this article, we are going to look at the main differences between Tasks and Threads. They are both used for concurrent programming, but Tasks were later introduced with .NET Framework 4.

To download the source code for this article, you can visit our GitHub repository.

Let’s start with the first question that comes to mind: why do both Tasks and Threads exist in C#?

Why Were Tasks Introduced?

Before .NET Framework 4, the only way to do concurrent programming was to use the Thread class. An instance of this class represents a managed thread that the OS internally schedules. Let’s see how to create a new thread:

Support Code Maze on Patreon to get rid of ads and get the best discounts on our products!
Become a patron at Patreon!
double exp1 = 0;
var t = new Thread(() => { exp1 = Math.Exp(40); });
t.Start();
        
// ...
        
t.Join();

Here we are spawning a new thread to do a “complex” computation. When we call the Join() method, the current thread waits until the target thread terminates.

There is nothing wrong with this code, but we should be aware that spawning a thread has a cost. We should never create a new thread every time we need to execute a “task” because thread initialization and disposal are expensive activities for the OS. A web server that creates a new thread for each request would not work when the load is high.

What should we do then?

From Threads to Thread Pool Threads

Microsoft decided to introduce a thread pool implementation inside the CLR. When the application starts, the thread pool contains no threads. They are created on-demand only when the application needs them. After the thread completes, it is not destroyed, unless it remains inactive for too much time.

Instead, it returns to the thread pool in a suspended state and is awakened whenever necessary. The thread pool implementation also has some configurable parameters, like the minimum and maximum threads that can be spawned.

Let’s write the same example we have seen above, now with thread pool threads:

double exp1 = 0;
var mres = new ManualResetEventSlim(false);
ThreadPool.QueueUserWorkItem<ManualResetEventSlim>((_mres) => 
{ 
    exp1 = Math.Exp(40); 
    mres.Set();
}, mres, false);

// ...

mres.Wait();

The ThreadPool.QueueUserWorkItem() method accepts a Thread that an available thread pool thread will later execute. The problem is that this method doesn’t return anything useful to monitor the thread status. Basically, we cannot know when the thread pool thread finishes executing the delegate.

To synchronize the thread pool thread and the current thread, we use the ManualResetEventSlim class. It is a lightweight object that works similarly to a semaphore. When we call the mres.Wait() method, the calling thread waits until the mres object receives a signal, emitted by the mres.Set() method.

However, this is not the best approach. The code is complex and hard to synchronize because it is intrinsically a fire-and-forget approach. We just know that a thread pool thread will execute the delegate we passed as the first argument. Moreover, in common with native threads, we are forced to handle exceptions inside the delegate function. 

The birth of a library that could simplify these operations was inevitable.

Finally, Tasks Came to Our Rescue

The library we are talking about is the Task Parallel Library (or TPL), which includes the Task class. A Task is an abstraction that allows us to easily use the thread pool or native threads without the complexity we saw previously. By default, each Task is executed in a background thread that belongs to the thread pool.

Once again, let’s rewrite the code above with tasks:

var task = Task.Run(() => Math.Exp(40));
        
// ...
        
var taskResult = task.Result;

To create a task, we mainly have 2 options, better explained here. In this case, we use the Task.Run() method, which returns an object of type Task<double>. This object has a Result property containing the result of the task, if already available.

If the result is unavailable, then the current thread synchronously waits until available. Not only is the code clearer, but also optimized because we are now using thread pool threads.

We can decide to execute a task on a new non-thread pool thread. This turns out to be useful when we know that the task will run for a long time. In this case, a thread pool thread isn’t suitable to run this task because it would stay busy too long, stealing resources from other tasks. Instead, we want to have a thread dedicated to that task. Check out our article on Long-Running Tasks in a Monolith ASP.NET Core Application to learn more.

Tasks have much more to offer compared to threads, let’s explore them!

Tasks vs Threads: The Main Differences

So far, we have seen the theoretical aspects of tasks, but it’s time to see them in action. The problem we need to solve today consists of minimizing the building time of a car. A car is made of many components that we can build in parallel. We will solve the problem using threads and using tasks, separately. 

Let’s start with a base class containing the methods we need to build a car:

public class CarBuilding
{
    protected CarBuilding() { }

    virtual protected Body BuildBody(int weight, int length, int width) =>
        new Body(weight, length, width);

    virtual protected Engine BuildEngine(int horsePower) => new Engine(horsePower);

    virtual protected Suspension BuildSuspension(int supportedKg) => new Suspension(supportedKg);

    virtual protected Painting Paint(string color, int bodyArea) => new Painting(color, bodyArea);

    virtual protected void Test(Body body, IEnumerable<Suspension> suspensions, Engine engine)
    {
        if (suspensions.Sum(s => s.SupportedKg) <= body.Weight || engine.Horsepower * 4 <= body.Weight)
            throw new ArgumentException("The car weighs too much");
    }
}

A car needs several components: a body, an engine, and suspensions. We will build them in threads/tasks, but following 2 rules:

  • The painting starts after the body is ready
  • The testing phase starts when all components are ready

Let’s start with the body!

Tasks Return Results

Now, let’s see how to use threads to build the car’s body:

Body body = null!;
var bodyThread = new Thread(() =>
{
    body = carBuilding.BuildBody(100, 5, 2);
});
bodyThread.Start();

One detail we have not underlined before is that threads do not have a return value after the execution.

Tasks can return a result, that we can access with the Result property:

var bodyTask = Task.Run(() => carBuilding.BuildBody(100, 5, 2));

This is very convenient because we don’t have to declare an external variable.

Once the body is ready, we have to paint it. How do we do that?

Tasks Are Chainable

Let’s start the thread for painting the body when it is ready:

bodyThread.Start();

// ...

bodyThread.Join();
Painting painting;
var paintingThread = new Thread(() =>
{
    painting = carBuilding.Paint("red", body.Width * body.Length);
});

With threads, dependencies between threads don’t stand out, but we have to find them by reading the code. Moreover, recognizing dependent threads is more difficult when many threads are involved in the “dependency graph”. With tasks, this is not a problem because they are chainable:

var bodyTask = Task.Run(() => carBuilding.BuildBody(100, 5, 2));
var paintingTask = bodyTask.ContinueWith(
    task => carBuilding.Paint("red", task.Result.Width * task.Result.Length)
);

With the ContinueWith() method, when the bodyTask terminates, the paintingTask automatically starts. ContinueWith() makes a dependency between 2 tasks clear and well-defined.

The ContinueWith() method accepts a delegate and, optionally, a TaskContinuationOptions. This second argument specifies the conditions under which the continuation task starts.

For example, if we wanted to start the painting process just in case the bodyTask didn’t throw any exceptions:

var paintingTask = bodyTask.ContinueWith(
    task => carBuilding.Paint("red", task.Result.Width * task.Result.Length),
    TaskContinuationOptions.OnlyOnRanToCompletion
);

Managing Tasks vs Threads

After the car’s body, we need suspensions too. We decide to build them concurrently, each one in a separate thread:

var suspensions = new ConcurrentBag<Suspension>();
var suspensionThreads = Enumerable
    .Range(0, suspensionCount)
    .Select(i =>
    {
        var t = new Thread(() =>
        {
            suspensions.Add(carBuilding.BuildSuspension(40));
        });
        t.Start();
        return t;
    });

// ...

foreach (var suspThread in suspensionThreads)
    suspThread.Join();

The first problem is that we are spawning several threads, but we have also seen how to fix this performance issue with thread pool threads. However, this time we need a thread-safe collection to store the built suspensions.

With tasks, the ConcurrentBag is unnecessary:

var suspensionTasks = Enumerable
    .Range(0, suspensionCount)
    .Select(i => Task.Run(() => carBuilding.BuildSuspension(40)));
var suspensionsTask = Task.WhenAll(suspensionTasks)
    .ContinueWith(task => task.Result.ToList());

The Task.WhenAll() method returns a task that will complete when all the tasks in the suspensionTasks list have been completed. This is very useful when we have to coordinate many antecedent tasks with a continuation task. In this case, either we chain a ContinueWith() to the task returned by the WhenAll() method.

Optionally, we use the Task.Factory.ContinueWhenAll() method:

Task.Factory.ContinueWhenAll(suspensionTasks, tasks => tasks.ToList());

What about exceptions?

Tasks Propagate Exceptions

In the final phase of the car-building process, we need to test if the components previously built are compatible. This step could generate an exception we want to catch and rethrow outside the thread. However, threads do not propagate exceptions. One possibility is to handle the exception in the thread by saving the caught exception:

Exception? thrownException = null;
var testing = new Thread(() =>
{
    try
    {
        carBuilding.Test(body, suspensions, engine);
    }
    catch (Exception exc) 
    { 
        thrownException = exc;
    }
});
testing.Start();

// ...

testing.Join();
if (thrownException is not null)
    throw thrownException;

The thrownException variable is null when no exception occurred in the thread. Once the thread terminates, we just have to check if thrownException is not null. If an exception occurred, we rethrow the saved exception.

Unlike threads, tasks do propagate exceptions, which are wrapped in an AggregateException. To retrieve the wrapped exceptions we can inspect the InnerExceptions property:

try 
{
    var testingTask = Task.Run(
        () => carBuilding.Test(bodyTask.Result, suspensionsTask.Result, engineTask.Result)
    );
}
catch (AggregateException exc)
{
    throw exc.InnerExceptions[0];
}

In this case, we just rethrow the first inner exception, but we can also handle it with the AggregateException.Handle() method.

To learn more about exception handling, check out our article Handling Exceptions in C#.

Tasks seem to beat threads in every respect, is it true?

Are Tasks Always the Right Choice Over Threads?

Regarding tasks, we have explored just the tip of the iceberg. Tasks are optimized to work on thread pool threads, and each of them has its local queue. We have not talked about child tasks, that leverage the data locality of the local queues. The Task class also contains heuristics to be able to find the best way to execute our tasks.

When it comes to ease of use, tasks win hands down. Besides the examples above, the Task APIs fully support CancellationTokens, which are essential to stopping a task early. On this subject, threads only provide Abort()/Interrupt() methods, whose generated exceptions are quite annoying to handle.

Task is also the core of asynchronous programming, as we have explained in this and this other article.

So, should we forget about the Thread class? No, of course. The point is that the use cases where we should use threads instead of tasks are very few.

When to Use Threads Over Tasks

We have to use a Thread when we need a foreground execution of some code. Tasks always run on background threads, which do not block the application from exiting.

The choice of a native Thread is justified when the thread has a particular priority. It is possible to indirectly change a task priority by specifying a custom scheduler, during creation. Implementing a Task scheduler isn’t always worth it, that’s why spawning a new thread is usually the best option.

A Thread has a stable identity associated. This is useful while debugging because we can know the identity of the thread that will execute our code. Tasks run on thread pool threads by default, but we have seen that this is configurable. Long-running tasks have their dedicated threads, so, even in this case, we should prefer tasks.

Conclusion

We can safely say that tasks are superior to threads from almost every point of view. Tasks should be our primary choice when we need concurrent/parallel programming. After all, a task is an abstraction that allows us to easily use and manage threads.

Liked it? Take a second to support Code Maze on Patreon and get the ad free reading experience!
Become a patron at Patreon!