複数のプロセスで同じリソースを読み書きする場合、競合についての問題は避けられません。MySQLなどのRDBではよくトランザクションという名前の機能でこれが実現されています。PHPのファイル操作のみで競合を対策する方法を紹介します。
競合でよく問題になるのが古いデータの読み込みや複数の編集の衝突です。これらを防ぐ方法としてファイルにアクセスできるのは常に1プロセスだけというのが考えられます。これはファイルのロック機能を利用すると実現できます。使う関数は flock です。
flock はファイルをロックします。ロックの種類は共有ロックと排他ロックがあります。共有ロックは他のプロセスが共有ロックを取得している間は他のプロセスも共有ロックを取得できます。排他ロックは他のプロセスがロックを取得している間は他のプロセスはロックを取得できません。ロックを取得できなかった場合は処理を待つかエラーを返します。大雑把に言えば書き込むときには排他ロックを取得し、読み込むときには共有ロックを取得するのがいいです。そうすると無駄なロック解除待ち時間が減ります。
実際のコード例が次です。
<?php // ファイル名を指定してファイルを開く $filename = 'tmp.txt'; $fp = fopen($filename, 'c+'); // ロックの取得 flock($fp, LOCK_EX); // ファイルに書き込み fwrite($fp, 'Hello, World!' . PHP_EOL); // 競合を試すためのスリープ sleep(10); // ロックの解除 flock($fp, LOCK_UN);
このコードはファイルを開いてロックを取得し、書き込みを行い、ロックを解除します。これを複数同時に実行すると片方はロックが即座に取得できず読み書きを待つことになります。このようにして競合を防げます。
ロックの間に起きたことをロールバックしたい時もあります。RDBのトランザクションの処理のような感じです。これは次のようにできます。
<?php
class TransactionFile
{
/**
* @var resource ファイルリソース
*/
private mixed $file;
/**
* @var bool 現在トランザクション中かどうか
*/
private bool $inTransaction = false;
/**
* @var string|null バックアップ用の一時ファイルパス
*/
private ?string $backupFile = null;
/**
* コンストラクタ
*
* @param resource $file 有効なファイルリソース
* @throws \InvalidArgumentException
*/
public function __construct(mixed $file)
{
if (!\is_resource($file) || get_resource_type($file) !== 'stream') {
throw new \InvalidArgumentException('有効なファイルストリームリソースを指定してください。');
}
$this->file = $file;
}
/**
* ファイル名からインスタンスを生成する静的ファクトリ
*
* @param string $filename
* @return static
*/
public static function makeFromName(string $filename): self
{
$resource = fopen($filename, 'c+');
if (!$resource) {
throw new \RuntimeException("ファイルを開けません: {$filename}");
}
return new self($resource);
}
/**
* トランザクション的にコールバックを実行する
*
* @param callable $callback 実行したい処理(TransactionFile自身が引数で渡される)
* @return void
*
* コールバック内で例外が発生した場合は自動的にロールバックされる。
* 例外が発生しなかった場合はコミットされる。
* @throws Throwable
*/
public function transaction(callable $callback): void
{
$this->startTransaction();
try {
$callback($this);
$this->commit();
} catch (\Throwable $e) {
$this->rollback();
throw $e;
}
}
/**
* トランザクションを開始する
* ファイルを排他ロックし、一時ファイルにバックアップを作成する。
*
* @return void
*/
public function startTransaction(): void
{
if ($this->inTransaction) {
throw new \RuntimeException('既にトランザクションを開始しています。');
}
// ファイルを排他ロック
flock($this->file, LOCK_EX);
// 一時ファイルを作成し、元のファイルをバックアップ
$this->backupFile = tempnam(sys_get_temp_dir(), 'txfile_');
if ($this->backupFile === false) {
throw new \RuntimeException('一時ファイルの作成に失敗しました。');
}
// バックアップファイルへコピー
if (!copy(stream_get_meta_data($this->file)['uri'], $this->backupFile)) {
throw new \RuntimeException('バックアップの作成に失敗しました。');
}
$this->inTransaction = true;
}
/**
* コミット(確定)する
* 変更を確定し、ロックを解放する。
*
* @return void
*/
public function commit(): void
{
if (!$this->inTransaction) {
throw new \RuntimeException('トランザクションが開始されていません。');
}
// コミット処理(特にバックアップの復元はしない)
fflush($this->file);
// ロックを解放
flock($this->file, LOCK_UN);
// 一時バックアップファイルを削除
if ($this->backupFile !== null) {
@unlink($this->backupFile);
$this->backupFile = null;
}
$this->inTransaction = false;
}
/**
* ロールバック(取り消し)する
* バックアップファイルの状態に戻して、ロックを解放する。
*
* @return void
*/
public function rollback(): void
{
if (!$this->inTransaction) {
throw new \RuntimeException('トランザクションが開始されていません。');
}
if ($this->backupFile !== null) {
// バックアップファイルから元のファイルへコピー
rewind($this->file);
ftruncate($this->file, 0); // ファイルサイズを0に
$backupContent = fopen($this->backupFile, 'r');
stream_copy_to_stream($backupContent, $this->file);
fclose($backupContent);
// ファイルをフラッシュ
fflush($this->file);
}
// ロックを解放
flock($this->file, LOCK_UN);
// 一時バックアップファイルを削除
if ($this->backupFile !== null) {
@unlink($this->backupFile);
$this->backupFile = null;
}
$this->inTransaction = false;
}
/**
* ファイルのクローズ処理
*
* @return void
*/
public function close(): void
{
// もしまだトランザクション中だったら、リソースを閉じる前にアンロックしておく
if ($this->inTransaction) {
$this->rollback();
}
if (is_resource($this->file)) {
fclose($this->file);
}
}
/**
* 任意の読み書きをするためのファイルリソースを返す
* @return resource
*/
public function getResource()
{
return $this->file;
}
/**
* ファイルの中身全体を$dataで上書きします
* @param string $data
* @return void
*/
public function write(string $data): void
{
rewind($this->file);
ftruncate($this->file, 0);
fwrite($this->file, $data);
}
/**
* ファイルの末尾に$dataを追記します
* @param string $data
* @return void
*/
public function append(string $data): void
{
fseek($this->file, 0, SEEK_END);
fwrite($this->file, $data);
}
/**
* ファイルの中身を読み込みます
* @return bool|string
*/
public function read(): bool|string
{
flock($this->file, LOCK_SH);
$data = stream_get_contents($this->file);
flock($this->file, LOCK_UN);
return $data;
}
}
$filename = __DIR__.'/tmp.txt';
$file = TransactionFile::makeFromName($filename);
$file->transaction(function(TransactionFile $file) {
// 書き込みテスト
$file->write('Hello, World!');
// ロールバック
throw new Exception('例外発生');
});
このクラスはファイルをトランザクション的に扱うためのクラスです。これは transaction メソッドに渡された処理の間は他プロセスからファイルの読み書きができません。また処理の間で例外が発生した場合はロールバックされます。このようにしてファイルの整合性を保ちながら競合を防げます。ロックを取得できなかった際の処理が緩いですがデッドロックを起こすことがないフローならばこれで十分です。デッドロックの可能性があるならば、更にタイムアウト処理を追加するなどの工夫をするのが無難です。