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.
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 to | StartNew(action, None, DenyChildAttach, TaskScheduler.Default) | StartNew(action, None, None, TaskScheduler.Current) |
| Scheduler used | Always the default (the thread pool) | The current scheduler, which may not be the thread pool |
| Child tasks can attach | No; DenyChildAttach rejects AttachedToParent | Yes |
async delegate | Unwrapped, returns Task<T> | Returns Task<Task<T>>; needs .Unwrap() |
TaskCreationOptions (e.g. LongRunning) | Not exposed | Yes, as a parameter |
Custom TaskScheduler | No overload accepts one | Yes, as a parameter |
| State-object overload | No | Yes; avoids the closure allocation |
CancellationToken | Yes | Yes |
| Use it | By default, for offloading work to the thread pool | Only 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.

Nice article.But I was wondering about “The async Awareness”! Why wrap the async method in a task as it already returns a task? I do it like this (Don’t have v10 so no lambda):
var task = await delayTaskAsync();
async Task<string> delayTaskAsync()
{
await Task.Delay(500);
return “Calculated Value”;
}
… or instead of fire and forget.
// Fire and forget for a while.
// Can be stated from a sync method as caller is sync and
// sub is async which do the waiting.
var task2 = delayTaskAsync();
// …will continue here without waiting.
// Handle possible error and/or info “Ended OK”
task2.GetAwaiter().OnCompleted(ForgetForAWhile);
// …will continue here without waiting.
Good question, Börje Henriksson. If the scenario is that simple like our example (which is intentionally kept simple to focus only on the Unwrap problem), there is no real need for such calls, but there can be real use cases. The fact is – wrapping is a side-effect here, the actual intention would be offloading. Let’s say, the subtask relies on SynchronizationContext and we don’t want it to come in our way of currently executing context, we would want the subtask to offload to the thread pool. Task.Run is the simplest way to do it, right?
Some additional info:
Fire and forget.
99 % of usage is with async methods which have no return value, like saving user data. By using this you don’t need to wait (and no blocking) and a must is a try catch in the async method which sets some property like string to “Success” or an exception string. You can leave out the try catch if you make access to the async return task in the OnCompleted(action). The action triggered by OnCompleted(action) then picks the property data or “task.IsFaulted” if it has access to it and deals with either the exception and/or success, like into to log file or info in a field. The info from the async method could do the same thing as the action method but it is more flexible to let the async method be general purpose and deal with a tailor made success/exception else you need a trigger or regular check to pick up the info. The OnCompleted(action) will handle that for you. – So the assigned async task is used for INFO ONLY and no modifications in the task to avoid problems with sync and blocked data.
Lovely details on the async scenarios :), thank you.
Great post.
I was studying a way to encapsulate a synchronous HTTP request method in my async/await application and I used
var response = await Task.Run(() => syncmethod())to solve my problem.Now I had a better understanding under the hood.
That’s great, Diego Faria. Thanks for the feedback!
Great help for the decision of Task.Run or StartNew!
Great article!
Glad to know you like it :). Thanks for reading and commenting.
Just perfect..
I’m glad you find it useful. Thank you for reading and appreciating.
Its a nice article which clears about when to use Task. Run over Task.Factory.StartNew. It rings me a bell on the usage. I am wondering on how this will differentiate the performance when the service runs on non IIS environment like direct windows console service management. Thank you for your effort!
I enjoy all the details in this post.
Great article! Nice comparison of .NET task creation options with direction for nich use of both.
Thanks!
Thank you very much. This is quite an interesting topic and we are glad you like the article.