C# Task-Based Asynchronous Programming
Task-based asynchronous programming, often called TAP, is the standard .NET pattern for representing work that may finish later. Instead of making a caller block a thread while waiting, a method returns a Task object that the caller can await, compose, cancel, or inspect. Understanding tasks directly makes async and await much clearer, because those keywords are built on top of Task and Task<T>.
Overview: How Task-Based Programming Works
A Task is a promise-like object managed by the .NET runtime. It represents an operation that can be in states such as waiting, running, completed, faulted, or canceled. Task means the operation completes without returning a value. Task<T> means it eventually produces a value of type T. The task is not the result; it is the handle you use to observe the result later.
The task-based asynchronous pattern replaced older .NET patterns based on callbacks and events. TAP methods conventionally end with Async, return Task or Task<T>, and start their work before returning the task. That last point matters: most tasks you receive from framework APIs are already active, sometimes called hot tasks. You usually do not call Start on them.
For I/O-bound work, a task often represents waiting for the operating system, network stack, database driver, or timer to report completion. No dedicated .NET thread has to sit blocked for the whole wait. For CPU-bound work, Task.Run can queue a delegate to the thread pool so another thread performs the calculation. Those two cases are different: async I/O is about not wasting threads, while Task.Run is about moving CPU work to a worker thread.
Tasks also capture outcomes. If an operation succeeds, its task completes successfully. If it throws, the task becomes faulted and stores the exception. If cooperative cancellation occurs, the task becomes canceled. The await operator observes those states: it returns the result, rethrows the exception, or throws an cancellation exception. You can also compose tasks with methods such as Task.WhenAll and Task.WhenAny.
Syntax
Task work = SomeOperationAsync();
Task<int> numberTask = CalculateAsync();
int number = await numberTask;
Task alreadyDone = Task.CompletedTask;
Task<int> cachedNumber = Task.FromResult(42);
Task background = Task.Run(() => DoCpuWork());
Task combined = Task.WhenAll(work, background);
| Form | Meaning |
|---|---|
Task |
An asynchronous operation with no result value. |
Task<T> |
An asynchronous operation that completes with a T value. |
Task.CompletedTask |
A reusable successfully completed task for methods that have no asynchronous work to do. |
Task.FromResult(value) |
Creates an already-completed Task<T> containing a known result. |
Task.Run |
Queues CPU-bound work to the thread pool and returns a task for it. |
Task.WhenAll |
Creates a task that completes after every supplied task completes. |
Task.WhenAny |
Creates a task that completes when the first supplied task completes. |
Examples
Example 1: Returning Completed Tasks
using System;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
Task<int> cachedCountTask = GetCachedCountAsync();
int count = await cachedCountTask;
Console.WriteLine($"Count: {count}");
Task auditTask = SaveAuditAsync();
Console.WriteLine($"Audit task completed: {auditTask.IsCompletedSuccessfully}");
await auditTask;
}
static Task<int> GetCachedCountAsync()
{
return Task.FromResult(42);
}
static Task SaveAuditAsync()
{
Console.WriteLine("Audit saved");
return Task.CompletedTask;
}
}
Output:
Count: 42
Audit saved
Audit task completed: True
Not every TAP method needs to perform real asynchronous work every time. A cache hit, validation shortcut, or no-op save can still return a task so callers use one consistent API. Task.FromResult wraps a known value in a completed Task<int>, while Task.CompletedTask represents successful completion with no value.
Example 2: Using Task.Run For CPU-Bound Work
using System;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
Console.WriteLine("Starting calculation");
Task<long> totalTask = Task.Run(() => SumSquares(5));
Console.WriteLine("Main can continue");
long total = await totalTask;
Console.WriteLine($"Total: {total}");
}
static long SumSquares(int max)
{
long total = 0;
for (int i = 1; i <= max; i++)
{
total += i * i;
}
return total;
}
}
Output:
Starting calculation
Main can continue
Total: 55
Task.Run queues the calculation to the thread pool. The main async flow gets a Task<long> immediately and can do other work before awaiting the result. Use this for CPU-bound work when moving the calculation away from the current thread is useful, such as keeping a UI responsive. Do not wrap naturally asynchronous I/O in Task.Run; use the API’s real async method instead.
Example 3: Combining Tasks With WhenAny And WhenAll
using System;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
Task<string> inventoryTask = CheckAsync("Inventory", 40, true);
Task<string> pricingTask = CheckAsync("Pricing", 20, true);
Task<string> shippingTask = CheckAsync("Shipping", 10, false);
Task<string> firstFinished = await Task.WhenAny(inventoryTask, pricingTask, shippingTask);
Console.WriteLine($"First status: {firstFinished.Status}");
try
{
await Task.WhenAll(inventoryTask, pricingTask, shippingTask);
}
catch (InvalidOperationException ex)
{
Console.WriteLine($"All finished with error: {ex.Message}");
}
PrintResult("Inventory", inventoryTask);
PrintResult("Pricing", pricingTask);
PrintResult("Shipping", shippingTask);
}
static async Task<string> CheckAsync(string name, int delayMilliseconds, bool succeeds)
{
await Task.Delay(delayMilliseconds);
if (!succeeds)
{
throw new InvalidOperationException($"{name} unavailable");
}
return $"{name} ok";
}
static void PrintResult(string name, Task<string> task)
{
if (task.IsCompletedSuccessfully)
{
Console.WriteLine(task.Result);
}
else
{
Console.WriteLine($"{name} failed");
}
}
}
Output:
First status: Faulted
All finished with error: Shipping unavailable
Inventory ok
Pricing ok
Shipping failed
The three checks start before the first await, so they run concurrently. Task.WhenAny tells you which task completed first, even if that task faulted. Task.WhenAll waits until every task has finished, then faults if any supplied task faulted. After that, each individual task can still be inspected to see which operations succeeded and which failed.
Example 4: Bridging Callback-Style Work With TaskCompletionSource
using System;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
TaskCompletionSource<string> source = new TaskCompletionSource<string>();
Task printerTask = PrintWhenReadyAsync(source.Task);
Console.WriteLine($"Before result: {source.Task.Status}");
source.SetResult("Signal received");
await printerTask;
Console.WriteLine($"After result: {source.Task.Status}");
}
static async Task PrintWhenReadyAsync(Task<string> signalTask)
{
string message = await signalTask;
Console.WriteLine(message);
}
}
Output:
Before result: WaitingForActivation
Signal received
After result: RanToCompletion
TaskCompletionSource<T> lets you create a task and complete it manually. It is useful when adapting an event, callback, message, or external signal into TAP. The consumer sees an ordinary Task<string> and awaits it. The producer later calls SetResult, SetException, or SetCanceled to finish the task.
How It Works Step By Step
- A TAP method is called and returns a
Taskobject. In most real APIs, the operation has already been started before the task is returned. - The caller may await the task, store it, pass it to another method, combine it with other tasks, or inspect status properties.
- If the task is incomplete when awaited, the current async method is suspended and its continuation is registered. The current thread is free to return to the thread pool, UI loop, or request pipeline.
- When the underlying operation completes, the task transitions exactly once into a final state: successful, faulted, or canceled.
- The continuation resumes.
awaitunwraps the result forTask<T>, returns normally forTask, or rethrows the stored failure.
The CLR and task infrastructure are careful about memory visibility. Data written before a task completes is visible to code that observes completion through await or task continuations. However, task-based programming does not make shared mutable state automatically safe. If multiple tasks mutate the same object concurrently, you still need synchronization, immutable data, channels, or another coordination strategy.
Common Mistakes
Starting A Task And Ignoring It
using System;
using System.Threading.Tasks;
class Program
{
static void Main()
{
Task.Delay(1000);
Console.WriteLine("Done waiting");
}
}
Output:
Done waiting
Task.Delay returns a task. Because the program does not await or return that task, it prints immediately. This same mistake with file, network, or database work can create races and lost exceptions.
using System;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
await Task.Delay(10);
Console.WriteLine("Done waiting");
}
}
Output:
Done waiting
Creating Cold Tasks With The Constructor
Task<int> valueTask = new Task<int>(() => 10);
int value = await valueTask;
This code creates a task object but never starts it, so awaiting it would wait forever. In application code, avoid the Task constructor unless you are building very specialized infrastructure. Use an existing async API, Task.FromResult, TaskCompletionSource<T>, or Task.Run depending on the situation.
using System;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
Task<int> valueTask = Task.Run(() => 10);
int value = await valueTask;
Console.WriteLine(value);
}
}
Output:
10
Best Practices
- Return
TaskorTask<T>from asynchronous methods and use theAsyncsuffix in method names. - Treat tasks returned by framework async APIs as already started. Await them or compose them; do not try to start them.
- Use
Task.FromResultandTask.CompletedTaskfor synchronous fast paths in APIs that must still return tasks. - Use
Task.Runfor CPU-bound work that should run on a thread-pool thread, not as a wrapper around true async I/O. - Start independent tasks before awaiting, then use
Task.WhenAllwhen all results are needed. - Remember that
WhenAnyreturns the completed task; you still need to await or inspect that task to observe its result or exception. - Always observe task failures. Forgotten tasks can hide exceptions until much later or make failures impossible to handle cleanly.
- Pass cancellation tokens through TAP APIs when callers need control over long-running work.
Practice Exercises
- Write a method
GetUserNameAsyncthat returnsTask<string>usingTask.FromResult. Await it fromMainand print the name. - Create two CPU-bound calculations with
Task.Run, start both before awaiting either one, and print their combined result. - Write three simulated service calls using
Task.Delay. UseTask.WhenAnyto print which one completes first, then useTask.WhenAllto wait for all of them.
Summary
- TAP is the standard .NET pattern where asynchronous operations are represented by
TaskandTask<T>. - A task is a handle to future completion, not the completed value itself.
- Most tasks returned by async APIs are hot and should not be manually started.
Task.Runis for CPU-bound work; real async I/O should use real async APIs.Task.WhenAllandTask.WhenAnyare the main tools for composing multiple tasks.TaskCompletionSource<T>adapts external completion signals into task-based code.- Always await, return, or otherwise observe tasks so results, exceptions, and cancellation are handled.
