Mert Özen Further With Every Line
Backend Asynchronous Programming in C# — #03

C# Task and Task<T>: A Promise That Starts Hot

Mert Özen Nov 7, 2026 10 min 2 views
C# Task and Task<T>: A Promise That Starts Hot

An async method starts running the moment it is called; await does not start work, it waits for its result. The task life cycle and Status values, creating tasks with Task.FromResult and TaskCompletionSource, awaiting the same task twice, starting work first and awaiting later, and the Task<Task> trap of Task.Factory.StartNew.

Version note: The code in this post uses C# 10 and the .NET 6 APIs; it was built and run with the .NET 10 SDK.

Guess First

When the order screen opens, an async method calculates the cart total. Five lines are written to the screen; two come from the main code and three from inside the method. In what order do those five lines appear? Pay special attention to the "Calculation started" line: does the method start running the moment it is called, or only when execution reaches the await line?

using System.Globalization;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

Console.WriteLine("Order screen opened");
Task<decimal> totalTask = CalculateTotalAsync();
Console.WriteLine("Waiting for the total");
decimal total = await totalTask;
Console.WriteLine($"Total: {total}");

static async Task<decimal> CalculateTotalAsync()
{
    Console.WriteLine("Calculation started");
    await Task.Delay(300); // as if waiting for the price service
    Console.WriteLine("Calculation finished");
    return 37.49m;
}

The Answer: Tasks Start Hot

Order screen opened
Calculation started
Waiting for the total
Calculation finished
Total: 37.49

"Calculation started" came before "Waiting for the total". So CalculateTotalAsync() started running the moment it was called; it did not wait for the await line. When an async method is called, its body starts running on the caller's thread, just like a normal method. When it reaches the first await on work that has not finished yet, it pauses and returns to the caller with a Task<decimal> in hand. The main code carries on, writes "Waiting for the total", and then awaits itself. When the 300 ms are up, the method resumes where it left off and returns the result, and the main code prints the total.

This is called a "hot task": in .NET, the tasks returned by async methods and by Task.Run have already started by the time you get them. await does not start any work; it waits for the result of work that has already started. The distinction looks small, but, as we will see later in this post, it is the key to running two jobs at the same time.

A Task Is a Promise, Not a Thread

When you order at a busy café, the cashier hands you a vibrating pager. The pager is not coffee; it is a promise that "your coffee is being made and we will let you know when it is ready". With the pager in hand you can sit down, check your phone, do something else. When it buzzes, you go and pick up your coffee. Or the coffee machine has broken down; the pager still buzzes, but what is waiting at the counter is an apology, not coffee.

Task<T> is that pager. It is an object representing the promise "I will give you a T later, or tell you why I could not". Task (the non-generic version) carries no result; it only reports "the work is done" or "the work failed", and it is the async counterpart of methods returning void. In both cases the promise is kept once and never changes again: a finished task stays finished forever.

Remember from the previous post: a task and a thread are not the same thing. The task from Task.Run represents work running on a pool thread; the task from Task.Delay represents a timer with no thread behind it at all. Both are the same type, because what a task promises is not "a thread will run" but "a result will arrive".

The Life Cycle of a Task

Every task has a Status property. Let's create tasks in different ways and look at their status:

Task cold = new Task(() => Console.WriteLine("never runs"));
Console.WriteLine($"new Task:      {cold.Status}");

Task<int> delayed = DelayedStockAsync();
Console.WriteLine($"async method:  {delayed.Status}");
int stock = await delayed;
Console.WriteLine($"after await:   {delayed.Status}, result {stock}");

Task failed = Task.Run(() => throw new InvalidOperationException("Out of stock"));
try
{
    await failed;
}
catch (InvalidOperationException)
{
}
Console.WriteLine($"thrown:        {failed.Status}, IsFaulted {failed.IsFaulted}");

Task canceled = Task.FromCanceled(new CancellationToken(canceled: true));
Console.WriteLine($"canceled:      {canceled.Status}, IsCanceled {canceled.IsCanceled}");

static async Task<int> DelayedStockAsync()
{
    await Task.Delay(200);
    return 5;
}
new Task:      Created
async method:  WaitingForActivation
after await:   RanToCompletion, result 5
thrown:        Faulted, IsFaulted True
canceled:      Canceled, IsCanceled True

A task's story ends in one of three ways: RanToCompletion (finished successfully), Faulted (threw an exception) or Canceled (was canceled). IsCompleted returns true for all three; in other words, "finished" does not mean "succeeded". If you want to ask about success, use IsCompletedSuccessfully.

The names of the unfinished states do not tell you much. A task returned by an async method shows WaitingForActivation, because no thread is running that task; the code the compiler generated is waiting for something to finish. A task started with Task.Run may show WaitingToRun or Running, depending on when you catch it. In practice you do not make decisions based on these intermediate states; you wait for the work to finish with await.

The only odd line in the list is Created. A task created with new Task(...) is "cold": it does not run at all until Start() is called. This leftover from the early days of TPL is almost never used today; we will come back to it in the pitfalls section.

Ways to Create a Task

In everyday code you get tasks from four places. The first and most common is async methods: when you write async Task<T>, the compiler creates the task for you and completes it with the value you return. The second is I/O APIs: ReadAsync, GetStringAsync, Task.Delay. The third is Task.Run: handing CPU-bound work to the thread pool and getting back the task that represents it. The fourth is ready-made tasks whose result you already have:

using System.Globalization;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
Dictionary<string, decimal> priceCache = new() { ["978-0441172719"] = 12.99m };

decimal cached = await GetPriceAsync("978-0441172719");
decimal fetched = await GetPriceAsync("978-0553293357");
Console.WriteLine($"Cached: {cached}, fetched: {fetched}");

Task<decimal> GetPriceAsync(string isbn)
{
    if (priceCache.TryGetValue(isbn, out decimal price))
    {
        return Task.FromResult(price); // the result is ready, no need to wait
    }
    return FetchPriceAsync(isbn);
}

async Task<decimal> FetchPriceAsync(string isbn)
{
    await Task.Delay(300); // as if calling the price service
    priceCache[isbn] = 9.50m;
    return 9.50m;
}
Cached: 12.99, fetched: 9.50

GetPriceAsync is not async, but it returns a Task<decimal>. If the price is in the cache, it returns an already completed task through Task.FromResult; if not, it forwards to the real async work. The caller does not tell the two cases apart; it awaits both. Awaiting a finished task is very cheap: the code continues on the same thread without waiting. To return work that carries no result as "already done", there is Task.CompletedTask; it is often used when implementing an interface's async method in a way that finishes synchronously.

TaskCompletionSource: Turning an Event into a Task

Sometimes you have an API that does not return a task and reports its result through an event or a callback, like the EAP from the previous post. TaskCompletionSource<T> is the bridge that connects such an API to the world of tasks. You create a task and you decide when, and with what result, it finishes:

PaymentTerminal terminal = new PaymentTerminal();
string receipt = await PayAsync(terminal, 37.49m);
Console.WriteLine($"Receipt: {receipt}");

static Task<string> PayAsync(PaymentTerminal terminal, decimal amount)
{
    TaskCompletionSource<string> source = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
    terminal.PaymentCompleted += (sender, receiptNo) => source.TrySetResult(receiptNo);
    terminal.PaymentFailed += (sender, reason) => source.TrySetException(new InvalidOperationException(reason));
    terminal.StartPayment(amount);
    return source.Task;
}

class PaymentTerminal
{
    public event EventHandler<string>? PaymentCompleted;
    public event EventHandler<string>? PaymentFailed;

    public void StartPayment(decimal amount)
    {
        // an old-style API: reports the result later through an event
        ThreadPool.QueueUserWorkItem(_ =>
        {
            Thread.Sleep(200);
            if (amount > 0)
            {
                PaymentCompleted?.Invoke(this, "R-1001");
            }
            else
            {
                PaymentFailed?.Invoke(this, "Invalid amount");
            }
        });
    }
}
Receipt: R-1001

source.Task is the promise that goes out; TrySetResult, TrySetException and TrySetCanceled are the three ways to keep it. Whichever is called first decides how the task ends, and later calls have no effect; that is what the "Try" prefix means. The RunContinuationsAsynchronously option keeps the code waiting on the task from running inside the thread that raised the event, on its call stack. Passing this option is a good habit when you set up a TaskCompletionSource in library code.

Getting the Result: await

The right way to get a task's result is await. This experiment shows nicely that a task is a promise, not a function:

int calls = 0;
Task<int> stockTask = CountStockAsync();

int first = await stockTask;
int second = await stockTask;
Console.WriteLine($"first {first}, second {second}, method ran {calls} time(s)");

async Task<int> CountStockAsync()
{
    calls++;
    await Task.Delay(100);
    return 5;
}
first 5, second 5, method ran 1 time(s)

We awaited the same task twice, and the method ran once. The task stores the result; the second await does not restart the work, it hands over the result that is already there.

Task<T> also has a Result property. If the task has finished, it gives you the result; if not, it blocks the calling thread until the work is done. That is exactly the blocking we talked about in the previous posts, and in some environments it locks the program forever. Let's set the rule now: in async code, get the result with await; do not write .Result or .Wait(). We will see step by step why it locks up in the deadlock post.

Tasks starting hot has a practical benefit. If you start two independent jobs first and wait for them afterwards, both run at the same time:

using System.Diagnostics;

Stopwatch watch = Stopwatch.StartNew();
decimal price = await GetPriceAsync();
int stock = await GetStockAsync();
Console.WriteLine($"One after another: {watch.ElapsedMilliseconds} ms");

watch.Restart();
Task<decimal> priceTask = GetPriceAsync(); // both start now
Task<int> stockTask = GetStockAsync();
decimal price2 = await priceTask;
int stock2 = await stockTask;
Console.WriteLine($"Started together:  {watch.ElapsedMilliseconds} ms");

static async Task<decimal> GetPriceAsync()
{
    await Task.Delay(300);
    return 12.99m;
}

static async Task<int> GetStockAsync()
{
    await Task.Delay(300);
    return 5;
}
One after another: 606 ms
Started together:  301 ms

The timings will be a little different on your machine, but the ratio stays the same. In the first half the second job did not start until the first one finished. In the second half both jobs started at once, and the total time dropped to the time of the longest job. The tidier way to write this is Task.WhenAll; we will look at it in detail in the concurrency post.

Task.Run and Task.Factory.StartNew

In older code you will see Task.Factory.StartNew instead of Task.Run. That was the way to start a task in .NET Framework 4.0; Task.Run came with 4.5 and is a shortcut set up with the right defaults. The difference shows up when you pass an async lambda:

using System.Diagnostics;

Stopwatch watch = Stopwatch.StartNew();
Task<Task> nested = Task.Factory.StartNew(async () => await Task.Delay(1000));
await nested;
Console.WriteLine($"StartNew:          {watch.ElapsedMilliseconds} ms");

watch.Restart();
await Task.Run(async () => await Task.Delay(1000));
Console.WriteLine($"Task.Run:          {watch.ElapsedMilliseconds} ms");

watch.Restart();
await Task.Factory.StartNew(async () => await Task.Delay(1000)).Unwrap();
Console.WriteLine($"StartNew + Unwrap: {watch.ElapsedMilliseconds} ms");
StartNew:          2 ms
Task.Run:          1000 ms
StartNew + Unwrap: 1002 ms

The first line did not wait a second. An async lambda returns a Task; StartNew runs the lambda and wraps its result, the inner task, in a task of its own. What you get is a Task<Task>: the outer task finishes the moment "the lambda started and reached its first await", while the inner task finishes a second later. We awaited the outer task. Task.Run knows about this case and unwraps the inner task automatically; with StartNew you need to call Unwrap() for the same result. In addition, StartNew uses the current TaskScheduler by default, while Task.Run always uses the thread pool. In short, use Task.Run to hand work to the thread pool; pick StartNew only when you need a special setting such as TaskCreationOptions.LongRunning and know what you are doing.

Common Pitfalls

Forgetting to await a task. If you call an async method and throw away the returned task, the work runs in the background, but nobody knows when it finished or whether it failed. The compiler warns you in this case:

await ProcessOrderAsync();

static async Task ProcessOrderAsync()
{
    SaveOrderAsync();
    await Task.Delay(200); // the order screen carries on with other work
    Console.WriteLine("Order saved");
}

static async Task SaveOrderAsync()
{
    await Task.Delay(100);
    throw new InvalidOperationException("Database is down");
}
Program.cs(5,5): warning CS4014: Because this call is not awaited, execution of the current method continues before the call is completed. Consider applying the 'await' operator to the result of the call.

When you run the program it prints "Order saved" and ends without a problem. The database error does not show up anywhere; the exception stays inside the task and is lost. Do not ignore CS4014; either add await or keep the task deliberately. This topic continues in the async void and fire-and-forget post.

Creating a cold task with new Task(...). If you forget Start(), the task stays in the Created state forever, and code that awaits it never continues. Use Task.Run to start work, Task.FromResult for a task whose result is ready, and TaskCompletionSource for a task that finishes under your control.

Lining up independent work for no reason. Writing a separate await on every line is correct, but if the jobs do not depend on each other, starting them first and waiting afterwards cuts the time down to the longest job. Conversely, do not try to start dependent jobs at the same time: you cannot issue an invoice before saving the order.

Mistaking IsCompleted for success. Faulted and Canceled tasks count as completed too. Check for success with IsCompletedSuccessfully.

A Small Experiment

★☆☆ In the program at the top, replace the await Task.Delay(300) line with await Task.CompletedTask. Did the order of the five lines change? Why?

★★☆ In the PaymentTerminal example, set the amount to 0. Await PayAsync inside a try/catch and print which exception was caught and with what message. Then look at the task's Status.

★★★ Add Console.WriteLine(nested.Result.Status) to the Task.Factory.StartNew example and see the inner task's status at that moment. Then explain why writing await await nested; gives the same result as Unwrap().

We have seen that a task is a promise, how it is created and how its result is obtained. But we still do not know what actually happens inside await: how does a method stop halfway and resume where it left off on another thread? In the next post we pull back the compiler's curtain and look at the async method's state machine and at continuations.