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
| Method | FileSize | Mean | Error | StdDev | Median |
|---|
| ReadAllBytes_Sync | 4096 | 69.71 us | 1.770 us | 5.191 us | 67.92 us |
| ReadAllBytes_Async | 4096 | 103.97 us | 2.078 us | 3.747 us | 103.62 us |
| | | | | |
| ReadAllBytes_Sync | 65536 | 75.84 us | 1.502 us | 3.360 us | 75.23 us |
| ReadAllBytes_Async | 65536 | 108.10 us | 2.161 us | 3.841 us | 108.16 us |
| | | | | |
| ReadAllBytes_Sync | 1048576 | 395.82 us | 6.743 us | 6.308 us | 395.84 us |
| ReadAllBytes_Async | 1048576 | 465.47 us | 8.049 us | 7.529 us | 463.86 us |
| | | | | |
| ReadAllBytes_Sync | 16777216 | 6,326.91 us | 194.350 us | 569.995 us | 6,284.30 us |
| ReadAllBytes_Async | 16777216 | 7,501.30 us | 181.798 us | 536.037 us | 7,504.65 us |
##SequentialRead / Buffer size benchmark
| Method | FileSize | BufferSize | Options | Mean | Error | StdDev | Median |
|---|
| FileStream_Read_Sync | 65536 | 4096 | None | 99.37 us | 1.952 us | 3.521 us | 100.20 us |
| FileStream_Read_Sync | 65536 | 4096 | SequentialScan | 94.84 us | 1.787 us | 3.443 us | 95.63 us |
| | | | | | | |
| FileStream_Read_Sync | 65536 | 16384 | None | 91.69 us | 1.512 us | 1.262 us | 91.27 us |
| FileStream_Read_Sync | 65536 | 16384 | SequentialScan | 97.00 us | 1.142 us | 0.892 us | 97.03 us |
| | | | | | | |
| FileStream_Read_Sync | 1048576 | 4096 | None | 452.29 us | 8.918 us | 18.015 us | 454.25 us |
| FileStream_Read_Sync | 1048576 | 4096 | SequentialScan | 407.05 us | 7.287 us | 17.319 us | 403.06 us |
| | | | | | | |
| FileStream_Read_Sync | 1048576 | 16384 | None | 435.45 us | 9.600 us | 28.305 us | 433.66 us |
| FileStream_Read_Sync | 1048576 | 16384 | SequentialScan | 401.00 us | 7.874 us | 8.086 us | 401.65 us |
| | | | | | | |
| FileStream_Read_Sync | 1048576 | 65536 | None | 427.36 us | 6.800 us | 8.095 us | 425.92 us |
| FileStream_Read_Sync | 1048576 | 65536 | SequentialScan | 393.48 us | 7.867 us | 15.156 us | 386.26 us |
| | | | | | | |
| FileStream_Read_Sync | 1048576 | 262144 | None | 589.31 us | 11.153 us | 24.715 us | 587.69 us |
| FileStream_Read_Sync | 1048576 | 262144 | SequentialScan | 588.35 us | 11.264 us | 22.753 us | 585.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:
- Startup path, single file, latency-sensitive: prefer sync.
- High concurrency server code: prefer async to avoid blocking worker threads.
- If you call
ReadAsync or WriteAsync, open the stream with FileOptions.Asynchronous. - For full-file sequential scans, test with
FileOptions.SequentialScan. - 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!