Ruby TIPS
ファイルの排他制御を行うには? その際のデッドロック問題とは?
1つのファイルに複数のプログラムから同時アクセスすると、上書きによりデータが消失する可能性がある。これを回避するために排他制御を行う方法と、その際に問題となるデッドロックを回避する方法について説明する。
複数のプログラムから1つの対象ファイルにデータを同時に書き込むと、適切にデータが記録されないことがある。そのような問題に対応するために排他制御を行う方法を解説する。また、複数のファイルを利用する場合に起こり得るデッドロックの問題と、それを避ける方法を見る。
ファイルの排他制御
複数のプログラムが1つのファイルにアクセスする状況では、データの読み出しと書き込みのタイミングによっては、一方のプログラムの書き込みが上書きされてしまい、情報が正しく更新されないことがある。ここでは、そのような状況の例を確認した後、ファイルをロックして他のプログラムからのアクセスを待ち状態にすること(=排他制御)により、問題を回避する方法を見ていく。
1つのファイルを同時に利用する場合の問題
具体的な例で問題を明らかにしておこう。図1のように、残高のデータが記録されているファイルがあるものとし、そのファイルにプログラムAとプログラムBがアクセスする。プログラムAがデータを読み出した後、プログラムBも同じデータを読み出したとしよう。続いて、プログラムAが残高を更新し、ファイルに書き込む。この時点ではまだ問題は起こっていない。しかし、プログラムBが残高を更新してファイルに書き込むと、プログラムAの書き込みがなかったことになってしまう。
プログラムAの書き込みが終わる前に、プログラムBがデータを読み出すと、プログラムBは更新前のデータを使うことになってしまう。そのため、プログラムAの更新がなかったことになり、本来14000であるべき残高が11000になってしまう。
図1.1のような状況だけでなく、プログラムBが先に処理を終え、ファイルに書き込むことも考えられる。そのような場合には、後から出力したプログラムAの結果だけが記録され、プログラムBによる更新がなかったことになってしまう。
では、簡単なプログラムを使って、このような状況をあえて作ってみよう。よくある状況としては、Webサイトのカウンターを更新する場面が考えられる。次のプログラムはカウンターのデータを読み出した後、しばらくしてからカウンターの値を増やし、ファイルに出力する。
File.open("counter.txt", mode = "r+"){|f|
s = (f.read).to_i # カウンターの値を読み出す
sleep(5) # 処理に時間がかかっている
s += 1 # カウンターの値を加算
f.seek(0)
f.write(s) # カウンターの値を書き込む
}
|
read
メソッドで読み出した文字列をto_i
メソッドで整数化する。sleep
メソッドを使ってわざと遅延を発生させ、値を増やした後でファイルに書き込む。
このプログラムを単独で実行するだけなら、多少時間がかかるだけで何の問題もない。しかし、プログラムを複数個実行すると、図1.1で見たような問題が起こる。
$ more counter.txt
14000
$ ruby sample001.rb & sleep 1; ruby sample001.rb
[1] 14755
[1]+ Done ruby sample001.rb
$ more counter.txt
14001
|
counter.txt
ファイルには14000という値が記録されている。リスト1.1のプログラムの実行時に&
を付けてバックグラウンドで実行し、同時にsleep
コマンドを使って1秒だけ待ち、その後に同じプログラムをもう一つ実行してみる。プログラムが2つ実行されたので、カウンターの値は14002になるべきだが、前のプログラムの更新がなかったことになり、14001にしかならなかった。
※macOSやLinuxでコマンド実行した場合のコマンド例。Windowsの場合、& sleep 1;
の部分は、コマンドプロンプトでは& timeout 1&
、PowerShellでは; Start-Sleep -s 1;
に置き換えて実行すればよい。
ファイルをロックして排他制御を行う
ファイルへの同時アクセスによる問題を回避するためには、データを読み込んでから更新が終わるまでの間、他のプログラムからのアクセスを待ち状態にするとよい。File
クラスのflock
メソッドに引数File::LOCK_EX
(排他: 自分自身だけが利用できる)を指定すると、ファイルをロックし、他のプログラムからのアクセスをブロックする(=待ち状態にする)ことができる。ファイルをアンロックし、ブロックを解除するにはFile
クラスのflock
メソッドに引数File::LOCK_UN
を指定する。これで、他のプログラムからのアクセスができるようになる。
File.open("counter.txt", mode = "r+"){|f|
f.flock(File::LOCK_EX) # ロックする(すでにロックされていたら待つ)
s = (f.read).to_i
sleep(5) # 処理に時間がかかっている
s += 1
f.seek(0)
f.write(s)
f.flock(File::LOCK_UN) # アンロックし、他のプログラムが読み出せるようにする
}
|
データを読み出す前にファイルをロックしておく。必要な処理とファイルへの書き込みが終わったらアンロックしておく。
ファイルのロックは、close
メソッドによりファイルを閉じても解除される。リスト1.2のように、{}
を使ったブロック構文でコードを書いている場合は、ブロックを抜けるときにファイルが閉じられるので、その時点でロックが解除される。
実行例は以下の通り。今度は正しく両方のプログラムの更新が反映されている。
$ more counter.txt
14000
$ ruby sample002.rb & sleep 1; ruby sample002.rb
[1] 17642
[1]+ Done ruby sample002.rb
$ more counter.txt
14002
|
counter.txt
ファイルには14000という値が記録されている。リスト1.1と同様の方法でプログラムを実行する。今度は最初のプログラムがデータを読み込んでから書き込むまでファイルがロックされるので、後から実行されるプログラムは待ち状態になる。ファイルがアンロックされたら後から実行されたプログラムがデータを読み出し、値を更新する。カウンターの値は正しく14002になっている。
※macOSやLinuxでコマンド実行した場合のコマンド例。Windowsの場合、& sleep 1;
の部分は、コマンドプロンプトでは& timeout 1&
、PowerShellでは; Start-Sleep -s 1;
に置き換えて実行すればよい。
なお、flock
メソッドのモードには他にもFile::LOCK_SH
(共有: 複数のアクセスを許可する)やFile::LOCK_NB
(ノンブロック: ブロックされる場合にも待ち状態にならず、falseを返す)という値と|
演算子を使ってそれらを組み合わせた値が指定できる。ただし、プログラムAがFile::LOCK_SH
を指定していても、プログラムBがFile::LOCK_EX
を指定してブロックすると、排他モードにするため(=自分だけが使える状態にするため)、プログラムBが待ち状態になるということに注意が必要である。
これまで、処理に何秒もの時間がかかる極端な例で同時更新の問題や解決方法を見てきたが、ファイルのロックからアンロックまでの処理はできるだけ短い時間で終わるようにしなければならない。例えば、ファイルをロックしたまま、ユーザーの入力を待つ、といった処理は避けるべきである。そのような場合には、データの読み出し時にはロックを行わず、データを書き込む前にロックを行い、再度データを読み出して、他のプログラムから更新がかけられていないかをチェックするというのも1つの解決法である。最初に読み出したデータと、後で読み出したデータが異なれば、他のプログラムが更新した、ということなので、更新をキャンセルしたり、更新後のデータを使って更新をやり直したりすることができる。
デッドロックに注意
複数のファイルを複数のプログラムやスレッドから利用する場合には、お互いにロックを掛け合って動きが取れない状態(=デッドロック)に陥る危険があることにも注意が必要である(図1.2)。
プログラムAがファイル1をロックし、プログラムBがファイル2をロックしたものとする。続いて、プログラムAがファイル2にアクセスしようとすると、プログラムAはブロックされ、待ち状態になる。さらにプログラムBがファイル1にアクセスしようとすると、プログラムBもブロックされ、待ち状態になる。ロックが解除できなくなってしまう。
念のため、デッドロックを起こすプログラムの例も示しておこう。
puts "プログラムAがファイル1を開き、ファイル1をロックします"
f1 = File.open("file1.txt", mode = "r+")
f1.flock(File::LOCK_EX)
sleep 2 # ちょっと待つ
puts "プログラムAがファイル2を開こうとします。ファイル2はロックされているので待ち状態になります"
f2 = File.open("file2.txt", mode = "r+") # file2.txtはロックされているので待ち状態になる
f2.flock(File::LOCK_EX)
f1.close
f2.close
|
puts "プログラムBがファイル2を開き、ファイル2をロックします"
f2 = File.open("file2.txt", mode = "r+")
f2.flock(File::LOCK_EX)
sleep 2 # ちょっと待つ
puts "プログラムBがファイル1を開こうとします。ファイル1はロックされているので待ち状態になります"
f1 = File.open("file1.txt", mode = "r+") # file1.txtはロックされているので待ち状態になる
f1.flock(File::LOCK_EX)
f2.close
f1.close
|
図1.2の状況をそのまま書いてみた。問題を起こしている原因は、file1.txtとfile2.txtを同時に開いていること。ファイルを同じ順序で使い、使い終わった時点で閉じておけば問題は回避できる。
これらのプログラムを実行してみると、デッドロックに陥り、反応が返ってこなくなる。動作を確認したら、Ctrl+Cキーを押して、処理を中断しておこう。
$ ruby sample003a.rb & sleep 1; ruby sample003b.rb
[1] 28563
プログラムAがファイル1を開き、ファイル1をロックします
プログラムBがファイル2を開き、ファイル2をロックします
プログラムAがファイル2を開こうとします。ファイル2はロックされているので待ち状態になります
プログラムBがファイル1を開こうとします。ファイル1はロックされているので待ち状態になります
^Csample003b.rb:8:in `flock': Interrupt
from sample003b.rb:8:in `<main>'
|
プログラムAとプログラムBがお互いにロックしているファイルにアクセスしようとするので、デッドロックに陥った。プログラムの実行を中断するためにはCtrl+Cキーを使う。
データベースなどではデッドロックの検出機能が提供されているものがあるが、通常のファイルでは検出が難しいので、デッドロックの危険を避けるように処理を設計する必要がある。この例であれば、ファイルを使い終わった時点でclose
メソッドを実行し、1つのプログラムで複数のファイルが同時に開かれないようにしておくとよい。例えば、sample3a.rb
ファイルであれば、f1.close
をsleep 2
の前または後に移動すればよい。あるいは、リスト1.1や1.2のようなブロック構文を使って書くとよい。また、プログラムAとプログラムBが異なる順序でファイルを取り扱わないようにしておくとよい。
同時書き込みやデッドロックの問題を回避するためだけでなく、突然のクラッシュなどでトラブルを起こさないようにするためにも、ファイルを必要以上に開いたままにするのは避けるようにしよう。
なお、デッドロックはファイルだけでなく、複数のスレッドでキューなどの共有データをMutexを使って排他的に利用する場合にも起こり得る(スレッドなどの扱いについてはまた回を改めて紹介する)。
まとめ
ファイルへの同時アクセスによる問題に対応するためには、File
クラスのflock
メソッドを使って、ファイルをロックし、他のプログラムからのアクセスをブロックするとよい。ファイルをロックするにはflock
メソッドの引数にFile::LOCK_EX
を指定し、ロックを解除するにはflock
メソッドの引数にFile::LOCK_UN
を指定すればよい。1つのプログラムで複数のファイルを取り扱うときにはデッドロックにも注意する必要がある。
API:Fileクラス カテゴリ:組み込みライブラリ
API:flockメソッド|closeメソッド カテゴリ:Fileクラス
※以下では、本稿の前後を合わせて5回分(第21回~第25回)のみ表示しています。
連載の全タイトルを参照するには、[この記事の連載 INDEX]を参照してください。
21. ファイルから1行/段落ごと読み込む(入力する)には?
Rubyでテキストファイルから文字列を読み込むための方法として、ファイル内の全テキスト内容を先頭から1行単位ずつもしくは1段落ずつループ処理する方法と、ファイルから読み込んだ全ての行を配列として返す方法を説明する。
22. ファイルに文字列を書き込む(出力する)には?
テキストファイルに文字列を書き込むための基本を解説。新規書き込みと追加の方法を確認した後、任意の位置から書き込む方法や読み書き両用モードでファイルを利用する方法を説明する。