fsyncは何を待っているのか — zil.cを読んで、SLOGの答え合わせをする
homenasに余っていたNVMeをSLOGとして足したのに、何も速くならなかった話を以前書きました(SLOGを足したのに何も速くならなかった話)。あの記事の結論は、当時のバックアップのrsyncがほぼ非同期書き込みだったから、というものです。結論は今も変わりません。ただ、「非同期書き込みはZILを通らない」を規則として覚えていただけで、ZILの中で何が起きていて、何が起きていないのかは、見たことがありませんでした。
arc.c、vdev_queue.cと読んできた流れで、今回はZIL本体のmodule/zfs/zil.cを読みました。対象はOpenZFS masterの4542a31(2026年10月9日)です。
4,920行あるのに、モジュールパラメータは8個しかありません。vdev_queue.cが1,195行に27個だったのとは対照的です。読んでみると、このファイルの大半は「どの順番で書くか」「誰がいつブロックを閉じるか」「どのロックを先に取るか」の話で、外から回せるつまみはほとんどありません。スケジューラが塩梅の集まりだったのに対して、ZILは「fsyncが返ったら、もう消えない」という約束を守るための手順の集まり、という印象です。
執筆時点の最新リリースzfs-2.4.4のzil.cとも比べました。違いはプールが停止(suspend)したときのエラーの返し方のあたりだけで、この記事に出てくる仕組みと既定値は2.4.4でも同じです。今回もソースを読んだ結果で、homenasで測った数字は出てきません。
書き込みのたびに記録が残り、たいていは捨てられる
write() と fsync() が通る道
図は横にスクロールできます上の青い道はすべての書き込みが通る。下のオレンジの道は fsync() などで zil_commit() が呼ばれたときだけ。sync=disabled のデータセットでは itx 自体が作られず、zil_commit() も何もせずに戻る。
write()が呼ばれると、zfs_write()は2つのことをします。
1つは、データ本体をARC上のdirtyデータとして今のtxgに積むことです。これは数秒ごと(zfs_txg_timeout、既定5秒)のtxg syncでプール本体に書かれます。もう1つは、zfs_log_write()で「どのファイルの、どこからどこまでを書き換えたか」という記録(itx)を作り、txgごとのリストにつなぐこと(zil_itx_assign())です。
読んでいて意外だったのは、このitxがふつうの非同期書き込みでも毎回作られることでした。書き込みの記録は、O_SYNCで開かれたファイルなら「同期リスト」に、それ以外ならファイルごとに分けた「非同期の木」にぶら下がります。ファイルの作成や削除、renameの記録は、最初から同期リストに入ります。
誰もfsync()を呼ばないまま、そのtxgがプール本体に書き終わると、zil_clean()がitxをまとめて捨てます。ZILブロックとしては1バイトも書かれません。ほとんどの書き込みの記録は、この道をたどって消えていきます。
fsync()が呼ばれると話が変わります。zil_commit()は、まずそのファイルの非同期の記録を同期リストに移し(zil_async_to_sync())、同期リストのitxをまとめて取り出します。それをZILブロックの下書き(lwb)に詰めてディスクに書き、flushが終わるのを待ってから戻ります。同期リストには他のファイルの作成や削除の記録も入っているので、それらも一緒に書かれます。SLOGが関わるのは、この下の道だけです。
あのときのrsyncに当てはめると、itxは作られては捨てられ、を繰り返していたはずです。rsyncは(私の使い方では)fsyncを呼んでいなかったので、ZILブロックはほとんど書かれず、SLOGには出番がありませんでした。
ついでにsync=disabledの中身も分かりました。zil_replaying()という関数は、名前に反してsync=disabledのときにもtrueを返します。それを見たzfs_log_write()などはitxを作らずに戻り、zil_commit()も入口で何もせずに0を返します。fsyncが一瞬で返る理由はこれで、そのfsyncが何も保証しない理由でもあります。
ディスクの上では、ただの片方向のチェーン
ディスク上の ZIL: ヘッダーから始まる片方向のチェーン
図は横にスクロールできますチェーンの情報(zil_chain_t)は昔はブロックの末尾にあった。今は先頭にあり(Slim ZIL)、確保したブロックのうち使った部分だけを書ける。古い形式も読める。
ZILはプールに1本ではなく、データセット(とzvol)ごとに1本ずつあります。データセットのヘッダーにあるzh_logが最初のZILブロックを指し、各ブロックの頭には次のブロックの場所が書かれています。ブロックを上書きすることはなく、新しいブロックをつないで伸ばしていきます。
面白いのは、次のブロックの場所は、今のブロックを書き出す前に確保されることです。zil_lwb_write_issue()は、今のlwbの書き込みを発行する直前にzio_alloc_zil()で次のブロックを確保し、その場所を今のブロックの頭に書き込みます。こうしておけば、停電の後でもチェーンを先頭から順にたどれます。逆に、次のブロックを確保できなかったときはチェーンをそれ以上伸ばせないので、ZILはいったん止まり、txgの書き出しが終わるのを待ちます(zil_commit_writer_stall())。
ZILが読み返されるのは、前の記事にも書いた通りクラッシュの後だけです。プールのimport時にzil_claim()でブロックを使用中として押さえ、データセットのmount時にzil_replay()で記録を再実行します。ふだんは、txgがプール本体に書き終わった時点で、その分のブロックがzil_sync()で先頭から解放されていきます。
lwbという下書きの一生
lwb の一生(7つの状態)
図は横にスクロールできます各箱の下の行は、その状態に移す関数。相乗りの記録を受け付けるのは OPENED の間だけ。READY から ISSUED へは、ブロックのディスク上の場所(前の lwb を発行するときに確保される)が決まりしだい進む。fsync() を待っているスレッドが起こされるのは FLUSH_DONE になったとき。
メモリの上でZILブロック1個分を表すのがlwb(log write block)です。include/sys/zil_impl.hに7つの状態が定義されています。全部覚える必要はなくて、押さえておきたいのは3点です。
まず、新しい記録を受け付けるのはOPENEDの間だけです。lwbが閉じられる(CLOSED)のは、いっぱいになったとき、相乗りを待つ必要がないと判断したとき、相乗りの待ち時間が切れたとき、のどれかです。後の2つは、次の次の節で書きます。
次に、READYからISSUEDへ進むには、そのブロックのディスク上の場所が要ります。場所は前のlwbを発行するときに確保されるので、lwbの発行は前から順に数珠つなぎで進みます。実際、zil_lwb_write_issue()は自分のlwbを発行した後、次のlwbがREADYで待っていれば、続けてそれも発行します。
最後に、fsyncを待っているスレッドが起こされるのはWRITE_DONEではなくFLUSH_DONEです。書き込みが完了しても、まだデバイスの揮発性キャッシュにいるだけかもしれないので、flushが終わるまでは返しません。
この最後の点を外すのがzil_nocacheflushです。1にすると、lwbが書いたデバイスを覚えておく関数(zil_lwb_add_block())が入口で何もせずに戻り、flushが一切出なくなります。fsyncは書き込みが完了した時点で返ります。SLOGの記事で書いた「PLPのないSSDは、完了を返した後でも電源断でデータを失いうる」という話と並べると、このパラメータが何を手放すのかがよく分かります。私は触るつもりはありません。
データをログに入れるのか、場所だけ書くのか
書き込みデータをログに入れるか、場所だけ書くか(zil_write_state)
図は横にスクロールできます既定値で記載(zfs_immediate_write_sz=32768、zil_special_is_slog=1、zil_maxcopied=7680)。「非同期の書き込み」は O_SYNC / O_DSYNC でも sync=always でもない、ふつうの write()。ブロックはそのファイルのブロックサイズ(多くは recordsize)。
itxに書き込みデータをどう持たせるかは、zil_write_state()が書き込みのたびに決めます。答えは3通りです。
| 状態 | データの扱い |
|---|---|
WR_INDIRECT | データはプール本体の本来の場所に直接書く(dmu_sync())。ログには、そのブロックの場所だけを残す |
WR_COPIED | itxを作る時点でデータをコピーして持たせる。1つの記録は7,680バイト(zil_maxcopied)まで |
WR_NEED_COPY | itxには場所だけ持たせ、ZILブロックに書く直前にデータを読みに行く。fsyncされずに捨てられれば、読みに行くこともない |
この分かれ道のうち、SLOGまわりで押さえておきたいのは3つです。
1つ目。プールにSLOGがあると、WR_INDIRECTは使われません(logbias=throughputとO_DIRECTの場合を除く)。同期書き込みのデータはすべてZILブロックに入り、SLOGに書かれます。SLOGへの書き込み量は、ほぼ同期書き込みの量そのものです。
逆にSLOGのないプールでは、32KiB以上で、ブロック(多くはrecordsize)の半分以上を書き換える同期書き込みはWR_INDIRECTになります。このとき、データはHDDの本来の場所に書かれ、ZILブロックの書き込みはそのデータの書き込みが終わるのを待ちます(lwb_child_zio)。大きな同期書き込みのfsyncは、データとログの両方の書き込みを待つことになります。
2つ目はlogbias=throughputです。これを設定したデータセットは、SLOGがあっても必ずWR_INDIRECTになります。書き込みについてSLOGに残るのは場所の記録だけで、データ本体はプール本体に行きます。大きな順次書き込みでSLOGを埋めたくないデータセットには使い道がありますが、知らずに設定されていると、SLOGはほとんど素通りされます。
3つ目。SLOGがなく、special vdevがあるプールでは、zil_special_is_slog(既定1)によって、special vdevがSLOGの代わりのように扱われます。ブロックそのものがspecialに入る(ブロックサイズがspecial_small_blocks以下の)ときだけWR_INDIRECTを許し、そうでなければデータをログに入れて、そのログブロックをspecialに置きます。置き場所の順番は後の図で出てきます。
homenasはOptaneのSLOGとspecialの両方を持っているので、SLOGの判定が先に効いて、同期書き込みのデータは全部SLOGに行っているはずです。logbiasをいじったデータセットはありません。
相乗りを待つのは、並んでいるときだけ
fsync の相乗り: 待つのは並行しているときだけ
図は横にスクロールできます横軸は時間の流れを表すだけで、長さは実測ではない。B でも lwb がいっぱいになれば、タイムアウトを待たずにその場で閉じて発行する。「直近の lwb 所要時間」は lwb の発行から flush 完了までの時間の移動平均(前回までの値 7、新しい値 1 の割合)。
fsyncが同時にたくさん来ると、ZILは記録を1つのlwbにまとめて、書き込みの回数を減らそうとします。ただ、その待ち方は思っていたより慎重でした。
zil_commit()を呼んだスレッドは、まず自分専用の待ち札(commit waiter)と、それをぶら下げた特別な記録(commit itx)を作ってキューに入れます。この記録はディスクには書かれず、lwbに詰められた時点で「このlwbが確定したら起こしてほしい」という印に変わります。そのうえでzl_issuer_lockを取り、キューの記録をlwbに詰めます。このロックを持てるのは一度に1スレッドだけで、ロックを待っている間に他のスレッドが自分の分まで詰めてくれていたら、何もせずに抜けます。コメントによると、32CPU・1,024スレッドで同期書き込みをする環境で、ロックの取り合いに使うCPUが最大5%減ったそうです。
詰め終わった後、lwbをすぐ閉じるかどうかはzil_process_commit_list()の最後で決まります。
- 並行してfsyncしているスレッドがいないとき(
zl_parallelが0)は、lwbをその場で閉じて発行します。待ちません。 - 並行していると判断したときは、lwbを開けたままにして戻ります。後から来るスレッドの記録が同じlwbに入るのを期待しているわけです。
開けたままのlwbを最後に閉じるのは、待っているスレッド自身です(zil_commit_waiter())。待つ時間の上限は「直近のlwbの所要時間 × zfs_commit_timeout_pct(既定10)%」です。仮に直近のlwbが、発行からflush完了まで1msかかっていたなら、待つのは最長0.1msです。この所要時間は、lwbが完了するたびに、前回までの値7に対して新しい値1の割合で混ぜる移動平均で更新されます。
「並行している」の判断も具体的でした。fsyncが来たときに前のlwbがまだ開いていればzl_parallelを8に、前のlwbのflushがまだ終わっていなければ少なくとも4にして、そこからburst(ひとまとまりの書き込み)が終わるたびに1ずつ減らします。並行が途切れても、数回のburstの間は「待つ」側に倒れたままになります。
zfs_commit_timeout_pctを上げれば相乗りは増え、1回ごとのfsyncは遅くなるはずです。試してはいません。DBのように多数の接続が同時にcommitする環境なら違いが見えるかもしれませんが、homenasにはfsyncを並行して大量に出す相手がいないので、手を出す理由がありません。
順番を守る親子関係と、flushの先送り
前の lwb より先には終わらない / flush は待ち人のいる lwb がまとめて出す
図は横にスクロールできます#1 に待ち人がいないのは、たとえば大きな fsync の記録が lwb 2つにまたがったとき。fsync を待つ印(commit itx)は最後の lwb にだけ入る。flush を預けられるのは、次の lwb がすでに発行済みのときだけ。
lwbは、発行された順にしか完了しません。zil_lwb_set_zio_dependency()が、新しいlwbのzioを、前のlwbのzioの「親」にしているからです。ZFSのzioは、子がすべて完了するまで親が完了しないので、これで完了の順番が発行の順番にそろい、待ち札も古い順に起こされます。
なぜ順番にこだわるのか、コメントは「zil_commit()の意味論のため」としか書いていません。私の理解では、replayがチェーンを先頭から順にたどるからです。後ろのlwbだけ先に確定させてfsyncを返しても、その時点で前のブロックが欠けていれば、停電後のreplayは欠けた所で止まり、返したはずの記録までたどり着けません。
同じ関数には、flushの先送りを支える役目もあります。zil_lwb_write_done()は、書き込みが終わったlwbに待ち札が1つもなく、次のlwbがすでに発行済みなら、自分ではflushを出さずに「書いたデバイスの一覧」を次のlwbに預けます。次のlwbがflushを出すとき、預かった分もまとめて片付けます。このとき、次のlwbのflushが前のlwbの書き込みより先に出てしまわないように、書き込みのzioにも親子関係を張っています。コメントには、非同期書き込みが多く同期書き込みが少ないワークロードで、同じデバイスへの複数のflushを1回にまとめられる、とあります。flushが重いデバイスほど効きそうです。
ZILブロックの置き場所は5段ある
ZIL ブロックの置き場所を探す順番(zio_alloc_zil)
図は横にスクロールできます上から順に試し、確保できた所で止まる。SLOG がない、いっぱい、壊れている、のどれでも 2 段目以降に下りる。vdev.c の冒頭コメントには 3 段(SLOG → 予約枠 → 通常)しか書かれていない。
ZILブロックをどこに置くかは、module/zfs/zio.cのzio_alloc_zil()が決めます。上から順に試して、確保できた所で止まります。
1段目で確保できたかどうかはLWB_FLAG_SLOGとして記録され、次の節の優先度の切り替えと、最後に見る統計の振り分けに使われます。SLOGがいっぱいでも壊れていても、ZILは止まらずに下の段に置き場所を移します。言い換えると、SLOGが小さすぎてあふれていても、エラーにはならず、静かにspecialやHDDのほうに書いているかもしれない、ということです。
2段目と4段目の「ZIL予約枠」(embedded log)は、各vdevのメタスラブのうち、プールを開いた時点で空きがいちばん多いもの1個を、ZIL専用に取り分けたものです。メタスラブが64個(zfs_embedded_slog_min_ms)を超えるvdevだけが対象です。目的はコメントに書かれていませんが、ZILのような短命なブロックで通常の領域を細切れにしないため、と私は理解しています。
vdev.cの冒頭にもこの順番の説明があるのですが、そこには3段(SLOG → 予約枠 → 通常の領域)しか書かれていません。special vdevの2段は後から足されたようです。vdev_queue.cを読んだときにも、コメントが機能の追加に追いついていない箇所がありました。
64MiBを超えると、SLOGへの書き込みは遠慮がちになる
SLOG への書き込みは 64MiB を超えると控えめになる
図は横にスクロールできます横軸は 1 回の burst(commit の列が空になるまで続く書き込みのひと続き)で ZIL に書く量。目盛りは比例していない。同期・非同期のクラスの上限は vdev_queue.c の既定値(ディスク 1 台あたり)。
zil_lwb_write_issue()は、lwbを書くzioの優先度を次のように決めます。lwbがSLOG以外にあるか、今のburstでZILに書く量(zl_cur_size)がzil_slog_bulk(既定64MiB)以下なら同期書き込み。SLOGにあって64MiBを超えたら、非同期書き込みの優先度に落とします。コメントには「1つのZILの書き手がSLOGを使い倒さないように」という趣旨のことが書かれています。
vdev_queue.cの回で見たように、同期書き込みのクラスはディスク1台あたり同時10本で固定、非同期書き込みのクラスはdirtyデータの量に応じて2〜10本です。大きな同期書き込みを延々と流すデータセットがあっても、64MiBを超えた分は控えめに扱われ、他のデータセットのfsyncがSLOGの前で長く待たされにくくなる、という狙いだと読みました。
ただし、masterでは回転しないブロックデバイス(SSDやNVMe)が既定でキューを通らなくなりました(vdev_queue.cの回の最後に書いたschedulerプロパティ)。OptaneのSLOGはキューを通らないので、この優先度の差がキューの中で効く場面は、2.4.4より狭くなりそうです。キューの外で優先度が使われる箇所までは、まだ追えていません。
当時の自分に見せたい値
ZILの統計は、Linuxではプール全体とデータセットごとの2箇所で見られます。
# 全体
cat /proc/spl/kstat/zfs/zil
# データセットごと(dataset_name の行で、どのデータセットか分かる)
grep -e dataset_name -e zil_ /proc/spl/kstat/zfs/homenas/objset-0x*
| 値 | 読み方 |
|---|---|
zil_commit_count | zil_commit()が呼ばれた回数(fsyncなど) |
zil_commit_writer_count | そのうち、自分でキューを処理した回数。zil_commit_countとの差が、他のスレッドに相乗りできた回数の目安になる |
zil_itx_count | 実際にZILブロックに書かれた記録の数。捨てられた記録は数えない |
zil_itx_indirect_bytes / zil_itx_copied_bytes / zil_itx_needcopy_bytes | 上の分かれ道で、それぞれの道を通ったデータの量 |
zil_itx_metaslab_slog_bytes / zil_itx_metaslab_normal_bytes | ZILブロックがSLOGに置かれたか、それ以外に置かれたか |
zil_commit_stall_count | 次のブロックを確保できず、txgの書き出しを待った回数 |
あのとき、rsyncを流しながらzil_itx_countを見ていれば、ほとんど動かないのが分かったはずです。SLOGを買う前に見るべきだったのは、この1行でした。今のhomenasでも、NFSの共有でどれだけの書き込みがSLOGに行っているのか、zil_itx_metaslab_slog_bytesとzil_itx_metaslab_normal_bytesを並べて一度確かめてみるつもりです。
読み終えて
「SLOGは同期書き込みにしか効かない」は、コードの上では「記録は毎回作られるが、fsyncが来なければディスクに書かれずに捨てられる」という形をしていました。SLOGが変えるのは、5段ある置き場所の1段目があるかどうかと、それにともなう2つの分かれ道(WR_INDIRECTを使わない、64MiBで優先度を切り替える)くらいです。あのとき速くならなかったのは当然で、そもそも書かれていなかったのでした。
その一方で、fsyncが返るまでの道のりには、相乗りを待つ時間、前のブロックを待つ順番、flushの先送りと、思っていたより多くの工夫が詰まっていました。fsyncのレイテンシに悩むワークロードでは、SLOGのデバイス選びと同じくらい、このあたりの振る舞いが効いてくる場面もありそうです。
行番号と既定値は4542a31時点のものです。図は自分で読んで起こしたもので、細部はGitHubの該当行で確かめてください。主な関数の位置を残しておきます。行番号はGitHubの該当行へのリンクです。
| 関数 | 行 | この記事での役割 |
|---|---|---|
zil_itx_assign() | 2725 | itxをtxgごとのリストにつなぐ |
zil_clean() | 2873 | txgが書けたらitxを捨てる |
zil_commit_flags() | 3986 | fsyncの入口。sync=disabledならここで戻る |
zil_process_commit_list() | 3157 | itxをlwbに詰め、すぐ閉じるかどうかを決める |
zil_commit_waiter() | 3612 | 待ち札を待つ。タイムアウトしたらlwbを閉じる |
zil_lwb_write_issue() | 1951 | lwbを発行し、次のブロックを確保する。優先度もここで決まる |
zil_lwb_write_done() | 1600 | flushを出すか、次のlwbに預けるか |
zil_lwb_set_zio_dependency() | 1711 | 完了の順番を守る親子関係 |
zil_lwb_flush_vdevs_done() | 1488 | 待ち札を起こす。所要時間の移動平均もここ |
zil_write_state() | 2226 | WR_INDIRECT / WR_COPIED / WR_NEED_COPYの分かれ道 |
zil_sync() | 4158 | txgが書けたらZILブロックを解放する |
zio_alloc_zil()(zio.c) | 4925 | ZILブロックの置き場所を探す |
投稿者: 宮田 | カテゴリー: パフォーマンス | 難易度: 上級