Skip to main content
ANVISoftware Solutions
Lesson 52 of 62Advanced15 min

Task and Task<T>

By the end of this lesson

Represent work in progress and retrieve its result.

A Task is an object that stands for work which may not have finished yet.

That is an unfamiliar idea at first, because every method you have written so far returned a finished answer. A method returning decimal hands you a number. A method returning Task<decimal> hands you something else: a promise that a number will exist, along with a way to find out when.

A cloakroom ticket is a fair comparison. The ticket is not your coat. It is proof that a coat is being looked after, and the means of collecting it later. You can hold the ticket, pass it to a friend, or go and collect the coat — but the ticket itself is not the thing you wanted.

Two forms, and the difference is only whether there is a result to collect:

Task
Work with no return value. It either completes, fails, or is cancelled. The asynchronous counterpart of a void method — saving an order, sending an email, deleting a file.
Task<T>
Work that produces a value of type T when it completes. Task<decimal> will eventually yield a decimal. Task<List<Order>> will eventually yield a list of orders.

A Task you receive is usually already running

This trips people up, so it is worth stating plainly. When a method returns a Task to you, the work has normally already been started. The request has been sent. The file read is under way.

You do not start it, and there is nothing to switch on. Your choice is when to collect the result — and whether to do something else first.

It is possible to create a task that has not been started, using a constructor and calling Start on it. Do not. That form exists for historical reasons and has no place in modern code. Every task you meet in practice arrives already in flight.

Receiving tasks, then collecting the results
C#
public class OrderReportService
{
    private readonly IOrderStore _orders;
    private readonly IRateService _rates;

    public OrderReportService(IOrderStore orders, IRateService rates)
    {
        _orders = orders;
        _rates = rates;
    }

    public async Task<string> BuildSummaryAsync(int customerId)
    {
        // Both calls are in flight from this point. Neither has been awaited.
        Task<List<Order>> ordersTask = _orders.GetForCustomerAsync(customerId);
        Task<decimal> rateTask = _rates.GetRateAsync("GBP");

        // Collect the results. The two waits overlap.
        List<Order> orders = await ordersTask;
        decimal rate = await rateTask;

        decimal total = orders.Sum(order => order.Amount) * rate;

        return $"{orders.Count} orders, {total:N2} GBP";
    }
}
  • Line by line, the first call starts fetching orders and immediately hands back a Task<List<Order>>. Execution moves on. The second call starts fetching the rate.
  • By the time the third statement runs, both round trips are already happening. Neither call blocked.
  • await ordersTask means "I need the value now". If the orders have already arrived, it continues immediately; if not, the thread is released until they do.
  • The second await often costs nothing, because the rate has been arriving while the orders were being fetched.
  • Compare this with awaiting each call on the line that makes it. That version takes as long as the two round trips added together. Here it takes as long as the slower of the two. The next lesson covers a clearer way to express this with Task.WhenAll.
  • Note what the method returns: Task<string>, not string. Asynchrony is visible in the signature all the way up the call chain.

What you can ask a Task

You rarely need these in application code, but they explain what a Task is holding:

IsCompleted
True once the task has finished for any reason, including failure and cancellation. Completed does not mean succeeded.
IsCompletedSuccessfully
True only when the work finished and produced its result without error.
IsFaulted and Exception
A task that failed holds the exception rather than throwing it where it happened. The exceptions lesson covers when and how it resurfaces.
Result
The produced value on a Task<T> — but reading it blocks until the task finishes, which defeats the purpose and carries real risks. Use await instead. This is covered in detail in the next lesson.

Returning a task without doing anything asynchronous

Sometimes a method must return a Task because an interface says so, but a particular implementation has nothing to wait for. A cache that already holds the value, or a test double that returns fixed data, or a validation step that decides nothing needs saving.

Two helpers cover this without pretending work is happening.

An in-memory store that satisfies an asynchronous interface
C#
public interface IInvoiceStore
{
    Task<Invoice?> FindAsync(string reference);
    Task SaveAsync(Invoice invoice);
}

public class InMemoryInvoiceStore : IInvoiceStore
{
    private readonly Dictionary<string, Invoice> _invoices = new();

    public Task<Invoice?> FindAsync(string reference)
    {
        _invoices.TryGetValue(reference, out Invoice? invoice);

        // Already-completed task carrying a value. No thread, no waiting.
        return Task.FromResult(invoice);
    }

    public Task SaveAsync(Invoice invoice)
    {
        _invoices[invoice.Reference] = invoice;

        // Already-completed task with no value.
        return Task.CompletedTask;
    }
}
  • Neither method is marked async, because neither awaits anything. Adding async here would add machinery for no reason and the compiler would warn about it.
  • Task.FromResult wraps a value you already have in a task that is complete the moment it is created. Awaiting it does not release the thread, because there is nothing to wait for.
  • Task.CompletedTask is the same idea with no value. It is a single shared instance, so it allocates nothing.
  • This pattern is how you write fast test doubles for asynchronous interfaces, and it is worth recognising when reading library code.

Summary

  • A Task represents work that may not have finished — it is a handle, not the result
  • Task has no return value; Task<T> produces a T when it completes
  • A task handed to you is normally already running, so starting work and collecting its result are separate steps
  • Task.FromResult and Task.CompletedTask satisfy an asynchronous signature when there is nothing to wait for
  • Many tasks can be in flight on very few threads, because waiting tasks occupy nothing

Practice

Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.

Think about it

Think about it

Look at BuildSummaryAsync above. Suppose you change the first two lines so that each call is awaited on the line that makes it. The orders query takes 400ms and the rate call takes 300ms.

How long does each version take, and why is the difference nothing to do with threads being added?

Show solution

The version shown takes roughly 400ms. Both requests are in flight together, so the total is the slower of the two, not the sum.

Awaiting on the line that makes each call takes roughly 700ms. The second request cannot start until the first has finished, because you asked for its result before moving on.

No extra threads are involved in either version. The overlap comes from the network and the database doing work at the same time while your code holds two outstanding tickets. Starting work and collecting its result are separate acts, and the gap between them is where concurrency lives.

Try it yourself

Try it yourself

Write an interface ICustomerStore with a method that fetches a customer by id asynchronously, then write an in-memory implementation that satisfies it without doing any real asynchronous work.

Return null for an unknown id rather than throwing.

Show solution

The implementation returns an already-completed task. It is not marked async, because there is nothing to await — adding the keyword would generate a state machine to wrap a dictionary lookup.

Why bother with an asynchronous signature for an in-memory store? Because the interface is a contract for callers, and a real implementation will hit a database. If the interface were synchronous, swapping in the real store later would force every caller to change.

The nullable return type Customer? states in the signature that a missing customer is an expected outcome rather than an error.

C#
public interface ICustomerStore
{
    Task<Customer?> FindAsync(int customerId);
}

public class InMemoryCustomerStore : ICustomerStore
{
    private readonly Dictionary<int, Customer> _customers = new();

    public void Add(Customer customer) => _customers[customer.Id] = customer;

    public Task<Customer?> FindAsync(int customerId)
    {
        _customers.TryGetValue(customerId, out Customer? customer);

        return Task.FromResult(customer);
    }
}

Saved in this browser only.