Comparing Task.Run and Task.Factory.StartNew in .NET

Task.Run was introduced in .NET Framework 4.5 as a streamlined alternative to Task.Factory.StartNew. While both APIs schedule delegates onto the thread pool, the factory overloads expose extra parameters for fine-grained customization of scheduling behavior. For typical background operations that do not require specialized configuration, Task.Run is the preferred default.

Creating Background Work Both methods accept a delegate and return a Task representing the queued operation. The following examples schedule equivalent logic:

Task.Run(() =>
{
    int value = ProcessData();
});
Task.Factory.StartNew(() =>
{
    int value = ProcessData();
});

In either case, the callback executes on a thread-pool thread. The underlying engine may reuse an existing worker rather than spawning a brand-new OS thread.

Awaiting Completion To yield control until the delegate finishes, apply the await keyword inside an async method:

private static async Task ExecuteAsync()
{
    Console.WriteLine($"Initiating on {Environment.CurrentManagedThreadId}");
    await Task.Run(() =>
    {
        Console.WriteLine($"Processing on {Environment.CurrentManagedThreadId}");
    });
    Console.WriteLine($"Resuming on {Environment.CurrentManagedThreadId}");
}

Because await captures the current context and resumes after the antecedent completes, the continuation typically runs on the original synchronization context. Returning Task instead of void is essential; an async void method cannot be awaited by its caller, which limits composability and error propagation.

Blocking callers can synchronously wait with WaitAll:

Console.WriteLine($"Initiating on {Environment.CurrentManagedThreadId}");
var job = Task.Run(() =>
{
    Console.WriteLine($"Processing on {Environment.CurrentManagedThreadId}");
});

Task.WaitAll(job);
Console.WriteLine($"Finished on {Environment.CurrentManagedThreadId}");

Here, the calling thread pauses at WaitAll until the scheduled delegate completes, then proceeds.

Long-Running Operations The most meaningful divergence is TaskCreationOptions.LongRunning, available only through Task.Factory.StartNew. Passing this hint tells the task infrastructure that the operation will persist for an extended duration, prompting it to allocate a dedicated thread outside the pool rather than consuming a pooled worker indefinitely:

Task.Factory.StartNew(() =>
{
    for (int i = 0; i < 1000; i++)
    {
        HeavyStep(i);
    }
}, TaskCreationOptions.LongRunning);

Reserve this overload for coarse-grained, compute-bound work that monopolizes a core for seconds or longer. For every other scenario, Task.Run is the appropriate choice.

Under the hood, Task.Run(action) is shorthand for:

Task.Factory.StartNew(
    action,
    CancellationToken.None,
    TaskCreationOptions.DenyChildAttach,
    TaskScheduler.Default);

Regarding async and await: these keywords manage control flow but do not themselves allocate threads. When execution reaches an await expression, the current method relinquishes control until the awaited operation completes. If the awaited operation is an IO-bound framework method, such as HttpClient.GetStringAsync, no thread-pool thread is occupied during the delay. Only when you explicitly construct a Task—commonly via Task.Run or Task.Factory.StartNew—does the scheduler queue work to a thread-pool thread.

Tags: C# .NET Task Parallel Library multithreading Async

Posted on Tue, 25 Aug 2026 16:29:16 +0000 by scvinodkumar