Perlの排他制御

'00.7.26 ('02.8.17更新)

ファイル操作の排他制御といえば、以下のものが代表的です。
flockとロックファイル(ディレクトリ)式に大きく分けられます。


ロックファイル(ディレクトリ)を使う

基本は単なる使用中の目印です。目印が無いときに入って、自分で目印を作り、操作が終わったときに消して出るだけですね。トイレみたい(^^) で、使用中なら何回かノックします。(せかすな〜)

open関数
ファイルオープン関数でファイル作成。
1.ファイルが存在するか。
2.しなければ作成。
と2段階を要するので、その隙に他のプロセスが割り込む可能性がある。
排他制御としては不完全。symlink の代替として使われることがあるが、それなら mkdir がお薦め。
      $retry = 5;                                # リトライ回数セット
      while (-f $lockfile) {                     # ファイルが存在するなら待つ
          if (--$retry <= 0) { &error("BUSY"); } # 5回ダメならあきらめる
          sleep(1);                              # 1秒待つ
      }
      →ここに隙がある
      open(LOCK,"> $lockfile");                  # ファイル作成
      close(LOCK);                               # 閉じる

        一連の処理

      unlink($lockfile);                         # 削除

      【while (-f $lockfile) は while (-e $lockfile)でもよい】
symlink関数
シンボリックリンク作成関数。Macだとエイリアス、Windowsだとショートカットにあたるファイルを作成します。
こちらは同名のものが存在すれば作成しないので、
1.作成→できなければすでに存在。
と1段階で済むので隙がない。
環境によっては使えないこともある。flockが使えないUNIXでよく使われる。
使える環境:UNIX (Mac OS Xを含む), MacPerl (OS9までのMac用Perl), シンボリックリンクに対応したWindowsのCygwinのPerl
使えない環境:一部のUNIX, WindowsのActivePerl
      $retry = 5;                                # リトライ回数セット
      while (!symlink(".", $lockfile)) {         # 作成。出来なければ待つ
          if (--$retry <= 0) { &error("BUSY"); } # 5回ダメならあきらめる
          sleep(1);                              # 1秒待つ
      }

        一連の処理

      unlink($lockfile);                         # 削除

      【ここで作成しているのはカレントディレクトリのシンボリックリンク】
      【MacPerlで使う場合は ":" とします】
symlink($$, $lockfile) とすることで、名前とは別にプロセス番号を保持することが可能です。その場合 readlink($lockfile) で分かるプロセス番号を元にロックファイルを作ったプロセスを特定することが出来ます。名前に埋め込むよりもすっきりしますね。他のプロセスが作ったロックを消さないためとかに使えます。(MacPerlではエラーになります)
mkdir関数
ディレクトリ作成関数。symlinkと同じく1段階で済むし、殆どの環境で使える。汎用性を求めるならベストの方法。
      $retry = 5;                                # リトライ回数セット
      while (!mkdir($lockdir, 0755)) {           # 作成。出来なければ待つ
          if (--$retry <= 0) { &error("BUSY"); } # 5回ダメならあきらめる
          sleep(1);                              # 1秒待つ
      }

        一連の処理

      rmdir($lockdir);                           # 削除
rename関数
ファイル名変更関数。これは逆の目印で、使用中はロックファイルが見つからない(名前が違う)ことで判断する。
      $retry = 5;                                # リトライ回数セット
      while (!rename($lockfile, $lockfile2)) {   # リネーム。出来なければ待つ
          if (--$retry <= 0) { &error("BUSY"); } # 5回ダメならあきらめる
          sleep(1);                              # 1秒待つ
      }

        一連の処理

      rename($lockfile2, $lockfile);             # ファイル名を戻す
ファイルそのものは常に存在するので、書き込むデータファイルの名前を変えて直接ロックすることも可能です。むしろ、そのように使われることの方が多いかもしれません。

フリーのCGIプログラムなどだと、symlink式とopen式と両方スクリプトが書いてあってユーザーに選ばせるとか、symlink関数が有効かどうかテストして自動で場合分けするとか、汎用性を求めるために複雑になっていますが、基本は同じです。

古いロックファイル(ディレクトリ)の削除

上記4つの方法は、プロセスが途中で止まってロックファイル(ディレクトリ)が残ることがあるのが最大の欠点です。

古いロックの自動消去を書くこともできます。たとえば、作られたのが10分以上前なら使用中ではないと見なして消去します。ただし、ロックを消すのにも隙があるといけませんね。作成時間を確認するのとロックを消すのと2段階かかりますから、そのままだと、新しいロックを消してしまう虞れがあります。

  1. プロセスA:作成時間10分以上前を確認
  2. プロセスB:作成時間10分以上前を確認
  3. プロセスA:ロック消去
  4. プロセスC:新規ロック作成
  5. プロセスB:ロック消去

というわけで、ここまで気を使ったスクリプトもあまりないのですが、悩んだ末に「ロック入れ替え方式」という画期的(おおげさ)な方法を考案しました。mkdir関数で書いてみます。

    $retry = 5;                                    # リトライ回数セット
    while (!mkdir($lockdir, 0755)) {               # 作成。出来なければ待つ
        if (--$retry <= 0) {                       # 5回ダメなら
            if (mkdir($lockdir2, 0755)) {          # ロックを消すための排他
                if ((-M $lockdir) * 86400 > 600) { # 作成時間が10分以上前なら
                    # ロック入れ替え
                    rename($lockdir2, $lockdir) or &error("LOCK ERROR"); 
                    last;                          # 一連の処理へ
                }
                else { rmdir($lockdir2); }         # 部分ロック削除
            }
            &error("BUSY");                        # あきらめる
        }
        sleep(1);                                  # 1秒待つ
    }

      一連の処理

    rmdir($lockdir);                               # 削除

ロックのrename失敗時には、安全のため、エラーとしてプロセス終了とします。

2つ目のロックは1つ目より残る可能性は薄いですけど、消せるといいですね。

シグナルトラップ

サーバから飛んでくるシグナルでプロセスが途中で止まる事があります。たとえば、あらかじめタイムアウト時間(5分とか10分とか)が決められているサーバだと、混んでいて実行に時間がかかるとシグナルを飛ばしてプロセスを止めようとします。そのまま止まるとロックが残ってしまうので、シグナルが来たときに「ちょっと待ったぁ〜」って感じにサブルーチン(シグナルハンドラ)を実行してロックを消すようにします。symlink関数で書いてみます。

    # シグナルハンドラをセット
    $SIG{HUP} = $SIG{INT} = $SIG{PIPE} = $SIG{QUIT} = $SIG{TERM} = \&unlock;
    sub unlock {
        # プロセス番号が一致して、自分が作ったロックならば消去
        if ($$ == readlink($lockfile)) {
            (必要ならtmpファイル削除等も)
            unlink($lockfile);
        }
        exit;
    }
    $retry = 5;                                # リトライ回数セット
    while (!symlink($$, $lockfile)) {          # 作成。出来なければ待つ
        if (--$retry <= 0) { &error("BUSY"); } # 5回ダメならあきらめる
        sleep(1);                              # 1秒待つ
    }

      一連の処理

    unlink($lockfile);                         # 削除

【MacPerlで symlink($$, $lockfile) はエラーになります。存在しないファイルのエイリアスは作れないようです】

ロックを作ったプロセスを特定しない場合は、他のプロセスが作ったロックを消さないために、ロックファイル作成後にシグナルハンドラをセットするようにします。その場合、ロック作成からセットまでの間に来たシグナルは防げません(確率低いですが)。ロック作成時刻を判断するなどして後で消すしかないでしょう。

flockを使う

flock関数
ファイルの排他制御専用関数。
使える環境:UNIX(NFSでは使えないことが多い),WindowsNT

flockは排他制御専用関数ですから、使えるならそれが一番簡単なわけです。他のプロセスを自動的に待たせることが出来ますし、無用のロックが残ることもありません。flockは他のflockを使うプロセスに使用中であると知らせるだけで、flockを使わないプロセスをロックすることは出来ません。(NTの場合はロックしちゃいます)

間違った書き方をしない限り確実にロックされるはずですが、大手プロバイダではflockが使えないところが多いようです。リムネットは使えないと明記してありますし、@niftyでも場合によってflockが利かなくなることを確認しています (サーバがZeusになってからは未確認)。

使うのはほとんど1と2だけですね。

読込モード

読込モードで開くときに 2 にしてもロックがかからなかったりします(Solaris)。より強力なロック(排他的ロック)ですから使っても大丈夫に思いがちですが。

  open(IN, "< $datafile"); # 読込モードで開く
  flock(IN, 2);            # ロックがかからない

読込モードで開くときには 1 か 5 を使います。

  open(IN, "< $datafile"); # 読込モードで開く
  flock(IN, 1);            # ロック成功

1,5 (共有ロック)を使えない環境があったりして確認は必要です。

追加モード

追加モードも危なそうです。

  open(OUT, ">> $datafile"); # 追加モードで開く
  →他のプロセスが書き込んで末尾がずれる。
  flock(OUT, 2);

なので、以下のようにファイルポインタのセットし直しが必須とされていますが、(「プログラミングPerl」とかで)

  open(OUT, ">> $datafile"); # 追加モードで開く
  flock(OUT, 2);             # ロック確認。ロック
  seek(OUT, 0, 2);           # ファイルポインタを末尾にセット
  print OUT "$data\n";       # 書き込む
  close(OUT);                # closeすれば自動でロック解除

実験してみると seek(OUT, 0, 2) が無くてもずれなかったりします。最近のUNIXでは、write(2) のたびに、lseek(2) で、末尾に移動して書き込むらしいですね。(ariesさんからの情報)

上書き・読み書きモード

上書きモードで開くとおかしくなることがあります。

  open(OUT, "> $datafile");  # 上書きモードで内容消去
  →他のプロセスが書き込む。あるいは読み込む。
  flock(OUT, 2);

ロックしてないのに内容消去ってのがいけませんね。読むプロセスがないとしても、消したつもりなのに内容が書かれているのもいけません。じゃあ上書きしたいときはどうするかというと、

  open(OUT, "+< $datafile"); # 読み書きモードで開く
  flock(OUT, 2);             # ロック確認。ロック
  truncate(OUT, 0);          # ファイルサイズを0バイトにする
  seek(OUT, 0, 0);           # ファイルポインタを先頭にセット
  print OUT "$data\n";       # 書き込む
  close(OUT);                # closeすれば自動でロック解除

上記と同じ事をしますが、下記の方が速いそうです。ファイルサイズが大きいほど差が出ます。プロセスが途中で止まるかもしれないことを考えると、書込む前にファイルサイズを0にしないので、安全度も少し高いですね。

  open(OUT, "+< $datafile"); # 読み書きモードで開く
  flock(OUT, 2);             # ロック確認。ロック
  seek(OUT, 0, 0);           # ファイルポインタを先頭にセット
  print OUT "$data\n";       # 書き込む
  truncate(OUT, tell(OUT));  # ファイルサイズを書き込んだサイズにする
  close(OUT);                # closeすれば自動でロック解除

アクセスカウンター等でサイズが増えても減ることはない場合には、truncateは必要ないです。

エラー処理

システムコールを行うとき(スクリプトの外に働きかけるとき)には、それが失敗する可能性も考えておきます。失敗したまま次に進むとデータが壊れますね。というわけで、エラー処理を入れた実用flock。dieではなく&errorで処理をしてもいいです。

  open(OUT, "+< $datafile") or die "Can't open    : $!";
  flock(OUT, 2)             or die "Can't flock   : $!";
  seek(OUT, 0, 0)           or die "Can't seek    : $!";
  print OUT "$data\n"       or die "Can't print   : $!";
  truncate(OUT, tell(OUT))  or die "Can't truncate: $!";
  close(OUT);

書き込みが数回に渡る時は、プロセスが途中で止まるとデータが壊れるので、tempファイルに書き込んで書き込み成功確認後にrenameする必要があります。その場合下記のまとめてロックを使います。(確認はファイルサイズや最後に書き込んだ行の内容等で行います)

まとめてロックする

操作するファイル数が多かったりして、一連のスクリプトを纏めてロックしたいときには、ロック専用の空のファイルを使います。tempファイル方式にも有効です。

ロックファイル式に似てますね。

  # ロックファイルは内容がどうなっても関係ないから上書きモードでいい。
  open(LOCK,"> $lockfile") or die "Can't open lockfile: $!";
  flock(LOCK, 2)           or die "Can't flock        : $!";

        一連の処理

  close(LOCK); # ロックの終了

タイムアウト

flock(XX, 2)は、排他したプロセスが何らかの理由で終わらないと、次のプロセスが延々待たされることになります。なので制限時間をセットすることもあります。可能性は低いでしょうから、実用性はあまりないかもしれません。使うとしたら、先行プロセスの待ち時間設定の部分でしょうか。

  eval {
      local $SIG{ALRM} = sub { die "time out" }; # 時間が来たら抜け出す
      open(OUT, "+< $datafile") or die;
      alarm(5);                        # 先行プロセスを待つ時間(5秒)
      flock(OUT, 2) or die;            # ロック確認。ロック
      alarm(300);                      # 自分自身の制限時間(5分)

        一連の処理

      close(OUT);                      # closeすれば自動でロック解除
      alarm(0);                        # 無事済んだのでリセット
  };
  if ($@ =~ /time out/) {
      タイムアウト時の処理
  }
  elsif ($@) { die }                   # タイムアウト以外の理由なら

以上です。