設定ファイルの更新時、読者には古い版の完全版か新しい版の完全版のどちらかが見えるようにしたい。しかし、ロック、rename、fsync がそれぞれ保証することは異なる。本稿では、この更新を所有権、可視性、永続性の観点から分解し、データ転送におけるバッファの寿命について考察する。
| 説明すべき現象 | 読むべき箇所 |
| 別の fd をクローズすると、なぜロックも消えるのか? | 第 1~2 節:inode とロック所有者 |
| rename の成功はすでにクラッシュに耐えうるのか? | 第 3 節:ファイルとディレクトリの個別同期 |
| sendfile 返却後、ソースの内容は変更可能か? | 第 4 節:コピーと参照の境界 |
| 遅延はロック、ログ、デバイスどちらにあるのか? | 第 5 節:完了ポイントごとの観測 |
1. inode フラグは単純な列挙状態マシンではない
i_state は合成可能な状態ビットの集合であり、I_NEW、I_DIRTY_SYNC などを 0 から始まる順序番号のように偽装してはならない。I_NEW は初期化の公開に関連し、dirty ビットは異なる書き戻し要件を記述し、I_FREEING などは回収の調整に関与する。具体的なビット値はターゲットカーネルの実装に属しており、バージョン横断的なチュートリアルの固定定数としては適さない。
I_DIRTY_DATASYNC は、fdatasync によって処理される必要がある inode メタデータの変更(データ読み取りに影響を与えるメタデータなど)を示し、単純な「データページの dirty」を意味するものではない。データページの dirty 状態は、mapping/folio レベルで別途追跡される。フィールド名が似ているからといって、保護範囲が同じであるわけではない。
参照カウントが 0 になっても、inode を直ちに破棄する必要はない。キャッシュ可能な未使用の inode はキャッシュ内に残しておき、回収時に追放できる。リンクされていない inode などの状況では異なる処理が行われる。初期化中の検索者、書き戻し者、回収者は調整を行う必要があり、unlock_new_inode() の役割は単なる「フラグのクリア」ではない。fs/inode.c と VFS ライフサイクルの説明 を合わせて読む方が、固定された関数チェーンを暗記するよりも信頼性が高い。
2. 3 種類のロックの所有者
| ロック | 範囲と所有者 | dup / fork | 解放の境界 |
POSIX F_SETLK/F_SETLKW | バイト範囲;プロセスに関連 | 子プロセスは親プロセスのロックを継承しない | そのプロセスが同一ファイルを指す任意の fd をクローズすると、そのファイルに対するこの種のロックが解放される |
OFD F_OFD_SETLK/F_OFD_SETLKW | バイト範囲;オープンインスタンスに関連 | オープンインスタンスを共有する記述子がロックを共有 | 関連する最後のオープン参照がクローズされるか、明示的にロック解除される |
Linux ローカル flock | 通常はファイル全体;オープンインスタンスに関連 | オープンインスタンスを共有すると、ロックも共有される | 関連する最後の参照がクローズされるか、明示的にロック解除される |
独立した open で得られた OFD 所有者は異なるため、同一プロセス内でも競合が発生する。同一オープンインスタンスの dup は、独立した競合者を生成しない。OFD の struct flock.l_pid 入力には 0 を指定すべきであり、OFD ロックを検索した際の PID フィールドは、通常のプロセスロックのような意味を持たない。fcntl 範囲ロック を参照。
これらは通常協調的ロックである。他の呼び出し者が規約に従ってロックを追加しない場合、依然として内容を読み書きできる可能性がある。歴史的な Linux の mandatory locking は、現代の一般的な保証として扱ってはならない。ローカルの flock と POSIX ロックは通常独立したメカニズムだが、NFS/SMB の実装では範囲ロックをシミュレートして相互作用することがあり、ローカルでの振る舞いをネットワークファイルシステムにそのまま持ち込むことはできない。flock(2) を参照。
l_len=0 は、ロックがファイル末尾まで及び、その後の拡張範囲も含むことを意味し、空の範囲を意味するものではない。ブロッキングロックでは、シグナル、終了、デッドロックの処理も必要であり、F_GETLK が返すのはその瞬間に観察された競合のみであり、その後の状態は変化する可能性がある。「まずロックがないことを確認し、次にロックなしで書き込む」という手法で、実際のロック取得を代替してはならない。
OFD の最終参照境界の検証
以下の Linux C サンプルは一時ファイルを使用し、2 回の独立した open と 1 回の dup を行う。struct flock の手動バイナリレイアウトに依存しない:
#define _GNU_SOURCE
#include <assert.h>
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
static int set_lock(int fd) {
struct flock lock = {
.l_type = F_WRLCK, .l_whence = SEEK_SET,
.l_start = 0, .l_len = 1, .l_pid = 0
};
return fcntl(fd, F_OFD_SETLK, &lock);
}
static void expect_conflict(int fd) {
int rc = set_lock(fd);
assert(rc == -1 && (errno == EAGAIN || errno == EACCES));
}
int main(void) {
char path[] = "/tmp/ofd-demo-XXXXXX";
int a = mkstemp(path);
assert(a >= 0);
int b = open(path, O_RDWR | O_CLOEXEC);
assert(b >= 0);
assert(unlink(path) == 0);
int alias = dup(a);
assert(alias >= 0);
assert(set_lock(a) == 0);
expect_conflict(b);
assert(close(a) == 0);
expect_conflict(b);
assert(close(alias) == 0);
assert(set_lock(b) == 0);
assert(close(b) == 0);
puts("OFD 独立所有者と最終参照:通過");
}
cc -std=c11 -Wall -Wextra -Werror ofd-demo.c -o ofd-demo でコンパイルできる。本例ではデモンストレーション検証のためにアサーションを使用しているが、実行時には -DNDEBUG を付けないこと。本番コードでは、各失敗分岐を明示的に処理すべきである。
3. 永続的置換:ファイル内容とディレクトリエントリの個別コミット
設定ファイルに対しては、読者が古い版の完全版か新しい版の完全版のどちらかしか見ないようにすることが一般的な目標である。元のファイルを直接 truncate してから書き込むと、並行して読み込んでいる読者に空ファイルや部分内容が見えてしまう。まず同一ディレクトリ内に一時ファイルを用意し、完全に書き込んで同期し、その後 rename でターゲットを置き換え、最後に親ディレクトリを同期する。
更新を 4 つの観測ポイントに分解する:
| ステップ | ここで確立される境界 | 未完了のこと |
| 一時ファイルへの書き込み完了 | データがインターフェースの受信側に書き込まれた | 同期完了の保証はない |
| 一時ファイルの fsync | 内容と必要なメタデータが同期された | ターゲットの名前がまだ置換されていない |
| rename によるターゲット置換 | ランタイム時の名前バインディングが変更された | ディレクトリの更新はまだ同期が必要 |
| 親ディレクトリの fsync | 契約に従ってディレクトリ更新が同期された | ファイル間トランザクションが自動的に形成されるわけではない |
最後のステップ前にクラッシュした場合、rename の成功だけで再起動後の名前状態を保証することはできない。fsync(2) を参照。
以下では、ファイル内容の同期とディレクトリの同期を 2 つの独立したアクションとして扱う。任意の段階でクラッシュをシミュレートし、どの名前バインディングで回復し、どのバージョンが読まれるかを直接確認できる。
図を準備しています
名前の原子置換はされたが、内容も永続化されたか?rename はランタイム時の名前バインディングを変更する。ファイル fsync とディレクトリ fsync は、それぞれ内容と名前の永続的境界を確立する。既存の fd は依然として古い inode を参照できる。
{"id":"durable-file-replacement","title":"名前の原子置換はされたが、内容も永続化されたか?","summary":"rename はランタイム時の名前バインディングを変更する。ファイル fsync とディレクトリ fsync は、それぞれ内容と名前の永続的境界を確立する。既存の fd は依然として古い inode を参照できる。","height":1000,"html":"<h2 data-i18n=\"heading\"></h2><p class=\"intro\" data-i18n=\"intro\"></p><div class=\"actions\"><button id=\"write\" data-i18n=\"write\"></button><button id=\"fileSync\" data-i18n=\"fileSync\"></button></div><div class=\"actions\"><button id=\"rename\" data-i18n=\"rename\"></button><button id=\"dirSync\" data-i18n=\"dirSync\"></button></div><div class=\"track-row\"><span data-i18n=\"live\"></span><div id=\"live\" class=\"track\"></div></div><div class=\"track-row\"><span data-i18n=\"stable\"></span><div id=\"stable\" class=\"track\"></div></div><div class=\"track-row\"><span data-i18n=\"held\"></span><div id=\"held\" class=\"track\"></div></div><div class=\"track-row\"><span data-i18n=\"b\"></span><div id=\"b\" class=\"track\"></div></div><div class=\"actions\"><button id=\"crash\" data-i18n=\"crash\"></button></div><div class=\"card\"><header data-i18n=\"recovered\"></header><div id=\"recovered\" class=\"readout\"></div></div><div class=\"copy\"><p id=\"explanation\" role=\"status\"></p><p class=\"reserve\" aria-hidden=\"true\" data-i18n=\"note\"></p></div><div class=\"actions\"><button id=\"reset\" data-i18n=\"reset\"></button></div><details class=\"scope\"><summary data-i18n=\"scopeLabel\"></summary><p data-i18n=\"scope\"></p></details>","css":".intro{margin:8px 0 18px;color:var(--muted);font-size:14px}.caption,.hint{font-size:12px;color:var(--muted);margin-top:12px}.control{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px 12px;align-items:center;margin:14px 0;font-size:13px}.control label{font-size:13px}.control output{color:var(--accent);min-width:5em;text-align:right;font-variant-numeric:tabular-nums}.control input{grid-column:1/-1;width:100%;margin:0}.presets{display:flex;gap:4px;align-items:stretch}.presets button{flex:1 1 0;min-width:0;overflow-wrap:anywhere;font-size:13px}.actions{display:flex;gap:8px;flex-wrap:wrap;margin:14px 0}.actions button{flex:1;min-width:0;font-size:13px;min-height:42px}.copy{display:grid;font-size:14px;margin:16px 0}.copy>*{grid-area:1/1;margin:0}.reserve{visibility:hidden}.scope{margin-top:14px;color:var(--muted);font-size:12px}.scope summary{padding:8px 0;cursor:pointer}.scope p{margin-top:10px}.two{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.card{border:1px solid var(--rule);border-radius:9px;padding:14px;min-width:0}.card header{font-size:14px;font-weight:600;margin-bottom:10px}.card button{font-size:12px;width:100%;min-width:0}.card small{display:block;font-size:12px;color:var(--muted)}.card code{font-size:12px;overflow-wrap:anywhere}.metrics{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.metrics>div{border-left:2px solid var(--accent);padding-left:10px;min-width:0}.metrics span{display:block;min-height:3.2em;color:var(--muted);font-size:12px}.metrics strong{font-size:23px;display:block;font-weight:500;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.formula{border-top:1px solid var(--rule);padding-top:12px;margin-top:14px;font-size:13px;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}.value{font:500 20px var(--viz-font-ui)}.legend{display:flex;flex-wrap:wrap;gap:6px 14px;font-size:12px;color:var(--muted);margin:12px 0}.legend span:before{content:'';display:inline-block;width:8px;height:8px;border-radius:2px;background:var(--accent);margin-right:6px}.legend .second:before{background:var(--second)}.check{display:flex;align-items:center;gap:8px;font-size:13px;margin:12px 0}.check input{width:16px;height:16px;flex-shrink:0;accent-color:var(--accent)}select,input[type=text],input[type=number]{background:var(--paper);color:var(--ink);font:inherit;font-size:13px;padding:10px;border:1px solid var(--rule);border-radius:7px;max-width:100%;min-width:0}select{width:100%;min-height:42px}.band{height:20px;display:flex;background:var(--surface);border-radius:4px;overflow:hidden}.band i{height:100%;display:block;background:var(--accent);transition:width .25s}.band i.second{background:var(--second)}button:disabled{cursor:default}.status{font-size:13px;min-height:4.8em}.mono{font-family:var(--viz-font-mono);font-variant-numeric:tabular-nums}.muted{color:var(--muted)}\n@media(max-width:480px){.two{gap:8px}.card{padding:12px 9px}.actions button,.presets button{font-size:12px}.metrics strong{font-size:20px}.metrics{gap:8px}}\n@media(prefers-reduced-motion:reduce){*,*::before,*::after{transition:none!important;animation:none!important}}\n.track-row{margin:16px 0}.track-row>span{display:block;font-size:12px;color:var(--muted);margin-bottom:7px}.track{display:flex;gap:5px;min-width:0}.cell{flex:1;min-width:0;min-height:48px;border:1px solid var(--rule);border-radius:5px;background:var(--surface);display:flex;align-items:center;justify-content:center;text-align:center;padding:5px 3px;font-size:12px;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.cell.on{background:var(--accent-soft);border-color:var(--accent)}.cell.gold{background:var(--second-soft);border-color:var(--second)}.cell.empty{border-style:dashed;color:var(--muted)}.cell.error{border-color:var(--second);text-decoration:line-through}.choices{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px;margin:14px 0}.choices button{min-width:0;padding:10px 4px;font-size:12px;overflow-wrap:anywhere}.choices button[aria-current=true]{border-color:var(--accent);background:var(--accent-soft)}.relation{display:grid;grid-template-columns:minmax(0,1fr) 28px minmax(0,1fr);align-items:center;gap:6px;margin:12px 0}.relation .cell{min-height:72px}.relation>i{text-align:center;font-style:normal;color:var(--accent)}.readout{font-size:13px;white-space:pre-line;min-height:6.4em;overflow-wrap:anywhere}.plot{position:relative;border-left:1px solid var(--rule);border-bottom:1px solid var(--rule);padding:8px 0;margin:18px 0}.plot-row{height:28px;position:relative;margin:4px 0;background:var(--surface)}.plot-row i{position:absolute;inset-block:5px;background:var(--accent);min-width:2px;transition:width .2s,left .2s}.plot-row b{position:relative;padding:4px 6px;font:11px/28px var(--viz-font-ui);background:var(--paper);border-radius:3px}.axis{display:flex;justify-content:space-between;font-size:11px;color:var(--muted)}\n\n.readout{min-height:4.8em}","js":"const $=s=>document.querySelector(s);const set=(id,v)=>$('#'+id).textContent=v;const t=k=>viz.t(k);function cells(id,values){$('#'+id).replaceChildren(...values.map(v=>{const e=document.createElement('div');e.className='cell '+(v.cls||'');e.textContent=v.text;e.title=v.title||v.text;return e;}));}function pressed(mode){document.querySelectorAll('[data-mode]').forEach(e=>e.setAttribute('aria-pressed',String(e.dataset.mode===mode)));}let binding='A',saved='A',b=0,bStable=0,dead=false;const value=v=>v?'N'+v:t('empty');function draw(){cells('live',[{text:'target → '+binding+' → '+(binding==='A'?'O':value(b)),cls:'on'}]);cells('stable',[{text:'target → '+saved+' → '+(saved==='A'?'O':value(bStable)),cls:'gold'}]);cells('held',[{text:dead?'—':'fd → A → O',cls:dead?'empty':'on'}]);cells('b',[{text:value(b)+' / '+value(bStable)}]);set('recovered',dead?saved+' → '+(saved==='A'?'O':value(bStable)):t('running'));for(const id of ['write','fileSync','rename','dirSync','crash'])$('#'+id).disabled=dead||(id==='rename'&&binding==='B');set('explanation',t('note'));}$('#write').onclick=()=>{b++;draw();};$('#fileSync').onclick=()=>{bStable=b;draw();};$('#rename').onclick=()=>{binding='B';draw();};$('#dirSync').onclick=()=>{saved=binding;draw();};$('#crash').onclick=()=>{dead=true;draw();};$('#reset').onclick=()=>{binding=saved='A';b=bStable=0;dead=false;draw();};draw();","audio":false,"strings":{"b":"B の現在/安定した内容","crash":"ここで電源断","dirSync":"fsync 親ディレクトリ","empty":"空ファイル","fileSync":"fsync B","heading":"名前の原子置換はされたが、内容も永続化されたか?","held":"古い fd の参照","intro":"新しい inode B が準備完了。書き込み、ファイル同期、rename、ディレクトリ同期を自由に配置し、電源を切断してから、ランタイム時と回復後のターゲットを比較せよ。","live":"ランタイム時:target → inode → 内容","note":"ディレクトリのみ同期し B は同期しない場合、「新しい名前が空の内容を指す」状態になる可能性がある。B を先に同期したがディレクトリを同期しない場合、古い名前バインディングが回復される可能性がある。","recovered":"再起動後に target 経由で読み取る","rename":"rename B → target","reset":"リセット","running":"まだ電源断されていない","scope":"同一ディレクトリ内の単一ファイル置換、I/O エラーなし。B は初期状態で空。フォールトモデルは明示的に同期された内容とターゲットバインディングのみを回復し、同期されなかった変更はすべて破棄される。これは許容される失敗シナリオであり、すべてのファイルシステム結果を列挙するものではない。再起動により元の fd は閉じられる。一時名名の回復、権限、ファイル間トランザクションは含まない。","scopeLabel":"モデル範囲と規約","stable":"回復根拠:バインディング → 安定した内容","write":"B の次のバージョンを書き込む"}}import os
import tempfile
from pathlib import Path
with tempfile.TemporaryDirectory(prefix="replace-demo-") as directory:
root = Path(directory)
target = root / "config"
target.write_bytes(b"old\n")
dirfd = os.open(root, os.O_RDONLY | os.O_DIRECTORY)
oldfd = os.open(target, os.O_RDONLY)
temporary = None
try:
with tempfile.NamedTemporaryFile(dir=root, delete=False) as file:
temporary = Path(file.name)
file.write(b"new\n")
file.flush()
os.fsync(file.fileno())
os.replace(temporary, target)
temporary = None
os.fsync(dirfd)
assert target.read_bytes() == b"new\n"
assert os.pread(oldfd, 4, 0) == b"old\n"
print("新規オープンで新バージョンが見える;古いオープン参照は依然として旧ファイルを読む:通過")
finally:
if temporary is not None:
temporary.unlink(missing_ok=True)
os.close(oldfd)
os.close(dirfd)
これはランタイム時の可視性と古い参照を検証するものであり、電源断をシミュレートするものではない。本番環境での更新では、新しいファイルの権限、所有者、ACL/xattr の設定、失敗後の状態の不確実性の処理を行う必要がある。ディレクトリをまたぐ移動には関連するディレクトリが含まれ、ファイルシステム横断の rename は通常この方法では完了できない。複数のファイルが各自の fsync を持ったからといって、自動的に同じトランザクションを形成するわけではない。
inode を置換しても、古いロックは自動的に移動しない
ロックと置換の組み合わせにはもう一つの罠がある。古い inode 上のロックは、置換後の新しい inode には自動的に移動しない。各プロセスが「現在のファイルを開いてロックする」場合、異なるバージョンにそれぞれロックがかかる可能性がある。更新の調整には、ID が安定しており、参加者が共通で遵守するロックオブジェクト、またはより高レベルのプロトコルが必要である。
4. sendfile / splice:どのデータ転送を減らすか
| インターフェース | 接続されるデータパス |
| sendfile | アプリケーションがまず read でユーザーバッファに読み込み、その後 write で下流に書き込むのを回避 |
| splice | 少なくとも一端が pipe であり、パイプバッファを介してサポートされるオブジェクトを接続 |
| vmsplice | ユーザーメモリ範囲を pipe に接続 |
カーネルが元のページを参照するか、コピーするか、中間バッファを使用するかは、ファイルシステム、転送、暗号化、デバイス能力、および具体的なパスに依然として依存する。
「ゼロコピー」とは通常、特定 CPU のデータコピーを減らすことを指し、DMA がない、ページ参照コストがない、sendfile が固定で 1 回コピー、SG を追加すると固定で 0 回コピーであることを保証するものではない。1 回のシステムコールにおけるユーザー/カーネル変換は、別のプロセスへの切り替えに相当しない。ブロッキング、スケジューリング、割り込みはまた異なるオーバーヘッドを増加させる。sendfile(2) を参照。
| インターフェース契約 | アプリケーションが維持する必要がある状態 |
| 正数を返すが、要求された長さより短い | 対応するオフセットを進め、残りの範囲を継続 |
| ノンブロッキングで EAGAIN を返す | 書き込み可能/読み込み可能になるまで待機し、完了した進捗を保持して再試行 |
| 下流が依然としてソースページを参照している | コンシューマーが完了する前に、依然として参照されている内容を変更しない |
| pipe には容量制限がある | 生産/消費のバックプレッシャーを管理し、無限に詰め込めない |
成功返却とソースメモリの再利用可能さは異なるイベント
vmsplice(SPLICE_F_GIFT) はメモリページ使用に対する厳格な約束であり、アラインメントなどの制限に従うことを要求し、その後贈与されたメモリを変更しないことを保証する。返却成功が自動的にユーザーマッピングを解除したり、アプリケーションに「即時再利用可能」であることを通知するものではない。通常の malloc バッファを呼び出し後に直ちに書き換えて、ネットワーク送信が完了したと仮定してはならない。vmsplice(2) を参照。
5. 観測はオブジェクトと完了ポイントに従う
/proc/locks、lslocks はロックの種類と範囲を観測できる。表示内容は PID namespace などの要因の影響を受け、OFD ロックは通常のロック保持 PID には対応しない。/proc/sys/fs/inode-state はシステムキャッシュの状態であり、現在アクティブなファイルの正確な数量ではない。同期遅延は、ダーティページ書き出し、ログコミット、デバイス完了、ネットワーク確認を区別する必要がある。単一の syscall の総所要時間から直接、ある層の破損を推論することはできない。
ソースコードのエントリポイントは、fs/inode.c、ファイルロック実装、fs/sync.c、fs/splice.c をそれぞれ沿って検索できる。ファイルロックのソースコードレイアウトはバージョンによって変化する可能性があるため、まずインターフェース名で位置特定を行う。ネットワークファイルの完了と再試行のセマンティクスは NFS に準拠する。