データの読み書きを行なうプログラムは,いつどのようなタイミングで 起動されても問題が起きないように注意して作成する必要がある。 どのような問題の可能性をどのように回避するかについて考える。
複数のプログラムが1つのデータファイルにある情報を利用して処理した結果を 書き戻すという処理をほぼ同時に行なうと整合性が取れなくなる。 たとえば,あるファイルに書かれている数値を 1だけ増やすプログラムを2つ走らせたとする。最初,ファイルの内容が
10
だったとして,1増やすプログラムを2回動かすと12になるはずである。 しかし以下のように,「プログラム起動その1」が発生した わずか10ms後に「プログラム起動その2」が起きた場合を考える。
時刻 | プログラム起動その1の流れ↓ | プログラム起動その2の流れ↓ |
---|---|---|
0ms | 起動開始 | |
10ms | データファイルを開く | 起動開始 |
20ms | 10を読み取る | データファイルを開く |
30ms | 変数に1を足す | 10を読み取る |
40ms | ファイルに11を書き込む | 変数に1を足す |
50ms | 終了 | ファイルに11を書き込む |
60ms | 終了 |
「プログラム起動その2」の実行主体がデータを読み取るタイミングでも まだ値は10なので,最終的な書き込み結果は11になる。
このように,あるファイルの中味を読み取りなんらかの修正を加える プログラムは,同じプログラムが同時進行している可能性を考慮し,
のような注意を払う必要がある。
同時に2つのプロセスが同じ処理を行なわないようにすることを 排他処理 という。ファイル修正の排他処理には ファイルロックが用いられる。
Rubyでは
Fileクラス
の flock
メソッドでロックを行なう。
上の例のように,データファイルに記録された数値を1増やすプログラムを 考えてみる。ここではDBM形式データベースに「10」と書いたものを用意し, その後任意の整数を足していくプログラムを考える。
まずDBM形式データファイルに "count" => "10"
というハッシュ値を格納する。以下のようなコマンドラインで作成できる。
ruby -rdbm -e 'DBM.open("counter"){|c| c["count"]=10}' # 以下のように確認 makedbm -u counter count 10
この値を増やしていくプログラムとして以下のものを使用する。
#!/usr/bin/env ruby # -*- coding: utf-8 -*- # ロックなしのまずい例 dbfile = "counter" require 'dbm' DBM.open(dbfile) do |c| # 変数cのハッシュがDBMファイルに直結 count = c["count"].to_i # 文字列なので整数に変換 STDERR.printf("現在の値は%dです。いくつ足しますか: ", count) count += STDIN.gets.to_i # 読み込んだ数を加算 c["count"] = count # データを更新して終了 end
1つだけ起動して動きを確認する。ここでは10を足してみる。
./count-plus.rb 現在の値は10です。 いくつ足しますか: 10 makedbm -u counter.db count 20
続いて,ファイルロックがないために起きる不整合の例を示す。 端末ウィンドウを2枚開き,2つのコマンドラインで同時に起動してみる。
端末その1
./count-plus.rb 現在の値は20です。 いくつ足しますか: 1
端末その2
./count-plus.rb 現在の値は20です。 いくつ足しますか: 2
両プログラムを起動してから,各ウィンドウで整数を入力する。 すると,あとで終わらせた方の加算値になっていることが分かる端末 2への値の入力をあとでやった場合)。
makedbm -u counter
count 22
ファイルロック処理を追加してみる。ロックの必要な処理の前に ロック設定処理を,後ろにロック解除処理を入れる。 流れとしては以下のようになる。
lockfile="ロックファイルの名前" open(lockfile, "w") do |lf| lf.flock(File::LOCK_EX) # ロック設定 DBM.open(datafile) do |x| : end lf.flock(File::LOCK_UN) # ロック解除 end
このような修正を行なった count-flock.rb を示す。
#!/usr/bin/env ruby # -*- coding: utf-8 -*- # ロック処理を追加 (ただしロック時間が無駄に長いのでのちに改善) dbfile = "counter" lockfile = dbfile + ".lck" # ロックに使うダミーファイル require 'dbm' open(lockfile, "w") do |lf| lf.flock(File::LOCK_EX) # ロック設定 STDERR.puts "#{dbfile} に対する作業開始" DBM.open(dbfile) do |c| # 変数cのハッシュがDBMファイルに直結 count = c["count"].to_i STDERR.printf("現在の値は%dです。\nいくつ足しますか: ", count) count += STDIN.gets.to_i # 読み込んだ数を加算 c["count"] = count # データを更新して終了 end STDERR.puts "作業終了" lf.flock(File::LOCK_UN) end
このプログラムを2つの端末で同時に起動する。
端末その1
先に起動して,入力待ちになったら端末その2でプログラムを起動し, その2の方で先に値を入れてから,その1に戻って値を入れる。
./count-flock.rb
現在の値は22です。
いくつ足しますか:
端末その2
その1での起動直後にこちらも起動し, 入力ガイドが出る前に値を入力する。
./count-flock.rb 3
↓
端末その1
その2で値入力後,その1でも値を入れると処理が完了する。
いくつ足しますか: 5
作業終了
端末その2
その1での更新処理が完了すると同時にこちらも処理が完了する。
counter に対する作業開始 現在の値は27です。 いくつ足しますか: 作業終了
このプログラムでは flock を行なうファイルとしてダミーファイルを open したが,これは排他処理を行なう必要のあるファイル形式がdbmだからで, 普通にopenして修正を行なうファイルの排他処理を行なうなら, そのファイルをopenしたオブジェクトでflockすればよい。
PStore と YAML は transaction ブロックそのものが排他処理を行なうため, flockなどの明示的操作はしなくても構わない。
先述のとおりflock処理は極力短くする。さもなくばロック解除待ちの
プロセスが溢れかえる。count-flock.rb
は,加算すべき値の入力処理もロック内で行なっているので,入力を
すぐに行なわないとずっとロックがかかったままとなる。データベース
更新処理のみをロックするように書き換える。データ更新部分だけを
抜き出したプログラム
count-flock2.rb
を示す。
#!/usr/bin/env ruby # -*- coding: utf-8 -*- # ロック処理を追加 (ロック時間短縮版) dbfile = "counter" lockfile = dbfile + ".lck" # ロックに使うダミーファイル require 'dbm' STDERR.print "いくつ足しますか: " x = STDIN.gets.to_i # 加算値を先に読み込んでおく open(lockfile, "w") do |lf| lf.flock(File::LOCK_EX) # ロック設定 STDERR.puts "#{dbfile} に対する作業開始" DBM.open(dbfile) do |c| # 変数cのハッシュがDBMファイルに直結 count = c["count"].to_i STDERR.printf("現在の値は%dです。", count) c["count"] = count+x # データを更新して終了 end STDERR.puts "作業終了" lf.flock(File::LOCK_UN) end
データの更新を伴う処理は,
という流れとなる。必要な修正が,元データの値に影響される場合は 上記の一連の処理全体をロックし,修正手続中のデータベースが 他のプロセスからアクセスできないようにする必要がある。
いっぽう,データベースを読み込むだけの処理もあるとする。 この場合,
という関係となる。このため,修正処理と,読み取りのみの処理が混在する 場合,読み取りのみの処理を共有ロック として処理する。
共有ロックは File::LOCK_SH
を指定する。
まとめると以下のようになる。
File::LOCK_EX
で指定)処理対象に他のロックが全く掛かっていない場合のみロックする。
File::LOCK_SH
で指定)処理対象に他の排他ロックが掛かっていない場合のみロックする。 既存の他のロックが共有ロックのみならロックする。
同じDBMデータファイルに対して共有ロックをかけ,読み取り処理のみを
行なうプログラム count-shlock.rb
を示す。
#!/usr/bin/env ruby # -*- coding: utf-8 -*- # 共有ロック処理 (共有ロックのプログラムだけなら同時に何個でも起動できる) dbfile = "counter" lockfile = dbfile + ".lck" # ロックに使うダミーファイル require 'dbm' open(lockfile, "w") do |lf| lf.flock(File::LOCK_SH) # 共有ロック設定 STDERR.puts "#{dbfile} に対する作業開始" DBM.open(dbfile) do |c| count = c["count"].to_i STDERR.printf("現在の値は%dです。\n", count) end sleep 10 # 同時起動の実験をするため敢えて時間をかける STDERR.puts "作業終了" lf.flock(File::LOCK_UN) end
2つの端末を開き,以下の組み合わせで起動して動きを確かめよ。
パターン | 端末1での操作 | 端末2での操作 |
---|---|---|
1 | (1)count-flock.rb を起動 (4)値を入力 |
(2)count-flock.rbを起動 (3)値を入力 |
2 | (1)count-flock.rb を起動 (3)値を入力 |
(2)count-shlock.rbを起動 |
3 | (1)count-shlock.rbを起動 | (2)count-flock2.rb を起動 (3)値を入力 |
4 | (1)count-shlock.rbを起動 | (2)count-shlock.rb を起動 |
(端末1で count-shlock.rb を起動した場合(パターン3,4)は, 10秒以内に端末2での操作を完了させる。難しい場合は count-shlock.rb の sleep値を延長すればよい。)
複数のプロセスが互いに別プロセスのロック解除を待ち続けて 永遠に解除が起きない状態を デッドロック という。
たとえば,2つのファイル A と B があり,それぞれに 数値が書かれていたとする。このとき
という2つのプロセスがほぼ同時に走ったとする。プロセス1は 「AをロックしてからBをロック」し。プロセス2は 「BをロックしてからAをロック」しようとしたとする。もし, 「プロセス1がAをだけをロック,プロセス2がBだけをロック」した状態に なったら,この状態は永遠に解除されない。
この例の場合,デッドロックを回避するには複数の資源をロックする順位を 決めておく。AとBであれば,A→Bという順番でのみロックするように 設計する。
大規模なソフトウェアではロックすべき資源は多数あるので, デッドロックを完ぺきに回避するためには入念な設計が必要である。 全体的に可能性を極力下げるようための方策として以下のものが挙げられる。
これらを注意するのと同時に,デッドロック回避が本質的に 難しいことを考慮して,
などを作り込んでおくとよい。