Task, async, and await: the lifetime of asynchronous work

Published At: · 10 min read

engineering software-development #csharp #dotnet #async

Series introduction: Async programming: identify the wait before choosing the tool

Let's follow a method that needs an I/O result. It sends an HTTP request, reads the response, and parses it. The parsing cannot happen until the response arrives. Meanwhile, the caller can keep the method's task and get on with work that doesn't need the parsed result yet.

There are two pieces of work to keep apart: the HTTP request that produces text, and our method that turns that text into a Response. Task, async, and await let us describe both without losing track of which result belongs to whom.

First, the task

A Task gives the caller a way to observe an operation's completion. It isn't a thread or a promise that work is running somewhere else. It can finish successfully, fail, or be cancelled. If it carries a value, the type is Task<T>: a Task<string> eventually gives its caller a string; a plain Task has no result value. The task can also be complete by the time the caller receives it.[1]

In our HTTP example, the task returned by the HTTP API describes the request. The task returned by our method describes everything our method still has to do, including using the response after it arrives. One carries the text; the other carries the parsed result.

What async changes

The async modifier lets a method use await. For a method returning a value of type T, the usual signature returns Task<T>; for a method with no result value, it usually returns Task. Inside the method we write the result as return value, and the caller receives it through the method's task.[1]

Calling an async method starts executing its code. It runs until it finishes or reaches an await whose operation is still incomplete. Then it can return an incomplete task to its caller and pick up the rest later. As explained in [the earlier post on why async exists]1, async doesn't run the method on a background thread.

Where await fits

await is where a method asks for an operation's outcome. If the task is complete, execution continues through the await immediately. Otherwise, the method suspends there and returns control to its caller; it doesn't hold a thread blocked just to wait. When the operation completes, the method continues after the await with the result.[1]

An await is also where a failure or cancellation becomes visible to this method. It can handle that outcome or leave it to whoever awaits its task. Reaching an await doesn't guarantee a pause: the operation might already be complete.[1]

Follow an HTTP request through the code

Here is the path with HttpClient.GetStringAsync:

static async Task<Response> FetchAsync(
    HttpClient httpClient,
    string url,
    CancellationToken cancellationToken)
{
    Task<string> responseTask =
        httpClient.GetStringAsync(url, cancellationToken);

    string json = await responseTask;
    return Parse(json);
}

Response and Parse are placeholders; the example is about the task flow, not the shape of the response. GetStringAsync starts the GET request and returns a Task<string>. That task completes when the response body has been read as text. FetchAsync has a different return type, Task<Response>, because its job includes parsing that text into a Response.[2]

Before the await, responseTask exists but json doesn't. If the response is pending, FetchAsync gives control back to its caller with an incomplete task of its own. When the HTTP task completes, the method gets the string at await responseTask and then calls Parse(json). Parse is still synchronous code; the HTTP wait is the asynchronous part.

Only after Parse returns does the Task<Response> complete successfully. If the HTTP response was already available at the await, the method can keep going without yielding, and its task may already be complete when the caller gets it. The same code works in both cases; the caller shouldn't build its logic around whether the method happened to pause.

The caller can then await the method's task:

Response response = await FetchAsync(
    httpClient,
    url,
    cancellationToken);

This await belongs to the caller. The await inside FetchAsync gets the response text; this one gets the parsed Response. Each method waits for the result it actually needs.

When the request doesn't succeed

Suppose the HTTP request fails. GetStringAsync's task faults, so await responseTask throws in FetchAsync. Since this method doesn't catch the exception, its Task<Response> faults too. The caller sees it when awaiting FetchAsync. GetStringAsync also treats a response status outside the success range as a request failure.[2]

Parsing has its own failure path. The request might succeed and still return text that Parse cannot use. In that case the HTTP task succeeded, but the exception from Parse faults the task returned by FetchAsync. A caller catching only HttpRequestException would not necessarily handle a parsing error. The outer task describes the outcome of the whole method, not just the network call.

Cancellation begins with a request, not a forced stop. This GetStringAsync overload accepts the caller's CancellationToken. If the HTTP operation observes cancellation while it is still active, the caller awaiting FetchAsync can see an OperationCanceledException. The token doesn't reach the separate Parse call in this snippet, and cancelling after the request has finished cannot undo a result already produced. A timeout can also surface as OperationCanceledException, so the exception type alone does not tell the caller why the operation stopped.[2]

Here the caller handles a request error or its own cancellation, leaving other failures to propagate:

try
{
    Response response = await FetchAsync(httpClient, url, cancellationToken);
    Console.WriteLine(response);
}
catch (HttpRequestException exception)
{
    Console.Error.WriteLine(exception.Message);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
    Console.WriteLine("Request cancelled.");
}

The filter checks whether the caller's token is cancelled, not what caused the exception. If a timeout and caller cancellation happen around the same time, this branch may report the timeout as "Request cancelled." If the token isn't cancelled, a timeout propagates. Parsing errors also propagate.

Where the method continues

When the awaited HTTP request completes, the code after await runs in a context determined by the environment. Some application frameworks provide a SynchronizationContext for that. In a UI application, the default await behavior can return the continuation to the UI context so code can safely update the screen. ASP.NET Core normally doesn't install a custom synchronization context.[3]

For example, inside a UI event handler, the default await lets the code after it update a UI control:

await FetchAsync(httpClient, url, cancellationToken);
statusLabel.Text = "Response loaded";

The handler needs its UI context to update that label. It also needs to decide how to report a failure from FetchAsync.

In general-purpose library code that doesn't need the caller's context, ConfigureAwait(false) can opt out of requiring a return to a captured context. It doesn't force a thread-pool switch: if the operation is already complete, code may continue right where it is. Application code that needs its UI context should keep the default behavior. The choice belongs to the continuation's needs, not to a rule that every await should use the same setting.[3]

In a context-independent library version of FetchAsync, only the await changes:

string json = await responseTask.ConfigureAwait(false);
return Parse(json);

Parse needs the text, not the UI context. This setting affects the continuation inside this method; it does not change how a caller's own await resumes.

Keep the task with the work

FetchAsync returns a task so its caller can decide when it needs the result and where to handle a failure. If another method sits between the caller and this request, it can pass that task along or await it as part of its own work. The result stays observable up the call chain.

If the middle method has nothing else to do, it can return the same task:

static Task<Response> GetResponseAsync(
    HttpClient httpClient, string url, CancellationToken cancellationToken)
{
    return FetchAsync(httpClient, url, cancellationToken);
}

There is no extra async or await in GetResponseAsync because it doesn't use the result. Its caller still receives the completion, failure, or cancellation of FetchAsync.

Blocking on .Result or .Wait() instead would occupy the current thread while the task is incomplete. In a UI application it can also deadlock when the continuation needs the blocked context. For this request, keeping the task and awaiting it preserves the path from the HTTP response to the parsed result.[3]

When requests are independent

This method needs the HTTP text before it can parse; those steps cannot overlap. But a caller with two independent requests can start both before awaiting their results, so their I/O waits overlap.

Task<Response> first = FetchAsync(httpClient, firstUrl, cancellationToken);
Task<Response> second = FetchAsync(httpClient, secondUrl, cancellationToken);

Response[] responses = await Task.WhenAll(first, second);

Both requests are started before the wait. WhenAll gives the caller a task for both results; it does not start the requests itself. This is useful when the requests are independent and the remote service can handle the overlap. A larger batch needs limits, which is a separate design decision.

Sources

  1. Task asynchronous programming model - C# - Microsoft Learn
  2. HttpClient.GetStringAsync Method - Microsoft Learn
  3. ConfigureAwait FAQ - .NET Blog

Footnotes

  1. Why async programming exists
Share on LinkedIn