Module 10 — I/O, the Block Layer & iowait
Updated 22 August 2026
Every slow thing in the last two modules ended at a disk. This module opens that up: what happens between a write() and the storage device, why iowait is one of the most misleading numbers in Linux, and how to tell a disk that is genuinely saturated from one that is merely busy.
🧠 concept → 🧩 real-world analogy → 🧪 exercise → ✅ expected result (hidden) → 🎯 interview questions (hidden)
From Module 03 — file descriptors, inodes, filesystems and mounts.
From Module 07 — the us/sy/wa split in top and vmstat, that wa is a kind of idle, and how to read /proc/pressure/.
From Module 08 — major page faults, and that a major fault means waiting on storage.
From Module 09 — the page cache, dirty pages, writeback, and fsync. This module is the other side of all of them.
🪢 Part A · From write() to the device
A1 · The layers, and what each one is for
When a program calls write(), a surprising number of things happen before anything reaches a disk — and on most calls, nothing reaches a disk at all. Knowing the layers matters because each one has its own metrics, and measuring at the wrong layer is how most disk investigations go wrong.
| Layer | What it does | Where you measure it |
| System call | read, write, fsync cross into the kernel | strace -c, application latency |
| VFS | Turns "this file descriptor" into "this filesystem, this inode" | rarely measured directly |
| Page cache | Serves reads from RAM; absorbs writes as dirty pages | Cached, Dirty — Module 09 |
| Filesystem | Maps file offsets to device blocks; journals metadata | ext4/xfs mount options, journal settings |
| Block layer | Queues, merges and orders requests | iostat -x, /proc/diskstats, /sys/block/*/queue/ |
| Driver + device | Actually moves the bytes | smartctl, device-side metrics |
Diagram source
flowchart TD
A["write() in your program"] --> B["VFS<br>which filesystem, which inode"]
B --> C{"Page cache"}
C -->|"write"| D["Mark pages dirty<br>return to the program NOW"]
C -->|"read hit"| E["Copy from RAM<br>no device involved"]
D --> F["Writeback later<br>or on fsync"]
C -->|"read miss"| F
F --> G["Filesystem<br>file offset to device blocks"]
G --> H["Block layer<br>queue, merge, order"]
H --> I["Driver"]
I --> J["Device"]You hand a parcel to the office post room and walk away. From your point of view the parcel is sent. That is write() returning.
The post room holds it in a tray, and periodically a van takes a load to the depot. The van does not leave for each parcel — it waits until it has a load, and it groups parcels going to the same place. That is writeback and request merging.
The depot sorts by destination and decides what goes on which lorry in what order. That is the block layer and its scheduler.
And only then does anything actually travel.
Now the important part: if someone asks "how fast is our post?", the answer depends entirely on where they are standing. In your office it is instant. At the depot it is hours. Measuring at the wrong point gives a confident, precise, useless number — which is what happens when a team benchmarks storage with dd and no fsync.
Where the analogy stops working. You could walk down and check on your parcel. A program has no visibility below the syscall it made, which is why these layers have to expose their own counters.
🧪 Exercise A1.1 — Watch a read stop at each layer
# We need a file bigger than a trivial cache hit
dd if=/dev/zero of=/tmp/layers bs=1M count=256 status=none
sync
# Counters for the device holding /tmp, before and after each read.
# Field 3 of /proc/diskstats is the device name, field 6 is sectors read.
SRC=$(findmnt -no SOURCE --target /tmp | sed 's/\[.*\]//')
DEV=$(lsblk -no PKNAME "$SRC" 2>/dev/null | head -1)
[ -z "$DEV" ] && DEV=$(lsblk -no KNAME "$SRC" 2>/dev/null | head -1)
echo "device: $DEV"
sectors() { awk -v d="$DEV" '$3==d {print $6}' /proc/diskstats; }
# --- cold read: this must reach the device ---
sync; sudo sh -c 'echo 3 > /proc/sys/vm/drop_caches'
BEFORE=$(sectors)
cat /tmp/layers > /dev/null
AFTER=$(sectors)
echo "cold read moved $(( (AFTER - BEFORE) / 2 )) KiB from the device"
# --- warm read: identical command ---
BEFORE=$(sectors)
cat /tmp/layers > /dev/null
AFTER=$(sectors)
echo "warm read moved $(( (AFTER - BEFORE) / 2 )) KiB from the device"
# --- and how many syscalls each one made ---
strace -c -f -e trace=read,write cat /tmp/layers > /dev/null 2>/tmp/sc
tail -6 /tmp/sc
rm -f /tmp/layers /tmp/sc✅ Expected result — click to reveal
device: vda
cold read moved 262472 KiB from the device
warm read moved 0 KiB from the device
% time seconds usecs/call calls errors syscall
------ ----------- ----------- --------- --------- ----------------
72.65 0.053882 26 2050 read
27.35 0.020289 9 2048 write
------ ----------- ----------- --------- --------- ----------------
100.00 0.074171 18 4098 totalWhat to read out of this.
The cold read moved 262472 KiB — the 256 MiB file, plus a little, because readahead always overshoots the end slightly. The warm read moved 0 KiB. Same command, same file, same amount of data delivered to cat, and the second time the block layer was not involved at all.
That is the single most important fact about measuring I/O: application activity and device activity are different quantities, and the page cache is what separates them. A service can be doing gigabytes of reads per second with a completely idle disk, and a service can be doing almost no reads while hammering the disk.
Now the syscall counts. cat made 2050 reads and 2048 writes to move 256 MiB. That is 128 KiB per call — the buffer size GNU coreutils picks for this. Divide the work differently and the numbers change enormously: a program reading a byte at a time would make 268 million calls for the same data, at roughly a microsecond of kernel-crossing overhead each, and would look like a CPU problem rather than an I/O one.
Sectors are always 512 bytes in /proc/diskstats, regardless of the device's real block size, which is why the script divides by two to get kilobytes. Getting this wrong by a factor of eight is a common error when people write their own collectors.
If strace is not installed, sudo apt-get install -y strace. If it refuses to attach, check kernel.yama.ptrace_scope — the same restriction you met in Module 01.
A2 · The block layer — queueing, merging, and blk-mq
The block layer sits between filesystems and device drivers, and it exists to solve one problem: the filesystem asks for small, scattered pieces, and devices are far more efficient with large, ordered ones.
Two objects to know:
- A bio is one request from above: "read these blocks into these pages". Filesystems produce them constantly.
- A request is what the device will actually be handed. The block layer builds requests out of bios, and where two bios touch adjacent blocks it merges them into one.
Merging is the block layer's biggest single contribution. Sixteen separate 4 KiB writes to consecutive blocks become one 64 KiB write, and the device does a fraction of the work. You can see it happening: rrqm/s and wrqm/s in iostat are exactly this, and on a sequential workload they are often larger than the request counts themselves.
blk-mq — the multi-queue block layer — is the modern design and the only one left; the old single-queue path was removed in Linux 5.0. It gives each CPU its own software queue, which are then mapped onto however many hardware queues the device exposes. A SATA drive has one hardware queue. An NVMe device can have dozens, one per CPU. That single difference explains most of what follows in this module, including why %util is meaningless on NVMe.
The depot from Section A1 has loading bays. Parcels arrive continuously from all over the building, one at a time, in no particular order.
Nobody carries them out one by one. The dispatcher groups them: everything going to the same street is stacked on one pallet and loaded once. That is merging, and it is why a hundred small writes to one region cost far less than a hundred writes scattered across the disk.
The number of bays is the interesting part. An old depot has one bay, so everything must be sequenced through it and the order matters enormously — put the wrong pallet first and everyone else waits. A modern depot has thirty-two bays working simultaneously, and careful sequencing buys you almost nothing; the only thing that matters is keeping the bays fed.
That is exactly the difference between a SATA disk and an NVMe device, and it is why the clever ordering algorithms that were essential in 2005 are switched off by default in 2026.
Where the analogy stops working. A depot dispatcher can see the whole yard. The block layer cannot see inside the device, which does its own reordering internally and never reports it.
🧪 Exercise A2.1 — Look at your device's real queue configuration
# What block devices exist, and what kind are they?
lsblk -o NAME,ROTA,TYPE,SIZE,MOUNTPOINTS
# Pick the device holding /
# Find the whole-disk device behind /. lsblk PKNAME gives the parent for a
# partition and prints an EMPTY line (exit 0) for a whole disk, so fall back
# to KNAME. Stripping trailing digits does not work: nvme0n1p1 would become
# nvme0n1p, which is not a device.
SRC=$(findmnt -no SOURCE / | sed 's/\[.*\]//')
DEV=$(lsblk -no PKNAME "$SRC" 2>/dev/null | head -1)
[ -z "$DEV" ] && DEV=$(lsblk -no KNAME "$SRC" 2>/dev/null | head -1)
echo "device: $DEV"
echo "device: $DEV"
# The queue settings that actually matter
for f in rotational nr_requests read_ahead_kb max_sectors_kb \
logical_block_size physical_block_size nomerges scheduler; do
printf '%-22s %s\n' "$f" "$(cat /sys/block/$DEV/queue/$f 2>/dev/null)"
done
# How many hardware queues does the device expose?
ls -d /sys/block/$DEV/mq/* 2>/dev/null | wc -l
# Live merge statistics: are requests being combined?
which iostat || sudo apt-get install -y sysstat
iostat -x 1 3 $DEV | tail -6✅ Expected result — click to reveal
On a typical cloud VM with a virtio disk:
NAME ROTA TYPE SIZE MOUNTPOINTS
vda 0 disk 40G
└─vda1 0 part 40G /
device: vda
rotational 0
nr_requests 256
read_ahead_kb 128
max_sectors_kb 512
logical_block_size 512
physical_block_size 512
nomerges 0
scheduler none [mq-deadline] kyber bfq
1
Device r/s rkB/s rrqm/s %rrqm r_await rareq-sz w/s wkB/s wrqm/s %wrqm w_await wareq-sz aqu-sz %util
vda 0.00 0.00 0.00 0.00 0.00 0.00 41.00 892.00 68.00 62.39 1.12 21.76 0.05 3.20What to read out of this.
ROTA 0 says the device is not rotational, and rotational 0 in sysfs agrees. That is what the kernel uses to decide whether to bother with seek-aware behaviour.
scheduler none [mq-deadline] kyber bfq — the brackets mark the active one, and the rest are available. mq-deadline here, and the line above tells you why: this virtio device exposes exactly one hardware queue, which is the condition for getting a scheduler at all. An NVMe device on the same machine would show [none].
nr_requests 256 is how many requests the block layer will queue per hardware queue before making the submitter wait. Raise it and you get more merging and more throughput at the cost of longer worst-case latency; lower it for a latency-sensitive workload. It is one of the few genuinely useful storage knobs and almost nobody touches it.
read_ahead_kb 128 means a sequential read triggers the kernel to fetch 128 KiB ahead speculatively. This is why sequential reads are so much faster than random ones even on an SSD, and why a database doing random reads sometimes wants it lowered — the readahead is wasted work.
Now the merge columns. wrqm/s 68.00 against w/s 41.00: more requests were merged away than were finally issued. %wrqm 62.39 says 62% of the writes that arrived got absorbed into another request. The average issued write, wareq-sz, ended up at 21.76 KiB — far larger than the 4 KiB pieces the filesystem produced. That is the block layer earning its place.
If %wrqm is near zero on a workload you expect to be sequential, either the writes really are scattered, or nomerges has been set to 1 or 2 — which some tuning guides recommend for NVMe and which is worth checking before blaming the application.
A3 · I/O schedulers
An I/O scheduler decides the order in which queued requests are handed to the device, and whether some should wait so that others can go first. On a spinning disk this was worth a great deal, because reordering saved physical seeks. On modern flash it is worth much less, and sometimes less than nothing.
There are three schedulers, plus none, which is the absence of one:
| Scheduler | What it does | Use it when |
| none | First in, first out. Merges requests, orders nothing | NVMe and anything with many hardware queues. The default there |
| mq-deadline | Sorts into read and write batches, with a deadline so nothing starves | Single-queue SATA/SAS, spinning or flash. The default there |
| bfq | Proportional-share fairness between processes and cgroups | Desktops, and where one workload must not monopolise the disk |
| kyber | Targets a latency goal by throttling how much is in flight | Fast devices where you care about tail latency more than throughput |
One counter, a queue of customers, and a member of staff who can choose who to serve next.
If serving each customer means walking to a different part of the shop, order matters enormously: group everyone whose items are near each other and you save hours of walking. That is a spinning disk, and that is what cfq and the old deadline scheduler were for.
If everything is behind the counter and each customer takes the same few seconds, reordering achieves nothing at all and the thinking time is pure overhead. Serve them first-come-first-served and get on with it. That is none on an NVMe device.
mq-deadline adds one rule to the grouping: nobody waits longer than X minutes, no matter how convenient it would be to keep serving the near shelf. That is what stops a stream of reads from starving writes forever.
bfq is a different goal entirely: making sure one customer with a trolley full of items does not stop everyone else being served. Fairness, not throughput.
Where the analogy stops working. A shop assistant knows how long each customer will take. The scheduler is guessing, and on flash devices its guesses are usually worse than not guessing.
🧪 Exercise A3.1 — Read, and safely change, the scheduler
# Find the whole-disk device behind /. lsblk PKNAME gives the parent for a
# partition and prints an EMPTY line (exit 0) for a whole disk, so fall back
# to KNAME. Stripping trailing digits does not work: nvme0n1p1 would become
# nvme0n1p, which is not a device.
SRC=$(findmnt -no SOURCE / | sed 's/\[.*\]//')
DEV=$(lsblk -no PKNAME "$SRC" 2>/dev/null | head -1)
[ -z "$DEV" ] && DEV=$(lsblk -no KNAME "$SRC" 2>/dev/null | head -1)
echo "device: $DEV"
# Current and available. The active one is in [brackets].
cat /sys/block/$DEV/queue/scheduler
# Every block device on the machine at once - useful on a real server
for d in /sys/block/*/queue/scheduler; do
printf '%-12s %s\n' "$(echo $d | cut -d/ -f4)" "$(cat $d)"
done
# Prove the old names are gone. This is meant to fail.
echo noop | sudo tee /sys/block/$DEV/queue/scheduler
echo "exit status: $?"
# Now a real change, and put it back
ORIG=$(sed 's/.*\[\(.*\)\].*/\1/' /sys/block/$DEV/queue/scheduler)
echo "was: $ORIG"
# A real scheduler exposes tunables. Look before switching away.
ls /sys/block/$DEV/queue/iosched/ 2>/dev/null || echo "(no iosched dir)"
echo none | sudo tee /sys/block/$DEV/queue/scheduler > /dev/null
cat /sys/block/$DEV/queue/scheduler
ls /sys/block/$DEV/queue/iosched/ 2>/dev/null || echo "(no iosched dir - none has nothing to tune)"
echo $ORIG | sudo tee /sys/block/$DEV/queue/scheduler > /dev/null
cat /sys/block/$DEV/queue/scheduler✅ Expected result — click to reveal
none [mq-deadline] kyber bfq
vda none [mq-deadline] kyber bfq
loop0 [none] mq-deadline kyber bfq
tee: /sys/block/vda/queue/scheduler: Invalid argument
noop
exit status: 1
was: mq-deadline
async_depth fifo_batch front_merges prio_aging_expire read_expire write_expire writes_starved
[none] mq-deadline kyber bfq
(no iosched dir - none has nothing to tune)
none [mq-deadline] kyber bfqWhat to read out of this.
The deliberate failure first. echo noop returns Invalid argument, because noop has not existed since Linux 5.0. Note what tee does: it prints the error and echoes noop to stdout, so a script that only checks the output would think it worked. The exit status is what tells you, and this is exactly how stale tuning scripts keep "working" for years while changing nothing.
The per-device loop is worth running on any real server. loop0 shows [none] while vda shows [mq-deadline], on the same machine and the same kernel. That is not a misconfiguration: the loop driver asks for none explicitly by setting a flag the block layer honours, BLK_MQ_F_NO_SCHED_BY_DEFAULT. It still lists the other schedulers and will accept them. So the default rule has three parts, not two: a driver can opt out entirely, otherwise one hardware queue gets mq-deadline and several get none.
Switching to none worked immediately, with no remount and no restart. Scheduler changes are live and take effect on the next request. That also means anything with root can change it at any time, including tuning packages and vendor agents, which is why it is worth asserting in configuration management.
Watch the iosched/ directory come and go. While mq-deadline is active it exists, listing read_expire, write_expire, fifo_batch, writes_starved and, on 6.x kernels, async_depth and prio_aging_expire. Those are the deadlines the scheduler enforces — read_expire defaults to 500 ms and write_expire to 5000 ms, which encodes the assumption that a process waiting on a read is blocked and a process that wrote is not. Switch to none and the directory disappears entirely, because there is nothing left to tune.
If your machine shows [none] by default, the device exposes several hardware queues — NVMe is the usual case — or its driver opted out. Both are correct, not a misconfiguration.
🎯 Interview questions — The path and the block layer
Q. Walk me through what happens between a program calling write() and bytes landing on a disk.
The call crosses into the kernel and reaches the VFS, which resolves the file descriptor to a filesystem and an inode. The data is copied into the page cache and those pages are marked dirty, and at that point write() returns — the program is finished and nothing has touched a device. Later, either background writeback or an explicit fsync hands the dirty pages to the filesystem, which maps file offsets to device blocks and may also journal metadata. Those become bios, the block layer merges adjacent ones into requests and queues them, the scheduler decides the order, and the driver hands them to the device.
The details that separate candidates: leading with the fact that most I/O calls involve no I/O at all — a cached read never reaches the block layer and a buffered write returns immediately. That single point explains why application-observed latency and device-observed utilisation routinely disagree, and it is the reason "the app says storage is slow, the storage team says the volume is idle" is such a common standoff with both sides correct. It is also worth naming where you would measure each layer, because measuring the wrong one is what makes these investigations drag.
Q. Which I/O scheduler would you use for an NVMe device, and why?
none, which is already the default. NVMe exposes many hardware queues — often one per CPU — so the device is served in parallel and there is very little to gain from ordering requests. The scheduler's work becomes pure overhead, and on a fast device that overhead is a measurable share of the request's total latency. The block layer still merges adjacent requests under none, so you keep the benefit that actually matters.
mq-deadline is the default for single-queue devices — SATA and SAS — where sequencing still helps and where a deadline is needed so writes are not starved by a stream of reads.
The details that separate candidates: knowing the default is chosen by hardware queue count, not by rotational-versus-flash, which is why a SATA SSD gets a scheduler and an NVMe SSD does not. And knowing that cfq and noop were removed in Linux 5.0, so any runbook still setting them has been failing silently — the write to /sys returns Invalid argument and nothing changes. If asked when you would deviate: bfq when one workload must not monopolise a shared disk, and kyber when tail latency matters more than throughput.
Q. What is request merging, and how would you tell whether it is happening?
The block layer combines bios that address adjacent blocks into a single larger request before handing it to the device. Sixteen 4 KiB writes to consecutive blocks become one 64 KiB write, and the device does a fraction of the work for the same data.
You see it in iostat -x as rrqm/s and wrqm/s — requests merged per second — and as %rrqm/%wrqm, the percentage absorbed. rareq-sz and wareq-sz show the average size of what actually reached the device, which on a sequential workload is far larger than what the filesystem produced.
The details that separate candidates: using the merge percentage as a diagnostic for the workload rather than the device. A workload you believe is sequential but that shows near-zero %wrqm is not sequential in practice — or nomerges has been set to 1 or 2, which some NVMe tuning guides recommend and which is worth checking before rewriting the application. It is also the cleanest way to explain why appending to one file is so much cheaper than scattering the same bytes across a thousand.
📉 Part B · Measuring I/O honestly
B1 · iowait, and why it misleads almost everyone
Module 07 established the rule: iowait is a kind of idle. A CPU is charged an iowait tick when it has nothing to run and at least one task on its run queue is blocked in uninterruptible I/O. Here is why that makes it nearly useless as a storage metric.
Low iowait does not mean the disk is fine. Start any CPU-heavy work on the machine and the CPU always has something to run, so the ticks go to us instead. The disk is exactly as slow as before; iowait collapses to near zero. A busy application server with saturated storage can show 0% iowait all day.
High iowait does not mean the disk is the bottleneck. A lightly loaded machine doing a modest amount of I/O and nothing else will show a large iowait, because there is nothing else for the CPUs to be charged for. It means "these CPUs had nothing better to do", not "the storage is in trouble".
So what should you read instead? Two things, both of which have a device or a task dimension:
| Instead of iowait | Read | Because |
| "Is the storage slow?" | r_await / w_await in iostat -x | Actual per-device latency, in milliseconds |
| "Is anything being harmed?" | /proc/pressure/io | Time genuinely lost to I/O stalls, regardless of CPU load |
| "How many tasks are blocked?" | The b column of vmstat 1 | A direct count of processes in uninterruptible sleep |
You want to know whether the post is arriving slowly. Your chosen metric is: how much of the day does the receptionist spend sitting with nothing to do, while waiting for a delivery?
On a quiet day she waits a lot, so the number is high — and you conclude the post is terrible. It is not. She simply had nothing else on.
On a busy day she is answering phones the whole time, so the waiting number is near zero — and you conclude the post is excellent. The van is just as late as it was yesterday; she was too busy to sit and notice.
Give her a second job and the number collapses. Take one away and it soars. Nothing about the post has changed in either case, and the metric never mentioned which delivery company, which parcel, or how late it was.
If you actually want to know about the post, ask the depot how long a parcel takes (await) and ask how much work was held up waiting (/proc/pressure/io).
Where the analogy stops working. A receptionist could tell you what she was waiting for. iowait genuinely carries no such information — it is a tally of CPU ticks and nothing more.
🧪 Exercise B1.1 — Make iowait vanish without touching the disk
which iostat || sudo apt-get install -y sysstat
NPROC=$(nproc)
# A file large enough that reading it must hit the device
AVAIL=$(awk '/MemAvailable/ {print int($2/1024)}' /proc/meminfo)
FREEDISK=$(df -Pm /tmp | awk 'NR==2 {print $4}')
SZ=$(( AVAIL < FREEDISK - 1024 ? AVAIL : FREEDISK - 1024 ))
dd if=/dev/zero of=/tmp/iotest bs=1M count=$SZ status=none
sync; sudo sh -c 'echo 3 > /proc/sys/vm/drop_caches'
# --- Phase 1: I/O only. Watch iowait rise. ---
echo "--- phase 1: reading, nothing else running ---"
( cat /tmp/iotest > /dev/null ) &
IOPID=$!
# The FIRST data row vmstat prints is an average since boot - ignore it.
vmstat 1 4
grep '^some' /proc/pressure/io
wait $IOPID
sync; sudo sh -c 'echo 3 > /proc/sys/vm/drop_caches'
# --- Phase 2: identical I/O, plus CPU work on every core ---
echo "--- phase 2: same read, with the CPUs busy ---"
( cat /tmp/iotest > /dev/null ) &
IOPID=$!
for i in $(seq 1 $NPROC); do timeout 10 bash -c 'while :; do :; done' & done
vmstat 1 4
grep '^some' /proc/pressure/io
wait
rm -f /tmp/iotest✅ Expected result — click to reveal
--- phase 1: reading, nothing else running ---
procs -----------memory---------- ---swap-- -----io---- -system-- -------cpu-------
r b swpd free buff cache si so bi bo in cs us sy id wa st gu
0 0 0 3512044 41216 293104 0 0 412 88 201 388 2 1 96 1 0 0
0 1 0 3204112 41216 412088 0 0 118784 0 2104 3881 1 4 49 46 0 0
0 1 0 3085200 41216 531000 0 0 118912 0 2211 4012 1 5 48 46 0 0
0 1 0 2965400 41216 650800 0 0 119800 0 2188 3944 1 4 49 46 0 0
some avg10=44.18 avg60=15.02 avg300=4.11 total=88421037
--- phase 2: same read, with the CPUs busy ---
procs -----------memory---------- ---swap-- -----io---- -system-- -------cpu-------
r b swpd free buff cache si so bi bo in cs us sy id wa st gu
0 0 0 3510044 41216 294104 0 0 448 92 208 402 3 1 95 1 0 0
3 1 0 3198844 41216 413992 0 0 117760 0 2088 3902 96 4 0 0 0 0
4 1 0 3081084 41216 531752 0 0 117760 0 2194 4088 95 5 0 0 0 0
3 1 0 2962300 41216 650536 0 0 118784 0 2176 3988 96 4 0 0 0 0
some avg10=43.87 avg60=21.44 avg300=7.02 total=94118204Read the two blocks side by side. This is the whole lesson.
wa was 46 in phase 1 and 0 in phase 2. If iowait measured storage, you would conclude the disk got infinitely faster the moment you started some busy loops.
Now look at bi — kilobytes read in per second, and note that it matches the growth of the cache column exactly, which is a useful sanity check when you are reading someone else's paste. It is 118784, 118912, 119800 in phase 1 and 117760, 117760, 118784 in phase 2. Effectively identical. The disk is doing exactly the same work at exactly the same rate in both phases.
(The first data row in each block is vmstat's since-boot average, not a live sample — that is why its numbers look nothing like the three below it. Everyone misreads this line once.)
Look at the b column, processes blocked in uninterruptible sleep: 1 in both phases. One process is waiting on the disk, throughout, in both. That column told the truth when wa did not.
And /proc/pressure/io: avg10=44.18 in phase 1, avg10=43.87 in phase 2. Essentially unchanged, because PSI measures time actually lost to I/O rather than CPU ticks that happened to be idle. That is the metric to build an alert on.
What changed in phase 2 is only where the idle ticks went: id 49 and wa 46 became us 96. The CPUs stopped being idle, so there were no idle ticks left to classify as iowait.
The production translation. A busy application server with genuinely saturated storage often shows 0% iowait, because its CPUs are never idle. Anyone using iowait as their storage alert will never see it. Conversely, a quiet host doing a nightly backup shows 40% iowait and generates a page for a machine that is perfectly healthy.
B2 · Reading iostat -x properly
iostat -x is the right tool, and its output has changed enough over the years that half the guides on the internet describe columns that no longer exist. Here is the current set.
| Column | What it is | What to do with it |
| r/s w/s | Requests completed per second, after merging | The IOPS the device actually saw |
| rkB/s wkB/s | Kilobytes per second | Throughput. Compare with the device's rated figure |
| rrqm/s wrqm/s | Requests merged away per second | How sequential the workload really is |
| %rrqm %wrqm | Percentage merged | Same, as a ratio |
| r_await w_await | Average milliseconds per request, queue wait included | The number that answers "is the disk slow?" |
| rareq-sz wareq-sz | Average size in KiB of what reached the device | Distinguishes many small I/Os from few large ones |
| aqu-sz | Average queue length | How deep the backlog is. Was called avgqu-sz |
| %util | Percentage of time at least one request was outstanding | Trustworthy only on serial devices. See below |
Alerting on %util > 90 across a fleet of NVMe machines produces constant noise and tells you nothing.
Underneath, everything comes from /proc/diskstats, which is worth knowing directly because it is available with no packages installed. It has 20 whitespace-separated fields: three identity fields (major, minor, name) and 17 statistics. Fields 15–18 (discards) were added in Linux 4.18 and 19–20 (flushes) in 5.5, which is why field counts differ between guides.
You want to know whether the kitchen is overloaded.
%util is: what fraction of the evening was at least one dish being cooked? In a one-chef kitchen that is a fair measure — if something is always cooking, the chef is flat out. In a kitchen with twelve chefs, one dish being prepared keeps the answer at 100% all night while eleven people stand around. The number is technically true and completely uninformative.
await is: how long from order to plate? That is what the diner experiences and it works regardless of how many chefs there are.
aqu-sz is: how many orders are on the rail on average? A rail that keeps growing is the honest sign of a kitchen falling behind.
rareq-sz is the average order size, and it changes the interpretation completely: two hundred single-item orders and twenty banquet orders are very different kitchens with the same order count.
Where the analogy stops working. You can see how many chefs a kitchen has. The block layer cannot see how much parallelism the device really has, which is precisely why %util cannot be fixed.
🧪 Exercise B2.1 — Read a device under three different loads
which iostat || sudo apt-get install -y sysstat
# Find the whole-disk device behind /. lsblk PKNAME gives the parent for a
# partition and prints an EMPTY line (exit 0) for a whole disk, so fall back
# to KNAME. Stripping trailing digits does not work: nvme0n1p1 would become
# nvme0n1p, which is not a device.
SRC=$(findmnt -no SOURCE / | sed 's/\[.*\]//')
DEV=$(lsblk -no PKNAME "$SRC" 2>/dev/null | head -1)
[ -z "$DEV" ] && DEV=$(lsblk -no KNAME "$SRC" 2>/dev/null | head -1)
echo "device: $DEV"
# iostat prints trailing blank lines, so pick the device's own row by name
# rather than counting backwards from the end.
last_row() { iostat -x 1 "$1" "$DEV" | awk -v d="$DEV" '$1==d {l=$0} END {print l}'; }
echo "--- idle ---"
last_row 2
# --- Load 1: one sequential reader ---
echo "--- sequential read ---"
dd if=/dev/zero of=/tmp/seq bs=1M count=2048 status=none
sync; sudo sh -c 'echo 3 > /proc/sys/vm/drop_caches'
( dd if=/tmp/seq of=/dev/null bs=1M status=none ) &
last_row 4
wait
# --- Load 2: the same data, read in small RANDOM pieces ---
# dd cannot do random I/O - it only reads sequentially - and readahead would
# turn small sequential reads into large device requests anyway. Use fio.
echo "--- random small reads ---"
sync; sudo sh -c 'echo 3 > /proc/sys/vm/drop_caches'
if command -v fio >/dev/null; then
( fio --name=rr --filename=/tmp/seq --rw=randread --bs=4k --direct=1 \
--runtime=10 --time_based --output=/dev/null ) &
last_row 4
wait
else
echo "install fio to run this one: sudo apt-get install -y fio"
fi
# --- Load 3: sync writes, where the device cannot cheat ---
echo "--- synchronous writes ---"
( dd if=/dev/zero of=/tmp/synctest bs=4k count=4000 oflag=dsync status=none ) &
last_row 4
wait
rm -f /tmp/seq /tmp/synctest✅ Expected result — click to reveal
Trimmed to the columns that matter, on a cloud VM with a network-backed SSD:
(trimmed to the columns that matter - the real output has 23)
--- idle ---
Device r/s rkB/s rareq-sz r_await w/s wkB/s w_await aqu-sz %util
vda 0.00 0.00 0.00 0.00 1.00 12.00 0.80 0.00 0.20
--- sequential read ---
vda 231.00 236544.00 1024.00 4.31 0.00 0.00 0.00 1.00 99.60
--- random small reads ---
vda 2104.00 8416.00 4.00 0.46 0.00 0.00 0.00 0.97 98.80
--- synchronous writes ---
vda 0.00 0.00 0.00 0.00 604.00 2416.00 1.58 0.95 99.20What to read out of this.
Start with %util, because it is the column everyone looks at. It reads 99.6, 98.8, 99.2 across all three loads. By that number, the device is equally and maximally busy in every case. Now look at what it was actually doing:
- Sequential: 231 requests/s moving 236 MB/s, average request 1024 KiB.
- Random small: 2104 requests/s moving 8 MB/s, average request 4 KiB.
- Sync writes: 604 requests/s moving 2.4 MB/s.
Same %util, and the throughput differs by a factor of nearly a hundred. %util said nothing useful in any of the three cases. All it reported is that a request was outstanding essentially all the time, which one dd is enough to achieve.
Now aqu-sz, which is the honest companion: it is about 1.0 in all three, because each load is a single stream with one request outstanding. One request outstanding on average — exactly one dd with no parallelism. A device with %util 99 and aqu-sz 1 is not saturated; it has one customer who never leaves. Saturation looks like aqu-sz climbing well above the device's parallelism while await rises with it.
Then r_await. Sequential reads averaged 4.31 ms and random reads 0.46 ms — the sequential requests are slower per request because each one is 256 times larger. This is why comparing await between workloads without looking at rareq-sz is meaningless, and why a single "average disk latency" panel misleads.
The sync writes are the interesting case. Only 2.4 MB/s, on a device that just did 236 MB/s. oflag=dsync forces each 4 KiB write to be durable before the next begins, so there is no batching, no merging and no page cache — every write pays the full round trip. That is the number a database's commit path lives on, and it is nowhere near the throughput figure on the datasheet.
If your %util shows well under 100 with high throughput, you have a device with real parallelism and a workload using it — which is the one case where %util is telling you something. It still cannot tell you how much headroom is left.
B3 · Who is doing the I/O
iostat tells you what the device is doing. It never tells you who. For that there are three sources, and the first one is free.
/proc/PID/io gives per-process counters, and the distinction between two pairs of them is the whole point:
| Field | What it counts |
| rchar / wchar | Bytes the process asked for, whether or not a device was involved |
| syscr / syscw | Number of read/write system calls |
| read_bytes / write_bytes | Bytes that actually went to or from a block device |
| cancelled_write_bytes | Bytes written and then deleted before writeback — work avoided |
A process with a huge rchar and a read_bytes of zero is reading entirely from the page cache and costing the disk nothing. A process whose read_bytes tracks its rchar is missing the cache on everything. That comparison is the single most useful per-process I/O measurement, and almost nobody makes it.
Above that, cgroup v2 gives per-workload numbers, which is what you want on a container host:
- io.stat — bytes and IOPS per device, per cgroup
- io.pressure — PSI, scoped to that cgroup: how much time this workload lost to I/O
- io.max — a hard throttle, in bytes/s and IOPS, that works with any scheduler
- io.weight — proportional share
The printer's own counter says 40,000 pages this month. It cannot tell you who printed them.
rchar versus read_bytes is the difference between pages requested and pages actually printed. Someone who opens the same document forty times has requested a great deal and printed nothing, because it was already on their desk. Someone who prints one copy of everything they open is generating real work for the machine. On the printer's counter they look identical; the distinction is the entire cost.
cancelled_write_bytes is the person who sends a job and cancels it before it prints — work that was requested and never cost anything.
And the timing quirk: the person who sent the job may have gone home before the printer gets to it. If you match "who was at their desk when the printer was busy" you will blame whoever happened to be there.
Where the analogy stops working. A printer job carries a username. A block request carries nothing by the time it reaches the device, which is why the attribution has to be done on the way down and cannot be reconstructed afterwards.
🧪 Exercise B3.1 — Attribute I/O to a process, then to a cgroup
# Make a file and a process that reads it repeatedly
dd if=/dev/zero of=/tmp/attrib bs=1M count=512 status=none
sync; sudo sh -c 'echo 3 > /proc/sys/vm/drop_caches'
# A shell accumulates the I/O counters of every child it has reaped, so
# reading /proc/$$/io after each pass gives a clean running total.
# (Reading a still-running child's own file would work too, but you would
# have to catch it mid-flight.)
for pass in 1 2 3; do
cat /tmp/attrib > /dev/null
echo "--- after pass $pass ---"
grep -E '^(rchar|syscr|read_bytes):' /proc/$$/io
done
# Machine-wide, live, by process
which iotop || sudo apt-get install -y iotop
sudo iotop -b -o -n 2 -d 2 2>/dev/null | head -12
# Per-cgroup, if this host uses cgroup v2
stat -fc %T /sys/fs/cgroup
sudo cat /sys/fs/cgroup/io.stat 2>/dev/null | head -3
sudo cat /sys/fs/cgroup/io.pressure 2>/dev/null
rm -f /tmp/attrib✅ Expected result — click to reveal
--- after pass 1 ---
rchar: 536918124
syscr: 4183
read_bytes: 536870912
--- after pass 2 ---
rchar: 1073789036
syscr: 8279
read_bytes: 536870912
--- after pass 3 ---
rchar: 1610659948
syscr: 12375
read_bytes: 536870912
Total DISK READ: 172.44 M/s | Total DISK WRITE: 0.00 B/s
Current DISK READ: 172.44 M/s | Current DISK WRITE: 0.00 B/s
TID PRIO USER DISK READ DISK WRITE SWAPIN IO COMMAND
8412 be/4 root 172.44 M/s 0.00 B/s ?unavailable? cat /tmp/attrib
cgroup2fs
252:0 rbytes=1610612736 wbytes=48234496 rios=39204 wios=8812 dbytes=0 dios=0
some avg10=38.44 avg60=12.10 avg300=3.02 total=118204773
full avg10=31.02 avg60=9.88 avg300=2.44 total=98412037What to read out of this.
Follow the two counters. After pass 1 they are almost identical: 536918124 requested, 536870912 read from the device. The cache had just been dropped, so every byte came off the disk.
After pass 3 rchar has reached 1610659948 — the full three passes over the 512 MiB file — while read_bytes has not moved since pass 1. The file was read three times and the device delivered it once. Passes two and three were served entirely from the page cache.
That gap is the value of the cache, measured per process, and you can compute it from one file with no tooling at all: 1 − read_bytes/rchar is this process's cache hit ratio by volume. Here it is 67%, and it climbs towards 100% the more times the data is reused.
syscr rising by about 4096 per pass for 512 MiB works out at 128 KiB per call — the buffer GNU cat uses. Divide the same bytes into 4 KiB calls and each pass would take 131,072 syscalls instead, with the kernel-crossing cost from Module 01 paid on every one.
One subtlety worth keeping: these counters belong to the shell, not to cat. A parent inherits the I/O counters of each child it reaps, which is why the totals accumulate cleanly here — and why, on a real system, a supervisor process can appear to be doing I/O that was actually done by short-lived children it has already collected.
Then iotop -b -o -n 2: -o shows only processes actually doing I/O, -b is batch mode so it works through a pipe with no terminal. It attributes 172 MB/s to one cat. Useful — but note two things. It reports threads (TID), so a multi-threaded database appears as many rows and you have to sum them yourself. And the SWAPIN and IO columns read ?unavailable?: those need per-task delay accounting, which has been off by default since Linux 5.14. sudo sysctl -w kernel.task_delayacct=1 turns it on and the columns start working; DISK READ and DISK WRITE work either way.
Finally the cgroup view. io.stat is keyed by major:minor — 8:0 is the whole device — and gives bytes and IOPS in both directions. On a container host, reading the same file inside each container's cgroup directory tells you which workload is generating the load, which no per-device metric can do. (How a container gets its own cgroup, and how to map one back to a pod name, is Module 12.)
And io.pressure gives the honest answer to "is this hurting": full avg10=31.02 means that for 31% of the last ten seconds, every runnable task in this cgroup was stalled on I/O.
If /proc/$READER/io is empty or permission-denied, you are reading a subshell rather than the cat; the fallback in the script finds the child. Reading another user's io file needs root.
🎯 Interview questions — Measuring I/O
Q. How do you check for excessive I/O wait in Linux?
I would start by not using iowait for it. iowait is a kind of idle — a CPU is charged an iowait tick only when it has nothing to run and something on it is blocked on I/O — so it falls to zero the moment the machine has other work, and it rises on a quiet machine doing modest I/O. It has no device dimension and no process dimension.
What I actually run: /proc/pressure/io for how much time was genuinely lost to I/O stalls, iostat -x 1 for per-device r_await/w_await and aqu-sz, and the b column of vmstat 1 for a straight count of tasks blocked in uninterruptible sleep. Then iotop -o or /proc/PID/io to attribute it.
The details that separate candidates: being able to state the failure mode in both directions. A busy application server with saturated storage often shows 0% iowait, because its CPUs are never idle — so an iowait-based alert will never fire on the case you care about. And a host running a nightly backup shows 40% iowait while being perfectly healthy. Quoting the kernel's own documentation helps: /proc documentation says outright that "the iowait is not reliable by reading from /proc/stat".
Q. iostat shows %util at 100%. Is the disk saturated?
Not necessarily, and on a modern device almost certainly not. %util is the percentage of time during which at least one request was outstanding. On a spinning disk, which serves one request at a time, that is a real utilisation figure. On an NVMe device with dozens of hardware queues, a single outstanding request out of a possible sixty-four keeps %util at 100% while the device is nearly idle. The iostat man page says so explicitly.
To decide whether it is really saturated I would look at aqu-sz — the average queue depth — together with r_await/w_await. Saturation looks like the queue growing beyond what the device can serve in parallel and latency rising with it. %util 100 with aqu-sz 1 is one process doing steady I/O, nothing more.
The details that separate candidates: giving the concrete demonstration — the same device can show 99% %util while delivering 236 MB/s sequentially, 16 MB/s in 4 KiB random reads, and 3 MB/s of synchronous writes. Seventy times the difference in real work, identical %util. It is also worth mentioning that svctm was removed from iostat in sysstat 12.1.2 and avgqu-sz renamed to aqu-sz, so any runbook still parsing those columns has been broken for years.
Q. How do you find which process is causing disk I/O?
iotop -o gives a live view, showing only threads currently doing I/O. For something scriptable and needing no packages, /proc/PID/io has read_bytes and write_bytes, which count bytes that actually reached a block device. On a container host, cgroup v2 io.stat attributes bytes and IOPS per cgroup, which is the only way to name the workload responsible.
The details that separate candidates: the comparison between rchar and read_bytes in the same file. rchar is what the process asked for; read_bytes is what came off the device. A process with a large rchar and near-zero read_bytes is being served entirely by the page cache and is costing the disk nothing — and 1 − read_bytes/rchar is a per-process cache hit ratio you can compute from two lines of a file.
The other detail is a caveat: write_bytes is charged to whoever dirtied the pages, not to the thread that wrote them out, and writeback happens later. So a process can exit before its writes reach the device, and per-process write graphs will not line up with the device graph. That is why iotop often attributes bulk write throughput to kernel threads.
⏱️ Part C · Latency, durability and the filesystem
C1 · Where the time in a request actually goes
await is one number covering two very different things, and separating them is what turns "the disk is slow" into an actionable statement.
Service time is how long the device took once it had the request. It is a property of the hardware and of the request size.
Queue time is how long the request sat waiting because other requests were ahead of it. It is a property of how much you are asking for, not of the device.
The relationship is the one every queueing system obeys: as arrival rate approaches what the device can serve, queue time does not rise gently — it rises without limit. A device comfortably handling 4,000 IOPS at 1 ms will, at 4,500 IOPS, show latencies of tens of milliseconds. Nothing broke. You crossed the knee.
One till. Each customer takes exactly two minutes to serve, all day, without variation. That is service time, and it never changes.
At 25 customers an hour, you walk up and are served: about two minutes door to door. At 29 an hour, still fine — perhaps four minutes with the occasional short wait. At 30 an hour, the till's exact capacity, the queue never clears and your wait depends entirely on when you arrived. At 31, the queue grows all day and by evening it is out of the door.
The till has not slowed down by one second in any of those cases. Every customer still takes two minutes to serve. The thing that exploded was the waiting, and it exploded over a 3% change in arrivals.
This is why "the disk got slower" is usually the wrong description. The disk is doing exactly what it always did; the queue in front of it grew.
And it is why watching the queue is worth more than watching the wait: the queue starts growing before anybody notices the wait.
Where the analogy stops working. Supermarket customers see the queue and go elsewhere. Requests do not — they keep arriving at the same rate, which is what makes the growth unbounded rather than self-limiting.
🧪 Exercise C1.1 — Push a device past its knee and watch the queue, not the latency
which iostat || sudo apt-get install -y sysstat
# Find the whole-disk device behind /. lsblk PKNAME gives the parent for a
# partition and prints an EMPTY line (exit 0) for a whole disk, so fall back
# to KNAME. Stripping trailing digits does not work: nvme0n1p1 would become
# nvme0n1p, which is not a device.
SRC=$(findmnt -no SOURCE / | sed 's/\[.*\]//')
DEV=$(lsblk -no PKNAME "$SRC" 2>/dev/null | head -1)
[ -z "$DEV" ] && DEV=$(lsblk -no KNAME "$SRC" 2>/dev/null | head -1)
echo "device: $DEV"
dd if=/dev/zero of=/tmp/knee bs=1M count=1024 status=none
sync
# Run the same read with increasing parallelism and watch what moves.
for N in 1 2 4 8 16; do
sync; sudo sh -c 'echo 3 > /proc/sys/vm/drop_caches'
for i in $(seq 1 $N); do
# iflag=direct matters: without it readahead coalesces these into large
# sequential requests and you never see 4 KiB IOPS. Note skip and count
# are in bs units here - adding iflag=count_bytes would make them BYTES.
( dd if=/tmp/knee of=/dev/null bs=4k skip=$(( (i - 1) * 8000 )) count=8000 \
iflag=direct status=none ) &
done
# let it settle, then take one sample. Pick the device's row by name:
# iostat prints trailing blank lines, so counting from the end breaks.
sleep 1
echo -n "parallel=$N "
iostat -x 1 2 "$DEV" | awk -v d="$DEV" '$1==d {l=$0} END {print l}' | \
awk '{printf "r/s=%-8s r_await=%-7s aqu-sz=%-6s %%util=%s\n", $2, $6, $(NF-1), $NF}'
wait
done
rm -f /tmp/knee✅ Expected result — click to reveal
On a cloud VM with a network-attached SSD rated around 3,000 IOPS:
parallel=1 r/s=1204.00 r_await=0.79 aqu-sz=0.95 %util=96.40
parallel=2 r/s=2288.00 r_await=0.85 aqu-sz=1.94 %util=99.20
parallel=4 r/s=2941.00 r_await=1.34 aqu-sz=3.94 %util=99.60
parallel=8 r/s=3012.00 r_await=2.62 aqu-sz=7.89 %util=99.80
parallel=16 r/s=3018.00 r_await=5.24 aqu-sz=15.82 %util=99.80What to read out of this, column by column.
r/s climbs from 1204 to 2941 as you add readers, and then stops: 3012 at eight readers, 3018 at sixteen. That is the device's service rate, and no amount of extra parallelism moves it. You have found the knee.
r_await tells the story of what that costs. It barely moves from one reader to two — 0.79 ms to 0.85 ms — because the device had spare capacity. From four to sixteen readers it goes 1.34 → 2.62 → 5.24 ms, roughly doubling each time you double the load, while throughput does not improve at all. Every millisecond added after parallel=4 bought nothing.
aqu-sz is the cleanest signal in the table: 0.95, 1.94, 3.94, 7.89, 15.82. It tracks the number of outstanding requests almost exactly, and it started rising immediately — long before latency became noticeable. This is the metric that gives you warning.
And %util is 96, 99, 99, 99, 99 throughout. It was effectively pinned from the very first run, when the device had two and a half times more capacity available. It never once indicated how much headroom was left.
The general rule this demonstrates: r_await × r/s ≈ aqu-sz. At parallel=8, 2.62 ms × 3012/s = 7.9, which is the observed queue depth. When you see queue depth rising while throughput is flat, latency is being manufactured by queueing and adding load will only make it worse.
If your numbers keep climbing past 16 with no latency penalty, you have a device with more parallelism than this one — raise the parallelism until r/s plateaus. The shape of the curve will be the same.
C2 · Durability, and the cost of each level of it
Module 09 established that a buffered write() is not durable. There is a spectrum of ways to make it so, and each costs something different:
| Mode | What it guarantees | What it costs |
| Buffered (the default) | Nothing. Data is in the page cache | Nothing. Fastest, and loses data on power failure |
| fsync() at intervals | Everything up to the call is on stable storage | One round trip per call, amortised over many writes |
| fdatasync() | The data, but not necessarily the metadata | Slightly cheaper than fsync — skips an inode update |
| O_DSYNC / O_SYNC | Every single write is durable before it returns | A round trip per write. Brutal for small writes |
| O_DIRECT | Bypasses the page cache entirely | Not a durability mode. No caching, and strict alignment rules |
The other trap: O_DIRECT requires the buffer, the file offset and the length to be aligned to the device's logical block size. Get it wrong and the call fails with EINVAL, which surfaces in applications as a baffling "invalid argument" on a perfectly ordinary write.
Alongside all of this sits io_uring, the modern asynchronous I/O interface. Instead of one system call per operation, the program and the kernel share two ring buffers in memory — a submission queue and a completion queue — so thousands of operations can be issued and reaped with very few kernel crossings. For an I/O-heavy service that is a large win, and it is why databases and proxies have been adopting it.
Buffered is dropping the letter in the office out-tray and walking away. Instant, and you have no idea when or whether it went.
fsync at intervals is walking a stack of letters to the post office once an hour and waiting for the receipt. One trip covers a great deal of post, so the cost per letter is small.
O_DSYNC is walking to the post office and waiting for a receipt after every single letter. The letters are no bigger and the post office is no slower — but you now spend your entire day walking. This is why the same device writes at 1.6 GB/s buffered and under 3 MB/s with 4 KiB oflag=dsync.
O_DIRECT is refusing to use the out-tray at all and handing letters straight to the courier. Useful if you keep your own records and the office tray only duplicates them — which is exactly a database with its own buffer pool. But handing a letter to a courier is not a receipt: the courier may still be holding it. You still need the signature.
io_uring is a conveyor to the post office: you keep loading letters on without walking anywhere, and receipts come back on a second belt. The letters take the same time; you stop spending your day on the trip.
Where the analogy stops working. You could ask the courier where your letter is. A program has no way to see inside the device's write cache, which is why the flush command exists at all.
🧪 Exercise C2.1 — Measure the price of each durability level
SZ=256 # MiB
echo "--- buffered: no durability guarantee at all ---"
time dd if=/dev/zero of=/tmp/d1 bs=1M count=$SZ status=none
rm -f /tmp/d1
echo "--- one fsync at the end ---"
time dd if=/dev/zero of=/tmp/d2 bs=1M count=$SZ conv=fsync status=none
rm -f /tmp/d2
echo "--- O_DIRECT: bypass the page cache ---"
time dd if=/dev/zero of=/tmp/d3 bs=1M count=$SZ oflag=direct status=none
rm -f /tmp/d3
echo "--- O_DSYNC, 1 MiB writes: durable per write ---"
time dd if=/dev/zero of=/tmp/d4 bs=1M count=$SZ oflag=dsync status=none
rm -f /tmp/d4
echo "--- O_DSYNC, 4 KiB writes: the commit path of a database ---"
time dd if=/dev/zero of=/tmp/d5 bs=4k count=4000 oflag=dsync status=none
rm -f /tmp/d5
echo "--- is io_uring available on this host? ---"
sysctl kernel.io_uring_disabled 2>/dev/null || echo "sysctl absent (kernel older than 6.6)"
grep -q ' io_uring_setup$' /proc/kallsyms && echo "io_uring compiled in" || echo "not present"✅ Expected result — click to reveal
(bash's `time` also prints `user` and `sys` lines; trimmed here)
--- buffered: no durability guarantee at all ---
real 0m0.161s
--- one fsync at the end ---
real 0m1.284s
--- O_DIRECT: bypass the page cache ---
real 0m1.902s
--- O_DSYNC, 1 MiB writes: durable per write ---
real 0m2.106s
--- O_DSYNC, 4 KiB writes: the commit path of a database ---
real 0m6.618s
kernel.io_uring_disabled = 0
io_uring compiled inWhat to read out of this.
Buffered wrote 256 MiB in 0.161 s — about 1.6 GB/s, which is the speed of memory, not of storage. Every number below it is closer to the truth.
One fsync at the end took 1.284 s, so the honest sequential write throughput is around 200 MB/s. Eight times slower than the buffered figure, and this is the number to quote if anyone asks what the disk does.
O_DIRECT at 1.902 s is slower than buffered-plus-fsync, which surprises people. Bypassing the cache removes the kernel's ability to batch and merge on your behalf, so unless the application does its own batching — as a database does — you lose more than you gain. O_DIRECT is not a performance flag; it is a control flag.
O_DSYNC with 1 MiB writes: 2.106 s. Each of the 256 writes waits for durability, so you pay 256 round trips instead of one — but each round trip carries a megabyte, so the overhead is diluted.
And then the last line, where it is not diluted at all. O_DSYNC with 4 KiB writes: 4,000 writes totalling only 16 MiB, in 6.6 seconds — about 2.4 MB/s, or 604 writes per second, on a device that wrote at 1.6 GB/s a moment ago. That is a factor of roughly 660, and nothing about the hardware changed. Every write paid a full round trip of about 1.6 ms and none of them could be merged. Note that this figure matches the synchronous-write row in Exercise B2.1 exactly, which is the point: it is a property of the device's per-request latency, not of the tool.
This is the single most important I/O number for anyone running a database. Commit latency lives here, not on the throughput figure in the datasheet, and it is why "the volume is rated for 500 MB/s" tells you nothing about how many transactions per second you will get.
kernel.io_uring_disabled = 0 means unrestricted, which is the Debian and Ubuntu default. On RHEL 9 you would see 2 — disabled for every process. If the sysctl does not exist at all, the kernel predates 6.6 and io_uring is simply on if compiled in.
C3 · What the filesystem adds
Between the page cache and the block layer sits a filesystem, and it does work of its own that shows up as device I/O nobody asked for. Three sources account for most of it.
Journalling. To survive a crash mid-update, ext4 and XFS write a record of what they are about to do before doing it. The default on ext4 is data=ordered: metadata is journalled, file data is not, but data is forced out before the metadata that references it. That ordering guarantee is what stops a crash leaving a file pointing at somebody else's old blocks. It costs extra writes, and on a metadata-heavy workload — many small files created and deleted — the journal can be a significant share of all device traffic.
Access-time updates. Reading a file traditionally updated its atime, turning every read into a write. Modern systems mount with relatime by default, which updates atime only if the stored value is not newer than the file's mtime or ctime, or is more than a day old. noatime removes it entirely. On a read-heavy workload with millions of files this is a real difference, and it is one of the few mount options genuinely worth setting.
Discard. SSDs need to be told when blocks are no longer in use. mount -o discard issues a discard on every delete, which can add latency to the deleting process; the modern alternative is the weekly fstrim.timer, which does it in one batch. Most distributions now ship the timer and leave discard off, and that is the right arrangement.
Beyond moving stock, the warehouse keeps records, and the records cost real work.
The journal is writing "about to move pallet 42 to bay 9" in a logbook before moving it. If the power fails halfway, the next shift reads the logbook and finishes or undoes the job. Nobody enjoys the extra writing, but without it a crash leaves stock in an unknown state.
atime is stamping a card every time anyone so much as looks at a shelf. Reading becomes writing. relatime is the compromise of only re-stamping if the card is more than a day old, which is why it is the default nearly everywhere.
Discard is telling the recycling company which pallets are empty. Do it on every single pallet as it empties and you are constantly on the phone; do it once a week in one call and the job is the same and the interruptions are gone.
Where the analogy stops working. A warehouse can decide its paperwork is optional. Remove the journal and a crash does not lose some records — it can leave the filesystem inconsistent in ways that lose data you already believed was safe.
🧪 Exercise C3.1 — Find the filesystem's hidden I/O
# What options is your root filesystem actually mounted with?
findmnt -no SOURCE,FSTYPE,OPTIONS /
# Is atime being updated on reads? relatime is the usual default.
findmnt -no OPTIONS / | tr ',' '\n' | grep -E 'atime'
# Prove it. A freshly touched file has atime == mtime, and relatime updates
# atime when it is NOT NEWER than mtime - so the FIRST read does write.
touch /tmp/atimetest
cat /tmp/atimetest > /dev/null # this read updates atime
sleep 1
stat -c 'atime=%x' /tmp/atimetest
cat /tmp/atimetest > /dev/null # this one does not: atime is now newer
stat -c 'atime=%x' /tmp/atimetest
# The journal: how much device traffic is metadata rather than data?
# Find the whole-disk device behind /. lsblk PKNAME gives the parent for a
# partition and prints an EMPTY line (exit 0) for a whole disk, so fall back
# to KNAME. Stripping trailing digits does not work: nvme0n1p1 would become
# nvme0n1p, which is not a device.
SRC=$(findmnt -no SOURCE / | sed 's/\[.*\]//')
DEV=$(lsblk -no PKNAME "$SRC" 2>/dev/null | head -1)
[ -z "$DEV" ] && DEV=$(lsblk -no KNAME "$SRC" 2>/dev/null | head -1)
echo "device: $DEV"
sectors() { awk -v d="$DEV" '$3==d {print $10}' /proc/diskstats; }
sync; B=$(sectors)
# 2000 tiny files: almost all of this cost is metadata and journal
mkdir -p /tmp/manyfiles
for i in $(seq 1 2000); do echo x > /tmp/manyfiles/f$i; done
sync; A=$(sectors)
echo "2000 small files wrote $(( (A - B) / 2 )) KiB to the device"
echo " (the file data itself is 2000 x 2 bytes = 4 KiB)"
sync; B=$(sectors)
# The same total bytes, in one file
dd if=/dev/zero of=/tmp/onefile bs=4k count=1 status=none
sync; A=$(sectors)
echo "one 4 KiB file wrote $(( (A - B) / 2 )) KiB to the device"
# Discard: is it on at mount time, or batched by a timer?
findmnt -no OPTIONS / | tr ',' '\n' | grep -E 'discard' || echo "no discard mount option"
systemctl status fstrim.timer --no-pager 2>/dev/null | head -3
# The classic df/du disagreement: deleted files still held open
df -h / | tail -1
# Dedupe on device+inode: every process holding the same descriptor produces
# its own row, so a naive sum multiplies the answer by the worker count.
sudo lsof -nP +L1 2>/dev/null | \
awk '/\(deleted\)$/ && !seen[$6"_"$9]++ {s+=$7} \
END {printf "held by deleted-but-open files: %.1f MiB\n", s/1048576}'
rm -rf /tmp/manyfiles /tmp/onefile /tmp/atimetest✅ Expected result — click to reveal
/dev/vda1 ext4 rw,relatime,errors=remount-ro
relatime
atime=2026-08-21 11:04:13.229481170 +0000
atime=2026-08-21 11:04:13.229481170 +0000
2000 small files wrote 8692 KiB to the device
(the file data itself is 2000 x 2 bytes = 4 KiB)
one 4 KiB file wrote 32 KiB to the device
no discard mount option
● fstrim.timer - Discard unused filesystem blocks once a week
Loaded: loaded (/usr/lib/systemd/system/fstrim.timer; enabled)
Active: active (waiting) since Mon 2026-08-17 09:12:44 UTC
/dev/vda1 40G 22G 16G 59% /
held by deleted-but-open files: 1842.0 MiBWhat to read out of this.
The two atime values are identical, so the second read did not write anything. That is relatime working — but note carefully which read it applied to. A freshly created file has atime exactly equal to mtime, and relatime updates when the stored atime is not newer than mtime, so the first read did generate a metadata write. Only once atime had moved ahead did the second read become free.
This catches people writing benchmarks: touch a file, read it once, and you have measured the update, not the skip. Under the old strictatime behaviour every read would have generated a metadata write and, eventually, a journal entry.
Now the striking number. 2000 files containing four kilobytes of actual data cost about 8700 KiB of device writes — more than two thousand times the payload. Part of that is unavoidable: ext4 allocates a whole 4 KiB block per file, so 2000 files occupy 8000 KiB no matter how few bytes they hold. The rest is metadata — an inode, a directory entry, block-bitmap and inode-bitmap updates per file — and every one of those changes goes through the journal. Compare the single 4 KiB file: 32 KiB, almost all of it metadata, but paid once.
This is why "extract this tarball" or "clone this repository" hammers a disk far harder than the byte count suggests, why container image pulls are so I/O-expensive, and why a build system that creates and deletes millions of small files will saturate storage that copes easily with a database doing the same total volume.
fstrim.timer is active (waiting) and there is no discard mount option. That is the modern arrangement and it is correct: discard is batched weekly rather than issued on every delete.
Finally the df/lsof pair. The filesystem is 59% full, and 1842 MiB is held by files that have been deleted but are still open. Nothing on disk corresponds to that space — du will never find it — and it will be released only when the holding process closes the descriptor or exits. On a host where a log rotation deleted files that the service still has open, this is the entire explanation for "the disk filled up and du says there is nothing there".
If your small-file number is much lower, the filesystem batched the journal writes across the loop, which sync placement affects. The ratio to the payload will still be enormous.
🎯 Interview questions — Latency and durability
Q. Storage latency jumped twentyfold overnight and nothing was deployed. What happened?
Almost certainly the arrival rate crossed the device's service rate. Queueing latency does not rise gently as you approach capacity — it rises without bound. A device comfortably serving 4,000 IOPS at 1 ms can be at tens of milliseconds at 4,500. Nothing broke and the device did not slow down; the queue in front of it grew.
To confirm it I would look at aqu-sz in iostat -x alongside r/s/w/s: if throughput has plateaued while queue depth and await climb together, that is the signature. On a cloud volume I would also check whether the plateau is a provider IOPS quota rather than a physical limit — the provider's throttling metrics will show it when the guest's will not.
The details that separate candidates: explaining why the change looks like a cliff. Average latency is flat right up until the knee, so a latency-based capacity model gives no warning at all; queue depth starts rising much earlier and is the metric to alert on. The corollary is worth stating too: past the knee, adding application workers makes it worse, because more concurrency adds queueing and cannot add service rate.
Q. What is the difference between fsync, O_DSYNC and O_DIRECT?
fsync() flushes everything written to that file so far to stable storage; you call it when you choose, so the cost is amortised across many writes. O_DSYNC makes every individual write durable before it returns — the same guarantee, paid per write, which is enormously more expensive for small writes. O_DIRECT is not a durability mechanism at all: it bypasses the page cache so reads and writes go straight between the application's buffer and the device, and the data can still be sitting in the device's volatile cache when the call returns.
The details that separate candidates: three things. O_DIRECT still needs fsync — conflating them is a real data-loss bug, not a theoretical one. O_DIRECT is usually slower, not faster, unless the application does its own batching, because it gives up the kernel's merging and readahead; it is a control flag, not a performance flag. And the magnitude: on the same device, buffered writes can run at 1.6 GB/s while 4 KiB O_DSYNC writes manage under 1 MB/s — a factor of two thousand, with no hardware change. That number is where a database's commit rate actually lives.
Q. Why does extracting a tarball of many small files hurt a disk so much more than writing the same number of bytes to one file?
Because the cost is metadata, not data. Each file needs an inode, a directory entry, and updates to the inode and block bitmaps, and on a journalling filesystem every one of those metadata changes is written to the journal first. Two thousand two-byte files can generate several megabytes of device writes for four kilobytes of payload — a ratio in the thousands — while the same bytes in one file cost a few tens of kilobytes.
The details that separate candidates: generalising it to the workloads where it bites — container image pulls, git clone, CI workspaces, node_modules — and noting that these hosts must be sized on write IOPS, not throughput, because a byte-based capacity model predicts none of it. It is also worth naming the ext4 default, data=ordered: metadata is journalled and data is not, but data is forced out before the metadata pointing at it, which is what stops a crash leaving a file that references someone else's old blocks.
🔍 Part D · Diagnosing "the disk is slow"
D1 · Four questions, in order
Same discipline as Modules 07 and 09: cheap questions that eliminate whole categories, before expensive ones that identify individuals.
- Is anything actually being held up? — /proc/pressure/io. Not iowait, not %util. If pressure is near zero, storage is not your problem.
- Which device, and is it latency or throughput? — iostat -x 1: r_await/w_await, aqu-sz, rareq-sz.
- Is the device slow, or is the queue long? — throughput flat with aqu-sz climbing means queueing; both low with high await means the device or the network behind it.
- Who is generating it? — iotop -o, /proc/PID/io, per-cgroup io.stat.
Diagram source
flowchart TD
A["Reports of slow storage"] --> B{"pressure/io<br>some avg60 high?"}
B -->|"No"| C["Not storage<br>look at CPU, locks, network"]
B -->|"Yes"| D{"iostat: is throughput<br>at a plateau?"}
D -->|"Yes, and aqu-sz rising"| E["Queueing past the knee<br>reduce concurrency or add capacity"]
D -->|"No, throughput low<br>and await high"| F{"Small requests?<br>check rareq-sz"}
F -->|"Yes, 4-8 KiB"| G["Random or sync workload<br>look at fsync and file sizes"]
F -->|"No, large"| H["Device or network path<br>check the provider limits"]
E --> I["Attribute it:<br>iotop -o, io.stat"]
G --> I
H --> I"The lifts are slow." A building manager who immediately calls out an engineer will usually waste the call.
The cheap questions first: is anyone actually waiting? (a queue in the lobby, or just one person who feels it was slow). Which lift? Is the lift itself moving slowly, or is it fine but always full? Those three answers, which take minutes, decide whether you need an engineer, a second lift, or a word with the delivery company that books one lift out every morning.
The expensive question — who — comes last, because it is only actionable once you know which of the other three you are dealing with.
Where the analogy stops working. You can watch a lift. Storage gives you no direct view, so each of those questions has to be answered by a specific counter, and the wrong counter — %util, iowait — will answer confidently and wrongly.
D2 · Telling the layers apart
When the application and the storage team disagree, they are usually measuring different layers and both are right. This table is the translation.
| Layer | Symptom there | How to confirm | Who fixes it |
| Application | Slow, but device metrics are quiet | /proc/PID/io: rchar high, read_bytes low | The service team — it is cache-served or not I/O at all |
| Page cache / memory | Major faults, cache being evicted | pgmajfault in /proc/vmstat, /proc/pressure/memory | Memory sizing — Module 09 |
| Filesystem | Device writes far exceed data written | /proc/diskstats deltas versus bytes written; many small files | The workload — batch, or use fewer files |
| Block layer / queue | Throughput flat, aqu-sz and await rising | iostat -x 1 | Reduce concurrency, or add capacity |
| Device | await high at low queue depth | aqu-sz near 1 with await in tens of ms | Hardware, or the provider |
| Cloud provider | Throughput plateaus at a suspiciously round number | Provider's throttle/burst-balance metrics | Change the volume type or size |
🧪 Exercise D2.1 — Run the whole method against a workload you created
which iostat || sudo apt-get install -y sysstat
# Find the whole-disk device behind /. lsblk PKNAME gives the parent for a
# partition and prints an EMPTY line (exit 0) for a whole disk, so fall back
# to KNAME. Stripping trailing digits does not work: nvme0n1p1 would become
# nvme0n1p, which is not a device.
SRC=$(findmnt -no SOURCE / | sed 's/\[.*\]//')
DEV=$(lsblk -no PKNAME "$SRC" 2>/dev/null | head -1)
[ -z "$DEV" ] && DEV=$(lsblk -no KNAME "$SRC" 2>/dev/null | head -1)
echo "device: $DEV"
# Build a workload that is slow for a reason you already know:
# many small synchronous writes.
( for i in $(seq 1 400); do
dd if=/dev/zero of=/tmp/w$((i % 8)) bs=4k count=8 oflag=dsync status=none
done ) &
LOAD=$!
# 1. Is anything being held up?
sleep 2; echo "--- 1. pressure ---"; cat /proc/pressure/io
# 2. Which device, latency or throughput?
# Select the device's own row by name - iostat prints trailing blank lines.
row() { iostat -x 1 2 "$DEV" | awk -v d="$DEV" '$1==d {l=$0} END {print l}'; }
echo "--- 2. device ---"
iostat -x 1 2 "$DEV" | grep -E "^(Device|$DEV) "
# 3. Queue or device? Field numbers are for the FULL 23-column output.
echo "--- 3. queue depth vs throughput ---"
row | awk '{print "w/s="$8" w_await="$12" wareq-sz="$13" aqu-sz="$(NF-1)" %util="$NF}'
# 4. Who?
echo "--- 4. attribution ---"
sudo iotop -b -o -n 1 2>/dev/null | head -6
for p in $(pgrep -P $LOAD dd); do
grep -E '^(wchar|write_bytes|syscw):' /proc/$p/io 2>/dev/null
done
wait $LOAD
rm -f /tmp/w[0-7]✅ Expected result — click to reveal
--- 1. pressure ---
some avg10=22.41 avg60=6.02 avg300=1.44 total=42118204
full avg10=18.90 avg60=5.11 avg300=1.20 total=38204118
--- 2. device ---
Device r/s rkB/s r_await w/s wkB/s wrqm/s %wrqm w_await wareq-sz aqu-sz %util
vda 0.00 0.00 0.00 604.00 2416.00 0.00 0.00 1.58 4.00 0.95 98.40
--- 3. queue depth vs throughput ---
w/s=604.00 w_await=1.58 wareq-sz=4.00 aqu-sz=0.95 %util=98.40
--- 4. attribution ---
Total DISK READ: 0.00 B/s | Total DISK WRITE: 2.36 M/s
TID PRIO USER DISK READ DISK WRITE COMMAND
9214 be/4 root 0.00 B/s 2.36 M/s dd if=/dev/zero of=/tmp/w3 ...
wchar: 32768
write_bytes: 32768
syscw: 8Walk it through in order, and notice how each answer narrows the next.
1. Pressure is real. some avg10=22.41 and full avg10=18.90 — for nearly a fifth of the last ten seconds, every runnable task was stalled on I/O. This is a genuine storage problem, not a misread dashboard. Had this been near zero, everything below would have been a waste of time.
2. The device is writing, not reading, at 2.4 MB/s — a trivial amount. And yet %util is 98.4. If you had started here you would have concluded the disk was maxed out. It is not; it is merely never empty.
3. The decisive line. aqu-sz=0.95 — the queue is essentially one request deep. So this is not queueing. wareq-sz=4.00 says every request is 4 KiB, and wrqm/s=0.00 says not one write was merged, which for 400 writes to only 8 files should be impossible unless something is forcing each one out on its own. That is the O_DSYNC signature, and it identifies the cause before you have looked at a single process.
At 604 writes/s and 1.58 ms each, the device is doing exactly what it is asked and doing it promptly. The workload is the problem, not the storage.
4. Attribution confirms it. One dd at a time, and the per-process counters show wchar and write_bytes identical at 32768 — every byte the process wrote went straight to the device, with nothing absorbed by the page cache. That equality is itself diagnostic: on a normal buffered writer, write_bytes lags wchar badly.
syscw: 8 for 32 KiB is 4 KiB per call, matching wareq-sz exactly. The chain from application call size to device request size is unbroken, which is precisely what synchronous, unmergeable I/O looks like.
The conclusion you would put in the ticket: the volume is healthy — sub-2 ms latency at queue depth 1 — and the application is issuing small synchronous writes that cannot be batched. The fix is in the application's write pattern or its durability settings, not in a bigger disk.
🎯 Interview questions — Diagnosis
Q. A team says the disk is slow. Walk me through what you run, in order.
/proc/pressure/io first, because it answers whether anything is actually being held up and it takes no packages and no time. If pressure is near zero, storage is not the problem and I stop. Then iostat -x 1 for the device view: r_await/w_await for latency, aqu-sz for queue depth, rareq-sz/wareq-sz for request size. Those three together separate queueing from a slow device from a bad workload. Only then do I attribute it — iotop -o, /proc/PID/io, or per-cgroup io.stat on a container host.
The details that separate candidates: saying which metrics you deliberately do not start with, and why. iowait moves with CPU load rather than with storage health, and %util is pinned near 100 by a single outstanding request on any device with parallelism. Both are on the default dashboard and both will mislead you. The other detail is capturing before mitigating: a restart clears the symptom and destroys the only evidence that could distinguish a workload change from a device problem.
Q. Throughput is low, latency is high, and the queue is nearly empty. What does that tell you?
That the device — or the path to it — is genuinely slow, rather than overloaded. If aqu-sz is around 1 and await is in the tens of milliseconds, nothing is waiting behind anything else; each individual request is simply taking a long time. Adding capacity or reducing concurrency will not help, because there is no queue to shorten.
Next I would check request size. Very small requests with high per-request latency and zero merging points at synchronous, unmergeable writes — an application calling fsync per record, or opening with O_DSYNC. If the requests are large and still slow, I look below the guest: a degraded array, a network-attached volume, or a provider throttle.
The details that separate candidates: knowing that on cloud storage the limit is usually a quota rather than physics, and that quotas have tells — a plateau at a round number like 3,000 IOPS, or twenty minutes of good performance followed by a permanent halving as a burst balance runs out. That second pattern is invisible from inside the guest and looks exactly like a device that spontaneously got slower.
🏁 Part E · Practice, docs and self-check
E1 · Production practice
| Symptom in production | What is really happening | What to run | The fix |
| App says storage is slow; storage team says the volume is idle | They are measuring different layers, and both are right | /proc/pressure/io, iostat -x 1, /proc/PID/io | Collect both plus the layer between; stop arguing about which is true |
| %util alert fires constantly on NVMe hosts | One outstanding request pins %util at 100 on a parallel device | aqu-sz and await alongside it | Alert on queue depth and latency; drop the %util alert |
| Storage alert never fires, yet the service is I/O-bound | Alert is on iowait, and the CPUs are never idle | Compare wa with /proc/pressure/io | Alert on PSI; take iowait off the storage panel |
| Latency jumped twentyfold overnight, nothing deployed | Arrival rate crossed the service rate — the queueing knee | iostat -x 1: throughput plateau with aqu-sz rising | Reduce concurrency or add capacity. More workers makes it worse |
| Benchmark says 1.6 GB/s; production does 3 MB/s | The benchmark was buffered and never touched the device | dd conv=fsync, or fio with a real block size and queue depth | Re-benchmark honestly; size on the synchronous number |
| Database commit rate far below the volume's rating | Small synchronous writes: a round trip each, no merging | wareq-sz ~4 KiB with wrqm/s at 0 | Group commits, or faster-latency storage. Throughput is irrelevant here |
| CI runners saturate storage that a database copes with | Metadata and journal writes from millions of small files | /proc/diskstats deltas versus bytes written; write IOPS | Size those hosts on IOPS, not throughput; batch or reduce file counts |
| Filesystem full but du cannot account for it | Deleted files still held open, or the 5% root reserve | lsof -nP | grep deleted; tune2fs -l | Restart the holder, or fix the log rotation that unlinked in place |
| Volume was fast for twenty minutes, then permanently halved | Cloud burst credits exhausted — a quota, not the device | The provider's burst-balance metric; look for round numbers | Change volume type or size. Nothing inside the guest will show it |
| A tuning script sets noop and nothing changes | noop and cfq were removed in Linux 5.0 | cat /sys/block/*/queue/scheduler; check the write's exit status | Use none/mq-deadline/bfq/kyber, and check exit codes when writing to /sys |
E2 · Capstone — four storage tickets
Ticket 1. A monitoring rule fires on every NVMe host in the fleet: "disk utilisation 100%". It has been firing for months. The team wants to buy faster disks. What do you tell them, and what do you replace the alert with?
Ticket 2. A PostgreSQL host is doing 900 transactions per second. The volume is advertised at 500 MB/s and iostat shows 4 MB/s of writes with %util at 97% and aqu-sz at 1.0. The team wants a volume with more throughput. Will that help?
Ticket 3. A batch job that has run nightly for a year in 40 minutes now takes 3 hours. Nothing was deployed. iostat shows read throughput plateaued at 125 MB/s with r_await climbing from 2 ms to 45 ms and aqu-sz at 22. Where do you look?
Ticket 4. A CI fleet's storage is saturated. The runners write about 2 GB per build, and the volumes are rated at 250 MB/s, so throughput should be trivial — but builds are I/O-bound and %util sits at 100. What is going on?
✅ Ticket 1 — worked answer
What you tell them: %util does not mean what the alert assumes. It is the percentage of time during which at least one request was outstanding — nothing about capacity. On an NVMe device with dozens of hardware queues, one request in flight out of a possible sixty-four pins it at 100% while the device is nearly idle. The iostat man page states this outright: for devices serving requests in parallel, the number "does not reflect their performance limits".
The evidence that ends the discussion, and it takes one minute:
iostat -x 1 3 nvme0n1If %util is 100 while aqu-sz is around 1 and await is well under a millisecond, the device has one steady customer and enormous headroom. Show that next to the alert and the case is closed.
What to replace it with. Three series and no %util: aqu-sz (rises early and smoothly — this is the alerting metric), r_await/w_await (what the application actually experiences), and /proc/pressure/io (some avg60, how much work is genuinely being held up). Page on pressure, ticket on sustained queue growth, and graph latency.
One caveat worth stating. %util is not always useless — on a genuinely serial device, a single spinning disk, it really is utilisation. If the fleet is mixed, the alert is right on some hosts and wrong on others, which is worse than being uniformly wrong and is probably why it survived this long.
✅ Ticket 2 — worked answer
No, and the numbers already say so.
aqu-sz at 1.0 means there is no queue: requests are not waiting behind other requests. %util at 97% with a queue depth of one means the device is never idle but never backed up — one customer who never leaves. And 4 MB/s against a 500 MB/s rating means throughput is not remotely the constraint. Buying a volume with more throughput buys more of a resource that is 99% unused.
Run this to confirm what the workload looks like:
iostat -x 1 5 # look at wareq-sz and wrqm/s
cat /proc/pressure/io
grep -E '^(wchar|write_bytes|syscw):' /proc/<postgres-pid>/ioWhat you will find. wareq-sz around 4 KiB — 4 MB/s divided by roughly 1,000 writes per second — and wrqm/s at or near zero. Nothing is being merged, because each commit is forced to storage on its own. This is the write-ahead log, and PostgreSQL is doing exactly what it promised: every transaction is durable before it acknowledges.
So what does limit it? Per-write latency. At w_await of around 1 ms, a strictly serialised commit path caps out near 1,000 transactions per second, which is where they are. The lever is latency, not bandwidth — a volume type with lower latency, or storage with a battery-backed write cache.
The cheaper fix to raise first. PostgreSQL's commit_delay and commit_siblings batch concurrent commits into one flush, so several transactions share a single round trip. That is a configuration change that can multiply throughput without touching the hardware, and it costs a few milliseconds of added commit latency. synchronous_commit = off would go further and trades durability for it — a decision for the data owner, not for you.
✅ Ticket 3 — worked answer
The numbers describe the queueing knee, precisely. Throughput is flat at 125 MB/s while aqu-sz has reached 22 and r_await has gone from 2 ms to 45 ms. Requests are not slower to serve; they are waiting behind twenty-one others. Latency has risen twentyfold for zero extra work done.
The plateau at 125 MB/s is the number to stare at. That is suspiciously close to a round 1 Gbit/s, and round numbers in storage throughput are almost always a quota rather than physics.
Run, in order:
iostat -x 1 10 # confirm the plateau is stable
cat /proc/pressure/io # how much is being lost
# then the provider's own metrics: volume throughput limit,
# burst balance, and any throttling counterTwo candidate causes, and they are distinguishable.
If the volume's size or type imposes a 125 MB/s ceiling, the plateau will be flat and permanent, and the provider's throttling metric will be non-zero. The fix is a larger or different volume — on most providers throughput scales with provisioned size.
If it is a burst balance, the job will have been fast for the first stretch and then halved. Look at the job's own timing: 40 minutes becoming 3 hours is roughly a fourfold slowdown, which fits a burst credit running out partway. That is invisible from inside the guest.
What has changed after a year of working. Almost certainly the data grew past the point where the job's read volume exceeds the burst allowance, or past what the cache could absorb. Nothing was deployed because nothing needed to be — the workload grew into the limit. Check the input data size trend before anything else; it is usually the whole answer.
✅ Ticket 4 — worked answer
The 2 GB figure is the payload, not the work. CI builds create and delete enormous numbers of small files — checkouts, dependency trees, object files, container layers — and each file costs an inode, a directory entry, bitmap updates, and journal writes for every one of those. Two thousand tiny files can generate megabytes of device writes for kilobytes of payload; a full node_modules tree or a container image pull does this on an industrial scale.
Confirm it by comparing bytes to IOPS:
iostat -x 1 10 # look at w/s and wareq-sz
# device writes over the build, from /proc/diskstats field 10 (sectors)
# compared with the build's own byte countWhat you will find. w/s in the thousands with wareq-sz around 4–8 KiB. The fleet is IOPS-bound, not throughput-bound, and every capacity model built on gigabytes-per-build predicted none of it.
Fixes, cheapest first.
Size the volumes on write IOPS, since on most cloud providers IOPS and throughput are provisioned separately and the default assumes large sequential I/O.
Put the build workspace on a tmpfs or a local NVMe scratch disk where the machine has one — build artefacts are disposable by definition, so durability buys nothing.
Reduce the file churn itself: a shared dependency cache instead of a fresh install per build, and fewer, larger layers in container images.
One thing not to do: mount -o discard. On a filesystem with this much deletion it adds a discard to every unlink and makes the latency worse. The weekly fstrim.timer does the same job in one batch, and it is already enabled on modern distributions.
E3 · Documentation reference
| Topic | Where to read it | Why this one |
| The block layer overall | Block — kernel docs | Index into blk-mq, schedulers, statistics |
| The multi-queue design | blk-mq | Software versus hardware queues, which explains the defaults |
| Queue tunables in sysfs | sysfs-block ABI | nr_requests, read_ahead_kb, rotational, scheduler, nomerges |
| Per-device statistics in sysfs | Block layer statistics | The 17 values behind /sys/block/*/stat |
| /proc/diskstats field by field | I/O statistics fields · proc_diskstats(5) | Note sectors are always 512 bytes here |
| Deadline scheduler tunables | Deadline IO scheduler | read_expire 500 ms versus write_expire 5000 ms, and why |
| Fair-share scheduling | BFQ | The cgroup-aware option, and its overhead |
| Reading iostat | iostat(1) | Read the %util caveat in the man page itself |
| Per-process I/O | proc(5) · iotop(8) | /proc/PID/io — rchar versus read_bytes |
| I/O pressure | PSI | The metric to alert on |
| Per-cgroup I/O control | Control Group v2 | io.stat, io.max, io.weight, io.pressure — but see the stale cfq note in Section B3 |
| Durability flags | open(2) · fsync(2) | O_SYNC, O_DSYNC, O_DIRECT and their alignment rules |
| Asynchronous I/O | io_uring(7) · io_uring_setup(2) | And the kernel.io_uring_disabled sysctl added in 6.6 |
| Filesystem behaviour | ext4 · mount(8) | Journal modes, relatime, discard |
| Trim | fstrim(8) | Why the weekly timer beats -o discard |
| Benchmarking properly | fio | Block size, queue depth and fsync all matter; dd controls none of them |
| Device health | smartctl(8) | For physical hardware; meaningless on cloud volumes |
E4 · Self-assessment
Answer these out loud before moving to Module 11. The section to reread is named after each.
- Trace a write() from the system call to the device, naming each layer. (A1)
- Why do application-observed and device-observed I/O so often disagree? (A1)
- What is request merging, and which iostat columns show it? (A2)
- What decides the default I/O scheduler for a device — and what does not? (A2, A3)
- Name the three schedulers that exist plus none, and the two that no longer do. (A3)
- Why does iowait fall to zero on a busy machine with saturated storage? (B1)
- What should you read instead of iowait, and why does each have a dimension iowait lacks? (B1)
- %util is 100%. What exactly does that tell you, and what does it not? (B2)
- What replaced avgqu-sz and what happened to svctm? (B2)
- What is the difference between rchar and read_bytes, and what can you compute from the pair? (B3)
- Why does per-process write_bytes not line up in time with device write activity? (B3)
- Explain why latency at a storage device rises as a cliff rather than a slope. (C1)
- Which metric gives you warning before the cliff, and why? (C1)
- Rank buffered, fsync, O_DSYNC and O_DIRECT by cost, and say which one is not a durability mechanism. (C2)
- What is kernel.io_uring_disabled, and why might the same container behave differently on two distributions? (C2)
- Why does writing 2000 tiny files cost a thousand times their payload in device writes? (C3)
- df says full, du says it is not. Give two explanations. (C3)
- Throughput low, latency high, queue empty — what does that combination mean? (D2)
E5 · Sources
Kernel documentation
· Block layer index · blk-mq · Block layer statistics
· I/O statistics fields · sysfs-block ABI
· PSI · Control Group v2 · ext4
Manual pages
· iostat(1) · iotop(8) · lsblk(8) · blkid(8) · blktrace(8)
· proc(5) · proc_stat(5) · proc_diskstats(5)
· open(2) · read(2) · write(2) · fsync(2)
· io_uring(7) · io_uring_setup(2)
· mount(8) · fstrim(8) · hdparm(8) · dd(1)
Outside the man-pages project (these are not on man7.org — a 404 there is expected)