Ruby TIPS

Ruby TIPS

ファイルの排他制御を行うには? その際のデッドロック問題とは?

2017年7月12日

1つのファイルに複数のプログラムから同時アクセスすると、上書きによりデータが消失する可能性がある。これを回避するために排他制御を行う方法と、その際に問題となるデッドロックを回避する方法について説明する。

ローグ・インターナショナル 羽山 博
  • このエントリーをはてなブックマークに追加

 複数のプログラムから1つの対象ファイルにデータを同時に書き込むと、適切にデータが記録されないことがある。そのような問題に対応するために排他制御を行う方法を解説する。また、複数のファイルを利用する場合に起こり得るデッドロックの問題と、それを避ける方法を見る。

ファイルの排他制御

 複数のプログラムが1つのファイルにアクセスする状況では、データの読み出しと書き込みのタイミングによっては、一方のプログラムの書き込みが上書きされてしまい、情報が正しく更新されないことがある。ここでは、そのような状況の例を確認した後、ファイルをロックして他のプログラムからのアクセスを待ち状態にすること(=排他制御)により、問題を回避する方法を見ていく。

1つのファイルを同時に利用する場合の問題

 具体的な例で問題を明らかにしておこう。図1のように、残高のデータが記録されているファイルがあるものとし、そのファイルにプログラムAとプログラムBがアクセスする。プログラムAがデータを読み出した後、プログラムBも同じデータを読み出したとしよう。続いて、プログラムAが残高を更新し、ファイルに書き込む。この時点ではまだ問題は起こっていない。しかし、プログラムBが残高を更新してファイルに書き込むと、プログラムAの書き込みがなかったことになってしまう。

図1.1 1つのファイルに同時アクセスすると情報が正しく更新されないことがある

プログラムAの書き込みが終わる前に、プログラムBがデータを読み出すと、プログラムBは更新前のデータを使うことになってしまう。そのため、プログラムAの更新がなかったことになり、本来14000であるべき残高が11000になってしまう。

 図1.1のような状況だけでなく、プログラムBが先に処理を終え、ファイルに書き込むことも考えられる。そのような場合には、後から出力したプログラムAの結果だけが記録され、プログラムBによる更新がなかったことになってしまう。

 では、簡単なプログラムを使って、このような状況をあえて作ってみよう。よくある状況としては、Webサイトのカウンターを更新する場面が考えられる。次のプログラムはカウンターのデータを読み出した後、しばらくしてからカウンターの値を増やし、ファイルに出力する。

sample001.rb
File.open("counter.txt", mode = "r+"){|f|
  s = (f.read).to_i  # カウンターの値を読み出す
  sleep(5)           # 処理に時間がかかっている
  s += 1             # カウンターの値を加算
  f.seek(0)
  f.write(s)         # カウンターの値を書き込む
}
リスト1.1 カウンターの値を増やすプログラム

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
実行例1.1 同時アクセスによりファイルが正しく更新されなかった例

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を指定する。これで、他のプログラムからのアクセスができるようになる。

sample002.rb
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)  # アンロックし、他のプログラムが読み出せるようにする
}
リスト1.2 カウンターの値を増やすプログラム

データを読み出す前にファイルをロックしておく。必要な処理とファイルへの書き込みが終わったらアンロックしておく。

 ファイルのロックは、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
実行例1.2 排他制御により、ファイルが正しく更新された例

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)。

デッドロック
図1.2 複数のファイルに同時アクセスするとデッドロックの可能性がある

プログラムAがファイル1をロックし、プログラムBがファイル2をロックしたものとする。続いて、プログラムAがファイル2にアクセスしようとすると、プログラムAはブロックされ、待ち状態になる。さらにプログラムBがファイル1にアクセスしようとすると、プログラムBもブロックされ、待ち状態になる。ロックが解除できなくなってしまう。

 念のため、デッドロックを起こすプログラムの例も示しておこう。

sample003a.rb
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
sample003b.rb
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.3 デッドロックを起こす2つのプログラム

図1.2の状況をそのまま書いてみた。問題を起こしている原因は、file1.txtとfile2.txtを同時に開いていること。ファイルを同じ順序で使い、使い終わった時点で閉じておけば問題は回避できる。

 これらのプログラムを実行してみると、デッドロックに陥り、反応が返ってこなくなる。動作を確認したら、CtrlCキーを押して、処理を中断しておこう。

ターミナル
$ 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>'
実行例1.3 デッドロックに陥り、動かなくなったプログラム

プログラムAとプログラムBがお互いにロックしているファイルにアクセスしようとするので、デッドロックに陥った。プログラムの実行を中断するためにはCtrlCキーを使う。

 データベースなどではデッドロックの検出機能が提供されているものがあるが、通常のファイルでは検出が難しいので、デッドロックの危険を避けるように処理を設計する必要がある。この例であれば、ファイルを使い終わった時点でcloseメソッドを実行し、1つのプログラムで複数のファイルが同時に開かれないようにしておくとよい。例えば、sample3a.rbファイルであれば、f1.closesleep 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回分(第19回~第23回)のみ表示しています。
 連載の全タイトルを参照するには、[この記事の連載 INDEX]を参照してください。

19. ファイルから文字列を読み込む(入力する)には?(基本編)

テキストファイルから文字列を読み込むための基礎を解説。ファイル操作をブロックで記述する方法や、ファイルを開く際に「テキスト読み出し専用モード」でアクセスしたり文字コードを指定したりする方法、BOM付きファイルを処理する方法を説明する。

20. ファイルから1文字ずつ読み込む(入力する)には?

Rubyでテキストファイルから文字列を読み込むための方法として、ファイルから1文字単位で文字を取得する方法と、ファイル内の全テキスト内容を先頭から1文字ずつループ処理する方法を説明する。

21. ファイルから1行/段落ごと読み込む(入力する)には?

Rubyでテキストファイルから文字列を読み込むための方法として、ファイル内の全テキスト内容を先頭から1行単位ずつもしくは1段落ずつループ処理する方法と、ファイルから読み込んだ全ての行を配列として返す方法を説明する。

22. ファイルに文字列を書き込む(出力する)には?

テキストファイルに文字列を書き込むための基本を解説。新規書き込みと追加の方法を確認した後、任意の位置から書き込む方法や読み書き両用モードでファイルを利用する方法を説明する。

23. 【現在、表示中】≫ ファイルの排他制御を行うには? その際のデッドロック問題とは?

1つのファイルに複数のプログラムから同時アクセスすると、上書きによりデータが消失する可能性がある。これを回避するために排他制御を行う方法と、その際に問題となるデッドロックを回避する方法について説明する。

サイトからのお知らせ

Twitterでつぶやこう!


Build Insider賛同企業・団体

Build Insiderは、以下の企業・団体の支援を受けて活動しています(募集概要)。

ゴールドレベル

  • 日本マイクロソフト株式会社
  • グレープシティ株式会社