Sequential vs random I/O, device and OS effects (seek time, read-ahead, page cache), FileOptions hints, the RandomAccess API, and memory-mapped files.
In .NET: use a normal FileStream (optionally FileOptions.SequentialScan and a bigger buffer) for sequential work. For random work use RandomAccess.Read/Write with a SafeFileHandle (offset-based, thread-safe, no shared Position), FileOptions.RandomAccess, or a MemoryMappedFile when you want the OS to page data in for you.
| Device | Sequential throughput | Random 4 KiB reads |
|---|---|---|
| HDD (7,200 rpm) | ~150โ250 MB/s | ~100โ200 IOPS (โ0.5โ1 MB/s) |
| SATA SSD | ~500 MB/s | ~50โ90k IOPS |
| NVMe SSD | 3โ14 GB/s | hundreds of thousands of IOPS or more |
| Cloud disk | Capped by tier (MB/s) | Capped by tier (IOPS) |
On an HDD random access can be hundreds of times slower than sequential. On SSDs the gap is smaller but still real, and cloud disks bill and throttle by IOPS, so many small random reads hit limits quickly.
FileOptions.SequentialScan / RandomAccess are hints (posix_fadvise on Linux, cache manager flags on Windows) to tune read-ahead and caching.| Need | API |
|---|---|
| Stream through a file once | FileStream / StreamReader, 64 KiB+ buffer, FileOptions.SequentialScan |
| Read records at known offsets, from many threads | File.OpenHandle + RandomAccess.Read(handle, buffer, offset) (.NET 6+) |
| Treat a large file like memory, OS-managed paging | MemoryMappedFile.CreateFromFile + CreateViewAccessor / CreateViewStream |
| Get the file length cheaply from a handle | RandomAccess.GetLength(handle) |
A pricing service looked up product records in a 40 GB fixed-width binary file on a cloud disk. It used one shared FileStream with Seek + Read under a lock, so requests were serialised and p99 was 300 ms. We switched to RandomAccess.ReadAsync(handle, buffer, offset) without a lock (offset-based reads are thread-safe), opened the file with FileOptions.RandomAccess | FileOptions.Asynchronous, and moved to a disk tier with more IOPS. p99 dropped to 15 ms. Hot records were then served from the page cache, which we sized the VM for.
Checks systems thinking about I/O performance and knowledge of modern .NET file APIs.
Green flags
RandomAccess and why a shared Position is a problem.Red flags
Seek on one stream from many threads without understanding the race.HDD: random read = seek arm + wait for platter rotation + transfer
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ ~4 ms seek + ~4 ms rotation + 0.02 ms data โ per random 4 KiB
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
sequential = one seek, then a continuous stream
SSD: no moving parts, but
- reads/writes happen in pages (4โ16 KiB), erases in large blocks
- parallelism across channels needs many outstanding requests (queue depth)
- small random WRITES cause write amplification
FileStream has a single Position. Two threads doing Seek then Read race each other, so you need a lock, which serialises all I/O.
Thread A: Seek(1000) โโโ โโโ Read โ reads at 5000! โ
Thread B: Seek(5000) โโ Read โโ
RandomAccess.Read(handle, buffer, offset) maps to pread/ReadFile with an explicit offset. No shared position, so many threads can read in parallel, which also gives SSDs the queue depth they need.
process virtual memory
โโโโโโโโโโโโโโโโโโโโโโโโโโ
โ view of data.bin โโโ page fault on first touch โโโบ OS reads 4 KiB page from disk
โ (looks like an array) โโโ later touches โโโบ straight from page cache, no system call
โโโโโโโโโโโโโโโโโโโโโโโโโโ
Pros: no system call per read once cached, the OS shares pages across processes, simple code for read-mostly data. Cons: page faults are hidden I/O (unpredictable latency on a cold cache), I/O errors surface as exceptions on memory access, and very large views need 64-bit address space.
Many systems turn random writes into sequential ones on purpose:
For your own files: sort work by offset, batch small reads into larger ones, and append instead of updating in place.
RandomAccess.Read.sync; echo 3 | sudo tee /proc/sys/vm/drop_caches on a test Linux box) to see cold vs warm.Parallel.ForAsync) and compare throughput on an SSD.// Random, parallel, offset-based reads of fixed-size records (.NET 6+)
public sealed class RecordFile : IDisposable
{
private const int RecordSize = 256;
private readonly SafeFileHandle _handle;
public RecordFile(string path) =>
_handle = File.OpenHandle(path, FileMode.Open, FileAccess.Read, FileShare.Read,
FileOptions.RandomAccess | FileOptions.Asynchronous);
public long Count => RandomAccess.GetLength(_handle) / RecordSize;
public async ValueTask<byte[]> ReadAsync(long index, CancellationToken ct)
{
var buffer = new byte[RecordSize];
int read = await RandomAccess.ReadAsync(_handle, buffer, index * RecordSize, ct);
if (read != RecordSize) throw new EndOfStreamException();
return buffer; // thread-safe: no shared Position, no lock needed
}
public void Dispose() => _handle.Dispose();
}
// Sequential scan with a larger buffer and a read-ahead hint
await using var fs = new FileStream("/data/events.log", new FileStreamOptions
{
Mode = FileMode.Open,
Access = FileAccess.Read,
Share = FileShare.Read,
BufferSize = 1024 * 1024,
Options = FileOptions.SequentialScan | FileOptions.Asynchronous
});
using var reader = new StreamReader(fs);
while (await reader.ReadLineAsync(ct) is { } line) Handle(line);
// Memory-mapped lookup in a read-mostly index
using var mmf = MemoryMappedFile.CreateFromFile("/data/index.bin", FileMode.Open, null, 0,
MemoryMappedFileAccess.Read);
using var view = mmf.CreateViewAccessor(0, 0, MemoryMappedFileAccess.Read);
long offset = 1_234_567L * 16;
long key = view.ReadInt64(offset);
long value = view.ReadInt64(offset + 8);
FileStream across threads for random reads with Seek + Read. The single Position causes races, so people add a lock and serialise all I/O. Instead: use RandomAccess.Read/ReadAsync with an explicit offset on a SafeFileHandle.Access pattern often matters more than code speed. I first ask whether the workload is sequential, random, or can be made sequential (sort, batch, append-only). Databases, Kafka and LSM stores all win by turning random writes into sequential ones, and the same trick works for application files.
For genuinely random reads, the modern .NET answer is RandomAccess on a SafeFileHandle: offset-based, lock-free across threads, and able to keep an SSD's queue busy. Memory-mapped files are great for read-mostly data that fits in the page cache, but they hide I/O inside page faults, which I monitor. And in the cloud, I check the disk tier's IOPS and throughput caps before blaming code; many "slow file" incidents are simply throttled disks.
A page fault is a CPU exception raised when code touches a virtual address whose page is not currently mapped to physical RAM for that process. The kernel handles it:
Minor faults are normal. A burst of major faults means the working set does not fit in RAM, and latency spikes follow.
CPUs load memory in cache lines of 64 bytes, so after one access the neighbouring bytes are already in cache (spatial locality), and recently used data is likely still there (temporal locality). Hardware prefetchers also spot sequential access and fetch ahead. A C# int[,] is stored row-major: one row's elements are next to each other. Walking row by row uses every byte of each line and the prefetcher keeps up; walking column by column jumps a whole row each step, wasting most of each line and missing cache, often 5โ10ร slower for large arrays. The same logic favours arrays of structs over lists of objects scattered on the heap, and struct-of-arrays layouts for hot numeric fields.
Never load the file. Stream it: open with a large buffer and SequentialScan, parse line by line with a fast CSV parser (or PipeReader + UTF-8 parsing), and push rows into a bounded Channel so the reader cannot outrun the writers. Insert in batches (e.g. 10,000โ50,000 rows) with SqlBulkCopy into a staging table, then merge into the real table in one transaction.
Make it resumable (checkpoint byte offset or line number per committed batch), send bad rows to a reject file with line numbers, make the load idempotent (file hash, staging swap), and emit progress metrics. Memory stays at a few hundred MB regardless of file size.
How to tell it (STAR)
**S:** Our [pricing service] had [300 ms] p99 even though CPU and disk looked quiet. **T:** I had to get it under [50 ms] before [a campaign launch]. **A:** Tracing showed every lookup waiting on a lock around one shared `FileStream`, so the SSD only ever had one read in flight. I switched to `RandomAccess.ReadAsync` with explicit offsets, removed the lock and checked the disk tier limits. **R:** p99 fell to [15 ms] and throughput rose [8x] on the same hardware.
Last updated 23 Oct 2026.