The Linux Block Layer Plug (blk_plug): Why Your Small Writes Turn Into One Big I/O

2026-09-01

When your process calls write() a hundred times against adjacent file offsets, you'd expect a hundred requests to hit the disk. Instead, iostat often shows one request. The reason is the block layer plug, a per-task deferral mechanism that batches I/O submissions before handing them to the device driver.

The mechanism: when a task enters a code path that will issue multiple bios (writeback, direct I/O of a large buffer, fsync flushing dirty pages), the kernel calls blk_start_plug(), which installs a struct blk_plug on current->plug. Each subsequent submit_bio() checks that pointer: if a plug is active, the request goes onto a per-task list instead of being dispatched to the hardware queue. When the plug is finished (blk_finish_plug()), or the list reaches BLK_PLUG_FLUSH_SIZE (16 requests), or the task is about to schedule out, the accumulated requests are sorted, merged with adjacent ones, and flushed to the driver.

The critical trick is the schedule hook. If a task holding a plug calls schedule() — typically because it hit a mutex or waited on a page — the scheduler calls blk_flush_plug() automatically. Otherwise a preempted task could sleep with I/O sitting in its plug list forever, and worse, it could deadlock if another task is waiting on that I/O.

Real-world example: The page cache writeback path (writepages) wraps its loop in a plug. If ext4 has 500 dirty pages in a file, the filesystem submits 500 bios. Without the plug, that's 500 driver dispatches. With the plug, they land on the list, merge into ~10 requests of 512KB each (assuming the pages are contiguous on disk), and get sorted before dispatch. On rotational media the sort saved seeks; on NVMe it still saves interrupt overhead and lets the device schedule internally.

Rule of thumb: if you're writing a filesystem, driver, or code that issues bursts of adjacent I/O, wrap the burst in blk_start_plug()/blk_finish_plug(). Merging four 4KB requests into one 16KB request is roughly a 4× reduction in per-request CPU cost (softirq completion, tag allocation, driver doorbell) — measurable at millions of IOPS.

From user space, you don't call the plug directly, but you inherit its effects: an fsync() that flushes 40MB of dirty pages runs under a plug, which is why iostat shows request sizes of 128KB–1MB even though your app wrote in 4KB chunks. If you see many small requests instead, either your writes aren't contiguous, or a lock is forcing frequent schedules that flush the plug prematurely.

Key Takeaway: The block plug is a per-task deferral list that batches and merges adjacent bios before dispatch, and the scheduler auto-flushes it whenever the holding task sleeps to prevent deadlock.

All newsletters