検索しても、「ファイルロック・ロック・排他制御の重要性・安全性」について、自分自身が満足できるほどに充実した内容の文書が見付からなかったので、作りました。
この記事は、相当な長文です。長いです。くどいです。しつこいです。悪い例を何種類も挙げ、なぜ失敗するのかを示し、たった1種類の「良い例」を提示するまでに、ものすごい寄り道をします。
排他処理関係では、落とし穴が無数にあります。適当にやっているとほぼ確実にどれかにハマります。だからこそ多くの「間違いの例」を挙げました。その結果長文になりました 。「正しい例」(10行かからずに書けますしこの文章の後半に確かに置いてあります)だけ見せても、その裏の意味を知らない限り、次回また、どれかの罠にハマるでしょうから。
その代わり、この文章を最後まで読み切ったときには、「排他制御」が如何なるものかについて、原理をよく理解してくれるだろう、自然に自分で考えて応用してくれるようになるだろう、と、そう信じています。
この記事では、CGIでカウンタ作るときとか、スレッドプログラムを作るときとか、データベースで重要な作業するときに必ず必要になる「ロック」「排他処理」の概念について横断的に述べていきます。
ただし、最初に、一番簡単な例だと思われるCGIアクセスカウンタで、問題の根元についてほとんど論じきります。DBのお話は最後に補足的に扱います 。スレッドはほとんど触れません。
naruto/CANO-Lab
mikiso@gf6.so-net.ne.jp
2005年に大学を卒業してコンピュータとは無関係な職業に就いた、趣味プログラマ。PerlとDelphiとMySQLが好き。最近はPHPも好き。
この文書の誤植レポートや改善案を歓迎します。このページへのリンクはご自由にどうぞ。
CGI, 排他処理, ロック, 理由, ファイルロック, ロックファイル, Perl, flock, mkdir, symlink, DB, RDB,
トランザクション, スレッド, ログ飛び, クリティカルセクション, セマフォ, MySQL, PostgreSQL
この記事全体の「難易度」ですが、一概に言えません、自作掲示板などのCGIを作っている人にとっては、中上級的な内容に感じるかもしれません。フリーの掲示板CGIとか配布しているサイトでも、ロックが無茶苦茶なところって結構ありますので。
DBを扱おうと思っている人にとって、ここの知識は初級レベルに相当します。 まじめなDBの解説書なら絶対書いてあると思いますので、手持ちの教科書などもっている方は復習のつもりで読むといいかもしれません。スレッド扱おうと思っている人にとっては、ここで述べる知識は入門編もいいところ、知らずにやるのは自殺行為です。
理論上壊れないレベルの「ファイルロック」を実現する。
OSそのものに致命的なバグがあったとか(まぁ考えなくていい)、停電が起きたとか、サーバがインド象に踏まれたといった物理的障害によって壊れることは今は想定しません(バックアップは常に取りましょう♪)。あくまで一般のプログラマが、「 同時アクセスで落ちない」ファイルロックを実現する術を身につけること。
CGI目的でこのページを見ている人が、全く初見の単語が並んでいて不安に思うかもしれませんので、最初に簡単に説明しておきます。
ここでは「データベース」とは、MySQLやPostgreSQL、Oracleなどの「リレーショナルデータベース」、RDBMSを指す単語とします。単純なテキストファイルなどだと、掲示板のデータファイルの大きさが10MBとかになれば死にそうになりますが、まさに「同時に実行する」「ファイルが壊れないようにする」「巨大なデータを正しく扱う」のを目的に開発されたDBシステムでは、100MBでも2GBでも、巨大なデータを高速安全に扱うことができます。単純なCGIに十分慣れた人が「過去ログを別ファイルにしないで3000件溜めてもいいのね?」とかで、次に興味を奪われがちなところです。
最近はレンタルサーバでも、MySQLなんかを開放してくれているところが増えてきたので、触ってみたい人は気軽に触れるでしょう。
このあとの解説で「プロセス」という単語が出てきますが、とりあえずPerlの実行されいているプログラム1個分、Windowsで実行されてメモリにロードされているアプリケーション1個分のことだと思ってください。Windowsでタスクマネージャを起動するとプロセスタブに並んでいるアレのことです。
スレッド(thread)とは、プロセスの中で、さらに複数の処理を同時に実行するための仕組みです。単純なPerlの掲示板などでは1プロセス=1スレッドなので考える必要ありませんが、ちょっと複雑なアプリケーションでは1プロセス=10スレッドで動いているなんていうこともあります。10個の別のプログラムを同時に動かしながら1つの大きな目的を達成するようなものです(Perlでもスレッドを扱えるそうですが筆者がやったことがないので割愛します)。 この記事の大部分では、プロセスとスレッドを混同して、厳密にはスレッドと書くべき場所で「プロセス」と書いてあったりしますが、分かりやすさのためなのでご了承ください。
公開するにあたり、以下の方々の協力を頂きました。ありがとうございました。
では本題に入りましょう。
まずは、一番単純なCGI+SSIのアクセスカウンタで、問題を検証していきます。Webサーバによって起動されて、単純に数字を1ずつ加えて報告するだけのプログラムです。
#!/usr/local/bin/perl open IN, "<counter.txt"; #ファイルを読み込み専用で開く $count = <IN>; #ファイルから1行読み込んで$count変数に代入 close IN; #ファイルを閉じる $count++; #数字を1増やす print "Content-type: text/plain\n\n$count"; #カウンタの値を出力 open OUT, ">counter.txt"; #ファイルを書き込み専用で開く print OUT $count; # $countの内容をファイル書き出す close OUT; #ファイルを閉じる
PerlでCGIを作ったことのある人なら何の困難もなく読めると思います。CやJavaやDelphi言語やVBのプログラマさんは、申し訳ありませんが適宜頭の中でfopenやらAssignやらcout<<やらprintfやらwritelnに置き換えてお読みください。
要するにファイルを開いて1行読み込み、1加えて改めて同じファイルに書き込む、という、それだけをするプログラムです。
さて、このプログラムを1度実行し、2度実行し…とやっていくと、確かにcounter.txtというファイルに記入された数字は、実行するたびに1ずつ上がっていきます。正しくカウンタとして動作しているように思えます。
でも、今までにCGIを作ったことがあって「ロック」という単語を聞いたことがある人なら、さすがにこのプログラムは「ヤバい」と感じるかもしれませんね。
実際にこのプログラムを1日100万アクセスのサイトのカウンタとして採用すると、まず間違いなく、恐怖の「ログ飛び」が発生します。1日300アクセスでも本当に運が悪いと飛ぶでしょう。いつの間にかデータファイルであるcounter.txtが『破壊され』、カウンタが1に戻ってしまう、という恐怖の現象です。
何故、「恐怖のログ飛び」は発生するのでしょうか?
こう質問すると、よく単純な答えとして返ってくるのは、
「同時に2つのプログラムがデータファイルにアクセスするから壊れるんだよ」
…というものです。そう聞いて、何となく「なるほど」、と思って、 とりあえずそう理解している人もいるんじゃないでしょうか。
「同時に2つのプログラムがデータファイルにアクセスしようとすると、データファイルは壊れる(ことがある)」「だから同時に1つのプログラムしかアクセスできないようにする機構が必要。」「それがロック処理なのだよ」
少なくとも、間違った事は書いてません。「ロック」自体を聞くのが初めて、ロックの目的って何だろう? …という人は、まず上の文章を頭にたたき込めば、ロックの1割くらいは理解したことになります。
WindowsでもUNIXでもいいんですが、近代的なOSでは、複数のプロセス(正確にはスレッド)が1つのOSの中で同時に動いています。難しい言葉ではプリエンプティブマルチタスクOS、などと言います 。当たり前過ぎて意識しないかもしれませんが、Wordでレポート作成中にソリティアが出来るのも、Windowsが 、1つしかないCPU(2つかもしれませんけど)で同時に2つ以上のプロセスが実行できるよう、陰で工夫しているからです。
CGIも一緒で、ほぼ同時にサイトへのアクセスがあると、ほぼ同時にCGIが2個起動して、ほぼ同時に同じファイルを処理しようとします。
さっきのプログラムを再掲します。
#!/usr/local/bin/perl open IN, "<counter.txt"; #ファイルを読み込み専用で開く $count = <IN>; #ファイルから1行読み込んで$count変数に代入 close IN; #ファイルを閉じる $count++; #数字を1増やす print "Content-type: text/plain\n\n$count"; #カウンタの値を出力 open OUT, ">counter.txt"; #ファイルを書き込み専用で開く print OUT $count; # $countの内容をファイル書き出す close OUT; #ファイルを閉じる
う~~ん確かに、同時に同じファイルで何か読み書きしようとしたら、いかにもまずそうだ、とは思います。なんとなく(?)ファイル壊れそうです。何が起こるか分かんない気がします。
そこで登場する排他処理とは、「俺が今からファイルに触るから、その間自分以外の奴は触るな」と他のプロセスに対して宣言すること(=ロックすること)に相当します。
宣言されている間は他の プロセスはファイルに触っちゃいけません。「終わったぜ」と言われるまで待たないといけません。トイレみたいなもんだと思いましょう(唐突)。10人が同時に駆け込もうとしても、中に入って鍵を閉めることができるのは常に1人。
ファイルを読み書きする前には必ず「入ってま~す」という目印を立てて、用を済ませたら「空いてま~す」という目印をどこかに立てておく、これをプログラムで実現しましょう。そうすれば、他の人(プロセス)は、「入ってます」になっている間は順番を待ち、「空いてます」になってから自分が入ればいいわけです。このようにして同時に複数のプロセスが同じファイル(ファイル以外でもいいですが)にアクセスするのを防ぐ、これが「排他処理」、他の人を排する処理なわけです。
複数のプロセス(スレッド)が同時に同じデータを処理しようとすることが破壊の原因。
排他制御の基本は「順番待ち」。同じデータに複数の人がアクセスする場合には、「自分以外の人は立ち入り禁止」な区域に入る前に、「入ってます」という目印を立てる。
ただ、 ここまでの説明は日本語のサイトを検索するだけで、ごまんと(誇張)見つかります。 ていうか、この文章を読んでる人のほとんどは、ここまでの話くらい、ご存じだろう、と推察します。
だけど、これだけで正しい排他制御ができるようには絶対なりません。次に進みましょう。
排他制御の目的は分かりました。
が、今までの文章を読んでどう思ったでしょうか。
「コンピュータって賢いと思っていたけど、同時に同じファイルにアクセスしようとするとランダムにファイルの中身が壊れちゃうのか、案外怖いもんだな」でしょうか。「同時にアクセスすると壊れるっていうけど、具体的にコンピュータの中で何が起きてるんだろう」でしょうか。そう思ってくれたなら、この文書を書いた甲斐があるというものです(笑)
こういう心配はもっともですが、ご安心下さい。実際のところ、OSとファイルシステムはちゃんと堅牢に動いています(少なくともUNIXとWindowsNT系のファイルシステムでは)。どんなに一生懸命同じファイルに同時にアクセスしようとしても、それが原因でディスクが読み取り不可能になったりOSが止まったりすることはありません。ファイルのリネームを10万回繰り返したってファイルが分裂したり消えたり、実体のないファイルができたりもしません。そういう最悪の事態はOSがちゃんと防いでいます。
あと「そんなにファイルを同時に触られるのがイヤだったら、UNIXやWindowsが自動的にロックして、2人目以降のファイルのopenではエラー出すようにしてくれりゃいいじゃないか、何でわざわざ、自前でロックなんて実装しなきゃいけないんだ」と思うかもしれません。が、そういうわけにもいきません。そもそも2人が同時にファイルを「読み込む」のは、何の問題もありませんし、プログラマが上手に制御すれば、ファイルを同時に「書き込む」のも問題ではありません。実際にDBシステムでは、1個のデータファイルが凄い勢いで数十個のスレッドによって読み書きされたりしますが、DBシステムを作ったプログラマが偉いので、データは壊れたりしません。Windowsのレジストリだって1つの大きなファイルですが、知ってか知らずか複数のアプリケーションが同時にアクセスしまくっていますし、問題は発生しません。
だから、「自分のプログラムではどこに排他制御が必要か」を考えて、実装しないといけないのは、そのプログラムを作っている貴方なのです。OSの動きを分かっていれば安全なプログラムは作れますし、分かっていなければいつまでたっても正しい排他制御は実現できません。
前置きはこのくらいにして、実際のプログラムを。
「入ってます」の目印に、とりあえず普通のファイルを作ってみて、その存在の有無で判定することにしましょう。いわゆる「ロックファイル」を使ったロック方法です。このロックファイルは、「入ってます」の目印専用のため、中身はなく、単純に存在の有無だけが重要なファイルです。
#!/usr/local/bin/perl
while (-e "lockfile.lock") {sleep(1);} #ロックファイルが存在する間、1秒ずつ待つ
open LOCK, ">lockfile.lock"; #ロックファイルがないので作る、ここから先は我が天下
close LOCK;
open IN, "<counter.txt";
$count = <IN>;
close IN;
$count++;
print "Content-type: text/plain\n\n$count";
open OUT, ">counter.txt";
print OUT $count;
close OUT;
unlink("lockfile.lock"); #ファイル削除、次の方どうぞ~
追加した部分は示してあります。Perlが分からない人のために述べると、"-e"はファイルが存在するかどうかを判定して真偽値を返す演算子、unlinkは単純にファイルを削除する関数です。sleep(1);で1秒間プログラムの実行を停止します。
追加した部分の意味は大丈夫でしょうか。「lockfile.lockというファイルが存在する間は待つ」「ロックファイルが存在しない場合にロックファイルを作成する(新規作成モードで追加してすぐ閉じる)」「実際のデータファイルを処理してから」「ロックファイルを削除しておく」ですね。
問題がないように思えるでしょうか。
実は、これでも、しっかりカウンタは飛びます。大事な数字がリセットされてしまいます。実際、1日12000件のアクセスがあった某サイトでは、こういうカウンタを使っていたら、2日間で3回もカウンタが飛んじゃったそうです(まぁ運も悪いけど)。カウンタとしての役目を果たしてませんね。
これの何が悪いのか、分かるでしょうか。残念ながら、排他制御全く知らない人にこれを見せて、何が悪いか自分で思いついて指摘できる人はほとんどいません。実質12行の短いプログラムですから、やる気があればここで少し悩んでみても良いでしょう。無理せず先に進んでも構いません。いずれにせよこの後の解説を読んだ後で悩むことになるでしょうから(笑)
何故これで数字がリセットされるのか考えましょう。プロセスAさんとプロセスBさんがいると考えてください。2人とも、WebサーバへのアクセスによってCGIとして起動されたPerlのプログラムです。偶然、ほとんど同じ時刻に起動されました。
まずAさんが、ほんの一瞬、例えば0.01秒だけ先に起動しました。まずはPerlの記述の順番に従って、ロックファイルを探しましょう。ロックファイルはこの時点で、存在していませんでした。Aさんは、「ラッキー、空いている」と思って、処理を次の行に進めます。次の行ではロックファイルをさくせ…
…この時点で、あろうことかAさんは眠ってしまいました。
UNIXやWindowsのようなOSでは、CPUは少数個(1個のことも多い)しかないのにプロセスはたくさんあるので、OSが、全てのプロセスに順番にほんの短い間だけCPU処理時間を割り当てては奪い、ということを繰り返して、見た目に同時に実行されているようにしています。プロセスは、黙々と自分の仕事をしているように思いこんでいますが、実はOSによって非常に短い時間の間に、眠らされて起こされて、を繰り返しています。
Aさんが眠っている間に、0.01秒だけ遅れて起動したBさんが実行を進めます。Bさんも同じように、ロックファイルを探そうとします。ロックファイルはこの時点で、まだ誰にも作成されていません。Bさんは「空いている」と思って、処理を次の行に進め、実際にロックファイルを作成します。Bさんは自分でロックを行ったので安心して本物のカウンターのデータファイルを開きます。データファイルには99999と書かれていました。Bさんは「よっしゃキリ番」と思った…かどうかは定かではありませんが、とにかくその中身を読み取って1加え、「次は100000をファイルに書き込もう」と考えて、データファイルを開きます。open
OUT, ">counter.txt"; が実行された時点で、データファイルは上書き用に新しく作成されて、counter.txt は、新しい空っぽのファイルとなります。もちろんその直後で正しい数字を書き込むつもりです。
…ところがこの時点で、今度はBさんが眠ってしまいます。代わってAさんが実行を再開します。Aさんはどこまで進んでましたっけ。確か、「ロックファイルは存在しない」と判断した直後で気絶していたのでした。そこでAさんは、その後何事もなかったかのように、自分もロックファイルを作成しようとします。open
LOCK, ">lockfile.lock"; は、既存のファイルがあっても何のエラーも出さずに、ただそれを上書きして成功します。Aさんは、Bさんが作ったロックファイルを上書きしつつ、それに気づかずに次の処理を進めます。
次の処理はデータファイルから読み取って1を加えることでした。Aさんはデータファイルを開きます。するとそのデータファイルcounter.txtは空っぽでした。何しろBさんがさっき自分用に新しく作ったファイルなので。何も知らないAさんはそれを見て、「じゃあカウンタはゼロだ、次は1を書き込もう」と考えます。…もう悲劇はだいたい想像がついたと思います。
…ここで再びBさんが復活、Aさんは意識不明となります。Bさんは「次は100000を書き込むぞ」と思ったところで気絶していたので、実際にファイルに「100000」と書き込んで、データファイルを閉じ、ロックファイルを削除して終了します。Bさんお疲れ様でした。
最後にAさんは再び目が覚めて、「100000」と書かれたデータファイルを上書きして颯爽と「1」と書き込みます。そしてその後ロックファイルを削除するときにはロックファイルは本当は存在していないかもしれませんが、そこで不審がっても今更しょうがないので、そのまま終了します。Aさんお疲れ様でした。
以上、最後に残ったデータファイルには「1」と書かれています。めでたしめでたしでカウンタデータが飛びました。
……こんな凄まじい偶然、本当に起こるんだろうかと思いますか? 答えはYESです。 相当な確率で起こります。混雑しているサーバで1秒に1回程度アクセスがある場合、100秒に1回くらいは、0.01秒レベルでほぼ同時のアクセスが来るはずです。あなたが配布を考えているこんなカウンタCGIがそこそこの人気サイト100か所で使われたとしたら、今日もどこかでカウンタが飛んでいますよ、の勢いです。
まずは何より、今までの一連の動作で、本当に「同時」にファイルに書き込んでいるわけじゃないことに気を付けてください。コンピュータなんて所詮単純作業の機械。OSは何も知らずに、単純にAさんとBさんを交互に起こしている「だけ」です。
AさんとBさんは、単純に交互に目覚めてはファイルの存在をチェックしたり、ファイルを作ったり、ファイルに書き込んだりしている「だけ」です。全く同時にアクセスする、ということは実際にはあり得ません。OSが仕様通りにうごいているんだから、悪いのはAさんBさんを作ったプログラマの方です。明らかに共犯で、ファイルを自分達で壊して知らん顔しているわけです。こんな実装をしてデータが飛んでも不思議でもなんでもありません。
最も想定外だったのは、ロックファイルを作っているはずなのに、そこのチェックをすり抜けていることでしょう。AさんもBさんも、同じ時間に起動しておきながら、平然とふたりしてロックファイルの壁を突破しています。
では、上のプログラムを、データが飛ばないように、思う存分自分で修正してみてください。いろんな案が出てくるでしょう。
open OUT, ">counter.txt"; すると一瞬ファイルが空になるので、open OUT, "+<counter.txt"; して、seekを使って上書きで書き込む6を除いて、実際に筆者はこれに近いものを見てきたぞ、というのばっかりです。
残念ながら 1番目2番目の方法は、ほとんど全く同じ仕組みによってロックの仕組みが突破され、データが失われてしまいます。無力ですねえ。
この程度のカウンタなら3~5番目の姑息的な方法が役に立つ「かも」しれません。とりあえずログが完全に消えるのは抑えられます。しかしよく考えていけばわかると思いますが、これらの方法ではカウンタとしての正しい動作とは、程遠いものになってしまいます。
3や5の方法だと、きっとAさんもBさんも同じ数字「100000」を表示してしまい、どっちがキリ番を取ったかで揉めてしまうような素敵カウンタが生まれてしまいます。キリ番直前にみんなで一斉にリロードしたおかげで、掲示板にキリ番報告が4人並んだとかいう逸話も知っています(ちゃんとリロードで回るカウンタで)。4の方法だと、AさんとBさんのどっちかは数字を取得できずカウントアップもできないという、無敵カウンタになりますね。6は論外。
…まあ残念ながら世の中にはこういう怪しいカウンタは多数存在します。所詮カウンタと思われるかもしれませんが、カウンタというものの定義を考えるに、同じ数字が2つ出てくるだとか数字が出てこないだとかいうのは、本来とんでもないことです。「OSとロックの仕様の限界でしょうがないんですよ」という名言も聞いたことがありますが、そんな人に会員番号の発行プログラムとか、コンサートチケットや座席番号発行とかのアクセスが集中する業務プログラムを委託しちゃったら怖すぎます。こういう大規模サイトも大抵、UNIXかWindowsのどっちかで動いていることはご存じの通りです。
姑息的方法はやめましょう。これが掲示板だったらどうなるか? 書き込んだ記事が失われます。掲示板ならまだいい、オークションシステムや在庫限定の通販システムだったらどうなるか? 「あなたが最高入札者です」とか2人に表示されてしまったら、10個限定のプレミアグッズの注文を11人から受けてしまったら、お金が振り込まれた後で客のデータが消えたら…後で訴訟問題になりかねません。
「正しくロックする」ことを学びましょう。
中途半端な知識でロックの真似事をしたり、中途半端な手段で修正しようとすると、火傷する。
ここまでで、Perlの中だけで適当にフラグを立ててロックを実現しようとすると、大抵は破綻することが分かりました。
「入ってませんか?」とノックして、空いてることを確認したのにね。トイレで言えばノックした後に他の人に目にも留まらないスピードで駆け込まれた、という状況(笑) 個室の中で2人がご対面。
困った。
そこで登場するのが、UNIXとWindowsNT (2000以降) で動くPerlの関数 flock です。
flockは、OSが準備しているファイルロック専門の仕組みを利用します。OS提供なので安全確実です。
え? こんな便利なものがあるんだから最初からこれを説明しろって? …それやると理解しないままになっちゃうじゃないですか(笑) そもそもflockは、ただ意味も分からず使っておけば良いというほど簡単な物ではなく、下手な使い方をしたら意味が無くなってしまいます。
それにflockは使い方もちょっと覚えづらく、存在は知っていても敢えてロックファイルなどの不完全メソッドを使う、という人もいますから、まずロックファイルを否定しておきたかったと。
flockが他の方式と何が違うか、というと、「入ってませんか?」というチェックから「私が入りました」という宣言までを、誰にも邪魔されずに一発でやってくれます。
というよりはむしろ、入っていますよ宣言を自分でやる代わりに、時の番人であるOS(UNIXとかWindows)に頼む、と思ってください。OSはそもそも、すべてのプロセスを生かしたり殺したり休ませたりしている張本人であり神様です。その神様が、下々のプロセスAさんやBさんからほぼ同時に「私にロックさせてください」と頼まれたとしても、神様は絶対にどちらかにしかロックを与えません。
その結果、flockでのロックに成功した場合、AさんやBさんは絶対に他の人が先にロックしていない、と自信を持てるわけです。
それじゃ、とりあえずこういうプログラムを書いてみましょう。
#!/usr/local/bin/perl open IN, "<counter.txt"; flock IN, 1; # 共有ロックが得られるまで待つ $count = <IN>; close IN; # ロックは自動的に解除される $count++; print "Content-type: text/plain\n\n$count"; open OUT, ">counter.txt"; flock OUT, 2; # 排他ロックが得られるまで待つ print OUT $count; close OUT; # ロックは自動的に解除される
flockの使い方はPerlのマニュアル参照…にしたいところですが、ついでに大事な概念も述べちゃいましょう。使い方や数字の意味はPerl固有のものですが、「共有ロック」「排他ロック」の違いはしっかり認識してください。
flockの使い方は以下の通り。
flock FILEHANDLE, OPERATION
FILEHANDLEはopenで開いたときに使ったファイルハンドル、OPERATIONは1,2,5,6,8のいずれかです(定数も宣言されていますので適当なマニュアル参照)。
排他ロック(exclusive lock)は、「俺がファイルを使うから、俺が終わるまで誰も触るな」という意味のロック(これまで説明していたロック)です。共有ロック(shared lock)は、「俺が読み込むから、他に同時に読み込みたい奴等は勝手にしてくれ、ただし書き込みたい奴は俺が終わるまで待て」という意味のロックです。共有ロックのことを読み取り専用ロック、リードロックとか言ったり、排他ロックのことを書き込みロック・ライトロックとか言ったりもします。
普通は同時に同じファイルを読み込んでもファイルは壊れませんから、共有ロックに意味がないように思えた人がいるかもしれません。が、共有ロックの後半「ただし書き込みたい奴は俺が終わるまで待て」が重要なのです。共有ロックを使えば、自分が読み取っている最中に他のファイルがそのファイルを変更することを避けられます。
なお、たまにflockが使えても共有ロックだけは使えない、というシステムがあるらしいですが、そういう場合はしょうがないので、常に排他ロックを使いましょう。読み込みがメインで書き込みがあまり起こらないようなプログラムでは、共有ロックが使えた方がやや効率は良いですが、小規模なプログラムなら排他ロックを使っていても大した違いはありません。
つまり。
共有ロックは、他の誰も排他ロックを取得していない場合(共有ロックなら他のプロセスがいくら取得していてもいい)に取得できます。排他ロックは、あらゆるロックを誰も取得していない場合にのみ取得できます。
言い換えると、ある1つのファイルに対して、あり得るロック状況は、「誰もロックしていない」「1プロセス以上(1でもいい)が共有ロックを得ている」「1プロセスだけが排他ロックを得ている」の3種類です。
…。
……。
難しかったですか? 今、重要な事を言いました。共有ロックと排他ロック、いつどっちを使えばいいはずなのか、ここで考えて下さい。あとでもう一度聞きます。
さてOPERATIONが1や2の場合は、「何がなんでも私にロックをください、くれないのなら私の存在価値なんてありません」と、OSにロックの取得を依頼したあとはプロセスの実行を停止して待ち続けます(この動作をブロッキングロックと言いますが、排他ロックとかと混同しないで下さい)。逆に5や6はノンブロッキングで、「今空いてますか? だったらロックしてください、ダメなら後でまたお願いします」というものです。いずれにせよ、いったんロックを取得してしまえば後のことは変わりません。(ブロッキングロックとシグナルを使って、特定の秒数だけ「待ち続ける」方法もあります。上級編かつUNIXオンリーなので他のサイト参照)
また、closeによってロックは自動的に解除されるので、カウンタや掲示板程度のプログラムなら、明示的にロックを解除する場面はありません(ちゃんとロック解除したんだという自己確認のために書く人もいます)。
というわけで、この関数を使うと、1つだけのプロセスがロックを取得できるようになりました。
さて。
せっかくflockを使った、今の「プログラム3」には、まだバグが2つあります。
1個の間違いは、ログが飛んでゼロに戻っちゃうバグです。
もう1個の間違いは、2人以上の人に同じ数字を表示してしまうバグです。
実質10行の中にどんな問題が隠されているのか。探してみて下さい。
#!/usr/local/bin/perl open IN, "<counter.txt"; flock IN, 1; # 共有ロックが得られるまで待つ $count = <IN>; close IN; # ロックは自動的に解除される $count++; print "Content-type: text/plain\n\n$count"; open OUT, ">counter.txt"; flock OUT, 2; # 排他ロックが得られるまで待つ print OUT $count; close OUT; # ロックは自動的に解除される
……(考えタイム)……
…………。
いいですか?
……答え、言っちゃうよー。
1個目のバグ、ログが完全に消えてしまう原因は、
open OUT, ">counter.txt";この行です。これが呼ばれた時点で、まだ排他ロックを取得していないのに、空っぽのファイルが作成されてしまいます。一瞬ですけどあなどってはいけません。 ちゃんとPerlの説明書に、「>を付けてオープンするとファイルはクリアされます」って書いてあります。(これは他のプログラムでもだいたい同じです)
Bさんが排他ロックを得る前にファイルを空っぽにしてしまえば、Aさんが共有ロックを得て読み出した値は、偶然やっぱり空っぽでした、という事態が起こりえます。空っぽのファイルを読み取ってしまったAさんは、Bさんの仕事が終了した後に、何も知らず「1」というファイルを作ってしまうでしょう。
実際にあるんですよこんなミス。本人はロック完璧にやってるつもりだから疑わないし。
2個目のバグはここ。
flock IN, 1; # 共有ロックが得られるまで待つ
前半の1回目のファイルオープンでは読み取るから、と「共有ロック」を使っていますが、これでいいでしょうか? ダメです。これだと2つのプロセスが同時に共有ロックを得て、同じ数字を読み取ってしまい、同じ数字を2か所で表示してしまうことになります。
でもこのパターン、よく見ます。何も考えず読み取りは共有ロックだと信じているパターン。確かに「共有ロック」の別名はリードロックですが、でもカウンタの数を読み取るのに共有ロックを使うなんて、重複カウントしてくれと言っているようなもんです。
「リードロック」の言葉に惑わされず、「同時に2つのプログラムが同じ値を読み取っても構わないのか」を考え、どんなロックの種類が必要なのかを吟味しましょう。くどいですが、プログラマであるあなた自身が考えるしかありません。
上記の問題をすべて修正したのがこちら。
#!/usr/local/bin/perl open INOUT, "+<counter.txt"; # 読み書き両用でオープン flock INOUT, 2; # 排他ロックが得られるまで永遠に待つ $count = <INOUT> $count++; print "Content-type: text/plain\n\n$count"; seek INOUT, 0, 0; # 書き込み場所をファイル先頭に巻き戻し~ print INOUT $count; close INOUT; # ロックは自動的に解除される
プログラム3より短くなりましたね。ロックは行数食うから面倒だと思ってる人がいますが、そうでもないです。
ファイルは1回だけ「読み書きモード」で開いて、排他ロックを得てから読み書きします。プログラムの作業途中なのにファイルを1回閉じてしまうのは、「ファイル閉じたから他の人使ってくれ」と言ってるようなもんです。
seekは、ファイルの読み取り位置を巻き戻したり進めたりする関数です。いったん$counter = <INOUT>で読み取った後は、ファイルの中の位置(読み書きのためのカーソルみたいなものだと思いましょう)がファイルの末尾になっちゃっているので、巻き戻してあげます(カセットテープにデータを入れていた人は、頭出しみたいなものだと思えば理解しやすい)
ともあれ、これでやっと、理論的には壊れないカウンタが完成しました。
実際は、ロックが得られるまで永遠に待たれるとCGI的には都合が悪いので(ロックしたまま暴走しているプロセスがある場合など)、すぐ諦める方のロックで試して、ダメだったら1秒ごとに最大5回挑戦し、それでもダメならエラーを出して終了、とか、そういう機構を組むこともあります。CGI特有の話になってくるので、実際のところは有名どころのCGIで勉強してください(有名だから完璧だとは限りませんが…)
flockの問題点は、稀に使えない環境があることです。Windows版を使っている場合、WindowsNT、2000、XP以降である必要があります。まあこれはいまどき問題にならないでしょうが、UNIXでも稀に使えないことがあるとされています(具体的にはストレージとしてNFSを使っている場合が該当するらしいのですが、個人的には使えないOSに出会ったことはありません)。
何があっても最大の互換性が必要なんだ、という場合には、Perlのmkdirコマンドを使う方法があります。mkdirは、新しいディレクトリ(フォルダ)を作るというのが本来の目的の関数です。OSにとって、2つのプロセスから同時に「ディレクトリ(フォルダ)作って」と頼まれた場合に、「はいはい」と平気で2つの同じ名前のディレクトリを作ったらファイルシステムが破綻します(同じ名前のディレクトリが2個!)。なので、OSはこういう場合は、ロックと同様、どっちかのプロセスの言うことを聞いてディレクトリを作って、もう片方からのリクエストに対してはエラーを返してくれます。これを使い、自分でディレクトリを作成できた場合に「入ってます」宣言状態、ということにして、最後にディレクトリを削除すればいいわけです。(プログラム2の、openを使ったロックファイルと何が違うか比較してみてください)
他にも、symlinkを使う方法(UNIXのみ)などがありますが、この辺になってくるとロック博物館の様相を呈してきますし、いろんな人が語っていますので、他のサイトを参考にしてくださいませ。
個人的には、「OSがそのために準備してくれている機能」を使わない、というのも気分が悪いので、flockを推奨しています。自分しか使わないようなスクリプトなら、flock以外を使う理由はありません。超細かい話ですが、WindowsとUNIXではmkdirでいけるものの、他のOSでもmkdirで安心できるとは限らない(ドキュメントに書いてない)でしょうし。symlinkについても、OS環境によっては稀にきちんとロックしてくれないとか書いてあるサイトもあります(ハッキリ言って興味ないので、自分で確認していません)。
本題と無関係ですが、「ロックが残る」というのは多くの人が困っている問題でしょうから、ここで少しだけ比較して触れておきます。
ロック専門関数flock以外の自前ロックを使う場合、OSが自動的にロックを開放してくれる、なんていう素敵なことはやってくれません。自前でロックを解除する必要があります。バグのあるCGIがロック解除し忘れたまま終了した場合、延々と他のプログラムが目的ファイルに触れなくなったりします。なのでよく、10分前より古いロックが存在したら、それはロック解除忘れだと判断して無条件にロックを削除して処理を続行する…なんていう処理を追加することがあります。
もちろん、どんなエラーも感心できるものではありませんが、「あるプロセスが些細なエラーで止まると、他のプロセスも動かなくなる」なんて、そんな対応、考えるの面倒くさいよね。
一方でflockを使った場合、プログラムがエラーを出して止まっても(いわゆるInternal Server Error)、自動でロックが解除されます。ラッキー。もちろんプログラムが、正しい動作もせずエラーで終了もしない無限ループに陥った場合は、延々と他のプログラムがファイルに触れなくなってしまうわけですが、その辺は他のどんなロックを使っても同様です。しかも最近は、大抵の商用レンタルサーバで、2分とか暴走してるCGIを自動的に停止する機能とか備えていると思うので、その時にロックも解除されます。
というわけで、エラー周りでも、やっぱりflockの方が優秀だと言えるんじゃないでしょうか。
以上から、使えるならflockが大推奨なんですが、まあそれでも、flockがそもそも使えないOSがあるんじゃ、しょうがない。不特定多数に配布するCGIを作っているなら、以上のような性質を理解した上で、flockとmkdirからオプションで選べる、とか、そうしとくのがいいんでしょう。これ以上先の具体的な実装方法や実践、flockはevalしようだの、mkdirの自前ロックを解除するときのコツだのの話については、他のサイトがたくさんあるので、そちら参照しておくれ(笑) 一応このページの末尾に参考になるリンクを1個挙げておきます。
いずれにせよ、この記事のフォーカスはそれ以前の部分だということでご理解くださいませ。
flock、最大の互換性と多少の面倒があるmkdir。その他もろもろ。とりあえずシンプルなカウンタで覚えることはこれで終了です。お疲れ様でした。
ここまでの議論を実験で確かめておきましょう。
最終的な検証は、実際に動かしてみるしかありません。そうはいっても、検証の対象は、0コンマ1秒未満の微妙なかねあいで成否が決まるプログラムです。適当にブラウザのリロードボタンを連打しまくるとかじゃ先に指が折れます。
というわけで、以下の実験は自前の自宅Webサーバ(何をやっても怒られない)と、自前の大量HTTPリクエスト同時発行プログラムを使って行いました。っていうかHTTPリクエスト同時発行プログラムは別名DoSプログラムですので、巷に多数存在するロックが不完全なCGIに向けて発射なんぞしようものなら簡単にデータ飛ぶでしょうし、ロックが完全だったとしても負荷のせいでプロバイダから契約解除されたり不正アクセスで逮捕されたり、さもなくばサイト管理人様から生ゴミ が届いたりしそうな代物です。犯罪に荷担するのはイヤなので公開は控えますがご了承ください。どうしても必要な場合は理由と、サイト管理者さんの場合はサイトのURLなどの身元情報も添えて、メールで個別にご相談ください。
自宅Webサーバのスペックは、Celeron 366MHz, Memory 192MB, Red Hat Linux 7, Perl 5.8, Apache 1.3.27です。またリクエストを発行するためのWindowsパソコンのスペックは、Celeron 2.6GHz, Memory 1024MB, Windows XP Professionalです。2つのマシンの間は、単なるスイッチングハブを通じて100MのLANで接続されています。
実験では、100個のリクエストをほぼ同時にWebサーバに投げ(これが1セット)、1セットを何度か繰り返していきました。

プログラム1(対策ゼロ)では、100回のリクエスト(1セット)だけで20回カウンタが飛んでいます。 さすがに対策ゼロだと論外の成績(笑)
プログラム2(ロックファイル)では20セット(2000リクエスト)で、最終の数字が2000ではなく、1948を指していました。時々、「1」とか「2」といった不可解なカウントが出てきました。22セット目(カウント2201~2300)で、遂にカウンタが飛んで、ゼロにリセットされてしまいました。
プログラム3(flockの使い方違い)では、1セットだけで9回ほど飛びました。プログラム1と同様、論外の成績。
なんかこう、flockを中途半端に使うとロックファイルより駄目な結果が出る、というのは興味深いですね。「こんなもの信頼できない」、とか言っちゃう人の気持ちが分からなくもありません。
最後にプログラム4で、100回リクエストを100セット繰り返したところ、見事に、ちゃんと10000でした。これだけの負荷でも問題なく動作する理想のカウンタが完成です(なお、100件全部から応答が返るまでの時間は平均2.5秒で、他と比べて特に遅くはなかったことを付け加えておきます)。
プログラム2ではシミュレーションの通り、完全にファイルが飛ぶためには偶然が2回重ならないといけないので、比較的「完全にとばす」のは難しいようですね。ロックを得ていない大量のプロセスが平気で空ファイルを読み取れてしまうプログラム1や3と違い、2では、一応形なりに存在するロックのお陰で、実際に同じファイルに殺到するプロセスの数が比較的小さく保たれているのでしょう。詳しい状況は考えてみて下さい。
もう少し続けましょう。カウンタ程度の規模のプログラムでは問題とならないのでさらっと流しておきましたが、ちょっと複雑になってくると、巨大なファイルを扱ったり、複数のファイルを同時に扱ったりすることも増えてきますよね。
みなさんは、
「ファイルが壊れないようにするためには、ファイルを出来るだけ短い時間で読み込んですぐ閉じ、書き込むときも出来るだけ短い時間で書き込んですぐ閉じるようにする、そうすれば比較的安全♪」と、思っていませんか?
大間違いです。
いわゆる「掲示板」プログラムで投稿があった場合。
上のような動作をするCGIプログラムがヤケに多いんですが、これがどれほど危険か、ここまで読んだ方なら分かるはずです。
例えば。
花子さんと太郎さんがほぼ同時に掲示板に書き込みをし、それぞれプロセスAさんとプロセスBさんほぼ同時に起動しました。ログデータを開いて、同じ時点でのログデータ(同じ内容)をメモリに読み込みました。
プロセスAさんは花子さんの記事をメモリ内で追加し、ログファイルを(きちんとロックして)書き込み、ロックを解除して終了。Aさんがログファイルの書き込みロックを解除するのを待っていたプロセスBさんは、まったく同じように、太郎さんの記事を書きこんで終了。
こんなことすると、花子さんの記事は上書きされて消えてしまいまます。花子さんの記事は、0.05秒の間しかこの世に存在できませんでした。
この間違いは意外と見過ごされています。なぜなら、とにもかくにも掲示板のログが「完全に消失」ということは起こらないからです。読んだり書いたりしている真っ最中はロックが効いていますので、書式的に破壊されたファイルが作成されることはありません。「書き込みが反映されない」とか、あるいは、投稿ではなく記事削除処理であれば、「消したはずの書き込みが復活しました」とかが起こるわけですが、まあせいぜいその程度です。その程度なら誰もバグ報告しないから、CGIを作った本人も気づかなかったりして。だけどバグはバグ、データ消失はデータ消失。
こんな誤動作を防ぐためにはどうすればいいでしょうか? 簡単です。ファイルを読み込んでから処理を行ってファイルを書き込むまで、他の誰にも触らせなければいいんですね。
途中でファイルを閉じてはいけません。flockを使っているのならロックが解除されてしまいます。mkdirなどを使った場合に敢えてファイルを開いたり閉じたり消したりするのは無駄としか言いようがありません。
こうしましょう。
ロックは1回だけ。シンプルに、かつ安全になりました。
カウンタの所でもほぼ同じことを言いましたが、あとで読み取ったデータを元にしてファイルに書き込みをするつもりがあるなら、読み込む時点から排他ロックしておかないとダメですよ。表示したいだけなら、共有ロックで十分ということになります。
ちゃんとした排他処理さえできていれば、「自分が処理をしている間はずーっとファイルは開きっぱなしで自分以外の誰にも触らせない」のが、データを守るためには最も安全なのです。
「データファイルが大きくなるほど、データが飛ぶ可能性が飛躍的に高まります」とか「こまめにファイルを閉じることでデータが飛ぶ危険を最小限にしています」とか言っている人のCGIは信用しないようにしましょう。2回もファイルを開いたり閉じたりすると、その間に何が起こってるか分からない、怖い! …そういう感覚が自然に沸くようになるとOKです。 一旦理解すれば、簡単なことですよね?
余談ですがこう書くと、「こまめにファイルを閉じた方がサーバの負荷は低くなるだろう」とか考える人がいます。でも、「ファイルを開いたり閉じたりする処理はそれ自体が結構負荷が高い」ですし、「ファイルアクセスしないメモリ上の中間処理は意外と速い」です。それに「高負荷の時にいかにも怪しい動作で動き続ける掲示板と、高負荷の時にも正しくデータが守られる掲示板の、どちらが優秀か」を考えれば、ファイルを不必要に閉じたり開いたりするメリットはありません。
flockがサポートされているOSで、flockを正しく使い、正しいロックをかけてもデータが消失する非常に稀なケースとしては、サーバの負荷が極端に高くなってプロセスが強制終了されてしまった、とか、OS自体が暴走してしまった、とか、そういう場合が考えられます。あるいはハードディスクの容量が限界に来た、とかでもデータが消失します。これらはどうしようもありません。
逆に言えば、そういう極端な話じゃないのにログが飛ぶなら、何かプログラムが間違っている可能性が高いわけです。
以下のような項目をチェックしましょう。
flockが成功したかどうか、戻り値を確かめていない、なんていうことはありませんか? せっかくflockがロックに失敗した、と戻り値を返しているのに、それを無視してファイルアクセスしては元も子もありません。flockが動かず、しかもエラーも返ってこないなんていう事もあります。ファイルシステム(NFS)の都合で、常に0が帰ってくるようなサーバも稀にあります。カウンターや掲示板CGIでのデータファイルの排他処理について、正しいのはどれか?
正解は「なし」です。正しい記述は1個もありませんね。
ここまで読んでくれた人、どのくらいいるんでしょうか(笑) とりあえずCGIのお話はここまでで終わりにします。
以降はデータベースの話を少しだけ。
MySQLやPostgreSQLなど、一般的なデータベースシステムは、この排他処理について非常に注意深く設計されており、簡単に排他処理を実現できるようになっています。さらに、Perlで単純なファイルを扱う場合にはファイル全体をロックすることしかできなかったのに対して(たとえ1MBのファイルのたった1行を変更するためだったとしても)、DBでは自分がいじる必要がある部分だけをロックする(行レベルロック・レコードロック)ことで、同時実行能力を確保することもできます。
既に述べましたが、データベースとは、要するに、普段掲示板CGI作者さんがテキストファイルでやってるようなデータをすっごく賢く大量に保存して、一瞬で検索したり統計取ってくれたりするため専用に開発されているシステムです。
DBでも、テキストファイルのロックのときとほとんど同じ問題が発生します。そして、理解していなければデータがおかしなことになるのも同様です。一瞬で処理が終わるテキストの掲示板ログと違って規模がでかい分だけ、排他処理を考える必要性は、むしろぐんと増してきます。
データベースを使えばデータは安全だと思っている人がいます。確かにログ消失等の極端なことがまず起こらないという意味では正しいんですが、そういうこと言う人に限って、排他処理が恐ろしいことになってたりするんですよね。使い方と原則を理解していなければ、データベースを使ってたって問題起きるに決まってます。
でも、既に大抵の問題は説明しました。ここまでのCGI編で述べた「原則」を理解していれば、DBにおける排他処理もその応用です。(DBに興味があってここから読み始めた方、ごめんなさい最初から読んでください)
ここではRDBMSとしてMySQLのMyISAM形式のテーブルのみを紹介します。
実はMySQLは、DBとしては特殊です。高速性を追求するために、多くの機能をそぎ落とした感があります。その分、高速です。後述するPotgreSQLが足下に及びません(体感でもそう感じますが、真面目にベンチマークとったことはないので、具体的な数字についてはそれ系の比較サイトを参照してください)。PerlのCGIから入ってくる人にとっては、PostgreSQLよりも理解しやすいし扱いやすいので、日本でも急速に普及しています。
高速ゆえに、排他処理も異様にシンプルで理解しやすく、テーブルロックしかありません。おかげ で、考え方は、今までPerlのCGIのところで散々述べてきた考え方が、ほとんどそのまま通用します。
テーブル(データの集まり)のリードロック(共有ロックのことね)を得るためには
LOCK TABLES tablename READ;
ライトロック(排他ロックですね)を得るためには
LOCK TABLES tablename WRITE;
します。ロックを解除するためには
UNLOCK TABLES;
です。以上、シンプル。
これらの明示的なロックを行わない場合、MySQLはレコードを書き込んだり変更したり削除する際にその瞬間だけテーブル全体を自動的にライトロックし、レコードを読み込む際に
はその瞬間だけテーブル全体を自動的にリードロックしてくれます。そこは全自動なので、同時に2つの書き込みが競合してデータが書式的に壊れたり消えたりする、ということは起こりません。CGI編との対応で言えば、ファイルアクセスごとにとりあえずflockだけはしてくれるみたいなものです。
なのに、明示的にロックをしないといけないのは、どういう場合でしょうか? 既にCGI編の最後の方で説明した通りですね。読み取りから書き込みまでの一連の処理全体をブロックすることで、隔離度は高まってデータは安全になる場合があります。より安全かつ不整合のないデータ処理を望むのなら、ちゃんと自分の頭の中で設計して、どこでロックするべきか考えないといけません。
この世界では使い古された例ですが、「郵便局のATMからお金を引き出す」という場合を考えます。10万円入っている口座から、太郎さんが10万円引き出すとしませう。この場合、プログラムのロジックは「残高が10万円残っているか?」「残っていれば10万円現金で払い出して」「残高から10万円減算しておく」です。例によって同時に引き出す人が1人なら問題はありません。ここで太郎さんと共謀した太郎さんのお父さんが、家族用に発行されたもう1枚のキャッシュカード(郵貯はこれができるんですね)を使って別のATMから、同時に10万円引きだそうと考えたとします。正午ぴったりに時刻を合わせて同時にATMを操作してみましょう。
こんな流れで合計20万円現金が出せちゃったらたまりません、こんなシステム開発した人は開発会社もろとも破滅しかねません。当然現実には不可能でしょう。こんなことのないように、MySQLを使った銀行(存在しないと思いますが)では、残高問い合わせの最初の時点から、「俺 後ではデータを書き換えるつもりだぞ」ということで、残高テーブルを明示的に排他ロックする必要があります。 自動の共有ロックなんざに頼ってられません。
これなら安全。
さて、MySQLのMyISAMテーブルはテーブル単位でしかロックできないので、300MBのテーブル全体を走査して検索するような事態が発生すると、数秒間~数十秒間、他の接続がそのテーブルの排他ロックを得られなくなって立ち往生してしまう、という問題が起きます。デザイン上の制約なので、そういう検索が起こらないよう、全てが一瞬で終わるように工夫しましょう(方法はたくさんあるのでマニュアル参照)。
どうしてもこれが避けられない場合にはPostgreSQLや、MySQLのInnoDBテーブルタイプを検討します。これらはもっと複雑な同時実行制御を備えていますが、その分理解が難しく、またシンプルな用途に使う場合には、逆に動作は低速になります(他のサイトを参照してください)。
排他制御は、難しいですが、理解してしまうとパズルみたいで面白いです。
まず、自分がファイルやメモリやテーブルなどの情報リソースに対して、どんな操作をやりたいのかを明確にしましょう。
「読み取って、処理して、書き込むまでの間、他の人に干渉させたくない」のか。「読み取りたいだけ」なのか。(アクセスログのように)「ただ1行書き込みたいだけ」なのか。今この情報を書き換えると、同時に実行するどのスレッド・プロセスが影響を受けるのか。
それが明確なら、後はパターンの適用です。頑張って下さい。
この右下に、上記の4種類の実装方法で作ったカウンタを置いておきます。
リロードでいくらでも回るカウンタですが、プログラム4以外のカウンタは飛んでいるかもしれませんので、信用しないであげてください 。測定開始は2005年4月9日です。
ま、人気ないサイトだと関係ありませんけど(涙)
flockが使えずsymlinkやmkdirを使う場合に、信頼性を一段上げる方法。
Program 1: 1
Program 2: 1
Program 3: 1
Program 4: 1