LoginSignup
3

More than 1 year has passed since last update.

Rubyでロックファイルによる簡易的排他制御

Posted at

冪等性がないやアクセス制限などの理由で、同時実行不可という要望はしばしば出てきます。普段ではデータベースに実行フラグを置いたり、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

参考

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
What you can do with signing up
3