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.
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:
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.
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 CancellationToken
s, 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.