All of BiblioTech's code written so far is synchronous: every line waits for the previous one to finish before running, and if an operation takes time (a network call, a disk read, a database query), the thread running it stays blocked, unable to do anything else, until it finishes. In a real application — a web API serving many BiblioTech members at once, or a desktop application that mustn't "freeze" while loading data — that blocking has a real cost. Asynchronous programming, with the async and await keywords, lets a method yield control while it waits for a slow operation, without blocking the thread, and pick back up automatically when that operation finishes. This lesson introduces Task, async/await, and exception handling in asynchronous code, and adds to Library a first LendBookAsync method that simulates a slow I/O operation, laying the groundwork for Module 5, where that simulation will become real I/O.

Contents

  1. Synchronous versus asynchronous code: why it matters
  2. Task and Task<T>: representing an operation in progress
  3. async and await: how to write an asynchronous method
  4. Simulating a slow operation with Task.Delay
  5. Exception handling in async methods
  6. Several tasks in parallel with Task.WhenAll
  7. LendBookAsync in Library, and a call from an async Main

  1. Synchronous versus asynchronous code: why it matters

Imagine Library had to check, before completing a loan, an external service that verifies whether a member has any pending sanctions. That check — over a network, or against a database — might take a second or more. With synchronous code, the thread handling that request stops completely for that entire second:

// Synchronous: the thread is fully blocked during the wait
void LendBook(Book book, Member member)
{
    CheckPendingSanctions(member); // takes 1 second; the thread can do nothing else meanwhile
    book.Lend();
}

In a small console application, that one-second block is barely noticeable. But on a web server handling hundreds of members at once, every thread blocked waiting on a check is a thread that can't serve any other member in the meantime: the server would need hundreds of threads just to be "waiting," most of them doing no useful work at all.

With asynchronous code, the thread is freed during the wait and can handle other tasks; as soon as the slow operation finishes, the method's execution continues automatically, without the programmer having to manage that "reconnection" manually:

Synchronous code Asynchronous code
During a slow (I/O) operation The thread blocks, waiting and doing nothing else The thread is free for other tasks
Keyword None special async in the declaration, await at the call site
Usual return type void, T Task, Task<T>
When it adds value Pure in-memory computation (little or no waiting) I/O operations: network, disk, database

It's important not to confuse asynchronous with "faster": an asynchronous operation doesn't complete any sooner just for being asynchronous (Task.Delay(1000) still takes a second). What changes is that, while waiting, the thread stays available to do other work instead of being blocked with no benefit at all.

  1. Task and Task<T>: representing an operation in progress

An asynchronous method doesn't return its result directly, but a Task object (if it returns no useful value, the asynchronous equivalent of void), or Task<T> (if it will eventually produce a value of type T). A Task represents an operation that may not have finished yet: it's a kind of "receipt" that can be checked later to obtain the result, once it's available.

Type Synchronous equivalent When it's used
Task void The asynchronous method produces no return value
Task<T> T The asynchronous method will eventually produce a value of type T
Task<int> taskWithResult = GetMemberCountAsync(); // the task may still be in progress here
int memberCount = await taskWithResult; // "await" waits for it to finish and unwraps the result

await task does two things at once: it waits (without blocking the thread) for the Task to finish, and it "unwraps" its result — from Task<int> you get a plain int, not a wrapped Task<int>. On a Task (with no result), await simply waits for it to finish, producing no value.

  1. async and await: how to write an asynchronous method

A method is declared asynchronous by adding the async modifier to its signature, and returning Task or Task<T> instead of void or T. Inside its body, await is used to wait for the result of another asynchronous operation:

async Task<string> GetGreetingAsync()
{
    await Task.Delay(500); // simulates some asynchronous work (more detail in the next section)
    return "Hello from an asynchronous method";
}

Three syntax rules worth fixing in your mind from the start:

  • The async modifier goes before the return type, just like public or static.
  • The declared return type is Task or Task<T>; inside the body, a return value; is written exactly as in a synchronous method that returned T directly — the compiler takes care of wrapping it in the Task<T>.
  • By convention, every asynchronous method ends its name with the suffix Async (GetGreetingAsync, and later LendBookAsync), so that whoever calls it knows, just from the name, that it should be used with await.

To call an async method, you use await before the call, and the calling method must itself also be async:

async Task ShowGreetingAsync()
{
    string greeting = await GetGreetingAsync(); // waits for the result without blocking the thread
    Console.WriteLine(greeting);
}

This propagation of async/await upward — any method that uses await must be async, and whoever calls it probably must be too — is known as "async all the way," and it's why Main needs to become asynchronous to be able to call LendBookAsync (section 7).

  1. Simulating a slow operation with Task.Delay

Task.Delay(milliseconds) returns a Task that finishes, with no real work done, after the given number of milliseconds. It's the usual tool for simulating a slow operation (a network call, a database query) in examples and tests, without yet depending on any real external system:

async Task SimulateSlowOperationAsync()
{
    Console.WriteLine("Operation starting...");
    await Task.Delay(2000); // simulates a 2-second wait, like a real network call
    Console.WriteLine("The operation has finished.");
}

This is exactly the role Task.Delay will play in LendBookAsync (section 7): this lesson still has no file, database, or real service to query — that arrives in Module 5 — but Task.Delay lets you already write and test the full asynchronous flow, with the same async/await that will later be used with real I/O.

  1. Exception handling in async methods

One of the advantages of async/await over older approaches to asynchronous programming is that exceptions are handled exactly the same way as in synchronous code: a regular try/catch around an await catches any exception thrown by the awaited operation, just as if the call were synchronous:

async Task PerformRiskyOperationAsync()
{
    await Task.Delay(500);
    throw new InvalidOperationException("Something went wrong during the operation.");
}

async Task HandleOperationAsync()
{
    try
    {
        await PerformRiskyOperationAsync();
        Console.WriteLine("Operation completed with no errors.");
    }
    catch (InvalidOperationException ex)
    {
        Console.WriteLine($"Handled error: {ex.Message}");
    }
}

The try/catch wraps the await, not the call to the method itself: the exception propagates naturally from inside the Task up to the point where its result is awaited, just as would happen with an exception thrown by a direct synchronous call. No special extra syntax is needed for "asynchronous exceptions": they're regular exceptions, handled with the tools you already know from the Exception Handling lesson (Module 2).

  1. Several tasks in parallel with Task.WhenAll

When there are several asynchronous operations that are independent of each other (neither needs the other's result to start), awaiting them one after another with await wastes the advantage of asynchrony: if each takes a second, awaiting them in sequence takes about two seconds total, even though neither depends on the other.

// Sequential: waits for the first task entirely, then the second -> about 2 seconds total
await SimulateSlowOperationAsync();
await SimulateSlowOperationAsync();

// In parallel: both tasks start at once and both are awaited together -> about 1 second total
Task task1 = SimulateSlowOperationAsync();
Task task2 = SimulateSlowOperationAsync();
await Task.WhenAll(task1, task2);

Task.WhenAll takes one or more already-started Tasks (notice the method is called without await, to get the Task without waiting on it yet) and returns a single Task that finishes when all of them have finished. It's the usual tool when you need to launch several independent operations — for example, several loans at once — and wait for all of them to complete before continuing. This lesson only introduces Task.WhenAll at an introductory level; Module 6 (Multithreading and Parallel Programming) goes deeper into real concurrent execution with Thread and Parallel, a different scenario from the one async/await solves (designed above all to avoid blocking a thread while waiting on I/O, not to split computation across several CPU cores).

  1. LendBookAsync in Library, and a call from an async Main

With everything above, Library gets an asynchronous version of a loan, which simulates — with Task.Delay — a slow check (for example, against a future sanctions service) before completing the loan, reusing the already-existing logic of Lend() and RegisterLoan():

class Library
{
    public List<LibraryItem> Catalog { get; } = new List<LibraryItem>();
    public List<Member> Members { get; } = new List<Member>();
    public List<Loan> Loans { get; } = new List<Loan>();

    private Dictionary<int, Member> _membersById = new Dictionary<int, Member>();

    public event Action<Loan> LoanRegistered;

    // ... AddItem, AddMember, FindMemberById, RegisterLoan unchanged ...

    public async Task LendBookAsync(Book book, Member member)
    {
        Console.WriteLine($"Checking availability of '{book.Title}'...");
        await Task.Delay(1000); // simulates a slow check; in Module 5 this will be real I/O

        if (!book.Available)
        {
            throw new InvalidOperationException($"'{book.Title}' is not available for lending.");
        }

        book.Lend();
        Loan loan = new Loan(book, member);
        RegisterLoan(loan);
    }
}

No earlier member of Library changes: LendBookAsync is a new method that reuses book.Lend() (which updates Available and already knows how to report to the console) and RegisterLoan(loan) (which adds the loan to Loans and fires LoanRegistered), wrapping that already-familiar logic in a simulated asynchronous wait. The if (!book.Available) check is done after the await Task.Delay(...), on purpose: it represents availability being re-checked right before confirming the loan, after the simulated "slow check," and not before.

To call LendBookAsync you need an asynchronous entry point. Since C# 7.1, Main can be declared as static async Task Main() (or static async Task Main(string[] args)):

class Program
{
    static async Task Main()
    {
        Library library = new Library();
        library.LoanRegistered += loan =>
            Console.WriteLine($"'{loan.Book.Title}' has been lent to {loan.Member.Name}");

        Book book1 = new Book("Hopscotch", "Julio Cortazar", "978-84-376-0495-4");
        Member member1 = new Member(1, "Ana Martinez");
        library.AddItem(book1);
        library.AddMember(member1);

        try
        {
            await library.LendBookAsync(book1, member1);
            Console.WriteLine("Loan completed successfully.");
        }
        catch (InvalidOperationException ex)
        {
            Console.WriteLine($"Could not complete the loan: {ex.Message}");
        }
    }
}

If the project uses top-level statements (the single-file style seen since the Hello World lesson, Module 1), there's no need to declare Main explicitly: just write await directly in the file, and the compiler generates an asynchronous Main behind the scenes:

// Program.cs, with top-level statements
Library library = new Library();
Book book1 = new Book("Hopscotch", "Julio Cortazar", "978-84-376-0495-4");
Member member1 = new Member(1, "Ana Martinez");
library.AddItem(book1);
library.AddMember(member1);

await library.LendBookAsync(book1, member1);
Console.WriteLine("Loan completed successfully.");

And, revisiting Task.WhenAll from the previous section, here's how you'd lend two books at once instead of one after the other:

Book book2 = new Book("Ficciones", "Jorge Luis Borges", "978-84-376-0496-1");
Member member2 = new Member(2, "Luis Gomez");
library.AddItem(book2);
library.AddMember(member2);

Task loan1 = library.LendBookAsync(book1, member1);
Task loan2 = library.LendBookAsync(book2, member2);
await Task.WhenAll(loan1, loan2); // both 1-second checks run at the same time
sequenceDiagram
    participant Main as Main (async)
    participant Lib as Library
    participant Book as Book

    Main->>Lib: await LendBookAsync(book1, member1)
    Lib->>Lib: await Task.Delay(1000)
    Note over Lib: the thread is free during the wait
    Lib->>Book: Available?
    Book-->>Lib: true
    Lib->>Book: Lend()
    Lib->>Lib: RegisterLoan(loan)
    Lib-->>Main: Task completed

Common Mistakes and Tips

  • async void anywhere except event handlers: an async void method returns no Task, so whoever calls it can't await it or catch its exceptions with a normal try/catch — an unhandled exception inside an async void can even bring down the process. Always use async Task (or async Task<T>); async void is reserved exclusively for UI event handlers, which by their signature can't return anything else (covered in Module 7).
  • Forgetting the await: writing library.LendBookAsync(book1, member1); without await compiles (with a compiler warning), but the method keeps running without waiting for the operation to finish; the loan might not be registered yet by the time the following code assumes it's complete. If the result of a Task matters for what comes next, it always needs its await.
  • Blocking with .Result or .Wait() instead of await: library.LendBookAsync(...).Wait(); "works," but it blocks the current thread exactly like the synchronous code from section 1 — wiping out the whole benefit of using async/await, and possibly even causing a deadlock in certain environments (for example, classic desktop applications with a dedicated UI thread). Always use await instead of .Result/.Wait().
  • Believing async makes code "run on another thread": async/await doesn't create new threads by itself; its goal is to free up the current thread while an I/O operation is awaited, not to split computational work across several cores. That other problem — computational parallelism with several threads — is what Module 6 solves with Thread and Parallel.
  • Tip: always name asynchronous methods with the Async suffix (LendBookAsync, not LendBook); it's a convention the whole C# community follows, and it helps you tell at a glance which methods need await at the call site.

Exercises

  1. Write a method async Task<string> GetRandomTitleAsync(Library library) that simulates a slow query with await Task.Delay(800) and then returns the Title of the first element in library.Catalog (use FirstOrDefault, recalling the LINQ lesson, and return a fallback text if the catalog is empty). Call it from an asynchronous Main with await.

  2. Add to Library a method async Task<bool> VerifySanctionsAsync(Member member) that simulates, with await Task.Delay(500), a query to an external service, and always returns false (no member has any sanctions, for now). Modify LendBookAsync so it first calls VerifySanctionsAsync(member) and, if it returns true, throws an InvalidOperationException before checking the book's availability.

  3. Using Task.WhenAll, write a snippet that lends, at the same time, three different books to three different members (you can reuse LendBookAsync), and shows a console message when done only once all three loans have completed.

Solutions

async Task<string> GetRandomTitleAsync(Library library)
{
    await Task.Delay(800);
    LibraryItem? first = library.Catalog.FirstOrDefault();
    return first?.Title ?? "The catalog is empty";
}

// From an asynchronous Main:
string title = await GetRandomTitleAsync(library);
Console.WriteLine(title);
public async Task<bool> VerifySanctionsAsync(Member member)
{
    await Task.Delay(500);
    return false; // simulation: no member has any sanctions yet
}

public async Task LendBookAsync(Book book, Member member)
{
    bool hasSanctions = await VerifySanctionsAsync(member);
    if (hasSanctions)
    {
        throw new InvalidOperationException($"{member.Name} has pending sanctions.");
    }

    Console.WriteLine($"Checking availability of '{book.Title}'...");
    await Task.Delay(1000);

    if (!book.Available)
    {
        throw new InvalidOperationException($"'{book.Title}' is not available for lending.");
    }

    book.Lend();
    Loan loan = new Loan(book, member);
    RegisterLoan(loan);
}
Task loan1 = library.LendBookAsync(book1, member1);
Task loan2 = library.LendBookAsync(book2, member2);
Task loan3 = library.LendBookAsync(book3, member3);

await Task.WhenAll(loan1, loan2, loan3);
Console.WriteLine("All three loans have been completed.");

Notice that all three calls to LendBookAsync are made without await at first (so all three tasks start at once), and only their combined completion is awaited with Task.WhenAll; had await been written on each line separately, each loan would have waited for the previous one to finish entirely before starting.

Conclusion

With async, await, Task/Task<T>, exception handling in asynchronous code, and Task.WhenAll, Module 4 (Advanced C# Concepts) comes to a close. Looking back over everything covered: interfaces (ILendable) for defining capabilities regardless of inheritance hierarchy; delegates and events (LoanRegistered) for notifying state changes; pattern matching and modern language features; generics (Repository<T>) for writing reusable, type-safe code; collections (List<T>, Dictionary<TKey, TValue>) that finally completed the Library class; LINQ for querying it expressively; and, in this last lesson, LendBookAsync as the model's first asynchronous operation. Library has gone from being an idea with two loose classes to a complete, queryable domain model — and now, one prepared to operate without blocking a thread while it waits.

Everything built so far, though, still lives only in memory: if the program ends, the entire catalog, its members, and the loan history all disappear with it. That's exactly the limitation Module 5 (Working with Data) tackles: the next stop is replacing Library's in-memory list with real persistence, starting with File I/O and Serialization, continuing with Database Connectivity and Entity Framework, and ending with JSON and REST APIs. This lesson's simulated Task.Delay will then stop being a simulation: LendBookAsync (and the methods added alongside it) will start awaiting real I/O operations — reading and writing files, querying a database — with exactly the same async/await learned here.

© Copyright 2026. All rights reserved