A Task is C#'s standard handle to a unit of work that's either running, scheduled to run, or already finished. Every async method you've seen returns one, every parallel call you make uses one, and almost everything in the System.Threading.Tasks namespace is built around them. This lesson covers what a Task actually represents, the difference between Task and Task<T>, the lifecycle a task moves through, the standard ways to create them, and the inspection properties and blocking pitfalls every C# developer eventually runs into.
A Task is an object that stands in for an operation. The operation might be running on a thread pool thread, waiting on a network call, scheduled to run later, or already done. The Task itself is just a normal .NET object on the heap that lets you ask "is it done yet?", "did it fail?", and "give me the result when you have it." The actual work the task represents lives somewhere else, usually on a thread pool thread or inside an I/O completion port at the OS level.
That separation between the handle (the Task object) and the work (whatever the task is doing) is central to everything else in this lesson. A Task is not a thread. It's a promise that something is happening or has happened, and a way to coordinate with that something when it finishes.
The work variable is the handle. The lambda runs on a thread pool thread (in this example, thread 4). The main thread doesn't block on the task; it keeps printing its own status line, then awaits the handle to make sure the work has finished before the program exits.
The task's Status property changes over time as the work moves through its lifecycle, and awaiting the task is how you wait for completion without blocking the current thread. The "what does await actually do" details belong to the _async & await_ lesson; here we focus on the task object itself.
There are two flavors of Task in the BCL: the non-generic Task and the generic Task<T>. The difference is whether the underlying operation produces a result.
| Type | Represents | Result Access |
|---|---|---|
Task | An operation that completes (or fails) but produces no value | None. You await the task for completion only. |
Task<T> | An operation that produces a value of type T when it completes | task.Result after completion, or T value = await task; |
Think of Task as the async version of a void-returning method, and Task<T> as the async version of a T-returning method.
Task<T> inherits from Task, so anywhere a Task is expected, a Task<T> works too. The opposite isn't true: if a method returns Task, you can't pull a result out of it because there isn't one. The compiler enforces this through the type system the same way it enforces that void methods can't be assigned to a variable. Awaiting a Task produces nothing; awaiting a Task<T> produces a T. That's the only practical difference at the call site.
A task moves through a small set of statuses during its lifetime. The TaskStatus enum has seven members in total, but four of them matter day-to-day: WaitingToRun, Running, RanToCompletion, Faulted, and Canceled. The other two (Created, WaitingForActivation, WaitingForChildrenToComplete) come up in narrower cases.
| Status | Meaning |
|---|---|
Created | The task was constructed via new Task(...) but not yet started. Rare in modern code, since Task.Run starts immediately. |
WaitingForActivation | The task is waiting for the runtime to schedule it. Common as the initial status of tasks returned by async methods. |
WaitingToRun | The task has been queued to the thread pool and is waiting for a thread to pick it up. |
Running | The delegate is actively executing. |
RanToCompletion | The work finished successfully. For Task<T>, the result is available. |
Faulted | The work threw an unhandled exception. The exception is stored on the task's Exception property. |
Canceled | The work was canceled, typically via a CancellationToken. Covered in the CancellationToken lesson. |
The transitions between these statuses follow a fixed shape. A task starts somewhere on the left side of the diagram below and ends in one of three terminal states on the right.
Three final states (RanToCompletion, Faulted, Canceled) are terminal. Once a task lands in any of them, it stays there forever. The Task.IsCompleted property is true for all three, since "completed" in the BCL means "no longer running", not specifically "finished successfully".
You can watch the transition with a small example.
The task starts in WaitingForActivation because Task.Run returned before the lambda actually began running, and the runtime scheduled it through the async state machine. By the time await returns, the task is in RanToCompletion and .Result is safe to read because the work has finished.
If the lambda had thrown, the status would be Faulted and accessing .Result (or awaiting the task) would rethrow the exception, wrapped in an AggregateException for .Result or unwrapped for await.
There are a handful of standard ways to get a Task or Task<T>. Each one fits a specific need.
Task.Run is the everyday way to push work onto the thread pool. You pass it a delegate (Action or Func<T>), it queues the delegate, and it returns a Task (or Task<T> if the delegate returned a value) that completes when the delegate finishes.
Use Task.Run when you have a synchronous CPU-bound calculation and want to run it off the main thread. It gets its own section below.
Task.FromResult<T> wraps an existing value in an already-completed Task<T>. No thread is started, no work is scheduled. The returned task is in RanToCompletion from the moment you receive it, and awaiting it returns immediately with the wrapped value.
This pattern shows up constantly when you're implementing an interface that returns Task<T> and you happen to have the value sitting in memory already. Wrapping it in Task.FromResult keeps the signature happy without spinning up the thread pool.
Task.FromResult allocates a new Task<T> for every call. For frequently called methods that return small value types (like Task<bool> or Task<int> from a cache hit path), consider ValueTask<T> instead. The _ValueTask_ lesson covers ValueTask in detail.
Task.CompletedTask is a static, pre-allocated, already-completed Task with no result. It exists for the same reason as Task.FromResult, but for Task-returning methods rather than Task<T>-returning ones.
Because Task.CompletedTask is a single shared instance, returning it costs nothing. Callers that await it skip straight past the suspension machinery, since the task is already complete the moment they see it.
Task.Delay returns a task that completes after the specified amount of time has passed. No thread is held during the wait; the runtime uses a timer internally. This is the right way to introduce delays in async code, and a common building block for retry logic and timeouts.
The 200 ms gap between the two timestamps is real wall-clock time, but no thread was sitting idle during it. Compare this with Thread.Sleep(200), which holds the current thread the whole time and is one of the worst things you can do inside an async method.
Task.Delay also accepts a CancellationToken, which lets you cancel the wait early. The _CancellationToken_ lesson covers cancellation in detail.
Task.Run is specifically for CPU-bound work, meaning work where the cost is computation rather than waiting on I/O. Resizing an uploaded product image, computing a recommendation score, parsing a large catalog feed, applying a discount algorithm to thousands of cart items: those are all CPU-bound. The thread is busy doing the work, not waiting on the network or disk.
For I/O-bound work (a database query, an HTTP call, a file read), you should call the I/O API's own async method (HttpClient.GetAsync, SqlCommand.ExecuteReaderAsync, File.ReadAllTextAsync) and await it directly. Wrapping a synchronous I/O call in Task.Run does work, but it just trades one thread for another, which is wasteful. The async I/O methods avoid the thread entirely while the operation is in flight.
Task.Run borrows a thread from the thread pool. While the lambda is running, that thread can't service anything else. Short CPU-bound work is fine; holding the thread for seconds starves the pool.
Task.Run queues to the thread pool, which starts with about Environment.ProcessorCount worker threads and grows under load. Saturating it with long-running CPU work degrades everything else, including request handling in ASP.NET Core. For work that runs for tens of seconds or longer, use Task.Factory.StartNew with TaskCreationOptions.LongRunning.
One common mistake is using Task.Run from inside an async method that's already on a request thread. In a web server, the call already happens on a thread pool thread, and wrapping Task.Run around synchronous work just moves it from one pool thread to another. Task.Run makes sense when you have a thread you specifically want to free up (the UI thread in a desktop app), not in the middle of a server-side pipeline.
Sometimes you need to bridge an older API into the async world. The classic shape is a library that takes a callback: "call this method, and when the result is ready, I'll invoke the callback you gave me." That API doesn't return a Task, and you can't await it directly. TaskCompletionSource<T> is the bridge.
A TaskCompletionSource<T> owns a Task<T> and exposes three methods that complete it: SetResult, SetException, and SetCanceled. You hand the task to callers, and you call one of those three methods when the underlying operation finishes.
The caller awaits CheckStockAsync as if it were any other async method. Underneath, the adapter creates a TaskCompletionSource<int>, hands its Task back to the caller, and parks the result-setting in the event handler. When the stock check finishes and the event fires, SetResult flips the task to RanToCompletion and the awaiter resumes.
Rules for TaskCompletionSource<T>:
SetResult after the task has already been completed throws InvalidOperationException. If multiple completion paths might race, use TrySetResult / TrySetException / TrySetCanceled, which return false on a duplicate completion instead of throwing.SetResult. That can cause subtle bugs if the calling thread is doing other work. Pass TaskCreationOptions.RunContinuationsAsynchronously to the constructor to force continuations onto the thread pool instead. This is the recommended default for almost every real adapter.TaskCompletionSource<T> is also useful outside bridging: signaling between parts of your own code, building custom awaitables (a "wait until inventory is restocked" primitive, for example), or exposing async hooks from a synchronous component. Anytime you control when a task should complete but the completion isn't a direct method call, use TaskCompletionSource<T>.
Each TaskCompletionSource<T> allocates an underlying Task<T>. For event-driven adapters that fire once per logical operation, this is negligible. For hot paths completing millions of operations per second, ValueTask<T> with IValueTaskSource<T> is the lower-allocation alternative.
Before async/await shipped in C# 5, the standard way to chain work after a task finished was Task.ContinueWith. You'd call ContinueWith on a task, pass it a delegate, and get back a new task that ran the delegate after the original completed.
It works, but it has rough edges. The continuation delegate receives the antecedent task as an argument, so you have to call .Result to pull the value out. A faulted antecedent doesn't automatically flow into the continuation, you have to inspect prev.IsFaulted yourself. Capturing the right SynchronizationContext requires passing TaskScheduler.FromCurrentSynchronizationContext() explicitly. async/await handles all of this with cleaner syntax.
The same code in modern style:
For new code, use async/await instead of ContinueWith. ContinueWith still has narrow uses (logging a task's final state, attaching cleanup logic, working with custom task schedulers), but it mostly appears in older codebases. Recognize it when reading it, prefer await when writing it.
Three task members let you block the current thread until a task completes: task.Result, task.Wait(), and task.GetAwaiter().GetResult(). All three are dangerous in async code, and the dangers come up often enough that they deserve a name: sync-over-async.
The code runs. The problem isn't the output; it's everything else. Three issues come up.
Deadlocks. In contexts that use a SynchronizationContext (classic ASP.NET on .NET Framework, WPF, WinForms), an async method captures the context and tries to resume on it after the await. If the calling thread is blocked on .Result, it can't service the resume, so the task can't complete, so the block never ends. ASP.NET Core doesn't install a synchronization context by default, which makes this rarer in modern server code, but it still bites in desktop apps. The _SynchronizationContext_ lesson covers it in depth.
Thread pool starvation. Every blocking call ties up a thread pool thread that could otherwise be serving requests. In a web server handling hundreds of concurrent requests, a few .Result calls per request can quickly exhaust the pool, causing latency spikes and timeouts under load. The whole point of async is to free threads while waiting; blocking undoes that.
Different exception wrapping. Awaiting a faulted task rethrows the original exception. Calling .Result or .Wait() throws an AggregateException wrapping the original, which changes how catch clauses match.
The rule of thumb: in async code, never use .Result, .Wait(), or .GetAwaiter().GetResult(). Use await. The common exception is at the very top of a console app's Main method, and even there, async Main (C# 7.1+) is the cleaner answer. The _SynchronizationContext_ lesson covers synchronization context details; the _Async Best Practices_ lesson covers the full best-practices list.
Several properties on Task let you ask questions about a task's state without blocking. You'll use these when implementing custom continuations, writing diagnostic logs, or building progress-reporting code that polls a task's status.
| Property | Type | Meaning |
|---|---|---|
Status | TaskStatus | The exact state from the enum (Running, RanToCompletion, Faulted, etc.). |
IsCompleted | bool | true if the task is in any terminal state (ran to completion, faulted, or canceled). |
IsCompletedSuccessfully | bool | true only if Status == RanToCompletion. Added in .NET Core 2.0. |
IsFaulted | bool | true if the task ended because of an unhandled exception. |
IsCanceled | bool | true if the task ended because of cancellation. |
Exception | AggregateException? | The exception(s) on a faulted task. null if the task didn't fault. |
IsCompleted is true for all three terminal states, which surprises people. "Completed" in the BCL means "no longer running", not "succeeded". For success specifically, check IsCompletedSuccessfully.
bad.Exception is an AggregateException because a single task can hold multiple exceptions in theory (this matters for WhenAll). For a normal single-exception fault, the original exception lives at Exception.InnerException.
Reading Exception on a faulted task marks the exception as "observed". If a task faults and the exception is never observed (no await, no .Exception access, no .Wait), the runtime raises an UnobservedTaskException event. .NET 4.5+ discards it by default, but it still signals a bug. Awaiting tasks, even ones whose results you don't care about, is the simplest way to make sure exceptions can't escape unnoticed.
10 quizzes