Asynchronous FileStream Read and Write in C

Handling asynchronous file operations in C# prevents thread blocking and enhances application scalability, especially when dealing with large files or high-concurrency environments. This guide demonstrates how to properly configure a FileStream for non-blocking I/O and how to read and write data using modern asynchronous methods like ReadAsync and WriteAsync alongside best practices such as cancellation tokens and memory management.

Enabling Asynchronous I/O

To achieve true non-blocking I/O at the operating system level, you must configure the FileStream with the useAsync parameter set to true, or by using FileOptions.Asynchronous. Without this flag, calls to ReadAsync and WriteAsync may execute synchronously on a thread pool thread rather than utilizing native asynchronous I/O completion ports.

Using FileStreamOptions is the recommended modern approach:

var options = new FileStreamOptions
{
    Mode = FileMode.OpenOrCreate,
    Access = FileAccess.ReadWrite,
    Share = FileShare.None,
    Options = FileOptions.Asynchronous
};

await using var stream = new FileStream("example.dat", options);

Asynchronous Writing with WriteAsync

To write data asynchronously, convert your data into a byte sequence (such as a byte array or ReadOnlyMemory<byte>) and call WriteAsync. The use of ReadOnlyMemory<byte> avoids unnecessary memory allocations.

using System.Text;

public async Task WriteToFileAsync(string filePath, string text, CancellationToken cancellationToken = default)
{
    var options = new FileStreamOptions
    {
        Mode = FileMode.Create,
        Access = FileAccess.Write,
        Share = FileShare.None,
        Options = FileOptions.Asynchronous
    };

    byte[] sourceBytes = Encoding.UTF8.GetBytes(text);
    ReadOnlyMemory<byte> memoryBuffer = sourceBytes;

    await using var fileStream = new FileStream(filePath, options);
    
    // Write data asynchronously
    await fileStream.WriteAsync(memoryBuffer, cancellationToken);
    
    // Flush to ensure data is written to the underlying device
    await fileStream.FlushAsync(cancellationToken);
}

Asynchronous Reading with ReadAsync

Reading a stream asynchronously requires allocating a buffer and looping until ReadAsync returns 0, which signals the end of the file (EOF).

using System.Text;

public async Task<string> ReadFromFileAsync(string filePath, CancellationToken cancellationToken = default)
{
    var options = new FileStreamOptions
    {
        Mode = FileMode.Open,
        Access = FileAccess.Read,
        Share = FileShare.Read,
        Options = FileOptions.Asynchronous
    };

    await using var fileStream = new FileStream(filePath, options);
    
    // Allocate a buffer (e.g., 4096 bytes)
    byte[] buffer = new byte[4096];
    Memory<byte> memoryBuffer = buffer;
    
    var stringBuilder = new StringBuilder();
    int bytesRead;

    // Loop until ReadAsync returns 0 (EOF)
    while ((bytesRead = await fileStream.ReadAsync(memoryBuffer, cancellationToken)) > 0)
    {
        string chunk = Encoding.UTF8.GetString(buffer, 0, bytesRead);
        stringBuilder.Append(chunk);
    }

    return stringBuilder.ToString();
}

Copying Between Streams

When transferring data directly from one file or stream to another, avoid manual buffer loops by using CopyToAsync:

public async Task CopyFilesAsync(string sourcePath, string destinationPath, CancellationToken cancellationToken = default)
{
    var sourceOptions = new FileStreamOptions
    {
        Mode = FileMode.Open,
        Access = FileAccess.Read,
        Options = FileOptions.Asynchronous | FileOptions.SequentialScan
    };

    var destinationOptions = new FileStreamOptions
    {
        Mode = FileMode.Create,
        Access = FileAccess.Write,
        Options = FileOptions.Asynchronous
    };

    await using var sourceStream = new FileStream(sourcePath, sourceOptions);
    await using var destinationStream = new FileStream(destinationPath, destinationOptions);

    await sourceStream.CopyToAsync(destinationStream, bufferSize: 81920, cancellationToken);
}

Key Considerations

  • Always Use await using: Ensure the FileStream is disposed via IAsyncDisposable to flush buffers and release operating system handles asynchronously.
  • Pass CancellationToken: Always pass a cancellation token to ReadAsync, WriteAsync, and FlushAsync to support cancellation requests and avoid hanging operations.
  • Optimize Buffer Size: For large files, standard buffer sizes between 4,096 and 81,920 bytes provide a good balance between memory usage and throughput.
  • Sequential Access: When reading files from start to finish, specify FileOptions.SequentialScan to allow the operating system to optimize read-ahead caching.