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

冪等性がないやアクセス制限などの理由で、同時実行不可という要望はしばしば出てきます。普段ではデータベースに実行フラグを置いたり、RedisやQueueで制御したりするのが一般的ですが、どれも実装コストが高くて、小規模プロジェクトにはコスパがやや高いと思います。
ロックファイルで制御を行えば、データベースの変更などが不要で、手軽いに排他制御ができます。

# TD;LR

```rb
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
```

__実行結果（例）__
２つのセッションで`run_synchronized`を同時実行してみます。

```rb
# １つ目のセッション
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"

# ２つ目のセッション
irb(main):002:0> run_synchronized
Locked at 2020-11-21 17:45:07
=> nil
```

# 本文
## `synchronized`メソッド
### 解説
このメソッドは今回の仕組みのコアです。OSのファイルシステムを活用して、ファイルの__排他ロック__をRubyのロジックのロックに転用しています。
そしてロックファイルの名前の違いで、異なるロックを同時に存在できます。

```rb
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
```
### 使い方

```rb
synchronized do
  # Do something in this block
end
```

もし排他ロックの取得が成功した場合、ブロック内のロジックが実行されます。ブロックの実行が完了した時(ブロックが例外発生した時も)、ロックが自動解除されます。
もし排他ロックの取得が失敗した場合、すでにロックされたとみなし、ブロックが実行されず、例外がraiseされます。

```rb
#<RuntimeError: [Error] run_synchronized in use>
=> #<RuntimeError: [Error] run_synchronized in use>
```


## `locked?` メソッド
### 解説
このメソッドはおまけみたいな感じで、ロックを触れずにロックされるかを確認できます。__共有ロック__の取得を試みます。もし取得できない(ロックされている)場合、該当ファイルが最後に更新された時刻を返すように作っています。
*ただし、ここにBugがあります。`synchronized`でロックの取得が失敗した時も、ファイルの更新時刻も変化するので、返された時刻は必ずしもロックされた時刻ではありません(実装により回避はできますが)。*

```rb
# ロックの取得
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
```

```rb
# ロックされた場合
irb(main):007:0> locked?(:run_synchronized)
=> 2020-11-21 18:18:45 +0900

# ロックされてない場合
irb(main):008:0> locked?(:run_synchronized)
=> false
```


# 参考
- [Ruby/Railsでロックファイルによる排他制御](https://www.techscore.com/blog/2012/12/25/rubyrails%E3%81%A7%E3%83%AD%E3%83%83%E3%82%AF%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB%E3%81%AB%E3%82%88%E3%82%8B%E6%8E%92%E4%BB%96%E5%88%B6%E5%BE%A1/)
- [Ruby でファイルのタイムスタンプを調べる](https://qiita.com/niwasawa/items/67b38948975587669719)
- [instance method File#flock](https://docs.ruby-lang.org/ja/latest/method/File/i/flock.html)