1. Introduction
Imagine you're the conductor of an orchestra (your application). If every time a violinist tunes their violin (long I/O) you wait for them before moving on to other instruments, the whole orchestra will stall. But if the violinist says: "I'll tune up, you keep playing and I'll give a sign when I'm ready" — that's asynchronous behavior!
In the world of C# and .NET 9 there are tools for that "multitasking without freezing". The main characters today are the async versions of Read and Write, called ReadAsync and WriteAsync.
They let you start a read or write operation and immediately "release" the current execution thread so it can do something else. When the I/O operation completes (for example, data is read from or written to disk), your code "wakes up" and continues from where it left off.
To use these methods we need two magic words that arrived in C# back in 2012 with version 5.0 (and now, in C# 14, they feel like native!):
- async: A modifier you add to a method to tell the compiler: "This method may contain asynchronous operations, and I'll use await inside."
- await: An operator you use before calling an async operation (like ReadAsync or WriteAsync). It means: "Start this operation, but don't block here. Give control back to the caller and come back when the operation is ready."
Don't worry if these ideas seem a bit fuzzy at first. We'll have a whole separate level dedicated to async and await (Level 58), where we'll dig into the details. For now it's enough to know they help us avoid blocking the main thread.
2. ReadAsync: read without rushing
The ReadAsync method lets you read data from a stream asynchronously. Instead of waiting for bytes to be read from disk, you start the read and immediately switch to other tasks.
Here's the typical signature for reading into a buffer:
public virtual ValueTask<int> ReadAsync(
byte[] buffer,
int offset,
int count,
CancellationToken cancellationToken = default
)
Or, what is used more in modern C# (and .NET 9), using Memory<byte>:
public virtual ValueTask<int> ReadAsync(
Memory<byte> buffer,
CancellationToken cancellationToken = default
)
Let's break down the parameters:
- buffer: This is a byte array (or Memory<byte>) where the data will be read into. Remember buffers for optimization? They work the same way here, but for async operations.
- offset: The offset in the buffer where to start writing the read bytes.
- count: The maximum number of bytes to read.
- CancellationToken cancellationToken: A very useful parameter that lets you cancel the operation if it's no longer needed (for example, the user closed the app or pressed the "Cancel" button).
- ValueTask<int>: A "promise" that when the operation finishes it will return an integer (int) equal to the number of bytes read. ValueTask is an optimized alternative to Task for scenarios where the result might be available synchronously or asynchronously.
Example 1: Asynchronous file reading
Say we have a large text file and want to read it without blocking the main thread. Here's a basic example:
using System;
using System.IO;
using System.Text;
using System.Threading.Tasks;
class Program
{
// Asynchronous function to read a file and count lines
public static async Task<int> CountLinesAsync(string filePath)
{
int lineCount = 0;
// Asynchronous file open
using FileStream fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, useAsync: true);
using StreamReader reader = new StreamReader(fileStream, Encoding.UTF8);
string? line;
while ((line = await reader.ReadLineAsync()) != null)
{
lineCount++;
}
return lineCount;
}
static async Task Main()
{
string filename = "bigtext.txt";
int count = await CountLinesAsync(filename);
Console.WriteLine($"File {filename} has {count} lines");
}
}
Notes on the code:
- Notice the useAsync: true parameter on FileStream. That's important for real async behavior.
- We use await with ReadLineAsync so the thread isn't blocked while a line is read.
- The Main method is now async (C# 7+ allows that).
If this were a real GUI app, while the file is being read (while ReadAsync waits for disk data) the user could still click buttons, scroll, and interact because the UI thread wouldn't be blocked. In a console app it's less visible, but the principle is the same.
3. WriteAsync: write without delay
Like ReadAsync, WriteAsync lets you write data to a stream asynchronously. This is very useful when you need to write a lot of data without making the app wait for the disk to finish.
Main signatures:
public virtual ValueTask WriteAsync(
byte[] buffer,
int offset,
int count,
CancellationToken cancellationToken = default
)
And using ReadOnlyMemory<byte> (we don't modify the buffer for writing):
public virtual ValueTask WriteAsync(
ReadOnlyMemory<byte> buffer,
CancellationToken cancellationToken = default
)
Parameters are similar to ReadAsync:
- buffer: A byte array (or ReadOnlyMemory<byte>) containing data to write.
- offset: The offset in the buffer from where to start taking data to write.
- count: Number of bytes to write.
- CancellationToken cancellationToken: For cancelling the operation.
- ValueTask: No return value, since the number of bytes written is specified by the count parameter.
Example 2: Asynchronous file writing
Now let's write something to a file — asynchronously as well.
using System;
using System.IO;
using System.Text;
using System.Threading.Tasks;
class Program
{
public static async Task WriteTestAsync(string filePath)
{
using FileStream fs = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.None, 4096, useAsync: true);
using StreamWriter writer = new StreamWriter(fs, Encoding.UTF8);
for (int i = 0; i < 10000; i++)
{
await writer.WriteLineAsync($"Line number {i}");
}
}
static async Task Main()
{
string filename = "testout.txt";
await WriteTestAsync(filename);
Console.WriteLine($"Write to {filename} completed.");
}
}
Here the loop writes 10000 lines and the main thread isn't blocked: if this were a GUI app the interface wouldn't "freeze".
Thanks to async and await, our console app can perform a file copy operation while still being responsive to user input (in our case, reacting to pressing Enter to cancel). This is a fundamental principle for building modern, performant and responsive C# apps.
4. Useful nuances
Visualization: how async read/write works
┌───────────────────┐ Start Async Read ┌────────────────────────────────┐
│Your code (UI/logic)│ ─────────────────────→ │ OS/I/O: asynchronous operation│
└─────┬─────────────┘ └───────┬────────────────────────┘
│(does other work) │(reads file, waits for disk)
│<────────────────────────────────────────→│
└─ Waits for Task, gets result ←──────────┘
Rough scheme: while the disk is slowly doing its thing, the code can do other work. Only when the data is needed does execution wait for the Task.
Practical use: where you really need this?
- Desktop apps: if your app reads or writes large stuff (log files, databases, video), async is a must-have. Even if your machine is fast, the user might open a file over the network and that speed can be painfully slow.
- Backend or web apps: dozens or thousands of users can hit the server simultaneously. If every thread blocks waiting for file I/O — hello, slowness and 502 Bad Gateway.
- Mobile apps: if opening or writing a file takes time, the user will see a lag. Use async!
- Any bulk file processing: apps that work with collections of files (archivers, parsers, analyzers) benefit from async I/O.
Synchronous vs Asynchronous read/write
| Method | Blocks thread? | Easy to implement? | Best performance? | Good for UI/servers |
|---|---|---|---|---|
| Synchronous (Read/Write) | Yes | Yes | No | No |
| Asynchronous (ReadAsync) | No | Almost | Yes | Yes |
5. Nuances and best practices
Buffering still matters: Even with ReadAsync and WriteAsync, reading or writing one byte at a time is still extremely inefficient. Asynchrony removes blocking but doesn't magically speed up reading each individual byte. Good starting buffer sizes are 4096-8192 bytes; for large files try 65536 or 131072.
"Async all the way down": If you start using async/await in one place, you usually want to propagate that approach through the call chain: if C does an async operation then C should be async Task, B should be async Task, and A too. Otherwise you can get blocking or even deadlocks in UI apps.
Exception handling: In async code use normal try-catch. You will often see OperationCanceledException and IOException — handle them explicitly.
Disposing resources (await using): Dispose streams and other IDisposable objects properly. If a type implements IAsyncDisposable, then await using will call DisposeAsync(); if it only implements IDisposable — Dispose() will be called.
What happens under the hood (brief): When you await, the compiler transforms the method into a state machine: the operation starts, the method is "paused", and control returns to the caller. When the result is ready the SynchronizationContext (in UI) or the ThreadPool (in console/server) resumes execution from where it left off. This lets a single thread serve many "paused" tasks without blocking.
In conclusion, asynchronous programming with async and await is a powerful tool for building responsive and scalable applications. It lets your code use system resources efficiently without blocking the UI or server threads. It may feel a bit unusual at first, but it's worth mastering! In the next lectures we'll dive deeper into async and parallelism. See you then!
GO TO FULL VERSION