In this post, we are going to talk about the difference between Task.Run and Task.Factory.StartNew.
If we ever engage in a discussion about task-based asynchronous programming in C#, almost certainly we are going to see some examples using either Task.Run or Task.Factory.StartNew. They are the most widely used ways for initiating a task asynchronously, in most cases in a similar fashion. That raises some common concerns: are they equivalent and interchangeable? Or how do they differ? Or which one is recommended?
Well, the answer to these questions needs a deep insight into these Task
constructs. We are going to use a unit test project and compare their behavior in different scenarios. Also, we are going to discuss the key factors that decide the method we should go for in a particular use case.
For brevity, we will use just StartNew
instead of Task.Factory.StartNew
for the rest of the article.
Let’s start.
Task Initiation by Task.Run
Task.Run
has several overloads that take an Action/Func
parameter, and/or a parameter for CancellationToken
. For simplicity, we are going to start with the basic Task.Run(action)
example:
void DoWork(int order, int durationMs) { Thread.Sleep(durationMs); Console.WriteLine($"Task {order} executed"); } var task1 = Task.Run(() => DoWork(1, 500)); var task2 = Task.Run(() => DoWork(2, 200)); var task3 = Task.Run(() => DoWork(3, 300)); Task.WaitAll(task1, task2, task3);
We initiate three tasks that run for different durations, each one printing a message in the end. Here, we use Thread.Sleep
just for emulating a running operation:
// Approximate Output: Task 2 executed Task 3 executed Task 1 executed
Although tasks are queued one by one, they don’t wait for each other. As a result, the completion messages pop out in a different sequence.
Task Initiation by StartNew
Now, let’s prepare a version of the same example using StartNew
:
var task1 = Task.Factory.StartNew(() => DoWork(1, 500)); var task2 = Task.Factory.StartNew(() => DoWork(2, 200)); var task3 = Task.Factory.StartNew(() => DoWork(3, 300)); Task.WaitAll(task1, task2, task3);
We can see that the syntax is quite the same as the Task.Run
version and the output also looks the same:
// Approximate Output: Task 2 executed Task 3 executed Task 1 executed
The Difference Between Task.Run and Task.Factory.StartNew
So, in our examples, both versions are apparently doing the same thing. In fact, Task.Run
is a convenient shortcut of Task.Factory.StartNew
. It’s intentionally designed to be used in place of StartNew
for the most common cases of simple work offloading to the thread pool. So, it’s tempting to conclude that they are alternatives to each other. However, if we examine what’s happening underneath, we will find the obvious differences.
Different Semantics
When we call the basic StartNew(action)
method, it’s like calling this overload:
Task.Factory.StartNew(action, CancellationToken.None, TaskCreationOptions.None, TaskScheduler.Current);
In contrast, when we call the Task.Run(action)
, that’s closely equivalent to:
Task.Factory.StartNew(action, CancellationToken.None, TaskCreationOptions.DenyChildAttach, TaskScheduler.Default);
We call it a close equivalent as things are slightly different when we use StartNew
for an async
delegate. We’ll discuss more on this later.
The revealed semantics clearly shows that Task.Run(action)
and StartNew(action)
differ in terms of TaskCreationOptions
mode and TaskScheduler
context.
Co-ordination with Child Tasks
So, Task.Run
provides a task with TaskCreationOptions.DenyChildAttach
restriction but StartNew
does not impose any such restriction. This means we can’t attach a child task to a task launched by Task.Run
. To be precise, attaching a child task, in this case, will have no impact on the parent task and both tasks will run independently.
Let’s consider a StartNew
example with a child task:
Task? innerTask = null; var outerTask = Task.Factory.StartNew(() => { innerTask = new Task(() => { Thread.Sleep(300); Console.WriteLine("Inner task executed"); }, TaskCreationOptions.AttachedToParent); innerTask.Start(TaskScheduler.Default); Console.WriteLine("Outer task executed"); }); outerTask.Wait(); Console.WriteLine($"Inner task completed: {innerTask?.IsCompleted ?? false}"); Console.WriteLine("Main thread exiting");
We initiate an inner task within the scope of the outer task with TaskCreationOptions.AttachedToParent
instruction. Here, we use the plain task constructor to create the inner task to demonstrate the example from a neutral perspective.
By calling outerTask.Wait()
, we keep the main thread waiting for the completion of the outer task. The outer task itself does not have much code to execute, it just starts the inner task and immediately prints the completion message. However, since the inner task is attached to the parent (i.e. the outer task), the outer task will not “complete” until the inner task is finished. Once the inner task is finished, the execution flow passes to the next line of outerTask.Wait()
:
Outer task executed Inner task executed Inner task completed: True Main thread exiting
Now, let’s see what happens in the case of Task.Run
:
Task? innerTask = null; var outerTask = Task.Run(() => { innerTask = new Task(() => { Thread.Sleep(300); Console.WriteLine("Inner task executed"); }, TaskCreationOptions.AttachedToParent); innerTask.Start(TaskScheduler.Default); Console.WriteLine("Outer task executed"); }); outerTask.Wait(); Console.WriteLine($"Inner task completed: {innerTask?.IsCompleted ?? false}"); Console.WriteLine("Main thread exiting");
Unlike the previous example, this time outerTask.Wait()
does not wait for the completion of the inner task and the next line executes immediately after the outer task is executed. This is because Task.Run
internally starts the outer task with TaskCreationOptions.DenyChildAttach
restriction which rejects the TaskCreationOptions.AttachedToParent
request from the child task. Since the last line of the code is executing before the completion of the inner task, we won’t get the message from the inner task in the output:
Outer task executed Inner task completed: False Main thread exiting
In short, Task.Run
and StartNew
behave differently when child tasks are involved.
Default vs Current TaskScheduler
Now, let’s talk about the difference from the TaskScheduler
context. Task.Run(action)
internally uses the default TaskScheduler
, which means it always offloads a task to the thread pool. StartNew(action)
, on the other hand, uses the scheduler of the current thread which may not use thread pool at all!
This can be a matter of concern particularly when we work with the UI thread! If we initiate a task by StartNew(action)
within a UI thread, it will utilize the scheduler of the UI thread and no offloading happens. That means, if the task is a long-running one, UI will soon become irresponsive. Task.Run
is free from this risk as it will always offload work to the thread pool no matter in which thread it has been initiated. So, Task.Run
is the safer option in such cases.
The async Awareness
Unlike StartNew
, Task.Run
is async
-aware. What does that actually mean?
async
and await
are two brilliant additions to the asynchronous programming world. We can now seamlessly write our asynchronous code block using the language’s control flow constructs just as we would if we were writing synchronous code flow and the compiler does the rest of the transformations for us. We don’t need to worry about the explicit Task
constructs when we return some result (or no result) from the asynchronous routine. However, this compiler-driven transformation may result in unintended outcomes (from a developer’s perspective) when we work with StartNew
:
var task = Task.Factory.StartNew(async () => { await Task.Delay(500); return "Calculated Value"; }); Console.WriteLine(task.GetType()); // System.Threading.Tasks.Task`1[System.Threading.Tasks.Task`1[System.String]] var innerTask = task.Unwrap(); Console.WriteLine(innerTask.GetType()); // System.Threading.Tasks.UnwrapPromise`1[System.String] Console.WriteLine(innerTask.Result); // Calculated Value
We initiate a task that queues a delegated asynchronous routine. Because of the async
keyword, the compiler maps this delegate as Func<Task<string>>
which in turn returns a Task<string>
on invocation. On top of this, StartNew
wraps this in a Task
construct. Eventually, we end up with a Task<Task<string>>
instance which is not what we desire. We have to call the Unwrap
extension method to get access to our intended inner task instance. This of course is not a problem of StartNew
, it’s just not designed with the async
awareness. But, Task.Run
is designed with this scenario in mind which internally does this unwrapping thing:
var task = Task.Run(async () => { await Task.Delay(500); return "Calculated Value"; }); Console.WriteLine(task.GetType()); // System.Threading.Tasks.UnwrapPromise`1[System.String] Console.WriteLine(task.Result); // Calculated Value
As we expect, we don’t need the extra Unwrap
call in case of Task.Run
.
One note. For testing purposes, we are using the Result
property of task
and innerTask
. But you should be careful with that since the Result property can potentially cause a deadlock in the application. We’ve talked about that in our Asynchronous Programming with Async and Await in ASP.NET Core article
Difference Between Task.Run and Task.Factory.StartNew with Object State
Whenever we deal with an asynchronous routine, we need to be aware of the “state mutation”. Let’s think about starting a bunch of tasks in a loop:
var tasks = new List<Task>(); for (var i = 1; i < 4; i++) { var task = Task.Run(async () => { await Task.Delay(100); Console.WriteLine($"Iteration {i}"); }); tasks.Add(task); } Task.WaitAll(tasks.ToArray());
We use a for
loop and Task.Run
to initiate three tasks, each one is expected to print the current iteration number (i
) e.g. “Iteration 1”, “Iteration 2” etc. But strangely, all are printing “Iteration 4”:
Iteration 4 Iteration 4 Iteration 4
This is because, by the time the tasks start to execute, the state of the variable i
(which is scoped outside of the iteration block) has been changed and reached its final value of 4. One way to solve this problem is to store the value of i
in a local variable within the iteration block:
var tasks = new List<Task>(); for (var i = 1; i < 4; i++) { var iteration = i; var task = Task.Run(async () => { await Task.Delay(100); Console.WriteLine($"Iteration {iteration}"); }); tasks.Add(task); } Task.WaitAll(tasks.ToArray());
Now, we get the desired output:
Iteration 3 Iteration 1 Iteration 2
But, there is a performance concern. Due to the lambda variable capture, there is an extra memory allocation for that iteration
variable. Though this is not a significant overhead in this simplest example, this might be a major concern in complex routines where many variables are involved. Task.Run
does not provide any solution for this but StartNew
does! StartNew
offers several overloads that accept a state object, one of them is:
public Task StartNew (Action<object> action, object state);
This provides a better way to overcome the state mutation problem without adding extra memory allocation overhead:
var tasks = new List<Task>(); for (var i = 1; i < 4; i++) { var task = Task.Factory.StartNew(async (iteration) => { await Task.Delay(100); Console.WriteLine($"Iteration {iteration}"); }, i) .Unwrap(); tasks.Add(task); } Task.WaitAll(tasks.ToArray());
As we can see, StartNew
captures the current value of i
and pass this immutable state to the delegated action. We don’t need the local copy anymore:
// Approximate Output: Iteration 1 Iteration 3 Iteration 2
Overall, StartNew
provides a means to avoid closures and memory allocation due to lambda variable capture in delegates and hence might give some performance gain. That said, this performance gain is not guaranteed and may not be significant enough to make any difference. So, if the memory profiling of a certain task usage indicates that passing a state object gives a significant benefit, we should use StartNew
there.
Advanced Task Scheduling
We now know that Task.Run
always uses the default task scheduler. Default scheduler uses the ThreadPool
which provides some powerful optimization features including work-stealing for load balancing and thread injection/retirement. In general, it facilitates maximum throughput and good performance.
So, certainly, we want to use the default scheduler mostly. However, in real-world applications, the business situation may demand complex work distribution algorithms requiring our own task scheduling mechanism. For example, we may want to limit the number of concurrent tasks. Or, we can think about a support request engine that may need to prioritize urgent requests and reschedule trivial pending requests. We need custom scheduler implementation in such cases. Since none of the Task.Run
overloads accept a TaskScheduler
parameter, StartNew
is the viable option here.
When to Use What
We have seen various scenarios of dealing with an asynchronous task by Task.Run and Task.Factory.StartNew. We should mainly use Task.Run
for general work offloading purposes as it is the most convenient and optimized way to do that. When we need an advanced level of customization in child task handling, task scheduling, bypassing the thread pool, or some proven memory optimization benefits, only then we should consider using StartNew
.
In a nutshell, Task.Run
gives us the benefits of convenience and built-in optimization whereas StartNew
provides the flexibility to customization.
Conclusion
In this article, we have learned about the difference between Task.Run and Task.Factory.StartNew. We have discussed some advanced use cases where StartNew
is the viable option, otherwise Task.Run
is the recommended method in general.