ファイルのロックに関する基礎知識

とほほのWWW入門 > とほほのCGI入門 > ファイルのロックに関する基礎知識
2000年6月4日初版 2000年8月27日改版

ロックはなぜ必要か?

たとえばカウンターを作成する際に、以下のような処理を行うとします。
  1: open(IN, $file);        # ファイルを開く
  2: $count = <IN>;          # カウンターを読み出す
  3: close(IN);              # ファイルを閉じる
  4: $count++;               # カウンターをひとつ増やす
  5: open(OUT, "> $file");   # ファイルを開く
  6: print OUT "$count\n";   # ファイルにカウンタを書き込む
  7: close(OUT);             # ファイルを閉じる
  8: print "$count\n";
ところが、この実装には問題があります。Aさんが5行目を実行し終えて6行目を実行するまでの間の僅かのタイミングに、Bさんが1行目と2行目を行うと、Bさんは空のファイルを読んでしまうことになります。文字 "" に1を加算すると値は1。これをBさんがファイルに書き込むと、カウンターファイルの破壊(カウンター値のリセット)が発生してしまいます。

コマンドラインでperlを使えるなら、上の処理を、for (...) で10000回くらいループするスクリプトを作成し、2つ同時に起動してみましょう。ファイルが壊れる様子を目で確認することができると思います。

これを防ぐためには、「今私が書き込んでいるから、他の人は読んだり書いたりしないでね」という処理を行う必要があります。これを排他制御とかファイルをロックすると呼びます。

flock()によるロック制御

Perlにはflock()というロック制御専用の関数が用意されています。flock()の使用法や問題点は、「とほほのperl入門」のflock()を参照してください。flock()を用いたカウンターの例は次のようになります。
  1: open(FD, "+< $file");   # 読み書きモードで開く
  2: flock(FD, 2);           # ファイルをロックする
  3: $count = <FD>;          # カウンター値を読み取る
  4: $count++;               # カウンターを増やす
  5: seek(FD, 0, 0);         # 書き込み位置を先頭に戻す
  6: print FD "$count\n";    # カウンター値を書き込む
  7: close(FD);              # ファイルを閉じる

シンボリックリンクやディレクトリによるロック制御

Windows 95/98や一部のUNIXなど、flock()を使用できないOSがあるため、flock()の代わりにシンボリックリンクやディレクトリを用いたロック機構を使用することがあります。

シンボリックリンクはUNIXのファイルの一種で、Windowsのショートカットファイル、Macのエイリアスファイルのようなものです。最初にこのロック方法を実現した人がシンボリックリンクを用いたためか、シンボリックリンクの例が広く紹介されていますが、ここでは、Windowsにも流用できることを考えて、ディレクトリを用いた例を紹介します。ロックを実現する上で両者に機能の差異はありません。

ロックディレクトリを用いた、まずい例

ロックを実装する際に、まずやってしまいそうなのが、次のような誤ったロック方法です。
  1: if (! -d $lockdir) {     # ロックディレクトリが無ければ
  2:   mkdir($lockdir, 0755); # 作成する
  3: } else {                 # さもなくば
  4:   exit(1);               # あきらめる
  5: }
  6: open(IN, $file);         # ファイルをオープンして
  7:   :                      # いろいろな処理をして
  8: close(IN);               # ファイルをクローズして
  9: rmdir($lockdir);         # ロックディレクトリを消す
ロックディレクトリが無ければ、誰も処理中ではないということなので、自分がロックディレクトリを作成して「処理中だよ」と宣言して、処理に入ります。ロックディレクトリがすでに存在していれば、誰かが処理中ということなので、あきらめて退散します。

一見するとうまく動きそうですが、1行目でロックディレクトリの有無を調べてから2行目でロックディレクトリを作成するまでの間に隙間があるため、この隙間にBさんが同じ処理をやっていると、2人とも「他に誰もロックディレクトリを作っていないので、自分が処理をはじめちゃお」という状態になってしまいます。

ロックディレクトリを用いた、よい例

上記の例では、「ロックディレクトリが無い」=「誰も処理中でない」=「自分が処理してもよい」と判断しているところに問題があります。これを次のように改造します。
  1: if (!mkdir($lockdir, 0755)) { # mkdirできなかったら
  2:   exit(1);               # あきらめる
  3: }                        # できたら
  4: open(IN, $file);         # ファイルをオープンして
  5:   :                      # いろいろな処理をして
  6: close(IN);               # ファイルをクローズして
  7: rmdir($lockdir);         # ロックディレクトリを消す
「ロックディレクトリが無ければ」+「作成して」の部分を「ロックディレクトリをまず作ってみて作成できたら」に変更しています。mkdir()は、すでにロックディレクトリが存在する場合は失敗し、まだロックディレクトリが存在していなければ作成して成功を返します。複数の人がほぼ同時にこの処理をやろうとしても、mkdir()処理はOS内部(カーネル)が行うため、決して重複することはありません。

ロック権を取れなかったら再トライする

次のようにして、ロック権をとれなかった時にあきらめず、何回か再トライすることができます。
  1: for ($i = 0; $i <= 10; $i++) {
  2:   if (mkdir($lockdir, 0755)) { # 成功したら
  5:     last;                      # ループを抜ける
  4:   } else {                     # さもなくば
  3:     sleep(1);                  # 1秒待って再トライ
  6:   }
  7: }

ロックディレクトリが残ってしまうのを防ぐ

しかしまだ問題があります。もし誰かがロックディレクトリを作成したまま、その人が何かの原因で異常終了してしまっていた場合、永遠にロックディレクトリが残ったままとなり、以後、誰もロック権を得ることができなくなってしまいます。

異常終了する際にも、ロックディレクトリを削除するようにしておきましょう。上記のループを抜けた直後に次の行を挿入します。

  $SIG{'TERM'} = $SIG{'PIPE'} = $SIG{'HUP'} = "sigexit";
  sub sigexit { rmdir($lockdir); exit(1); }
これは、スクリプトがシグナルを受けて異常終了する際に sigexit() サブルーチンを呼び出し、ロックディレクトリの削除(掃除)をしています。

しかし、これでも完璧ではありません。mkdir()をやった後、シグナルの宣言を行うまでの間に異常終了するとロックディレクトリは残ってしまうし、先にシグナルの処理を宣言してしまうと、mkdir()が失敗した時にも、誤って自分が人のロックディレクトリを消してしまうことになります。

また、OS自体が突然止まった場合や、SIGKILLなどハンドリングできないシグナルで異常終了した場合などは、やはりロックディレクトリは残ってしまいます。

mkdir()に失敗した場合は、ロックディレクトリの日付を見て、一定期間以上古い場合は、ロックディレクトリが何らかの原因で残ってしまったのだと判断して、消してしまうのも手です。(私はこの方法を採用しています。)

追記:古いと判断したファイルを削除すると、古いと判断して削除するまでの隙間に、他のプログラムも古いと判断して削除して新しく作成している可能性があるというご指摘をいただきました。やはり、このへんが flock() を使わないロック方法の限界なのでしょうか・・・(2000.8.27追記)

でも、flock()によるロックであれば、スクリプトが異常終了しても、OSが異常終了しても、ロックが残ってしまうことは絶対にないため、flock()が使えるならその方が安全です。

ロックしておけばファイルは壊れないか

ロックを行ったとしても、ファイルが壊れない訳ではありません。ロックとは別の理由でファイルは壊れます。
  1: open(OUT, "> file.txt");
  2: flock(OUT, 2);
  3:   :
  4: flock(OUT, 8);
  5: close(OUT);
なんてことをやっていると、1行目と2行目の間でロックもしていないのにファイルが空になっている状態が発生し、この時に別の人がファイルを読んで処理して書き込むと・・・ファイルは壊れます。

他にも、ファイルにデータを書き込んでいる最中に、ディスクが溢れた、システムが異常停止したなどの理由で、ファイルは壊れます。

結論

ロックしておかないとファイルは壊れます。ロックをしていてもファイルは壊れます。下手なロックをしているとよく壊れます。下手をするとロックしない時よりもよく壊れます。上手なロックをしていると壊れる確率を減らすことができます。

  1: $lockfile = "lockfile.loc";
  2: $datafile = "count.txt";
  3: $tempfile = "count.tmp";
  4: open(LOCK, $lockfile);
  5: flock(LOCK, 2);
  6: while (1) {
  7:   open(IN, $datafile);
  8:   $count = <IN>;
  9:   close(IN);
 10:   $count++;
 11:   open(OUT, $tempfile) || last;
 12:   print OUT "$count\n" || last;
 13:   close(OUT) || last;
 14:   rename($tempfile, $datafile);
 15:   last;
 16: }
 17: close(LOCK);
のように、書き出しは一度テンポラリファイル(一時ファイル)に行い、その書き込みがうまくいったことを確認して、オリジナルファイルとテンポラリファイルを rename() で入れ替える。ロックはその前後で別ファイルで行う・・・なんてことをすると、少しは壊れにくくなるかもしれません。

Copyright (C) 2000 とほほ
http://wakusei.cplaza.ne.jp/twn/wwwcgi8.htm