はじめに (対象読者・この記事でわかること)
この記事は、Linux システムプログラミングに興味があり、プロセス生成とファイル I/O の仕組みを深く理解したい方を対象としています。
fork() した直後に子プロセスが open() したファイルに親も書き込める理由、逆に同じ FD から同時に write() するとどうなるのか、close() すべきタイミングなど、実装で「なんとなく動いている」部分を原理レベルで整理します。
記事を読み終えると、ファイルディスクリプタテーブルと vnode の参照カウントの関係、O_APPEND フラグの重要性、親子で排他ロックを掛ける方法が身につき、マルチプロセスログライタやソケット継承型デーモンをバグなく実装できるようになります。
前提知識
この記事を読み進める上で、以下の知識があるとスムーズです。 - Linux の fork()・exec()・open() の基本的な挙動 - ファイルディスクリプタ番号とファイルディスクリプタテーブルの違いをイメージできること - C 言語でポインタと構造体を扱えること
fork() が複製する「ファイルディスクリプタ」とは何か
Unix系カーネルは「ファイルディスクリプタテーブル」と「vnode(正確には file 構造体)」の 2 段階でファイル状態を管理しています。
プロセスごとに持つファイルディスクリプラテーブル(task->files)には「ファイルポインタ(struct file *)」の配列があり、open() するたびに空いているインデックスへポインタが格納されます。
このポインタが実体の vnode を指しており、vnode 側に参照カウンタがあります。fork() ではこのポインタ配列がコピー(現カーネルでは copy-on-write だが論理的には dup() と同じ)されるため、親も子も同じ vnode を指すことになります。
つまり「FD を複製する」=「ファイルポインタの参照カウントを +1 する」だけで、ディスク上のファイルやソケットバッファは全くコピーされません。これが親子で同一ファイルに書き込める根本理由です。
親子で同一 FD に write() する際の問題と正しい実装
ステップ1:書き込みの衝突を観察する最小実験
まずは排他制御なしで同時書き込みがどうなるか体験しましょう。以下のコードをコンパイルして実行すると、子プロセスと親プロセスがそれぞれ 100 万回カウンタをインクリメントして同じファイルへ書き込みます。
C#define _GNU_SOURCE #include <stdio.h> #include <unistd.h> #include <stdlib.h> #include <sys/wait.h> int main(void) { int fd = open("counter.txt", O_WRONLY|O_CREAT|O_TRUNC, 0644); if (fd < 0) { perror("open"); exit(1); } pid_t pid = fork(); if (pid == 0) { /* child */ for (int i = 0; i < 1000000; i++) { dprintf(fd, "%d\n", i); } close(fd); exit(0); } /* parent */ for (int i = 0; i < 1000000; i++) { dprintf(fd, "%d\n", i); } close(fd); wait(NULL); return 0; }
実行後に wc -l counter.txt を叩くと、200 万行にならず、大抵 199 万行前後になります。複数の write() が同一位置を上書きしたり、部分的に失われたりしているためです。
ステップ2:O_APPEND フラグで原子性を担保する
上記 5 行目の open() フラグに O_APPEND を追加するだけで、カーネルは各 write() の先頭を必ずファイル末尾へ移動させてから書き込むため、データの消失はなくなります。
Cint fd = open("counter.txt", O_WRONLY|O_CREAT|O_TRUNC|O_APPEND, 0644);
この状態で再実行すると wc -l で 200 万行が確実に得られます。パフォーマンスを気にするなら、親子で独立したバッファを持たない write() システムコールを直接使う、あるいは fflush() のタイミングを揃える必要があります。
ステップ3:排他ロックを掛けて順序を保証する
ログの順序まで正確に揃えたい場合は flock() や fcntl() で排他ロックを掛けます。子プロセス側の例:
C#include <sys/file.h> for (int i = 0; i < 1000000; i++) { flock(fd, LOCK_EX); dprintf(fd, "child %d\n", i); flock(fd, LOCK_UN); }
ロック取得に失敗した場合は sched_yield() や短い usleep() でリトライします。大量のプロセスを fork() するデーモンでは、ロック競争で性能が落ちるため、ロギング専用の Unix ドメインソケットを用意して単一のロガープロセスに集約する設計が主流です。
ハマった点とエラー解決
- close() したつもりがまだ親から書き込めてしまう
→ ファイルポインタの参照カウントが 0 でないと vnode は解放されない。親子両方でclose()する必要がある。 - ソケットを継承したのに子が exit() すると接続が切れる
→ ソケット FD にSOCK_CLOEXECを設定しておくか、fcntl(fd, F_SETFD, FD_CLOEXEC)で実行時 close フラグを立てておけば exec() 後に自動的に閉じられる。 - ログローテーションでリネーム後に親子でパスがずれる
→ ログファイルはopen()時のパスではなく inode を指しているので、リネームしても古い FD からは書き続けられる。ローテーション実装はrename()+open()ではなくfreopen()やlogrotateのcopytruncateオプションを使う。
解決策
上記の通り、O_APPEND 使用・明示的な flock・CLOEXEC フラグ管理を徹底することで、fork() 後も安全に FD を共有できます。加えて、大規模システムではログ集約プロセスを分離し、子プロセス側は exit() 前に close() すべき FD リストを整理しておくと、ファイルリークを防げます。
まとめ
本記事では、fork() 後も親子が同一ファイルディスクリプタを共有できる仕組みと、そのまま書き込む際の注意点を解説しました。
- ファイルディスクリプタは「ファイルポインタの参照カウント」を増やすだけで複製される
- O_APPEND を付けないと write() が互いに上書きしてデータが消失する
- ログ順序を保証したい場合は flock() や専用ロガープロセスを用意する
この知識により、マルチプロセスでログを一箇所に集約したり、親プロセスが Listen ソケットを保持し子が accept() したりする高負載サーバを、FD リークや破損ログなしで実装できるようになります。次回は「exec() 後に FD を引き継がない方法と、systemd を使ってサービスを再起動しても接続が切れない構成」を取り上げます。
参考資料
- Linux プログラミングインターフェース 第5章「ファイルディスクリプタの継承」
- man 2 fork, man 2 open, man 2 flock
- Advanced Programming in the UNIX Environment, 3rd Edition, Chapter 8
