Linuxのclose、エラー後の再試行が別のファイルを閉じる理由

白い四角いアーチの上に薄紫色の留め具が載り、左奥に緑の立方体、右手前に桃色の多面体が置かれている。

ファイルを閉じるcloseがエラーを返したら、成功するまで繰り返せばよいのでしょうか。Linuxでは、その再試行が別のファイルを閉じてしまうことがあります。最初の呼び出しが、エラーを報告するより前に、ファイル記述子の番号を解放しているためです。

ここで分けたいのは、番号が利用可能になったことと、書き込みなどの後処理が問題なく終わったことです。同じ整数が手元に残っていても、次に同じ対象を指すとは限りません。Linux man-pages 6.19のclose(2)を基に、エラーの意味と再試行の危険を追います。[1]

簡単にまとめると?

  • Linuxのcloseは、処理の早い段階でファイル記述子を解放します。その後にエラーを返しても、同じ番号はすでに別の用途へ再利用され得ます。
  • エラーを無視してよいわけではありません。書き込み失敗がcloseで初めて分かる場合があるため、診断とデータ保全の対応を行い、同じ番号へのcloseの再送とは分けます。
  • シグナル割込みEINTRの扱いは、Linuxの実装とPOSIX.1-2024の規定を区別します。一般的な「EINTRなら再試行」をcloseへ無条件に適用しません。

ファイル記述子は、対象そのものではなく番号

ファイル記述子、file descriptorは、プロセスが開いたファイルなどを参照するときに使う小さな整数です。例えば3番があるファイルAを指していても、3という値が永久にAへ結び付いているわけではありません。closeで解放した後は、別のファイルBを開く操作が同じ3番を使うことがあります。[1、DESCRIPTION]

また、記述子と、その下で保持される「開かれたファイルの状態」は別です。複製された記述子などが同じ状態を参照している場合、一つの番号を閉じても、その状態への参照がすべてなくなるとは限りません。closeの戻り値から、ほかの参照や進行中の処理まで一括して消えたと解釈しないことが必要です。

番号を解放してから、エラーが分かることがある

Linuxの説明で重要なのは、closeの内部の順序です。カーネルは早い段階で記述子を解放し、再利用可能にします。その後、ファイルシステムや装置への書き出しなど、エラーを生じ得る処理が続きます。[1、Dealing with error returns from close()]

つまり、戻り値が−1だったことだけでは、「まだその番号が元の対象を指している」とは言えません。最初から番号が無効だったEBADFと、有効な番号を解放した後で報告されたエラーは、同じ状態ではありません。エラー番号を残し、どの段階の問題かを調べる必要があります。

特に、以前のwriteで受け付けたデータについて、後のcloseで書き込み失敗が報告される場合があります。資料はネットワークファイルシステムや容量制限の例を挙げています。[1] writeが一度成功したから、最後のcloseの結果は見なくてよい、ということにはなりません。

同じ3番への再試行が、Bを閉じる流れ

二つのスレッドが同じプロセス内で動く、次の順序を考えます。最初、記述子3はAを指しています。スレッド1がclose(3)を呼び、カーネルは3番を解放します。その後、スレッド2がBを開き、空いた3番を受け取ります。続いてスレッド1へ、最初のcloseのエラーが返ります。

ここでスレッド1が「失敗したので再試行」とclose(3)をもう一度呼ぶと、現在3番を使っているBを閉じてしまいます。この二回目は、Aに対する最初の処理を再開しているわけではありません。整数3をその時点で解釈する、新しい操作です。

記述子3は最初Aを指す。最初のcloseで番号が空き、別のスレッドがBへ3を割り当てる。古い処理がcloseのエラーを見て同じ3を再度閉じるとBが閉じられる。
Linuxの説明に沿って、再利用が起きる一つの順序を表した独自モデル。実機でエラーを注入した測定ではなく、すべてのcloseでこの順序になるという図でもありません。

今回の独自モデルでは、番号表をA→空き→Bと更新し、古い処理が持つ「3」という値から二回目の対象を解決するとBになることを確かめました。モデルが示すのは、番号の再利用を許す条件下での反例です。再利用の頻度や、競合が発生する時間幅を測ったものではありません。

問題を防ぐには、どの処理が記述子の終了を担当するか、いつほかの処理から使えなくするかを決めます。一つの変数へ無効値を代入するだけでは、別の変数や別のスレッドが保持する古いコピーまで更新されません。所有権と寿命の管理が必要で、単なる再試行回数の制限では解決しません。

EINTRという名前だけで、処理を共通化しない

EINTRはシグナルによる割込みを示します。しかし、「システムコールがEINTRを返したら同じ引数で再試行する」という共通処理を、Linuxのcloseへ無条件に使うことはできません。Linux man-pagesは、この場合も記述子は閉じられると説明し、エラー後のclose再試行を避けるよう注意しています。[1]

一方、POSIX.1-2024のcloseの規定では、EINTRを返す場合は記述子が開いたままです。閉じた後で処理が未完了ならEINPROGRESSを返す場合と区別されています。また、POSIX_CLOSE_RESTARTの定義による条件もあります。[2、DESCRIPTION] この規定を読むことと、Linuxの現行説明にある挙動を読むことを混ぜると、再試行の判断が逆になります。

本稿の再利用の注意はLinuxを対象にしています。他のOSへ移すコードでは、対象の実装、ライブラリーの契約、採用する規格の版を確認して終了処理を分けます。規格に関数や条件が書かれていることだけで、その関数が手元のLinux環境に実装されているとは扱いません。ここでは互換ラッパーの実装や、個々の環境での適合試験までは行っていません。

エラーの確認と、終了の再実行を分ける

再試行を避けることは、エラーを捨てることではありません。どのファイルへのどの書き込みで問題が起きた可能性があるかを、記録と照合します。必要に応じてデータを別に保全し直す、上位の処理を失敗として返すなどの対応を検討します。エラーを見た後の「何を回復するか」は、元の番号をもう一度閉じる要求とは異なります。

さらに、closeが成功したことも、データが永続記憶へ保存された証明にはなりません。資料は、必要に応じてcloseの前にfsyncで書き込みエラーを確認する方法を挙げています。[1] ただし、fsyncの成功条件、ファイル名を含むディレクトリーの保存、装置の挙動はそれぞれ別に検討すべき範囲であり、本稿では完全な障害復旧手順を提示していません。

別のスレッドの入出力を止めたい場合にも、closeだけを中断の合図と考えると見落としがあります。Linuxでは、進行中のブロッキング入出力が下位の開かれた状態への参照を保持し、別スレッドのcloseの後に成功して完了する場合があります。[1、Multithreaded processes and close()] 停止の通知、処理の完了待ち、最後の解放を協調させる設計が必要です。

終了処理を点検するときは、戻り値を確認しているかだけでなく、番号を誰が所有し、再利用後に古い値を使わない仕組みがあるかを確認します。「番号は解放されたか」と「保存や入出力は成功したか」を別々に追うことで、エラー処理が別の対象を壊す事態を避けやすくなります。

出典