冪等性がないやアクセス制限などの理由で、同時実行不可という要望はしばしば出てきます。普段ではデータベースに実行フラグを置いたり、RedisやQueueで制御したりするのが一般的ですが、どれも実装コストが高くて、小規模プロジェクトにはコスパがやや高いと思います。
ロックファイルで制御を行えば、データベースの変更などが不要で、手軽いに排他制御ができます。
TD;LR
require 'fileutils'
DIR_NAME = 'tmp/locks'
# 排他制御用ラッパー
def synchronized(key = :default_lock)
# フォルダーがない時エラーが生じるので、あらかじめ生成しておく
FileUtils.mkdir_p(DIR_NAME)
lock_file_path = "#{DIR_NAME}/#{key}"
File.open(lock_file_path, 'w') do |lock_file|
# 排他ロック
if lock_file.flock(File::LOCK_EX|File::LOCK_NB)
yield
else
raise "[Error] #{key} in use"
end
end
end
# ロックの取得
def locked?(key = :default_lock)
lock_file_path = "#{DIR_NAME}/#{key}"
return false if !File.exist?(lock_file_path)
File.open(lock_file_path, 'r') do |lock_file|
# 共有ロックの取得を試みる、失敗した時はファイルの最終更新日時を返す
lock_file.flock(File::LOCK_SH|File::LOCK_NB) ? false : lock_file.mtime
end
end
# 使い方
def run_synchronized
locked_at = locked?(:run_synchronized)
if locked_at
puts "Locked at #{locked_at.strftime("%Y-%m-%d %H:%M:%S")}"
return
end
synchronized(:run_synchronized) do
p 'Start....'
sleep(10)
p 'End...'
end
end
実行結果(例)
2つのセッションでrun_synchronized
を同時実行してみます。
# 1つ目のセッション
irb(main):002:0> run_synchronized
"Started at 2020-11-21 17:45:07"
"Ended at 2020-11-21 17:45:17"
=> "Ended at 2020-11-21 17:45:17"
# 2つ目のセッション
irb(main):002:0> run_synchronized
Locked at 2020-11-21 17:45:07
=> nil
本文
synchronized
メソッド
解説
このメソッドは今回の仕組みのコアです。OSのファイルシステムを活用して、ファイルの__排他ロック__をRubyのロジックのロックに転用しています。
そしてロックファイルの名前の違いで、異なるロックを同時に存在できます。
def synchronized(key = :default_lock)
# フォルダーがない時エラーが生じるので、あらかじめ生成しておく
FileUtils.mkdir_p(DIR_NAME)
lock_file_path = "#{DIR_NAME}/#{key}"
File.open(lock_file_path, 'w') do |lock_file|
# 排他ロック
if lock_file.flock(File::LOCK_EX|File::LOCK_NB)
yield
else
raise "[Error] #{key} in use"
end
end
end
使い方
synchronized do
# Do something in this block
end
もし排他ロックの取得が成功した場合、ブロック内のロジックが実行されます。ブロックの実行が完了した時(ブロックが例外発生した時も)、ロックが自動解除されます。
もし排他ロックの取得が失敗した場合、すでにロックされたとみなし、ブロックが実行されず、例外がraiseされます。
#<RuntimeError: [Error] run_synchronized in use>
=> #<RuntimeError: [Error] run_synchronized in use>
locked?
メソッド
解説
このメソッドはおまけみたいな感じで、ロックを触れずにロックされるかを確認できます。__共有ロック__の取得を試みます。もし取得できない(ロックされている)場合、該当ファイルが最後に更新された時刻を返すように作っています。
ただし、ここにBugがあります。synchronized
でロックの取得が失敗した時も、ファイルの更新時刻も変化するので、返された時刻は必ずしもロックされた時刻ではありません(実装により回避はできますが)。
# ロックの取得
def locked?(key = :default_lock)
lock_file_path = "#{DIR_NAME}/#{key}"
return false if !File.exist?(lock_file_path)
File.open(lock_file_path, 'r') do |lock_file|
# 共有ロックの取得を試みる、失敗した時はファイルの最終更新日時を返す
lock_file.flock(File::LOCK_SH|File::LOCK_NB) ? false : lock_file.mtime
end
end
# ロックされた場合
irb(main):007:0> locked?(:run_synchronized)
=> 2020-11-21 18:18:45 +0900
# ロックされてない場合
irb(main):008:0> locked?(:run_synchronized)
=> false