コラム:ロック処理

 CGIを扱う上で最も重要と言っていいのがファイルの保護です。
 CGIの最大の特徴である、外部アクセスによるサーバ上のファイルの実行・読み書きという機能は、同時にファイルを破壊することもできるということですから、トラブルの元となるような動作を起こさないようにしなければなりません。
 もちろんサーバのOS等のシステムそのものの機能によって、ある程度そういったトラブルは防げるようになっていますが、それだけでは防ぎきれないトラブルというものがありますので、それについては各自で防ぐしかありません。その方法の一つがロック処理です。
 ここでは、mkdir関数を用いた排他処理によるロックのやり方について、なぜそれが必要なのか、どうしてそうしなければならないか、といった概念的なことから解説します。

ロックの概念

  A       B

ロック開始     ↓
  ↓     ロック失敗→↓
ファイル読込        ↓
  ↓           ↓
各種処理          ↓
  ↓        ロック解除待ち
ファイル書込        ↓
  ↓           ↓
ロック解除         ↓
  ↓     ロック開始←←
  ↓       ↓

 これは排他処理によるロックの簡単な概念図です。
 1つのCGIに対してA・B2つのリクエストがほぼ同時にあって、Bの方がやや遅れて処理されていると思ってください。
 排他処理とは、このように複数の処理(プロセス)が同時に行われている中で、1つの処理が作業している間、他の処理にそれを邪魔されないようにガードする。言い換えれば、他の処理が作業している間、その作業が終わるのを待つ仕組みのことです。
 この場合は、ファイルの読み込みから書き込みまでの間をロックし、ファイルに発生する不具合を防いでいます。

 ではなぜこのようなことをする必要があるのでしょうか?

ロックの必要な状況

  A       B

ファイル読込    ↓
  ↓     ファイル読込
各種処理      ↓
  ↓     各種処理
ファイル書込    ↓
  ↓     ファイル書込
  ↓       ↓

 先ほどの処理をロックなしでやった場合の図です。
 一見問題なさそうにも見えますが、例えばこれが掲示板の発言処理だったとします。
 すると、A・B共に同じ状態のファイルを読み込んで、各々自分の発言内容を追加して保存していることになります。つまり、Aがファイルを読み込み、発言内容を追加して保存した後、BはAの発言内容が反映される前に読み込んだ内容に発言を追加して保存してしまっているのです。それでは結果的にAの発言が残らないことになってしまいます。
 こうならないためには、Bが確実にAの発言処理が終わった後にファイルを読み込む必要があります。それを可能にしているのがロックというわけです。

 CGIは、不特定多数の人が同じプログラムに同時にアクセスできるため、このような事態が発生することがあります。
 プログラムの処理は基本的に一瞬ですが、チャットや人気サイトの掲示板のような場所では、その一瞬に複数の人が同時に発言を行うこともありえます。また、1つのCGIに限らずアクセスが集中して、サーバそのものが重くなると、その一瞬も幾分長くなります。低い確率に思えますが、最小でたった2人のアクセスで発生できてしまうのですから、見過ごせない状況になるというわけです。

間違ったロック

  A       B

ファイル読込    ↓
  ↓     ファイル読込
各種処理      ↓
  ↓     各種処理
ロック開始     ↓
  ↓     ロック失敗→↓
ファイル書込        ↓
  ↓        ロック解除待ち
ロック解除         ↓
  ↓     ロック開始←←
  ↓       ↓
  ↓     ファイル書込
  ↓       ↓

 時々、このようにファイルの書き込みの前後でだけロックを行っているプログラムを見かけます。
 先ほど説明したとおり、他の処理の書き込みが終わってから読み込みを始めるようにしないといけないので、このようなロックの仕方は全く意味がありません。
 ロックはファイルを読み込む前に行う、というのが鉄則ですので、忘れないようにしてください。

ロックの問題点

  A      B        C        D      
                                                  
ロック開始    ↓        ↓        ↓      
  ↓    ロック失敗→↓    ↓        ↓      
 処理          ↓    ↓        ↓      
  ↓          ↓  ロック失敗→↓    ↓      
ロック解除        ↓        ↓  ロック失敗→↓  
  ↓    ロック開始←←        ↓        ↓  
  ↓      ↓            ↓        ↓  
  ↓     処理            ↓        ↓  
  ↓      ↓            ↓        ↓  
  ↓    ロック解除          ↓        ↓  
  ↓      ↓      ロック開始←←        ↓  
  ↓      ↓        ↓            ↓  
  ↓      ↓       処理            ↓  
  ↓      ↓        ↓            ↓  
  ↓      ↓      ロック解除          ↓  
  ↓      ↓        ↓      ロック開始←←  
  ↓      ↓        ↓        ↓      
  ↓      ↓        ↓       処理      
  ↓      ↓        ↓        ↓      
  ↓      ↓        ↓      ロック解除    
  ↓      ↓        ↓        ↓      

 排他処理によるロックは、ファイルを保護するために必要なわけですが、「ロック中他の処理の割り込みを一切認めない」という強固なものであるが故に問題もあります。
 ロック中の処理が長ければ長いほど待ち時間も長くなってしまうので、図のようにたくさんのリクエストが同時に行われた場合、同時に待ちになる処理が多くなってしまうのです。

 サーバが一つの処理を行っている間は、その処理に必要なメモリやCPUを食われてしまうので、こうして延々待たされ続ければ、サーバがパンクしてしまいかねません。
 サーバ側にもそれを防ぐ機能が備わっていますが、ロック処理そのものにもそうならないような仕組みを組み込むべきでしょう。

 また、ロック中の処理をできるだけ軽くするという努力も忘れてはいけません。ロック後に読み込むファイルの内容が無くとも出来るような処理は、ロックの前にやっておくとよいでしょう。

ロック処理の実例

# ロック開始 #
sub lock{

my $dir = $_[0];
# 古いロック(3分以前)は削除
if(-e $dir){
	my $t = (stat($dir))[9];
	if($t < time - 180){ &unlock($dir); }
}
# ロックできなかったら5回まで1秒間待ってみる
local $retry = 5;
while(!mkdir($dir,0755)){
	if(--$retry <= 0){ return 0; }
	sleep(1);
}
return 1;

}

# ロック解除 #
sub unlock{
	rmdir("$_[0]");
}

 実用的なロック処理の例です。ロック処理はサブルーチンで関数化して使用するのが一般的です。
 mkdir関数を利用したこのロック関数は、基本的には指定されたディレクトリを作ろうとしてみて、作ることが出来たらロック成功ということで1を返し、出来なかったら1秒間待ってもう一度試そうとします。
 そのままでは、もし何らかの理由でロックがかかりっぱなしになっていた場合、永久に待ち続けることになってしまうため、ループの回数をカウントし、5回試してまだ駄目だったら0を返して終了しています。

 また、stat関数はファイルのさまざまな情報を得られる関数なのですが、これによってディレクトリの更新日時を見て、3分以上前に作られたものだった場合にロックを解除することで、なんらかの理由でロックがかかりっぱなしになっても自動的に復旧するようにもしています。
 この部分は、トラブルが完全に復旧するまで状態を保持したい場合、やらない方がいいでしょう。

 ロック解除処理は、指定されたディレクトリをrmdir関数で削除するだけなので関数化する必要もないように思いますが、分かりやすい関数名で用意しておくという意味があります。

 一般的なロックの方法は、このようにキーとなるファイルやディレクトリの存在の有無によって、ロックされているかどうかの判断を行います。ここではmkdir関数を使っていますが、他にもrename関数を使った方法などがあります。
 mkdir関数はディレクトリを作成する関数ですが、作成できたら1を返し、既にそのディレクトリがあるなどの理由で作成できなかったら0を返します。ディレクトリがあるかどうかのチェックと作成を同時に行えるので、割り込みの発生しない、完全なロックを行うことが出来ます。

 実際にこの関数を使う場合は、次の様にします。

# ロック開始
&lock('lockdir') or &error('只今混雑しています。
しばらくお待ちください。'); 〜処理〜 # ロック解除 &unlock('lockdir');

 lock関数は、キーになるディレクトリを引数として指定します。また、成功したら1、失敗したら0を返すので、5回リトライして失敗した場合はエラー処理を実行させます。error関数は各自用意しましょう。
 unlock関数もキーになるディレクトリを引数として指定します。当然ですが、正しく対応したディレクトリを指定しましょう。

複数のロック

# ロックA開始
&lock('lockdir_a') or &error('只今混雑しています。
しばらくお待ちください。'); 〜処理A〜 # ロックA解除 &unlock('lockdir_a'); # ロックB開始 &lock('lockdir_b') or &error('只今混雑しています。
しばらくお待ちください。'); 〜処理B〜 # ロックB解除 &unlock('lockdir_b');

 一つのシステムで複数のファイルの読み書きを行う場合で、それぞれのファイルに特に関連性が無い場合、両方で同じロック処理を行ってしまうと、待たせる必要の無い処理まで待たせてしまうことになります。
 そういう場合は処理ごとにロックに使用するディレクトリ名を変えてやると、他の処理の邪魔をしません。ロック中は一切他の処理が割り込めませんので、このようにして少しでも無駄なロックをしないよう工夫しましょう。

複合ロックによるトラブル(デッドロック)

  A       B

ロックP開始    ↓
  ↓     ロックQ開始
ロックQ開始    ↓
  ↓     ロックP開始
  ?       ↓
          ?

 2種類のロックP・Qを同時に扱うシステムがあり、一つのプログラムではP→Qの順にロックしており、他のプログラムではQ→Pの順でロックしていたとします。
 これらのプログラムが図のようにほぼ同時に動いた場合どうなるでしょう?

 答えは「互いにロック解除待ちになる」です。
 AはBが終わるまで、BはAが終わるまで先に進めませんから、ロック処理が待ちっ放しになるような場合、結果的にサーバがパンクしてしまいます。
 ですが、先ほどの実例のように数回で止める様になっていれば、一方ないし両方がエラーになって終わります。

 このような互いにロックを掛け合ったような状況をデッドロックといって、データベースを利用したシステムなどで見られる現象です。
 普通のCGIでは、待ちっ放しになるようなロックをしなければ起こらない現象ですが、このような事も在り得るということで、複雑なプログラムを組む場合にはご注意ください。

≪Index