はじめに (対象読者・この記事でわかること)

この記事は、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() の先頭を必ずファイル末尾へ移動させてから書き込むため、データの消失はなくなります。

C
int 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 ドメインソケットを用意して単一のロガープロセスに集約する設計が主流です。

ハマった点とエラー解決

  1. close() したつもりがまだ親から書き込めてしまう
    → ファイルポインタの参照カウントが 0 でないと vnode は解放されない。親子両方で close() する必要がある。
  2. ソケットを継承したのに子が exit() すると接続が切れる
    → ソケット FD に SOCK_CLOEXEC を設定しておくか、fcntl(fd, F_SETFD, FD_CLOEXEC) で実行時 close フラグを立てておけば exec() 後に自動的に閉じられる。
  3. ログローテーションでリネーム後に親子でパスがずれる
    → ログファイルは open() 時のパスではなく inode を指しているので、リネームしても古い FD からは書き続けられる。ローテーション実装は rename()+open() ではなく freopen()logrotatecopytruncate オプションを使う。

解決策

上記の通り、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