ぐらめぬ・ぜぷつぇんのはてダ(2007 to 2011)

2007年~2011年ごろまで はてなダイアリー に書いてた記事を引っ越してきました。

flock(), fcntl()

Xhwlayとか、YakiBikiで使おうと思ってたチケットトークンの扱いで気になってたものの一つに、flockの範囲の扱いがあった。つまるところ、ファイルをどうロックしてどこでアンロックするのが良いのか?という話。
例えば、次に示すPHPコードはチケットの扱いに問題が発生する。

<?php
// チケットIDを取得。ここの部分は話題の範囲外なので省略。
$tickt_id = ... ;
// 現在時刻の取得
$now = time();
// チケットファイル名を返却する関数。同様に実装省略
$ticket_file = get_ticket_file($ticket_id);
// チケットの最終更新時刻を取得
$mtime = filemtime($ticket_file);

// (A1)

if ($mtime === false) {
// 存在しない場合は無効なチケットIDとして処理
...
} else if ($now - $mtime > $expire_limit) {
// 現在時刻との差分が有効期間($expire_limit)(second)を越えていれば、
// チケットファイルを削除することでチケットIDを無効化する。
unlink($ticket_file);
// (A2)
}

例えば0.001秒の差で持って2つのリクエストが到達し、ApacheプロセスXとYがこのPHPを起動したとする。プロセスXはA1のポイントまで到達した瞬間、Kernelによりsleep状態にされ、プロセスYが処理を続行する。プロセスYはA1のポイントに到達し、更にA2のポイントまで実行してしまうかもしれない。確立してゼロでは無いので、実行「した」と仮定してしまおう。その後、プロセスXがKernelによって起こされ、A1地点から処理が続行される。
そうなると、最初に起動されたのはプロセスXの方であるのに、チケットIDは後発のプロセスYが消費してしまう、という事態が発生する。
これは結局の所、いわゆるCGIなどでのカウンタファイルのロック問題にも見られるファイルロックの問題になる。つまるところ手垢の付いた問題であり、以下のURLなどで解説されている通りである。
http://www.stackasterisk.jp/tech/php/php02_01.jsp

但し、上掲のURLに記載されているのはファイルカウンタのように、ロック解放後も永続してファイルが存在するケースである。flock()中のunlink()はfalseを返す、すなわちファイルは削除できない。以下のようなコードは動作しない事になる。

<?php
$lock_f = ... // ロックファイル名
$now = time();
$fp = fopen($lock_f, 'w+');
if (!$fp) { /* エラー処理 */ }
if (!flock($fp, LOCK_EX)) { /* エラー処理 */ }
if ($now - filemtime($lock_f) > $expire_limit) {
    unlink($lock_f);
}
flock($fp, LOCK_UN);
fclose($fp);

問題点として、これはトランザクション的な処理になるわけだが、ロックオブジェクト(ファイル)自身が操作対象になっていることに
原因があるように思える。トランザクション「的」と行ったのは、トランザクション自体は下に示すように結構厳密な定義がある。
http://www.techscore.com/tech/sql/11_03.html
また、今回の例に挙げたようなチケットトークンをファイルシステムを使うことでvalidate/invalidateするような処理が果たしてACID特性に適合しているか今の自分のオツムでは判別できなかったので、断言できず「的」とした。
http://en.wikipedia.org/wiki/ACID

・・・とりあえず、これを解決するのであればロックオブジェクトと操作対象を分離する、つまり有効期間の判定値をファイルのmtimeでは無くする。というわけで、ファイルの内容にチケットの発行時刻を保存し、チェック側ではそれをチェックすることで分離できると思う。
チケット発行側:

<?php
<?php
$now = time();
$lock_f = ...; // チケットIDのファイル
$fp = fopen($lock_f, 'a+'); // R/W, ファイルポインタは終端に、無ければ作成
if (!$fp) { /* エラー処理 */ }
ftruncate($fp, 0); // 発行なので、fopenのモードを"w"とかにすれば不要?
fseek($fp, 0, SEEK_END);
fwrite($fp, $now);
fflush($fp);
fclose($fp);

チケットチェック側:

<?php
$lock_f = ...;

// 未発行のチケットIDの強制インジェクションを避ける為、
// 存在しなければエラーとなるよう、"a+"ではなく"r+"にする。
$fp = fopen($lock_f, 'r+');
if (!$fp) { /* エラー処理(未発行のチケットID) */ }
if (!flock($fp, LOCK_EX)) { /* エラー処理 */ }

$now = time();
// チケットファイルに書き込まれたタイムスタンプを取得
$mt = fread($fp, 64);
if ($now - $mt > $expire_limit) {
// (A) : 有効期限が過ぎていた場合の処理
}

// 以下、チケット無効化処理

ftruncate($fp, 0);
fseek($fp, 0, SEEK_END); // or rewind($fp);
// (B)
fwrite($fp, '0');
fclose($fp);

// (C)

@unlink($lock_f);

(B)で、"0"をファイルに書き込んでいる。unlink()する為には一旦ファイルをクローズする必要があるが、その隙間に別プロセスがチケットにアクセスする可能性がある。この対策として、"0"を書き込むことで別プロセスは確実に(A)の有効期限切れの処理に進む。また結果として後から来たプロセスの方が先に(C)を通過したとしても、unlink()のエラーチェックは行っていないし、いずれにせよチケットファイルが削除されることには代わりはない。
多分こんな感じにしないといけないと思う。CGIでカウンタを作る時の定石のような話である為、以下のURLなどで結構以前から詳しく取り上げられており、ファイル操作が楽なPHPなだけに却ってうっかり嵌ってしまいそうな箇所ではある。

その上、PHPの場合は例により(w実装がかなりごちゃごちゃしてるらしく、以下のHPの"PHP/ファイルロック"以下のページで詳細に解説されている。

が・・・PHPでのflock()の実装などはさておき。

実はちょっと気になっていたのが、"flockはアドバイザリ・ロックですよー"という記述である。これは結構方々で見られる。

FreeBSDのマニュアルなどでは、「問合せ型ロック」とも表記されているようである。

で。ちょっと分からなくなったのが、flock()とfcntl()のロックの挙動の違いである。「4.4BSDの設計と実装」(ASCII)や、「Advanced Programming in the UNIX Environment Second Edition」までひっくり返したのだけれどどうもBSDLinux間で実装が若干異なるようであるし、BSDでさえも、4.4BSD本の説明と、5.4RELEASEのmanページとでは何だかlast-closeセマンティクスの部分がちぐはぐな感じがする。いわゆる「強制ロック」(mandatory lock)についてはどちらも、ファイルシステム側の対応とファイルのmodeに対して特殊なビットを立てなければならない点については一致しているのでこれは良いのだが・・・。
例えば、

このインタフェースの別のさほど重要でないセマンティクス上の問題は、ロックが fork(2) システムコールを使用して作成された子プロセスによって継承されないことです。
flock(2) インタフェースは、はるかに合理的な last close セマンティクスを採用し、ロックが子プロセスによって継承されるようになっています。
ライブラリを使用するときにロックの整合性を確実にする、またはロックを子プロセスに渡したいアプリケーションについては flock(2)システムコールをお勧めします。

http://www.jp.freebsd.org/cgi/mroff.cgi?sect=2&cmd=&lc=1&subdir=man&dir=jpman-5.4.0%2Fman&subdir=man&man=fcntl

とfcntlのmanページにあるのに、flock()のmanページには

あるファイルについてのロックを保持しているプロセスがフォークし、子プロセスが明示的にそのファイルをアンロックする場合、親プロセスはそのロックを失います。

http://www.jp.freebsd.org/cgi/mroff.cgi?subdir=man&lc=1&cmd=&man=flock&dir=jpman-5.4.0%2Fman

とある。これは矛盾していないか?fcntl()の方では「flock()ならlast closeですよ、そっちがオススメですよ」と書いてあり、flock()の方では「どこか一つのプロセスがアンロックしちゃうと、全部アンロックされちゃいますよ」と書いてあるように読める。
どっちが正しいのだろう?

レコードのロックは fork(2) で作成された子プロセスには継承されないが、 execve(2) の前後では保存される。

http://www.linux.or.jp/JM/html/LDP_man-pages/man2/fcntl.2.html

flock() によって作られるロックは、オープンされたファイルのテーブル・エントリと関連付けられる。
したがって、ファイル・ディスクリプタの複製 (fork(2) や dup(2) などにより作成される) は同じロックを参照し、これらのファイル・ディスクリプタのどれを使ってもこのロックを変更したり解放したりできる。

http://www.linux.or.jp/JM/html/LDP_man-pages/man2/flock.2.html

これを読むと、Linuxに於いては

  • fcntl() : fork()による継承は無理。
  • flock() : fork()/dup2()での継承は可能。

と読める。

このインタフェースの別のさほど重要でないセマンティクス上の問題は、ロックが fork(2) システムコールを使用して作成された子プロセスによって継承されないことです。

http://www.jp.freebsd.org/cgi/mroff.cgi?sect=2&cmd=&lc=1&subdir=man&dir=jpman-5.4.0%2Fman&subdir=man&man=fcntl

ロックはファイルにかけられるものであって、ファイル記述子にかけられるものではありません。
すなわち、 dup(2) または fork(2) によって複製されたファイル記述子は、ロックの複数のインスタンスとはならずに、1 つのロックへの複数の参照になります。
あるファイルについてのロックを保持しているプロセスがフォークし、子プロセスが明示的にそのファイルをアンロックする場合、親プロセスはそのロックを失います。

http://www.jp.freebsd.org/cgi/mroff.cgi?subdir=man&lc=1&cmd=&man=flock&dir=jpman-5.4.0%2Fman

これについても、BSDに於いては

  • fcntl() : fork()による継承は無理。
  • flock() : fork()/dup2()での継承は可能。

と読める。

4.4BSD本によると、fcntl()によるロックはプロセスエントリと結びつけられており、flock()によるロックはファイルテーブルと結びつけられているらしい。

flock() : 
ファイル記述子 -> ファイルテーブル -> i-node -> ロック情報
fcntl() : 
プロセスエントリ -> ロック情報?

となるのか?うぅ、ソースコード追わなきゃだめなのか・・・?ただ、Linuxに於いても同様の実装になっているとすればここまでのflock()/fcntl()のfork()/dup2()時の実装については納得が行く。flock()であればファイル記述子が複製されても、その指しているファイルテーブルは同じなので、結果同じロック情報を見ることになる。すなわち、継承される。しかしfcntl()だとプロセスに結びついてしまう為、fork()してプロセスエントリが分かれてしまうと、元のロック情報を見れない、つまりロックは継承されない。

一方のHP社のマニュアルには・・・

ロックは fork() システムコールによって子プロセスに継承されません。

http://docs.hp.com/ja/B2355-60129/fcntl.2.html

ロックは、ファイル記述子に対してではなく、ファイルに対して行われます。したがって、次の点に注意してください。
* fork() 呼び出しで生成された子プロセスにはロックは継承されません。

http://docs.hp.com/ja/B2355-60129/flock.2.html

というように、どちらを使ってもfork()/dup2()などのファイル記述子の複製系では継承不可能とされている。

実はHP社のマニュアルを良く読むと、

flock() は、どの UNIX 標準の一部でもありません。
したがって、プラットフォーム間で移植性のあるアプリケーションを開発する場合には、 flock() ではなく fcntl() ファイルロックインタフェースを使用してください。

http://docs.hp.com/ja/B2355-60129/flock.2.html

と言う風にある。これは"Advanced Programming in UNIX Environment Second Edition"の"Section 14.3 Record Locking"の"History"に簡単な表が載っている。

ぶっちゃけて言うと、flock()はUNIXの標準ではない。FreeBSD5.2.1/Linux2.4.22/MacOSX10.3/Solaris9のいずれもflock()は実装されている。しかし、POSIX1.(1980年代中〜後半)および、POSIXより後に制定されたSingle UNIX Specification(1990年代)にもflock()は無い。UNIXの規格としてはflock()は無いようだ。つまりflock()はBSDオリジナル?であってるのかな?ちなみに強制ロック(Mandatory Lock)については、FreeBSD5.2.1, MacOSX10.3では実装されていないらしいし、POISX/SUSにも無い。そう言われてみれば、4.4BSD本にも次のような記述がある。

4.2BSDはUNIXの精神に合致せず、なおかつ特権プログラムが使用できないような付加的な保護機構を追加するようなことをせず、推奨ロックのみの実装を行った。

第6章、「6.4.3 ファイル記述子のロック」(248P)。BSD5になっても引き継がれていると言うことか・・・。
これが為に、PHPコンパイルでもわざわざHAVE_FLOCKというdefine値が存在し、flock()の存在しないシステムではこれをundefすることで、fcntl()によるエミュレーションを行うようになるそうだ。うう、ソース確認してねえ。

ここからはまだ頭の中が整理されて居ないので、曖昧模糊とするのだが、元々4.2BSDではファイルの全範囲ロックしか提供していなかったようである。
もっともそれがflock()/fcntl()両方について該当するのか迄は不明。で、4.4BSDの開発に辺りPOSIXに準拠する為、fcntl()でファイル範囲ロックを実装する必要が出てきた。ところがPOSIXのfcntl()に完全に従ってしまうと、

  • 複数のプロセスから参照されているファイル記述子に対して、どこか一つでもcloseシステムコールを呼ぶと全部ロックが解放される。
  • 排他ロックを得るためにはファイルを書き込みモードでオープンしなければならない。ファイルに対する書き込み権限を有していないと、排他ロックが得られない。

などの制限が発生してしまう。
そこで、4.4BSDではfcntl()はPOSIX準拠にして、4.2BSDでの

  • 最後にファイルがcloseされる時にファイルロックが解放される。
  • 書き込み権限を持っていなくても排他ロック可能

という相反する特徴を、実装済だったflock()に残すことにしたようだ。
ではどうやってfcntl() - POSIXセマンティクスと、flock() - BSDセマンティクスを見分けるか?
ここで、fcntl()の場合はロックをプロセスに対して行い、flock()のロックはファイル記述子に行うことで二つを見分けられるようにした。正確にはi-nodeから参照されるロックを表現する構造体(ややこしいことにこれが"flock"構造体と呼ばれているようだ)のl_pidフィールドが使われていて、fcntl()を用いてロックした場合にはl_pidにプロセスIDが入る。つまり、プロセスと結びつく。ところがflock()でロックした場合、flock構造体を用いるところは一緒だがl_pidには-1が入る。というのも、flock()の場合はファイル記述子(正確にはファイル・テーブル)と結びつく・・・つまりi-nodeと結びつくので、プロセスIDを入れる必要がないから、らしい。こういったテクニックを組み込むことで、4.2BSDセマンティクスとPOSIXセマンティクスを同居させることに成功したようである。

ブロックするロックを保持しているプロセスがファイル記述子を以前に flock(2) でロックしていた場合、 fcntl(F_GETLK) は l_pid に -1 を入れて戻ります。

http://www.jp.freebsd.org/cgi/mroff.cgi?sect=2&cmd=&lc=1&subdir=man&dir=jpman-5.4.0%2Fman&subdir=man&man=fcntl

これはLinuxにそのままつながっているのかもしれない。