php-5.2.5以前のfile_put_contentsではLOCK_EXによる排他ロックは動かない(5.2.6でFix)

(キーワード:file_put_contents file_get_contents)

5.2.6でBugFixされています。
PHP: PHP 5 ChangeLog

Fixed bug #43182 (file_put_contents() LOCK_EX does not work properly on file truncation). (Ilia)

なので5.2.6以降なら動きますが、5.2.6未満なら動かないはずです。
といっても、動くのも動かないのも動作検証はしてないけど。
以下、このChangeLogに至るまでに調べたメモ。

きっかけ

file_put_contentsは便利です。
いちいちfopen->fwrite/fread->fcloseする必要がないからです。
PHP: file_put_contents - Manual

cronで動かす場合や簡易的な個人作業ログを吐き出す場合は問題ありません。
しかし複数ユーザー(プロセス)が対象ファイルにアクセスするような場合、
ファイルロックについて考慮しなければなりません。

どうすればよいでしょうか。
マニュアルを見るとLOCK_EXというそれっぽいオプションがあります。

LOCK_EX 書き込み処理中に、ファイルに対する排他ロックを確保します。

いいじゃないのと思いつつ、一応「file_put_contents ロック」でググってみます。

検索結果2つ目と3つ目に不吉なタイトル。
file_put_contentsのLOCK_EXは正常に動かないという情報が。

そうなのかーと思いつつ、マニュアルにはそんな注意ないし、
検索しても特に引っかからないので「本当に?」という疑問がありました。
じゃあ調べましょう。

phpのソースを落としてくる

今ではGithub上にphpのソースがあるのでそこから取得します。
php/php-src · GitHub

/Users/kanno/workspace/lib/php% git clone git://github.com/php/php-src.git

それなりに大きいのでちょっと時間がかかります。
落としてきた時点ではmasterなわけで、この時点では5.4でした。

該当箇所に見当をつける

とりあえずLOCK_EXでgrepしてみます。
いろんな種類のファイルがありますが実体が見たいので.cに絞ります。

/Users/kanno/workspace/lib/php/php-src% grep LOCK_EX ./**/*.c
./Zend/zend_execute.c:#define PZVAL_UNLOCK_EX(z, f, u) zend_pzval_unlock_func(z, f, u)
./ext/dba/dba.c:	DBA_HND(gdbm, DBA_LOCK_EXT) /* Locking done in library if set */
./ext/dba/dba.c:	DBA_HND(qdbm, DBA_LOCK_EXT)
./ext/dba/dba.c:			lock_mode = (lock_flag & DBA_LOCK_WRITER) ? LOCK_EX : 0;
./ext/dba/dba.c:			lock_mode = (lock_flag & DBA_LOCK_CREAT) ? LOCK_EX : 0;
./ext/dba/dba.c:			lock_mode = (lock_flag & DBA_LOCK_TRUNC) ? LOCK_EX : 0;
./ext/dba/dba.c:			if (   ( (lock_mode&LOCK_EX)        && (other->lock.mode&(LOCK_EX|LOCK_SH)) )
./ext/dba/dba.c:			    || ( (other->lock.mode&LOCK_EX) && (lock_mode&(LOCK_EX|LOCK_SH))        )
./ext/pdo_sqlite/sqlite/src/os_unix.c:    int rc = flock(pFile->h, LOCK_EX | LOCK_NB);
./ext/pdo_sqlite/sqlite/src/os_unix.c:  int rc = flock(pFile->h, LOCK_EX | LOCK_NB);
./ext/session/mod_files.c:			flock(data->fd, LOCK_EX);
./ext/standard/file.c:	REGISTER_LONG_CONSTANT("LOCK_EX", PHP_LOCK_EX, CONST_CS | CONST_PERSISTENT);
./ext/standard/file.c:static int flock_values[] = { LOCK_SH, LOCK_EX, LOCK_UN };
./ext/standard/file.c:	} else if (flags & LOCK_EX) {
./ext/standard/file.c:	if (flags & LOCK_EX && (!php_stream_supports_lock(stream) || php_stream_lock(stream, LOCK_EX))) {
./ext/standard/flock_compat.c:	else if (operation & LOCK_EX)
./ext/standard/flock_compat.c:        case LOCK_EX:           /* exclusive */
./main/streams/userspace.c:		case LOCK_EX:
./main/streams/userspace.c:			Z_LVAL_P(zvalue) |= PHP_LOCK_EX;
./win32/flock.c:		case LOCK_EX:			/* exclusive */
/Users/kanno/workspace/lib/php/php-src% 

出ました。ext/standard/file.cというのが怪しい。
このファイルにfile_put_contentsという文字列があるか調べてみます。

/Users/kanno/workspace/lib/php/php-src% grep file_put_contents ext/standard/file.c
/* {{{ proto int file_put_contents(string file, mixed data [, int flags [, resource context]])
PHP_FUNCTION(file_put_contents)

いました。PHP_FUNCTIONが何かはさておきなんかここがfile_put_contentsの実体っぽい。

file_put_contentsの中を見てみる(v5.4)

エディタでext/standard/file.cを開きます。エディタはそう、Vimです。
そこそこ長いので一部省略します。

PHP_FUNCTION(file_put_contents)
{
  // (省略:初期化)

	if (flags & PHP_FILE_APPEND) {
		mode[0] = 'a';
	} else if (flags & LOCK_EX) {
		/* check to make sure we are dealing with a regular file */
		if (php_memnstr(filename, "://", sizeof("://") - 1, filename + filename_len)) {
			if (strncasecmp(filename, "file://", sizeof("file://") - 1)) {
				php_error_docref(NULL TSRMLS_CC, E_WARNING, "Exclusive locks may only be set for regular files");
				RETURN_FALSE;
			}
		}
		mode[0] = 'c';
	}
	mode[2] = '\0';

	stream = php_stream_open_wrapper_ex(filename, mode, ((flags & PHP_FILE_USE_INCLUDE_PATH) ? USE_PATH : 0) | REPORT_ERRORS, NULL, context);
	if (stream == NULL) {
		RETURN_FALSE;
	}

	if (flags & LOCK_EX && (!php_stream_supports_lock(stream) || php_stream_lock(stream, LOCK_EX))) {
		php_stream_close(stream);
		php_error_docref(NULL TSRMLS_CC, E_WARNING, "Exclusive locks are not supported for this stream");
		RETURN_FALSE;
	}

	if (mode[0] == 'c') {
		php_stream_truncate_set_size(stream, 0);
	}

  // (省略:たぶん書き込み処理)
}

LOCK_EXを見てちゃんとモードをcにしてます。そのモードを使ってopenしてるっぽい。つまり動いているっぽい。
あれーこれちゃんと動くんじゃないのかーと思いつつ、解読の仕方が違うのかとあれこれ悩む。
ちなみにphp_stream_open_wrapper_exが具体的に何をするかとか全然知らない。
途中まで追っていったけどよく分からなくなって引き上げた。C言語難しい。

バージョンを戻してみる

Gitだとこれが楽!

/Users/kanno/workspace/lib/php/php-src% git checkout PHP-5.1                                           
Switched to branch 'PHP-5.1'
/Users/kanno/workspace/lib/php/php-src% 

これだけ。素敵!

file_put_contentsの中を見てみる(v5.1)

結構変わっているので、問題となる部分だけ抜き出します。

PHP_FUNCTION(file_put_contents)
{
  // (省略)
	stream = php_stream_open_wrapper_ex(filename, (flags & PHP_FILE_APPEND) ? "ab" : "wb", 
			((flags & PHP_FILE_USE_INCLUDE_PATH) ? USE_PATH : 0) | ENFORCE_SAFE_MODE | REPORT_ERRORS, NULL, context);
  // (省略)
}

今度は同じphp_stream_open_wrapper_exでも渡すモードの判定がおかしい。
「(flags & PHP_FILE_APPEND) ? "ab" : "wb"」なので、LOCK_EXだとwbで開いてしまう。
この後ろでlock取得っぽいことはしているんですが、ファイルを開く時にwモードで中身切り詰めて空にしてるのでダメです。
上記ブログに書かれていたような不具合が発生します。

たしかにバグってますね。

修正されたバージョンを調べる

このあたりで再度ググったりしたんですが、そしたらちゃんとBugにありました。
#43182 [Opn]: file_put_contents' LOCK_EX flag is useless with advisory locking

あとはこの番号#43182をもとにChangeLogから探すだけ。
冒頭で述べたように、5.2.6で対応されたようです。

おわり

ネットの情報を鵜呑みにせず、気になるところは自分で調べることが大事ですね。