Part 3 - The I/O Path¶
Let's trace what actually happens when a 4 KB write hits OpenZFS.
Why This Chapter Matters¶
Most confusion about ZFS behavior comes from one mismatch:
- syscall completion and durable pool commit are not the same moment.
This chapter follows the write path from Linux VFS entry, through DMU transaction work, into txg sync, through ZIO and VDEV, and finally to uberblock/config rewrite. It ends with a condensed read-path summary so "I/O path" is covered in both directions.
Scope and Assumptions¶
To keep the trace concrete, we assume:
- Linux platform (
module/os/linux/zfs/*) - normal buffered write (not
O_DIRECT) - 4 KB write to a regular file
- no catastrophic I/O error path
We still call out where sync writes (O_SYNC/O_DSYNC/fsync) diverge via ZIL.
Diagram 1 - End-to-End Write Sequence¶
sequenceDiagram
participant U as Userspace
participant V as Linux VFS
participant ZPL as zpl_file.c
participant VN as zfs_vnops.c
participant DMU as dmu_tx + dmu
participant DBUF as dbuf/ARC
participant TXG as txg_sync_thread
participant SPA as spa_sync
participant ZIO as zio pipeline
participant VDEV as vdev queue/backend
U->>V: write(fd, buf, 4096)
V->>ZPL: zpl_iter_write()
ZPL->>VN: zfs_write()
VN->>DMU: dmu_tx_create + holds + dmu_tx_assign
DMU->>DBUF: dirty dbufs in memory
VN-->>U: write returns
Note over TXG,SPA: later (txg sync)
TXG->>SPA: spa_sync(txg)
SPA->>ZIO: build/execute write zios
ZIO->>VDEV: vdev_queue_io() / vdev_disk_io_start()
VDEV-->>SPA: I/O completion
SPA-->>TXG: txg committed
Step 1 - Syscall Entry at ZPL¶
Primary file:
module/os/linux/zfs/zpl_file.c
Entry wiring:
zpl_file_operationsregisters.write_iter = zpl_iter_write.
What zpl_iter_write() does:
- validates generic write constraints
- builds
zfs_uio_tfrom kernel iterator state - acquires credentials/fstrans context
- calls
zfs_write(ITOZ(ip), &uio, filp->f_flags | zfs_io_flags(kiocb), cr)
This is the Linux adapter layer. It converts VFS-facing structures into the core vnode path.
Step 2 - Core Write Logic in zfs_write()¶
Primary files:
module/zfs/zfs_vnops.c(corezfs_write())module/os/linux/zfs/zfs_vnops_os.c(Linux glue paths that invoke core vnode logic)
Important ownership detail:
zfs_write()is implemented inmodule/zfs/zfs_vnops.c.- For the specific path traced in this chapter,
zpl_iter_write()calls corezfs_write()directly.
Inside zfs_write() the write is processed in chunks, and each chunk typically gets its own DMU transaction.
Key responsibilities in this stage:
- range locking (
RL_APPEND/RL_WRITER) - block size and offset handling
- quota/limit checks
- transaction hold setup and assignment
- write logging intent (
zfs_log_write(...)) - sync policy decision:
commit = (O_SYNC | O_DSYNC) || os_sync == ZFS_SYNC_ALWAYS
Step 3 - DMU Transaction Setup and Assignment¶
Primary files:
module/zfs/zfs_vnops.cmodule/zfs/dmu_tx.cmodule/zfs/dmu.c
Canonical pattern visible in zfs_write():
dmu_tx_create(...)dmu_tx_hold_sa(...)dmu_tx_hold_write_by_dnode(...)dmu_tx_assign(tx, DMU_TX_WAIT)
Then payload writes occur via:
dmu_write_uio_dbuf(...)inmodule/zfs/dmu.c
Inside dmu_write_uio_dbuf() (via dmu_write_uio_dnode()):
- target dbufs are prepared with
dmu_buf_will_dirty_flags(...)/dmu_buf_will_fill_flags(...) - bytes are copied from the
uiointodb->db_datawithzfs_uio_fault_move(...) - dirty state is attached to the txg through the dbuf dirty-record path (
dbuf_dirty(...))
And finally:
dmu_tx_commit(tx)
This means foreground writes are transactional and ordered, but still memory-first at this point.
One caveat on "memory-first": dmu_tx_assign(tx, DMU_TX_WAIT) is not
unconditional in-memory progress.
- if the open txg cannot take the tx (
ERESTART), assignment retries viadmu_tx_wait(...), which can sleep until a txg syncs out. - if pool-wide dirty data has hit
zfs_dirty_data_max,dmu_tx_wait()blocks until the sync thread frees dirty space. - even below that hard limit,
dmu_tx_delay(...)injects an artificial sleep once dirty data crosseszfs_dirty_data_max * zfs_delay_min_dirty_percent / 100(60% by default), and the delay grows sharply as dirty data approaches the max.
This is the write throttle (module/zfs/dmu_tx.c; see the large block
comment above dmu_tx_delay()). It paces foreground writers to match
sync-thread throughput instead of letting dirty data grow without bound.
Step 4 - Dirtying Happens in Memory (dbuf/ARC)¶
Primary files:
module/zfs/dmu.cmodule/zfs/dbuf.cmodule/zfs/arc.c
In the DMU write path:
- dbufs are marked dirty (
dmu_buf_will_dirty_flags(...)anddbuf_dirty(...)) - in-memory state is updated under transaction context
- no immediate requirement to write final data blocks to disk before syscall return
Conceptually:
- DMU/dbuf records "what changed in txg N"
- a single dbuf can hold multiple dirty records (
db_dirty_records) for different txgs at once - those records are tracked newest-to-oldest, which allows one txg to sync while a newer txg is still taking foreground writes
- txg sync later turns that dirty state into physical I/O
Step 5 - ZIL Branch for Sync Semantics¶
Primary files:
module/zfs/zfs_vnops.cmodule/os/linux/zfs/zpl_file.cmodule/zfs/zil.c
For sync semantics:
zfs_write()may callzil_commit(zilog, zp->z_id)when commit policy requires it.zpl_fsync()eventually reacheszfs_fsync(), which also driveszil_commit(...).
What ZIL provides:
- per-dataset intent logging for synchronous semantics
- replayable records after crash/power loss
- lower latency sync behavior than waiting for full txg sync
Important nuance:
- ZIL is for sync/replay semantics; final long-term pool state still converges through txg sync.
zil_commit()does not bypass the I/O stack: it packs intent records into log write blocks (lwbs), andzil_lwb_write_issue()(module/zfs/zil.c) issues each lwb as a real zio (zio_rewrite(...)under azio_root(...), dispatched withzio_nowait(...)) immediately, before txg sync runs.- net effect: a sync write traverses the ZIO pipeline and vdev layers twice -
once right away via the ZIL lwb write, and again later when
spa_sync()writes the final block tree.
Step 6 - txg Scheduler Hands Off to Sync Thread¶
Primary file:
module/zfs/txg.c
Key anchors:
txg_sync_thread(...)txg_quiesce_thread(...)andtxg_quiesce(...)
Default timeout behavior:
zfs_txg_timeout = 5seconds (module/zfs/txg.c)
Who performs the quiesce:
txg_quiesce_thread()picks the current open txg and callstxg_quiesce().txg_quiesce()grabs every per-CPUtc_open_lockso no new tx can enter that txg, advancestx_open_txg(opening txg N+1 for new writers), then waits for all outstanding tx handles in txg N to be released.- the now-quiesced txg is handed to the sync thread via
tx_quiesced_txg.
So the open -> quiesced transition happens in txg_quiesce_thread, before
the sync thread ever sees the txg.
When a txg is quiesced and ready:
- sync thread consumes
tx_quiesced_txgand callsspa_sync(spa, txg)
This is where dirty in-memory intent starts becoming durable on-disk state.
Step 7 - spa_sync() and dsl_pool_sync() Converge Dirty State¶
Primary files:
module/zfs/spa.cmodule/zfs/dsl_pool.c
Key anchors:
spa_sync(...)spa_sync_iterate_to_convergence(...)dsl_pool_sync(...)
What happens conceptually:
spa_syncestablishes sync context for this txg.dsl_pool_syncdrains dirty datasets and callsdsl_dataset_sync(...).- root zios are created (
zio_root(...)) and waited on. - passes continue until dirty state converges.
The convergence loop is why "one write syscall" does not map to "one immediate disk write."
Step 8 - ZIO Write Pipeline Executes¶
Primary files:
include/sys/zio_impl.hmodule/zfs/zio.c
Pipeline definition facts:
enum zio_stagedefines 27 stage bitsZIO_WRITE_PIPELINEcomposes a default write stage set- stage execution dispatch goes through
zio_pipeline[]function table
Common write-stage flow:
OPEN
-> WRITE_BP_INIT
-> ISSUE_ASYNC
-> WRITE_COMPRESS
-> ENCRYPT
-> CHECKSUM_GENERATE
-> DVA_THROTTLE
-> DVA_ALLOCATE
-> READY
-> VDEV_IO_START
-> VDEV_IO_DONE
-> VDEV_IO_ASSESS
-> DONE
Stage handlers in module/zfs/zio.c include:
zio_write_bp_initzio_write_compresszio_encryptzio_checksum_generatezio_dva_throttlezio_dva_allocatezio_vdev_io_startzio_vdev_io_donezio_vdev_io_assess
Conditional pipeline behavior is worth calling out explicitly:
- deduplicated writes can switch to/include
ZIO_STAGE_DDT_WRITE(ZIO_DDT_WRITE_PIPELINE) - gang behavior activates
ZIO_STAGE_GANG_ASSEMBLE/ZIO_STAGE_GANG_ISSUE(ZIO_GANG_STAGES) when gang assembly is needed - all of these are composed through the same stage-bitmask mechanism (
io_pipeline), not a completely separate engine
For the 4 KB buffered write traced in this chapter, you usually stay on the default write pipeline and do not take gang paths.
Diagram 2 - Write Pipeline (Mermaid)¶
graph LR
OPEN["OPEN"] --> BP["WRITE_BP_INIT"]
BP --> ASYNC["ISSUE_ASYNC"]
ASYNC --> COMP["WRITE_COMPRESS"]
COMP --> ENC["ENCRYPT"]
ENC --> CKSUM["CHECKSUM_GENERATE"]
CKSUM --> THR["DVA_THROTTLE"]
THR --> ALLOC["DVA_ALLOCATE"]
ALLOC --> READY["READY"]
READY --> VS["VDEV_IO_START"]
VS --> VD["VDEV_IO_DONE"]
VD --> VA["VDEV_IO_ASSESS"]
VA --> DONE["DONE"]
ALLOC -. allocation .-> MS["metaslab_alloc(...)"]
style ALLOC fill:#ffe8cc,stroke:#333
style MS fill:#d9f7d6,stroke:#333
Step 9 - VDEV Queueing and Device I/O¶
Primary files:
module/zfs/zio.cmodule/zfs/vdev_queue.cmodule/os/linux/zfs/vdev_disk.c
Execution anchors:
zio_vdev_io_start(...)calls into vdev layer and queues leaf I/O viavdev_queue_io(...)- completion path returns through
zio_vdev_io_done(...)andvdev_queue_io_done(...) - Linux leaf backend submits via
vdev_disk_io_start(...)
This is where logical ZIO work becomes actual block-layer requests.
Step 10 - Atomic Commit Boundary (Uberblock/Config Rewrite)¶
Primary files:
module/zfs/spa.cmodule/zfs/vdev_label.cinclude/sys/uberblock.hinclude/sys/uberblock_impl.h
Key anchors:
spa_sync_rewrite_vdev_config(...)(spa.c)vdev_config_sync(...)(vdev_label.c; drives the label and uberblock sync lists)
During sync completion:
- vdev configuration (including uberblock-bearing label writes) is rewritten to commit the txg state
- then
spa->spa_ubsyncis advanced at end ofspa_sync
Practically, this is the durable boundary that later imports select from label uberblocks.
Diagram 3 - txg State vs Sync Internals¶
This diagram intentionally repeats Part 1's txg framing so this chapter can be read standalone.
graph LR
OPEN["OPEN"] --> QUIESCE["QUIESCE"]
QUIESCE --> SYNCPHASE["SYNC phase"]
SYNCPHASE --> SYNCED["SYNCED"]
subgraph DETAIL["inside SYNC phase"]
direction LR
SPASYNC["spa_sync()"] --> DSLSYNC["dsl_pool_sync()"]
DSLSYNC --> ZIO["zio_root()/zio pipeline"]
ZIO --> VDEV["vdev queue + backend I/O"]
VDEV --> UBER["vdev config/uberblock rewrite"]
end
SYNCPHASE -. runs .-> SPASYNC
style OPEN fill:#d7ecff,stroke:#333
style QUIESCE fill:#fff4c2,stroke:#333
style SYNCPHASE fill:#f7d9ff,stroke:#333
style SYNCED fill:#d9f7d6,stroke:#333
Read Path Summary (Condensed)¶
The write path above is txg-centric. The read path is shorter and split by cache hit vs miss.
Primary files:
module/os/linux/zfs/zpl_file.cmodule/zfs/zfs_vnops.cmodule/zfs/dmu.cmodule/zfs/dbuf.cmodule/zfs/arc.cmodule/zfs/zio.c
Typical read flow:
read()enterszpl_iter_read()inmodule/os/linux/zfs/zpl_file.c.zpl_iter_read()calls corezfs_read()inmodule/zfs/zfs_vnops.c.zfs_read()callsdmu_read_uio_dbuf()/dmu_read_uio_dnode()(module/zfs/dmu.c) to fetch dbufs and copy data to theuio.- ARC/dbuf hit case: dbuf data is already resident, so bytes are copied and returned without issuing new device I/O.
- ARC/dbuf miss case: dbuf/ARC path issues
arc_read(...), which drives the ZIO read pipeline and VDEV I/O before data is copied back to the caller.
Key contrast with writes:
- reads do not create txg dirty state. Under the default sync policy they do not involve the ZIL either, but with
sync=always(or FRSYNC platforms)zfs_read()callszil_commit()before taking the range lock, so pending sync writes are durable before the read proceeds.
Diagram 4 - Read Path (Hit vs Miss)¶
flowchart TD
R0["read()"] --> R1["zpl_iter_read()"]
R1 --> R2["zfs_read()"]
R2 --> R3["dmu_read_uio_dbuf()/dmu_read_uio_dnode()"]
R3 --> Q{"dbuf/ARC hit?"}
Q -->|Yes| H1["copy from dbuf to uio"]
H1 --> H2["return to userspace"]
Q -->|No| M1["arc_read()"]
M1 --> M2["ZIO read pipeline"]
M2 --> M3["vdev I/O"]
M3 --> M4["fill dbuf/ARC + copy to uio"]
M4 --> H2
style Q fill:#fff4c2,stroke:#333
style M2 fill:#d7ecff,stroke:#333
style M3 fill:#ffe8cc,stroke:#333
Practical "Where To Put Breakpoints" List¶
If you are debugging this path live, these are high-signal anchors:
module/os/linux/zfs/zpl_file.c:zpl_iter_writemodule/zfs/zfs_vnops.c:zfs_writemodule/zfs/dmu_tx.c:dmu_tx_assignmodule/zfs/dmu.c:dmu_write_uio_dbufmodule/zfs/dbuf.c:dbuf_dirtymodule/zfs/txg.c:txg_sync_threadmodule/zfs/spa.c:spa_syncmodule/zfs/dsl_pool.c:dsl_pool_syncmodule/zfs/zio.c:zio_dva_allocate/zio_vdev_io_startmodule/os/linux/zfs/vdev_disk.c:vdev_disk_io_start
Key Takeaways¶
write()return does not imply full txg commit to final pool state.- Foreground path records transactional intent; sync path materializes it.
- ZIO is the unifying execution engine for real I/O work.
- Metaslab allocation is where physical placement decisions happen.
- ZIL exists to satisfy sync semantics without waiting for full txg flush.
- Uberblock/config rewrite during
spa_syncis the durable transaction boundary.
Next¶
-> Part 4 - Block Pointers in Code: connect this runtime path to blkptr_t, DVA encoding, and checksum/compression metadata.