Sync vs Async File I/O in .NET: Raw Throughput, Scalability, and Buffer Size

 
 
  • Gérald Barré

This post is part of the series 'Async/await in C#'. Be sure to check out the rest of the blog posts of the series!

The default recommendation for .NET is to prefer async file I/O for scalability. That is generally good advice. However, there are cases where sync file I/O can be faster. If you desktop application loads multiple files during startup, synchronous IO can make you application feel more snappy.

That does not mean async is bad. It means sync and async optimize for different goals:

  • Use sync I/O when you care about fastest single-operation latency, such as loading data during application startup.
  • Use async I/O when you care about scalability and thread availability under concurrent workloads.

#Async File I/O

Async I/O is designed to allow other work to proceed while waiting for I/O operations to complete. This can improve scalability and responsiveness in scenarios with many concurrent operations, such as web servers or applications that perform background processing.

However, async I/O introduces overhead from task scheduling, state machines, and continuations. For small or moderate reads, that overhead can dominate the total time, making async appear slower in raw benchmarks.

Note that async I/O must be enabled at the stream level to achieve true asynchronous behavior.. If you call async APIs on a stream not configured for async, the runtime may fall back to a less efficient path. In practice, that means you must use the FileOptions.Asynchronous flag when opening the stream:

C#
await using var stream = new FileStream(
    path,
    FileMode.Open,
    FileAccess.Read,
    FileShare.Read,
    bufferSize: 64 * 1024,
    options: FileOptions.Asynchronous);

var buffer = new byte[64 * 1024];
var readCount = await stream.ReadAsync(buffer);

#Buffer Size Is a First-Class Tuning Knob

The benchmark also shows that buffer size can move results significantly. For many sequential workloads, a moderate-to-large buffer such as 64 KB often gives a good balance:

  • It reduces syscall overhead compared to tiny buffers.
  • It avoids the extra memory pressure and allocation cost of very large buffers.

There is no universal best value. Benchmark your own workload, but do not leave buffer size as an afterthought.

#Sequential Reads Should Use SequentialScan

When reading from start to end without seeks, FileOptions.SequentialScan is usually a good hint to the OS cache manager. Sequential reads are common in scenarios like loading a configuration files.

C#
using var stream = new FileStream(
    path,
    FileMode.Open,
    FileAccess.Read,
    FileShare.Read,
    bufferSize: 64 * 1024,
   options: FileOptions.SequentialScan);

For async sequential reads, combine both flags:

C#
options: FileOptions.Asynchronous | FileOptions.SequentialScan

#Benchmark

##File.ReadAllBytes benchmark

MethodFileSizeMeanErrorStdDevMedian
ReadAllBytes_Sync409669.71 us1.770 us5.191 us67.92 us
ReadAllBytes_Async4096103.97 us2.078 us3.747 us103.62 us
ReadAllBytes_Sync6553675.84 us1.502 us3.360 us75.23 us
ReadAllBytes_Async65536108.10 us2.161 us3.841 us108.16 us
ReadAllBytes_Sync1048576395.82 us6.743 us6.308 us395.84 us
ReadAllBytes_Async1048576465.47 us8.049 us7.529 us463.86 us
ReadAllBytes_Sync167772166,326.91 us194.350 us569.995 us6,284.30 us
ReadAllBytes_Async167772167,501.30 us181.798 us536.037 us7,504.65 us

##SequentialRead / Buffer size benchmark

MethodFileSizeBufferSizeOptionsMeanErrorStdDevMedian
FileStream_Read_Sync655364096None99.37 us1.952 us3.521 us100.20 us
FileStream_Read_Sync655364096SequentialScan94.84 us1.787 us3.443 us95.63 us
FileStream_Read_Sync6553616384None91.69 us1.512 us1.262 us91.27 us
FileStream_Read_Sync6553616384SequentialScan97.00 us1.142 us0.892 us97.03 us
FileStream_Read_Sync10485764096None452.29 us8.918 us18.015 us454.25 us
FileStream_Read_Sync10485764096SequentialScan407.05 us7.287 us17.319 us403.06 us
FileStream_Read_Sync104857616384None435.45 us9.600 us28.305 us433.66 us
FileStream_Read_Sync104857616384SequentialScan401.00 us7.874 us8.086 us401.65 us
FileStream_Read_Sync104857665536None427.36 us6.800 us8.095 us425.92 us
FileStream_Read_Sync104857665536SequentialScan393.48 us7.867 us15.156 us386.26 us
FileStream_Read_Sync1048576262144None589.31 us11.153 us24.715 us587.69 us
FileStream_Read_Sync1048576262144SequentialScan588.35 us11.264 us22.753 us585.58 us

SequentialRead is not always faster. When it is not faster, the values are very close. Also, benchmark on the file system is not very stable. The takeaway is that you should benchmark with realistic file sizes and concurrency levels, not microbenchmarks only.

#Practical Rules

Use these defaults unless profiling proves otherwise:

  1. Startup path, single file, latency-sensitive: prefer sync.
  2. High concurrency server code: prefer async to avoid blocking worker threads.
  3. If you call ReadAsync or WriteAsync, open the stream with FileOptions.Asynchronous.
  4. For full-file sequential scans, test with FileOptions.SequentialScan.
  5. Benchmark with realistic file sizes and concurrency levels, not microbenchmarks only.

Choosing sync vs async is not about ideology. It is about matching the I/O model to the workload.

##Additional resources

Do you have a question or a suggestion about this post? Contact me!

Follow me:
Enjoy this blog?