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
- Synchronous versus asynchronous code: why it matters
TaskandTask<T>: representing an operation in progressasyncandawait: how to write an asynchronous method- Simulating a slow operation with
Task.Delay - Exception handling in
asyncmethods - Several tasks in parallel with
Task.WhenAll LendBookAsyncinLibrary, and a call from an asyncMain
- 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.
Task and Task<T>: representing an operation in progress
Task and Task<T>: representing an operation in progressAn 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 resultawait 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.
async and await: how to write an asynchronous method
async and await: how to write an asynchronous methodA 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
asyncmodifier goes before the return type, just likepublicorstatic. - The declared return type is
TaskorTask<T>; inside the body, areturn value;is written exactly as in a synchronous method that returnedTdirectly — the compiler takes care of wrapping it in theTask<T>. - By convention, every asynchronous method ends its name with the suffix
Async(GetGreetingAsync, and laterLendBookAsync), so that whoever calls it knows, just from the name, that it should be used withawait.
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).
- Simulating a slow operation with
Task.Delay
Task.DelayTask.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.
- Exception handling in
async methods
async methodsOne 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).
- Several tasks in parallel with
Task.WhenAll
Task.WhenAllWhen 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).
LendBookAsync in Library, and a call from an async Main
LendBookAsync in Library, and a call from an async MainWith 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 timesequenceDiagram
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 voidanywhere except event handlers: anasync voidmethod returns noTask, so whoever calls it can'tawaitit or catch its exceptions with a normaltry/catch— an unhandled exception inside anasync voidcan even bring down the process. Always useasync Task(orasync Task<T>);async voidis reserved exclusively for UI event handlers, which by their signature can't return anything else (covered in Module 7).- Forgetting the
await: writinglibrary.LendBookAsync(book1, member1);withoutawaitcompiles (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 aTaskmatters for what comes next, it always needs itsawait. - Blocking with
.Resultor.Wait()instead ofawait:library.LendBookAsync(...).Wait();"works," but it blocks the current thread exactly like the synchronous code from section 1 — wiping out the whole benefit of usingasync/await, and possibly even causing a deadlock in certain environments (for example, classic desktop applications with a dedicated UI thread). Always useawaitinstead of.Result/.Wait(). - Believing
asyncmakes code "run on another thread":async/awaitdoesn'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 withThreadandParallel. - Tip: always name asynchronous methods with the
Asyncsuffix (LendBookAsync, notLendBook); it's a convention the whole C# community follows, and it helps you tell at a glance which methods needawaitat the call site.
Exercises
-
Write a method
async Task<string> GetRandomTitleAsync(Library library)that simulates a slow query withawait Task.Delay(800)and then returns theTitleof the first element inlibrary.Catalog(useFirstOrDefault, recalling the LINQ lesson, and return a fallback text if the catalog is empty). Call it from an asynchronousMainwithawait. -
Add to
Librarya methodasync Task<bool> VerifySanctionsAsync(Member member)that simulates, withawait Task.Delay(500), a query to an external service, and always returnsfalse(no member has any sanctions, for now). ModifyLendBookAsyncso it first callsVerifySanctionsAsync(member)and, if it returnstrue, throws anInvalidOperationExceptionbefore checking the book's availability. -
Using
Task.WhenAll, write a snippet that lends, at the same time, three different books to three different members (you can reuseLendBookAsync), 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.
C# Programming Course
Module 1: Introduction to C#
- Introduction to C#
- Setting Up the Development Environment
- Hello World Program
- Basic Syntax and Structure
- Variables and Data Types
- Arrays and Strings
Module 2: Control Structures
Module 3: Object-Oriented Programming
- Classes and Objects
- Methods
- Constructors and Destructors
- Inheritance
- Polymorphism
- Encapsulation
- Abstraction
- Structs and Records: Value Types and Reference Types
Module 4: Advanced C# Concepts
- Interfaces
- Delegates and Events
- Pattern Matching and Modern C# Features
- Generics
- Collections
- LINQ (Language Integrated Query)
- Asynchronous Programming
Module 5: Working with Data
- File I/O
- Serialization
- Database Connectivity
- Entity Framework
- Working with JSON and Consuming REST APIs
Module 6: Advanced Topics
- Reflection
- Attributes
- Dynamic Programming
- Memory Management and Garbage Collection
- Multithreading and Parallel Programming
Module 7: Building Applications
Module 8: Best Practices and Design Patterns
- Coding Standards and Best Practices
- Design Patterns
- Dependency Injection and Inversion of Control
- Unit Testing
- Code Review and Refactoring
