In this post, we are going to talk about the difference between Task.Run and Task.Factory.StartNew.

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

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.

Support Code Maze on Patreon to get rid of ads and get the best discounts on our products!
Become a patron at Patreon!

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.

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