Perlによるファイルの排他制御を考える rename編

Perlでファイルの排他制御を行うにあたり、実運用に耐えるコーディングについて考えます。
今回はrenameを用いた排他制御を考えます。

具体的な方法

rename関数を用いた排他制御では、ファイルロックの状態を「ファイルの有無」でプログラムに判断させます。
ファイルが非ロックの場合と、ロック状態の切り替えを、ファイル名に依存する方式です。
つまり、実際のファイルそのものを排他制御するわけではありません。

renameも完璧ではない

rename関数が完全に同時実行された場合、rename関数の衝突が生じます。
この際、rename関数は結果を「真(ファイル名変更に成功した)」と返します。
つまり、本来であれば「衝突」によってファイル名変更に失敗しているにも関わらず、あたかも成功したかのように振る舞うのです。
flockによる排他制御と同様、rename関数を用いた排他制御も完璧ではありません。

懸念する点

デッドロック問題

flockを用いた排他制御は、ファイルそのものをロックしました。
Perlスクリプトが何らかの理由で終了すればflockによるファイルロックも解除されます。
しかし、rename関数によるファイル名変更の場合、ファイルロック状態を作り出すために実ファイルを操作しています。
Perlスクリプトが終了したとしても「ファイル名を変更した(ファイルのロック状態)」という結果が残るため、ロックされっぱなしになってしまいます。
つまり、デッドロックしてしまうのです。

正常なロックの解除

仮に「プロセス1」と「プロセス2」が同時にアクセスした時、
「プロセス1」がファイル名を変更して作り出した「ロック状態」を、
「プロセス2」が誤って「ロック解除」してしまう可能性が考えられます。
ファイル名を変更しただけでは、それがデッドロックなのか、本当にファイルロックされているのか判断できないのです。

renameの衝突

前述のとおり、rename関数によるファイル名変更処理は衝突すると「真(成功)」を返します。
あたかも「ファイル名を変更した」かのように振る舞うため、プログラムは「ファイルロックに成功した」と誤解してしまいます。

検討すべき点

デッドロックの解消方法

ファイル名を変更したまま、PerlScriptが終了するとデッドロック状態になります。
このままではファイルへのアクセスができず、正しい動作は期待できません。
何らかの方法で、デッドロック状態を解消する必要があります。


しかし、「ファイル名の変更」では、ファイルの更新日時は変更されません。
そこで、「いつ、ファイル名が変更されたのか」を示す値を、変更先のファイル名に含める必要があります。


(例)2009年1月1日 10時10分10秒のアクセス
 lock_20090101101010

変更先のファイル名をユニークにする

変更先のファイル名に日付を入れても、完全に同じ日時でアクセスが発生した場合、ユニーク性が破れてしまいます。
そこで、Perlのプロセス1つ1つに対して重複せずに振られている、「プロセスID」をファイル名に含めます。


(例)プロセスID「1000」、2009年1月1日 10時10分10秒のアクセス
 lock_1000_20090101101010

ファイル名変更の失敗を検出する

「ファイル名変更」の衝突による失敗は、rename関数の戻り値からでは判別できません。
しかし、ファイル名変更処理が失敗している場合、実ファイル名は変更されていません。
ファイル名を変更した直後に、該当のファイルが存在するか確認する事で失敗を検出します。

サンプル

上記を加味しサンプルを書いてみました。
今回はファイル書き込み部分は割愛しています。

#!D:/usr/local/Perl/bin/Perl.exe

#---
# Script実行前に、lockディレクトリと、その中にlock.txtを作成してください。
# lockディレクトリの場所は、任意の場所を指定してください。
#---

print "◆ファイルの排他制御renameで実現するサンプルです。\n";

eval{
	# Alarmシグナルキャッチ時
	local $SIG{ALRM} = sub { die "timeout"; };

	# 前のプロセスの終了〜ファイル書き込みに、10秒間だけ待つ
	#  一連の処理が10秒間以内に終わらないとタイムアウトします。
	alarm(10);

	# プロセスIDの取得
	$id = $$;

	# time値の取得
	$time = time;

	# ロックファイルが格納されているディレクトリ
	$dir = "/lock/";

	# ファイル名の決定
		# アンロック状態のファイル名
		$unlock = $dir."lock.txt";

		# ロック状態のファイル名
		$lock = $dir."lock_".$id."_".$time.".txt";

	# ロックファイルディレクトリの一覧を取得
	if(! opendir(dir,$dir)){
		die "diropen";
	}
	@dir = grep{/^lock_/} readdir(dir);
	closedir(dir);

	# ファイルロックできる状態になるまで待つ
	while(! -f $unlock){
		sleep(1);
		# デッドロックを解消
		foreach(@dir){
			if($_ =~ /^lock_[0-9]{1,}_([0-9]{1,})\.txt/){
				# ロック状態のファイル名がある場合
				if(time - $1 > 59){
					if(! rename($dir.$_,$unlock)){
						die "rename";
					}
					if(! -f $unlock){
						die "rename";
					}
					print "デッドロックを解消しました。\n";
				}
			}
		}
	}

	# ファイルロック
	if(! rename($unlock,$lock)){
		die "rename";
	}
	if(! -f $lock){
		die "rename";
	}
	print "ファイルをロックしました。\n";

	# もしテストするとしたら、ここで無限ループ
	#  以下は無限ループのサンプルです。
	#while(){;}

	#---
	# ファイル読み書きの処理
	#---

	# ロックを解除
	#  ここでファイル名変更の失敗は想定しません
	if(! rename($lock,$unlock)){
		die "rename";
	}
	print "ファイルロックを解除しました。\n";

	# Alarmを初期化
	alarm(0);
};

# Alarmを初期化(dieした時のため)
alarm(0);

if($@ =~ /timeout/){
	print "ファイル書き込みがタイムアウトしました。\n";
	# ファイルロックを解除
	#  エラーは表示しても意味がないため表示しません
	if(! rename($lock,$unlock)){;}
}elsif($@ =~ /diropen/){
	print "ロックファイルディレクトリを開けませんでした。\n";
}elsif($@ =~ /rename/){
	print "ファイル名を変更できませんでした。\n";
}elsif($@){
	print "その他のエラーです。[".$@."]\n";
}

if($@){
	exit(1);
}

print "おしまい\n";

exit(0);