1. Introduction
Remember our example with downloading a big image. While it downloads, the app "freezes". The same thing happens with text files, especially when they're large (logs tens of gigabytes, huge CSV reports, database backups in text format).
Imagine you're writing an app that:
Parses a huge log file to find errors. If you read it synchronously, your UI will just "stick" for a few seconds or even minutes while the operation completes. The user will think the program crashed.
Writes data to a report file as it's generated. If writing blocks the main thread, both data generation and UI will suffer.
A web server that needs to handle thousands of requests. Each request may require reading or writing a file. If every file I/O is synchronous, server threads will sit idle waiting for the disk and the server will quickly get overwhelmed by incoming requests.
In these scenarios asynchronous I/O becomes not just a "nice-to-have" but a life saver. It lets your app do useful work while the disk "thinks" (for example, update UI, handle other requests, or perform computations).
Basic concepts: async/await and tasks
- The keyword async indicates that a method may contain "await points" (await).
- The await operator temporarily hands control back while an asynchronous task completes (for example, reading a file).
- An async method performs I/O without blocking the current thread: while there's no data — the thread is free.
All that is the basis of the async magic for file work.
2. Async methods for files
In modern .NET most of the main classes for file work have async counterparts. For text files you usually use:
- StreamReader.ReadLineAsync()
- StreamReader.ReadToEndAsync()
- StreamWriter.WriteLineAsync()
- StreamWriter.WriteAsync()
- Also static methods: File.ReadAllTextAsync(), File.WriteAllTextAsync() and others.
| Read | Write |
|---|---|
|
|
|
|
|
|
3. Asynchronously reading an entire text file
Let's read the whole file into a single string. That's what you do for small files: configs, small logs.
using System;
using System.IO;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
string path = "input.txt";
// Asynchronously read the whole file
string fileContents = await File.ReadAllTextAsync(path);
Console.WriteLine("File contents:");
Console.WriteLine(fileContents);
}
}
Note: the Main method is now marked async Task Main(). You can do this starting from C# 7.1. One await — and everything runs asynchronously!
4. Asynchronous line-by-line reading of a large file
When a file is really big, loading it fully into memory is not a great idea. Better read it line by line:
using System;
using System.IO;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
string path = "biglog.txt";
// Open StreamReader for async reading
using StreamReader reader = new StreamReader(path);
string? line;
while ((line = await reader.ReadLineAsync()) != null)
{
// Here you can process the line (for example, search for errors)
Console.WriteLine(line);
}
}
}
How does this work?
Each await reader.ReadLineAsync() frees the thread — especially useful if the file is on a network drive or in the cloud. Asynchronous processing is critical with tens of thousands of lines and concurrent users (for example, in a server API).
5. Asynchronous writing of lines to a file
You can similarly write data to a file asynchronously (for example, when generating reports):
using System;
using System.IO;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
string path = "output.txt";
using StreamWriter writer = new StreamWriter(path);
for (int i = 0; i < 5; i++)
{
await writer.WriteLineAsync($"Line number {i + 1}");
}
// You can explicitly call FlushAsync to guarantee the write
await writer.FlushAsync();
Console.WriteLine("Data written asynchronously!");
}
}
Calling FlushAsync() isn't always required — on closing the StreamWriter the buffer will be flushed. But if you need a guarantee "right now", use it.
6. Interaction of multiple async file operations
Suppose you need to read one text file and simultaneously write a transformed version to another:
using System;
using System.IO;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
string sourcePath = "even_biggerlog.txt";
string destinationPath = "copy_biggerlog.txt";
using StreamReader reader = new StreamReader(sourcePath);
using StreamWriter writer = new StreamWriter(destinationPath);
string? line;
int linesProcessed = 0;
while ((line = await reader.ReadLineAsync()) != null)
{
// A bit of magic: convert all letters to uppercase
string processed = line.ToUpperInvariant();
await writer.WriteLineAsync(processed);
linesProcessed++;
}
Console.WriteLine($"Lines processed: {linesProcessed}");
}
}
Both reading and writing here are async. Each await yields control, allowing the app to do something else.
7. Practical use cases: where it's used?
- Web development (ASP.NET Core): uploading/downloading files doesn't block handling other requests; the server stays responsive.
- Desktop apps (WPF, WinForms): when opening a log or saving a report the UI doesn't "hang".
- Game engines: async loading of assets (textures, models) lets animations and gameplay continue uninterrupted.
- Big data processing: parsing huge CSV/JSON/XML line-by-line, processing "on the fly" without extra memory usage.
- Background services and daemons: logging, caching, queue processing with efficient use of threads and disk.
Bottom line: async helps build modern, responsive and scalable apps. "Blocking" is bad, async is good!
8. Nuances and best practices
Don't forget to await! If you call a method with the Async suffix without awaiting it, you'll get a Task but the code will continue, which leads to ordering bugs.
// BAD: forgot await
FileManager.ReadTextFileAsync("nonexistent.txt"); // will start, but Main will continue
Console.WriteLine("I executed immediately, while the file is still reading (or already threw)! That's bad!");
The compiler will usually warn about a missing await, but it won't stop the build.
using for everything that is IDisposable: all streams (FileStream, StreamReader, StreamWriter) must be disposed properly. Use using blocks or using declarations (C# 8+) to guarantee closing and flushing buffers.
Buffer size (bufferSize): StreamReader/StreamWriter are already optimized, but for special needs you can experiment. Defaults are comfortable (in FileStream it's common to use 4096 bytes).
Error handling: async methods also throw exceptions. Wrap operations in try-catch. The exception will "surface" when you await the corresponding Task.
ConfigureAwait: in libraries and web scenarios where you don't need the synchronization context (GUI), use await SomeAsync().ConfigureAwait(false). This reduces overhead for context switches. In console and UI apps you can usually omit it.
Practice — and soon async Task will feel as natural as Console.WriteLine.
9. Common mistakes and important nuances of async file operations
If you don't use await (and just call a method with the Async suffix), you'll get a Task object, but the result won't be awaited automatically. You must wait for it via await or explicitly wait (which is usually undesirable).
You can't await async methods from sync code without "lifting" async up the stack. Using .Result or .GetAwaiter().GetResult() can lead to deadlocks — it's better to make the calling methods async.
Don't read and write the same file at the same time (even asynchronously). That risks races and data corruption.
Asynchrony frees the calling thread, but doesn't make operations faster: if the disk or network are slow, async will be just as slow — only without blocking the UI or worker threads.
GO TO FULL VERSION