Updated on

Use Task.Run. It is Task.Factory.StartNew with the two defaults almost every caller wants: TaskScheduler.Default instead of the current scheduler, and DenyChildAttach instead of allowing child tasks to attach, plus unwrapping for async delegates.

Task.Factory.StartNew is the general form, and it earns its place in four situations: a custom TaskScheduler, attached child tasks, LongRunning and the other creation options, and the state-object overloads that avoid a closure allocation.

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

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. Both of them start a Task rather than a thread, a distinction our article on tasks compared with threads works through in detail.

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

What Is Task.Factory.StartNew in C#?

Task.Factory.StartNew creates a task and schedules it to run immediately. It is the general-purpose starter on TaskFactory, reached through the static Task.Factory property.

The minimal call takes a delegate and returns a Task. A Func<TResult> gives back a Task<TResult> whose Result carries the value.

The fuller overloads are where it differs from everything else. They accept a CancellationToken, a TaskCreationOptions value such as LongRunning or AttachedToParent, a TaskScheduler, and a state object passed to the delegate without capturing a closure.

Two defaults catch people out. Without an explicit scheduler it uses TaskScheduler.Current, not the default one, so a call made inside another scheduler’s context stays in that context. And it does not unwrap async delegates, so an async lambda produces a Task<Task<T>>.

Task.Run exists to supply better defaults for that common case, which is why it is the one to reach for unless a specific overload is the reason.

The Difference Between Task.Run and Task.Factory.StartNew

Both start a task, and Task.Run is a shortcut for one specific StartNew call. Every difference comes from the arguments it fixes.

Task.Run(action) is StartNew(action, CancellationToken.None, TaskCreationOptions.DenyChildAttach, TaskScheduler.Default). StartNew(action) uses TaskCreationOptions.None and TaskScheduler.Current.

Those two substitutions produce three visible behaviours. Child tasks marked AttachedToParent attach under StartNew and are ignored under Task.Run, so awaiting the parent waits for them in one case and not the other. StartNew runs on whatever scheduler is current, which on a UI thread means no offloading at all. And Task.Run unwraps an async delegate while StartNew hands back a nested task.

The fourth difference is what StartNew has and Task.Run does not: overloads for creation options, a custom scheduler, and a state object.

None of that makes one faster than the other. Both queue the same kind of work; they differ in where it is queued, what it is allowed to do once running, and what the returned task represents.

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. We block here because these examples run outside an asynchronous method; our article on the difference between await and Task.Wait explains why the awaiting form is the right one in application code. 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.

Offloading to the pool is also where the two mental models meet, and our article on asynchronous programming compared with multithreading draws the line between waiting on I/O and occupying a thread.

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.

One note before the code. We read the Result property directly here to force completion, which is fine in a demonstration but risky in application code, where Result can cause a deadlock. We’ve talked about that in our Asynchronous Programming with Async and Await in ASP.NET Core article:

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.

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. This applies to for loops and not to foreach loops, whose iteration variable stopped being shared between iterations in C# 5. 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

Fanning work out over a collection has tidier forms than a hand-rolled loop, and our article on Parallel.ForEachAsync() and Task.Run with WhenAll compares them.

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 Should We Use Task.Run and When StartNew?

Task.Run for offloading work to the thread pool, which is nearly always what we are doing. StartNew when one of four specific needs applies.

The first is a custom TaskScheduler: limiting concurrency, prioritising work, pinning to a single thread. No Task.Run overload takes a scheduler, so StartNew is the only option.

The second is TaskCreationOptions, most often LongRunning for work that would otherwise occupy a pool thread for minutes.

The third is attached child tasks, where a parent should not complete until its children do. Task.Run denies attachment by design.

The fourth is the state-object overloads, which pass a value to the delegate without capturing a closure. Measure before choosing StartNew for this: the saving is one allocation per task, which matters in a tight loop and nowhere else.

Outside those four, Task.Run is the safer default because its defaults are. Stephen Toub of the .NET team described Task.Run as “a quick way to use Task.Factory.StartNew without needing to specify a bunch of parameters”.

Task.Run(action)Task.Factory.StartNew(action)
Equivalent toStartNew(action, None, DenyChildAttach, TaskScheduler.Default)StartNew(action, None, None, TaskScheduler.Current)
Scheduler usedAlways the default (the thread pool)The current scheduler, which may not be the thread pool
Child tasks can attachNo; DenyChildAttach rejects AttachedToParentYes
async delegateUnwrapped, returns Task<T>Returns Task<Task<T>>; needs .Unwrap()
TaskCreationOptions (e.g. LongRunning)Not exposedYes, as a parameter
Custom TaskSchedulerNo overload accepts oneYes, as a parameter
State-object overloadNoYes; avoids the closure allocation
CancellationTokenYesYes
Use itBy default, for offloading work to the thread poolOnly when one of the rows above is the reason

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. Both sit inside a wider family, and our overview of asynchronous programming patterns in .NET places them alongside the alternatives.

Tested with .NET 10.0.10.