シェルスクリプトで排他・共有ロック&セマフォ


どのUNIXでも通用するロック機構はあるのか?

他言語では大抵用意されているロック機構であるが、シェルスクリプトにはロック機構を直接実現するコマンドが無い。flockコマンドlockfコマンドなどOSによっては独自コマンドがあるが、OS独自ゆえ、それらを使ってしまうとシェルスクリプトの可搬性は失われてしまう。(しかも前述の2つのコマンドは、ファイルロックのコマンドであるうえに、ロックがかかっている間は次の行に進めない)

「特定のOSに縛られてしまうくらいなら他の言語を使う方がよっぽどマシだ」といって、シェルスクリプト信者(POSIX原理主義者)を増やせないのが悔しかったのだが、ようやく作るのに成功したので報告する。

これで、

が、POSIXの範囲でできるようになった。能書きはいいから使いたいという人は、上記コマンドをダウンロードしてもらって(ごく簡単な使い方はコマンドソースの冒頭に記してある)、本記事ではいかにそれらを実現したかというアイデアをまとめることにする。

その0. シェルスクリプトでのロック管理方法

ロックに関連するシステムコールを司るコマンドはPOSIX範囲にはない。では何を使って管理するかといえば、ファイルだ。1つのディレクトリー直下には同名ファイルが1つしか存在できない、という性質を賢く利用するのがシェルスクリプトでロックを管理するための原理である。

ベタな方法だと見くびることなかれ。もともとOSは、物理的に1台しかないHDDへのアクセス要求を捌くために内部で必ず排他制御をやっている。ファイルによるロック管理とは、OSが備えている洗練された排他制御機構の活用に他ならない!

基本ルール1. ロックファイルを作れた者勝ち

もう少し具体的に言えば、ロック管理のためのディレクトリーを1つ用意し……

  • 早い者勝ちでファイル(ロックファイル)を作る。
  • 「成功者はロック成功(アクセス権取得)」と取り決める。
  • 失敗者は暫くしてから再度ロックファイル作成を試みる。
  • 成功者は用事が済んだらロックファイル消す。

である。これを基本ルール1として制定する。

基本ルール2. 一定以上古いロックファイルは消してよい

ただ、成功者がロックをしたまま消し忘れたり、何らかの理由で異常終了してしまうとアクセス権が紛失されてしまうという問題がある。これは終了シグナルを検出(trapコマンドで)したらロックファイルを消す等である程度対応できるが、SIGKILL(強制終了)などがあるので完全ではない。

仕方が無いので、ロックファイルのタイムスタンプを確認し、一定以上古いロックファイルは消してよいという基本ルール2を制定する。ただ、可能ならば生成元のプロセスが既に存在しないことも確認する方が親切だ。

ここで、一つ注意しなければならないことがある。一定以上古いロックファイルを消すという役割は、1つのロック管理ディレクトリーに対して1つのプロセスにしか与えてはならないということだ。もし、あるプロセスAが一定以上古いロックファイル検出し、今からそれを消して新たに作り直そうとしている時、プロセスBが同じロックファイルを古いと判断して削除し、新規作成まで済ませてしまったらどうなるだろう。プロセスAはこの後、プロセスBの作ったロックファイルを誤って消してしまうことになる。これは、古いファイルの検出、削除、作り直しという操作が、アトミックに(単一操作で)できないという制約に起因する。よって、古いロックファイルの削除役は一人でなければならないのである。

尚、ここで出てきた「一定以上古いファイルを検出する」という処理がPOSIXの範囲では面倒なのだが、これに関しては過去の記事「findコマンドで秒単位に新旧比較したい」で解決しておいたのでここでは割愛する。

以上を踏まえ、各ロックを実現するアイデアをまとめる。

その1. 排他ロックはどうやる?

排他ロックとは、誰にも邪魔されない唯一のアクセス権を獲得するためのロックだ。ファイルを読み書きする場合などに用いる。

ロックファイル作成方法はいろいろある

ファイルを用いて排他ロックを実現する方法というのは実はよく知られており、単純である。ロック管理用ディレクトリーの中でロックファイルを作ればよいわけだが、既存ファイルがある場合に失敗するようにして作成するには例えば次の方法がある。

  • mkdir <ロックファイル>
  • ln -s <何かの元ファイル> <ロックファイル>
  • ln <何かの元ファイル> <ロックファイル>
  • (set -C; : > <ロックファイル>)

ポイントはアトミックに(単一操作で)作るという点である。つまり存在確認処理と作成処理が同時ということだ。もし存在しないことを確認できて、いざ作成しようとした時に他のプロセスに素早く作成されてしまったら、ロックファイルを上書きできてしまうのでアクセス権が唯一のものではなくなってしまう。

私が作った排他ロックコマンド“pexlock”では最後の方法を用いた。後で紹介する共有ロックコマンドでは複数のアクセス権を管理するためにディレクトリー(mkdir)を用いており、それと区別させるためだ。

その2. 共有ロック・セマフォはどうやる?

共有ロックとは、そのロックを申請した全てのプロセスでアクセス権を共有するためのロックだ。自分がファイルを読み込んでいる間、他のプロセスもそれを読み込むだけなら許すが、書き込み許さない、というプロセス同士がアクセス権を共有したい場合などに用いる。

一方セマフォとは、共有ロックの最大共有数を制限するロックだ。物理デバイスの数だけプロセスを同時に走らせたい場合などに用いる。セマフォは共有ロックの応用で実現できるため、ここでまとめて説明する。

2.1. 共有ロックファイル(ディレクトリー)の構造

排他ロックファイルに比べるとだいぶ複雑であるが、共有ロックファイル(ディレクトリー)は次の構造を持たせる。

共有ロックファイル"lockname_HOGE"のファイル構成
LOCKDIR/           ← ロックファイル管理ディレクトリー
|
+-lockname_HOGE/   ← ロックファイル一式(親ディレクトリー)
  |
  +-lockname_HOGE/ ← ロックファイル一式(同名の子ディレクトリー)
    |                 ・このディレクトリーのハードリンク数-2が共有数
    |
    +-uniq_num1.pid1/ ← 共有ロックをかける度に作成するサブロック名
    +-uniq_num2.pid2/    (一意な番号+呼出元プロセスID)
    :
    |
    + modifying  ← 上記のサブロック名ディレクトリーを追加削除する際の
                   アクセス権のための排他ロックファイル(作業時のみ存在)

これにはいくつか工夫が凝らしてある。

2.1.1. 同名で二重化されたディレクトリー

なぜ同名のディレクトリーを二重に作っているのか。これは共有ロックファイル(ディレクトリー)をアトミックに作るための巧妙な仕掛けである。

後で改めて説明するが、共有ロックディレクトリーの中には、共有中のプロセスによって一意に作られたディレクトリー(サブロックディレクトリー)が必ずなければならない。これは共有中のプロセス数を把握できるようにするためである。それゆえ、もし何も考えず本番のロック管理ディレクトリーに直接に新規作成してしまうと、作成した瞬間の共有ロックディレクトリーは空であるため、共有プロセスが0(もはや誰も共有していない)と見なされて削除される恐れがある。よって本番のロック管理ディレクトリーに直接作ることは避けなければならない。

そこで、予め別の安全な場所でサブロックディレクトリーまで中身を作っておき、mvコマンドを用いて本番ディレクトリーに移動させる。ところがもし移動先に既存の共有ロックディレクトリーがあると通常mvコマンドは、その共有ロックディレクトリーのサブディレクトリーとして移動を成功させてしまう。共有ロックディレクトリーの直下にわざわざ同名のディレクトリーを置くのはこの問題への対策である。同名のディレクトリーが直下にあれば、mvコマンドも移動を諦めてくれる。

2.1.2. 中に<固有番号+プロセスID>のディレクトリーを作る

これまた巧妙な仕掛けだ。先程説明した二階層同名ロックディレクトリーの下層側(子)に、共有ロックを希望する各プロセスがさらに1つディレクトリーを作る。この際、ディレクトリー名が衝突しないように<固有番号+呼出元プロセスID>という命名規則(固有番号は、作成日時と作成プロセスIDに基づいて作ればよい)による一意な名称(サブロック名)を付ける。

目的は先程も述べたが、現在の共有プロセス数を把握するためである。共有数が把握できれば後述するセマフォ(共有数に制限を設ける)を実現できるし、また共有数0になった際に共有ロックディレクトリー自体を削除するという判断もできる。

では具体的にどうやって共有数を把握するか? 中に作成したディレクトリー数を素直に数えるという方法もあるが、もっと軽い方法がある。共有ロックディレクトリー(子)のハードリンク数を見るという方法だ。

ls -ld <共有ロックディレクトリー(子)>

を実行した時、2列目に表示される数字がそれである。この数字を-2すると、直下のサブディレクトリーの数になる(理由は割愛)。従って上記のコマンドで2列目の数を取得すれば、いちいち全部数えずとも、共有数を計算できるのである。

2.1.3. 共有数を増減・参照する際は、更に排他ロック

  • 共有数が0だったら共有ロックディレクトリーを削除する
  • 共有数が上限に達したらそれ以上の共有を拒否する(セマフォ制御)

といった操作は、どうしてもアトミックに行うことができない。共有数を調べて処理を決めようとしている時に共有数が変化してしまう恐れがある。これを防ぐため、共有数を参照する時と共有数を増減させる時は、そこで更に排他ロックを掛けなければならない。

共有数を参照したい場合は今列挙したとおりだが、増減させたい時というのは次の場合である。

  • 共有ロックを追加したい場合
  • 共有ロックを削除したい場合
  • 共有ロックディレクトリー(子)内のサブロック名ディレクトリーのうち古いものを、基本ルール2に従って一斉削除したい場合

以上、列挙した操作を行う場合に作る排他ロックファイルが

<共有ロックディレクトリー(子)>/modifying

というファイルである。

2.2. 共有ロック・セマフォの実装のまとめ

以上の理屈に基づいてコードに起こしてみたものをいくつか例示する。実際のコードには異常系の処理等があり、これより込み入っているが、わかりやすくするためにそれらは書いていない。

2.2.1. 共有ロックファイル(ディレクトリー)新規作成

共有ロックディレクトリーが存在しない場合に新規作成する部分のコードである。安全な場所で2.1.で記した共有ロックディレクトリーを作成し、本番ディレクトリーにmvコマンドで移動している。もしmvに失敗した場合は、共有ロックディレクトリーが既に存在していることを意味しているので、次に例示するシェル関数add_shlock()へ進む。

共有ロック新規作成サンプル
LOCKDIR="/PATH/TO/LOCKDIR" # ロックの管理を行うディレクトリー
lockname="ロック名"        # 共有ロック名
MAX_SHARING_PROCS=上限数   # セマフォモードの場合に使う上限数

# 安全な場所で共有ロックファイルを新規作成
callerpid=$(ps -o pid,ppid | awk '$2=='$$'{print $1;exit}')
sublockname=$(date +%Y%m%d%H%M%S).$$.$callerpid
tmpdir="$LOCKDIR/.preshlock.$sublockname"
shlockdir_pre="$tmpdir/$lockname/$lockname/$sublockname"
mkdir -p $shlockdir_pre

# 本番ディレクトリーへの移動を試みる
try=3 # リトライ数
while [ $try -gt 0 ]; do
  # mvに成功したら新規作成成功
  mv $shlockdir_pre $LOCKDIR 2>/dev/null && {
    echo "$LOCKDIR/$lockname/$lockname/$sublockname" # 後で削除できるよう、
    break                                            # サブロック名ファイルパスを返す
  }

  # 失敗したら追加作成モードで試みる
  add_shlock && break  # add_shlock()の中身は別途説明

  try=$((try-1))
  [ $try -gt 0 ] && sleep 1                          # リトライする場合は1秒待つ
done
case $try in 0) echo "timeout, try again later";exit 1;; esac

# 安全な場所として作ったディレクトリーを削除
rm -rf "$tmpdir"

尚、「安全な場所」を確保するためにロックファイルディレクトリーの中に.preshlock.<サブロック名>という一意なディレクトリーを作っている。そのため、^\.preshlock\.[0-9.]+$に該当するロック名は予約名として使用禁止とする。

2.2.2. 共有ロックファイル(ディレクトリー)追加作成

前記のコードで共有ロックディレクトリーの新規作成に失敗した場合にはこちらのシェル関数add_shlock()にやってくる。

共有数を増減・参照するのでまず排他ロックファイルmodifyingを作り、共有数が上限に達していないか調べ、達していなければサブロック名ディレクトリーを1つ作る。最後に排他ロックファイルを消すのも忘れぬようにする。

add_shlock()…共有ロックファイル(ディレクトリー)追加作成サンプル
add_shlock() {
  # 共有数アクセス権取得(失敗したらシェル関数終了)
  (set -C; : >$LOCKDIR/$lockname/$lockname/modifying) || return 1

  # 共有数(=共有ロックファイル(子)の数-2)が制限を超えていないか
  # ※ この処理はセマフォ制御の場合のみ
  n=$(ls -dl $LOCKDIR/$lockname/$lockname | awk '{$2-2}')
  [ $n -ge $MAX_SHARING_PROCS ] || return 1 # 超過時は関数終了

  # 共有ロック追加
  sublockname=$(date +%Y%m%d%H%M%S).$$.$callerpid
  mkdir $tmpdir/$lockname/$lockname/$sublockname
  echo "$LOCKDIR/$lockname/$lockname/$sublockname" # 後で削除できるよう、
                                                   # サブロック名パスを返す
  # 共有数アクセス権解放
  rm $LOCKDIR/$lockname/$lockname/modifying
}

2.2.3. 共有ロックファイル(ディレクトリー)削除

自分で作った共有ロックを削除する場合は、ロック成功時に渡されたロックファイル(サブロック名まで含む)のフルパスを渡す。それに基づいてサブロック名ディレクトリーを削除、更に古いディレクトリーも削除した結果、共有数0になっていたら共有ロックディレクトリーごと削除する。

共有ロックファイル(ディレクトリー)削除サンプル
lockfile="ここにロックファイル名(サブロック名まで含む)"

try=3 # リトライ数
while [ $try -gt 0 ]; do
  # 共有数アクセス権取得
  (set -C; : >${lockfile#/*}/modifying) && break

  try=$((try-1))
  [ $try -gt 0 ] && sleep 1
done
case $try in 0) echo "timeout, try again later";exit 1;; esac

# ロック解除対象のサブロック名ディレクトリーを削除
rmdir $lockfile

# 共有数(=共有ロックファイル(子)の数-2)が0なら共有ロックdir自身を削除
n=$(ls -dl ${lockfile#/*} | awk '{$2-2}')
if [ $n -le 0 ]; then
  rm -rf ${lockfile#/*}/..
else
  # 共有数が0でなければ、共有数アクセス権を解放するのみ
  rm ${lockfile#/*}/modifying
fi

いやぁ、共有ロックは大変だ。

このようにして巧妙な技をいくつか組み合わせれば、共有ロック・セマフォもPOSIXの範囲のシェルスクリプトで実現できる。(実際のコマンド→pexlockコマンドpshlockコマンドpunlockコマンドpcllockコマンド

だが、ここまで複雑だともはやパズルだ。巧妙な構造にした共有ロックディレクトリーのみならず、一定以上古いファイルを消す(pcllockコマンド内で実装)には日時計算が必要であるため前述の別記事の技を駆使し、かなり大掛かりだ。

とはいうものの、こうして何とかコマンド化してしまえたのだから、今後はコマンドを呼び出すだけで簡単にロックができるようになるうえ、同時にどのUNIXでも、将来長きに渡り動くという究極の可搬性を備えたロックが手に入った。POSIX原理主義万歳!!