4
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have 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

参考

4
3
0

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
  3. You can use dark theme
What you can do with signing up
4
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?