ぐらめぬ・ぜぷつぇんのはてダ(2007 to 2011)

2007年~2011年ごろまで はてなダイアリー に書いてた記事を引っ越してきました。

sessionのデフォルトファイルハンドラとflock

PHPでのflockについてこの前調べた。で、PHPはセッションデータをどのように永続化するのかについてモジュール化できるようになっている。これをセッションハンドラモジュールと呼称しておく。PHPはファイルに保存して永続化するファイルモジュールと、ユーザー定義
関数によりPHPスクリプトレベルで永続化処理を自由に実装可能なユーザー関数モジュールの二つをデフォルトで提供している。このうちflock()が影響するのはファイルモジュールである。これがどうなっているのか気になって、調査した。
先に結論を述べる。
ファイルモジュールはsession_start()の呼び出しによりファイルにflock()による排他ロックを取得し、session_destroy()あるいはリクエスト処理の完了時などにclose()(Win32においては明示的にflock(LOCK_UN))を行い排他ロックを解除している。従って、同一セッションIDのリクエストが殺到した場合にも、セッションデータの読み書きは各々独立し、矛盾が発生する事は無い。

以下、上記結論に至ったソースコード探索の経緯と、実際にsleep()を利用した簡単な実験スクリプトによる目視確認結果を載せる。

(全部書いた後で気づいたのですが、自分、外部公開しているAPI的な関数を"インターフェイス関数"と呼んだり、省略して"IF関数"などと書いたりしてます。そこだけご注意下さい。)

まずPHP5.2.5のソースをあたってみる。

ext/session/mod_files.c

typedef struct {
  int fd;
  char *lastkey;
  char *basedir;
  size_t basedir_len;
  size_t dirdepth;
  size_t st_size;
  int filemode;
} ps_files;

/* ... */

static void ps_files_close(ps_files *data)
{
  if (data->fd != -1) {
#ifdef PHP_WIN32 
    /* On Win32 locked files that are closed without being explicitly unlocked
       will be unlocked only when "system resources become available". */
    flock(data->fd, LOCK_UN);
#endif
    close(data->fd);
    data->fd = -1;
  }
}

static void ps_files_open(ps_files *data, const char *key TSRMLS_DC)
{
  char buf[MAXPATHLEN];

  if (data->fd < 0 || !data->lastkey || strcmp(key, data->lastkey)) {

    /* ... */
    /* buf にはセッションデータを保存するファイル名が格納される。 */
    /* ... */

    data->fd = VCWD_OPEN_MODE(buf, O_CREAT | O_RDWR | O_BINARY, data->filemode);

    if (data->fd != -1) {
#ifndef PHP_WIN32
      /* check to make sure that the opened file is not a symlink, linking to data outside of allowable dirs */
      if (PG(safe_mode) || PG(open_basedir)) {
        /* 省略 */
      }
#endif
      flock(data->fd, LOCK_EX);

      /* ... */

    } else {
      php_error_docref(NULL TSRMLS_CC, E_WARNING, "open(%s, O_RDWR) failed: %s (%d)", buf, 
          strerror(errno), errno);
    }
  }
}

VCWD_OPEN_MODE()というのはPHP内部で提供されているファイル操作関数のラッパーマクロである。TSRM/tsrm_virtual_cwd.h中で定義されており、ZTSの有無の切り替えをラップしている。ZTSが未定義の場合は通常のopenシステムコールを呼ぶだけになるが、ZTSが定義されているとVIRTUAL_DIRがdefineされ、TSRM/tsrm_virtual_cwd.c中で定義されているvirtual_open関数へブリッジするようになっている。
えーっと、その、つまり、あんまりVCWD_OPEN_MODE()自体は気にしなくて良いと言うこと。単にZendThreadSafeを意識するかしないかを隠蔽しているだけなので。
ps_filesという構造体を定義していて、中にintのfdというメンバがある。当然コレが、openしたファイル記述子が入るところになる。
で、実は当初「flock()までしてないだろー。だからPHPは以下略」と予想していたのだが。意外や意外、ちゃんとps_files_open()でflock()で排他ロックをかけ、ps_files_close()でロックを解除している。(PHP_WIN32はこの際無視。Linux/UNIXプラットフォーム系であれば、close()で自動的にアドバイザリ・ロックは解除される)

続いてPHP4.4.8のコードを確認する。
・・・のだが、見てみたところext/session/mod_files.cの該当部分について言えば、PHP5.2.5と同じだった。細かいところは当然違い、例えばPHP5.2.5のps_files_open()にある、openしたファイルがシンボリックである場合のopen_basedirチェック処理などがPHP4.4.8にはまだ実装されていない。またps_files構造体のfilemodeメンバも、PHP4.4.8には存在しない。
とはいえ、flock周りの流れは同じである。

ここまでで、PHPのsession処理のデフォルトハンドラである、ファイルハンドラは意外にもちゃんとflock()によるアドバイザリ・ロック(排他ロック)を取得してから処理していることが分かった。

・・・いや。
ちょっと待った。

これ、単に$_SESSIONをファイルに読み書きする時にopen-closeする時、flock()してますよー、という事までしか分かっていない。
知りたいのはPHPモジュールが起動しCookieのセッションIDを取得し該当するファイルをopenしてから、PHPスクリプトが終了し$_SESSION値をファイルに書き込み終わってcloseするまで、その間がflock()により排他ロックされているのか否か、だ。

というわけで、結局ext/session/session.cに戻る。ちなみに、面倒くさくなったので以降はPHP5.2.5のソースのみを追う。

ここで、PHPでのセッションの流れを復習しておこう。セッションはリクエストが来ないと始まらない。と言うことは、PHPモジュールのインターフェイス的にはPHP_R{INIT|SHUTDOWN}_FUNCTIONが開始と終了の為の何らかのコードを実行している筈である。セッションは通常session_start()をコールすることで開始されるが、session.auto_startをphp.iniなどで有効化することで、自動的にsession_start()が呼ばれるという有り難いんだか迷惑なんだかきわめて微妙な機能もある為、まず「当たり」を付ける意味もあり、PHP_R{INIT|SHUTDOWN}_FUNCTIONをチェックしてみる。

ext/session/session.c

PHP_RINIT_FUNCTION(session)
{
  php_rinit_session_globals(TSRMLS_C);

  if (PS(mod) == NULL) {
    /* ... session.save_handlelrのチェックとロード ... */
  }

  if (PS(auto_start)) {
    php_session_start(TSRMLS_C);
  }
  return SUCCESS;
}
/* ... */
PHP_RSHUTDOWN_FUNCTION(session)
{
  php_session_flush(TSRMLS_C);
  php_rshutdown_session_globals(TSRMLS_C);

  return SUCCESS;
}

ということで、ちょっと追っかけてみる。が、その前に。例によりよく分からないマクロが多用されている為、少しその辺を抑えておく。

  • PS()マクロ

ext/session/php_session.h

#ifdef ZTS
#define PS(v) TSRMG(ps_globals_id, php_ps_globals *, v)
#else
#define PS(v) (ps_globals.v)
#endif

これは、PHP拡張モジュールがINIファイルの値にアクセスする為に使う典型的なコード。ext_skelでも似たようなコードを吐く。えっとー、実はps_globalsという変数は明示的な宣言はされてません。なんでかというと、
ext/session/session.c

/* {{{ PHP_INI
 */
PHP_INI_BEGIN()
  STD_PHP_INI_BOOLEAN("session.bug_compat_42",    "1",         PHP_INI_ALL, OnUpdateBool,   bug_compat,         php_ps_globals,    ps_globals)
  /* ... */
  STD_PHP_INI_ENTRY("session.hash_bits_per_character",      "4",         PHP_INI_ALL, OnUpdateLong,    hash_bits_per_character,          php_ps_globals,    ps_globals)

  /* Commented out until future discussion */
  /* PHP_INI_ENTRY("session.encode_sources", "globals,track", PHP_INI_ALL, NULL) */
PHP_INI_END()
/* }}} */

で、STD_PHP_INI_****()マクロにより、内部的にphp_ps_globalsという型でもって自動的に実体が作られる・・・らしい、です。
っつーかSTD_PHP_INI_系のマクロって辿ってくと何が何だか分からなくなってきて死んだので、全速でここは逃げます。本筋とは違うし。
で、php_ps_globalsというのは
ext/session/php_session.h

typedef struct _php_ps_globals {
  char *save_path;
  char *session_name;
  char *id;
  /* ... */
  int send_cookie;
  int define_sid;
  zend_bool invalid_session_id;  /* allows the driver to report about an invalid session id and request id regeneration */
} php_ps_globals;

という定義から分かるように、まぁセッション関係のINI設定などの容れ物のようです。
つまり、例えば

PS(send_cookie)

というのが出てきたらこれは、上のphp_ps_globals構造体の send_cookie メンバにアクセスしてるんだな、と言うことになります。

  • PS_MOD()

今は省略。後々、ちょっとしたキーポイントになります。

では元に戻って追っていきましょう。とりあえず最初はphp_session_start(TSRMLS_C)あたりから行きましょう。ちなみにsession_start()PHP関数も、単にphp_session_start()を呼んでいるたったの3行です。
ext/session/session.c

PHPAPI void php_session_start(TSRMLS_D)
{
  /* ... パラメータチェックのコードが長いので、思い切って端折ります。 ... */
  php_session_initialize(TSRMLS_C);
  /* ... */
  php_session_reset_id(TSRMLS_C);
  PS(session_status) = php_session_active;
  php_session_cache_limiter(TSRMLS_C);
  /* ... */
}

このコード中では、まだ実際のファイルハンドラのIF関数の呼び出しを行っているようには見えません。
となると、次に怪しいのはphp_session_initialize()関数です。名前からしてそれっぽい。
ext/session/session.c

static void php_session_initialize(TSRMLS_D)
{
  /* ... */
  /* Open session handler first */
  if (PS(mod)->s_open(&PS(mod_data), PS(save_path), PS(session_name) TSRMLS_CC) == FAILURE) {
    /* ... */
    return;
  }
  /* ... */
  if (PS(mod)->s_read(&PS(mod_data), PS(id), &val, &vallen TSRMLS_CC) == SUCCESS) {
    php_session_decode(val, vallen TSRMLS_CC);
    efree(val);
  } else if (PS(invalid_session_id)) { /* address instances where the session read fails due to an invalid id */
    /* ... */
  }
}

いかにもそれっぽいのが出てきています。PS(mod)というのが気になったと思いますが、ちょっと置いておきまして続けて、RSHUTDOWNの方へ戻って進みましょう。復習すると、RSHUTDOWNではこんなんなってました。
ext/session/session.c

PHP_RSHUTDOWN_FUNCTION(session)
{
  php_session_flush(TSRMLS_C);
  php_rshutdown_session_globals(TSRMLS_C);
  return SUCCESS;
}

という事で順繰りに見ていきます。
ext/session/session.c

static void php_session_flush(TSRMLS_D)
{
  if (PS(session_status) == php_session_active) {
    PS(session_status) = php_session_none;
    zend_try {
      php_session_save_current_state(TSRMLS_C);
    } zend_end_try();
  }
}

static void php_rshutdown_session_globals(TSRMLS_D)
{
  /* ... */
  if (PS(mod_data)) {
    zend_try {
      PS(mod)->s_close(&PS(mod_data) TSRMLS_CC);
    } zend_end_try();
  }
  /* ... */
}

zend_try - zend_end_try() がやたらと気になりますが、というかそそられますがそこはぐっと欲望を抑えて。php_session_flush()側で呼ばれているphp_session_save_current_state()を見てみます。
ext/session/session.c

static void php_session_save_current_state(TSRMLS_D)
{
  /* ... */
    if (PS(mod_data)) {
      /* ... */
        ret = PS(mod)->s_write(&PS(mod_data), PS(id), val, vallen TSRMLS_CC);
      /* ... */
    }
  /* ... */
  if (PS(mod_data))
    PS(mod)->s_close(&PS(mod_data) TSRMLS_CC);
}

一旦流れを整理してみます。

今追っていたのは、PHPのリクエスト開始, 終了時に実行されるPHP_R{INIT|SHUTDOWN}_FUNCTIONでした。PHPのセッション機構はリクエストがあって動きますので、この二つをチェックすることでデフォルトのファイルハンドラがいつファイルをopenし、closeしているのか
タイミングを調べることができる筈です。そこでソースを追ったところ、"PS(mod)"というのが引っ掛かってきました。これに着目すると・・・

PHP_RINIT_FUNCTION:
PS(mod)->s_open() -> PS(mod)->s_read()

PHP_RSHUTDOWN_FUNCTION:
PS(mod)->s_write() -> PS(mod)->s_close()

という流れが見えてきました。
ではPS(mod)というのは?というと、ps_module構造体のポインタのようです。
ext/session/php_session.h

typedef struct _php_ps_globals {
  /* ... */
  ps_module *mod;
  /* ... */
} php_ps_globals;

typedef struct ps_module_struct {
  const char *s_name;
  int (*s_open)(PS_OPEN_ARGS);
  int (*s_close)(PS_CLOSE_ARGS);
  int (*s_read)(PS_READ_ARGS);
  int (*s_write)(PS_WRITE_ARGS);
  int (*s_destroy)(PS_DESTROY_ARGS);
  int (*s_gc)(PS_GC_ARGS);
  char *(*s_create_sid)(PS_CREATE_SID_ARGS);
} ps_module;

ps_module構造体は関数ポインタを格納する構造体のようです。となれば、ここには恐らく、セッションハンドラのIF関数のポインタがセットされる筈です。ではここが初期化されるのはどこか?というと、実は先ほどは省略して見過ごしていた、PHP_RINIT_FUNCTION中になります。
ext/session/session.c

PHP_RINIT_FUNCTION(session)
{
  php_rinit_session_globals(TSRMLS_C);

  if (PS(mod) == NULL) {
    char *value;

    value = zend_ini_string("session.save_handler", sizeof("session.save_handler"), 0);
    if (value) {
      PS(mod) = _php_find_ps_module(value TSRMLS_CC);
      // ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑
    }

    if (!PS(mod)) {
      /* current status is unusable */
      PS(session_status) = php_session_disabled;
      return SUCCESS;
    }
  }

  if (PS(auto_start)) {
    php_session_start(TSRMLS_C);
  }
  return SUCCESS;
}

PHPAPI ps_module *_php_find_ps_module(char *name TSRMLS_DC)
{
  ps_module *ret = NULL;
  ps_module **mod;
  int i;

  /* ps_modulesという配列をループし、与えられたモジュール名と
  一致するs_nameメンバを持つps_module構造体へのポインタを返す。
  */
  for (i = 0, mod = ps_modules; i < MAX_MODULES; i++, mod++)
    if (*mod && !strcasecmp(name, (*mod)->s_name)) {
      ret = *mod;
      break;
    }
  
  return ret;
}

/* ps_modules配列は下記コードにより初期化されている。*/
static ps_module *ps_modules[MAX_MODULES + 1] = {
  ps_files_ptr,
  ps_user_ptr
};

ps_files_ptrやps_user_ptrは、各mod_*.hに定義されています。
ext/session/mod_files.h

extern ps_module ps_mod_files;
#define ps_files_ptr &ps_mod_files

ということは、ps_files_ptrというのはps_mod_filesへのポインタとなります。その定義はmod_files.cに見られます。
ext/session/mod_files.c

ps_module ps_mod_files = {
  PS_MOD(files)
};

ここで、PS_MOD()マクロにより次のように展開されます。(見やすいように改行)

ps_module ps_mod_files = {
  files,
  ps_open_files,
  ps_close_files,
  ps_read_files,
  ps_write_files,
  ps_delete_files,
  ps_gc_files,
  php_session_create_id
};

ps_で始まるシンボルは、みなmod_files.c中の関数です。ここでps_module構造体の定義を確認すると
ext/session/php_session.h

typedef struct ps_module_struct {
  const char *s_name;
  int (*s_open)(PS_OPEN_ARGS);
  int (*s_close)(PS_CLOSE_ARGS);
  int (*s_read)(PS_READ_ARGS);
  int (*s_write)(PS_WRITE_ARGS);
  int (*s_destroy)(PS_DESTROY_ARGS);
  int (*s_gc)(PS_GC_ARGS);
  char *(*s_create_sid)(PS_CREATE_SID_ARGS);
} ps_module;

となっています。

ようやくつながってきました。もう一度R{INIT|SHUTDOWN}の流れを思い起こすと・・・

PHP_RINIT_FUNCTION:
PS(mod)->s_open() -> PS(mod)->s_read()

PHP_RSHUTDOWN_FUNCTION:
PS(mod)->s_write() -> PS(mod)->s_close()

ここで、PS(mod)が何なのかを探った結果、ps_module構造体へのポインタであり、各メンバはsessionの保存ハンドラモジュールの提供しているインターフェイス関数へのポインタでした。
デフォルトのfilesモジュールであれば、結局これは

PHP_RINIT_FUNCTION:
ps_open_files() -> ps_read_files()

PHP_RSHUTDOWN_FUNCTION:
ps_write_files() -> ps_close_files()

となることがマクロの追跡で判明しました。

ところでmod_file.cを見ると上記のps_{open|read|write|close}_files()の定義が見あたりません。代わりに、

PS_OPEN_FUNC(files)
PS_CLOSE_FUNC(files)
PS_READ_FUNC(files)
PS_WRITE_FUNC(files)

というマクロを使った関数らしき実装が見られます。これもphp_session.hで定義されているマクロにより、

int ps_open_files(...)
int ps_close_files(...)
int ps_read_files(...)
int ps_write_files(...)

となります。ちゃんと実装されているわけです。

長丁場の探索でしたが、終わりが見えてきました。冒頭、次の二つの関数がファイルモジュールにおいてファイルをopen(),close()しており、ちゃんとflock()していることを確認しました。

  • ps_files_close(ps_files *data)
  • ps_files_open(ps_files *data, const char *key TSRMLS_DC)

では、これが実際にsession.cから呼ばれるPS_****_FUNC(files)関数でどう呼ばれているか、ソースを追った結果だけ示します。

PS_OPEN_FUNC(files) : ps_files_open()までは呼んでいない。
↓
PS_READ_FUNC(files) : この中で呼んでいる。
↓
ps_files_open()
↓
open(), flock(LOCK_EX)

PS_DESTROY_FUNC(files), PS_CLOSE_FUNC(files)
↓
ps_files_close()
↓
flock(LOCK_UN)(Win32の場合の明示的処理), close()

締めです。これをPHP_R{INIT|SHUTDOWN}_FUNCTIONの流れに組み込んでみましょう。

PHP_RINIT_FUNCTION:
PS(mod)->s_open() => PS_OPEN_FUNC(files)
↓
PS(mod)->s_read() => PS_READ_FUNC(files) => open(), flock(LOCK_EX)

(この間、PHPのスクリプトが実行される。)

PHP_RSHUTDOWN_FUNCTION:
PS(mod)->s_write() => PS_WRITE_FUNC(files)
↓
PS(mod)->s_close() => PS_CLOSE_FUNC(files) => flock(LOCK_UN), close()

以上のように、PHPスクリプトの実行中はセッションデータファイルがアドバイザリ・ロックによる排他ロックがかかることが分かりました。

結論

探索の結果、PHPの提供するセッションハンドラモジュールの一つ、デフォルトで用いられるファイルモジュールについて言えば、session_start()から開始されるスクリプト実行中、セッションデータを格納するファイルをflock()による排他ロック(アドバイザリ・ロック)を取得している事を確認できた。

目視実験

以下のようなスクリプトを用意する。
session_file_acid.php

<?php
session_start();

$sleep = isset($_GET['sleep']) ? $_GET['sleep'] : 0;

$_SESSION['c'] = isset($_SESSION['c']) ? $_SESSION['c'] + 1 : 1;

if ($sleep) { sleep($sleep); }

echo $_SESSION['c'];

特に解説の必要はないと思う。セッションを開始し、セッションのカウンタ値をインクリメントし、sleep秒数が指定されて入ればsleepし、セッションのカウンタ値を表示するだけである。

結論の通り、PHPのデフォルトセッション機構ではセッション開始と共にファイルが排他ロックされる。従って、以下のように2クライアントからのリクエストが発生した場合でもカウンタ値は矛盾なくインクリメントされるはずである。

  • リクエストA(先) : session_file_acid.php?sleep=10
  • リクエストB(後) : session_file_acid.php

リクエストAが先にflock(LOCK_EX)し、10秒のsleepに入る。この間に発生したリクエストBも、flock(LOCK_EX)によりブロックされストップする。10秒経過後、リクエストAは+1されたカウンタ値を表示し、flockを解除する。その直後リクエストBが動きだし、リクエストAにより+1された値にさらに+1された値を表示するはずである。
これがもし排他ロックが用いられなかった場合、リクエストBはAにより+1される"前"の値を読んでしまい、それに+1する。つまりリクエストA, B共に同じ値を表示してしまうはずである。

結果だけを記すと、ソースコード探索の結論に従い、矛盾なくカウントされた値が表示された。すなわちAがsleep中はBもブロックされ、カウンタ値はAに+1されたものがBに表示された。

発展考察

確かにPHPのデフォルトのファイル保存モジュールを使用している場合は正しくファイルロックがかかり、同時にリクエストが発生した場合もセッションデータの読み書きは独立する。しかしユーザー関数モジュールにより、例えばデータベースなどを使用する場合はACID特性を自前で保証する必要がある。

以上のような施策が考えられる。