Perl Hackers Hub

第6回UNIXプログラミングの勘所(2)

forkとファイルハンドル

UNIX系のOSでは、複数のプログラムが、それぞれプロセスという単位で動作しています。forkというシステムコール[1]が呼び出されると呼び出したプロセスの複製がOSによって作成され、複製されたプロセス(子プロセス)がexecveというシステムコールを使って別のプログラムにすり替わる、というしくみでさまざまな処理を実行するようになっています。

「複製」と言っても、全部の情報が複製されるわけではありません。プロセスのメモリイメージが複製される[2]一方で、プロセスが開いている「オープンファイル記述」open file description注3は複製されません。forkのあとは、親プロセスと子プロセスの両者が、単一のオープンファイル記述を指す「ファイル記述子」file descriptor注4を持つことになります図2⁠。

図2 fork(2)とファイル記述
図2 Google Scholar

forkとDBIの誤用例

「SQLiteのデータベースファイルが壊れた」⁠MySQLのパケットが壊れているというエラーが表示される」といった症状に出くわしたことがある人もいると思います。これらはほとんどの場合、forkに関連したファイルハンドルの取り扱いミスに由来する問題です。

SQLiteは、ファイルロック(flock)を使ってデータベースファイルへのアクセスを排他制御しています。したがって、データベース接続を開いたあとでforkすると、親子両方のプロセスで1つのファイルロックを共有することになります。この状態で親子から同時にデータベースファイルにアクセスしても、両プロセスの間での排他制御は動きませんから、データベースファイルが壊れてしまうのです。

MySQLの場合はTCPソケット(あるいはUNIXソケット)を使用してデータベースサーバに接続します。このソケットもファイルと同様、ファイル記述を用いて管理されるリソースなので、fork後は親子のプロセスで同一の接続を共有する形になります。この状態で親子がサーバと同時に通信しようとすると混信が発生し、エラーが表示される、というわけです。

my $dbh = DBI->connect(...);
...
my $pid = fork;
die "fork failed:$!"
    unless defined $pid;
# 両プロセスで同一のハンドルを使用 ⇒ エラー
if ($pid == 0) {
    # 子プロセス
    $dbh->do(...);
    ...
}
$dbh->do(...);

forkしたあとはリソースを解放すべし

親プロセスと子プロセスの両方から同じファイルにアクセスしたい場合は、どちらかのプロセスでもう一度ファイルを開きなおす必要があります。Perlで直接ファイルを開いている場合は次のようになります。

open my $fh, '<', ...;
...
my $pid = fork;
die "fork failed: $!"
    unless defined $pid;
if ($pid == 0) {
    # 子プロセス側ではファイルにアクセスしないからclose
    close $fh;
    ...
    exit(0);
}
# 親プロセス
my $line = <$fh>;
...

この例では子プロセス側でcloseを呼び出し、ファイル記述子を閉じています。子プロセスのファイル記述子が閉じられても、親プロセスのファイル記述子とオープンファイル記述は使用可能なままです[5]⁠。

forkしたあとのDBIの閉じ方

一方、DBIの場合はDBI::disconnect()を呼び出してはいけません。これはデータベースの切断処理が、単にファイル記述子を閉じるのみならず、ロックの解除やデータベースのロールバック処理を行うようになっているからです。単にデータベースハンドルをundefしても、やはり切断処理が走ります。

データベースハンドルを開いた状態でforkした場合は、親プロセスか子プロセスのいずれかで、データベースハンドルのInactiveDestroyフラグをセットし、そのうえでデータベースハンドルをundefしてください。そうすることで、DBIのロールバック処理を行うことなく、ただデータベース接続のみを閉じることができます。

my $dbh = DBI->connect(...);
...
my $pid = fork;
die "fork failed:$!"
    unless defined $pid;
if ($pid == 0) {
    # 子プロセス
    $dbh->{InactiveDestroy} = 1;
    undef $dbh;
} else {
    # 親プロセス
    $dbh->do(...);
}

なお、InactiveDestroyフラグは明示的なDBI::disconnect()への呼び出しを無効化しません。あくまでもDBI::disconnect()ではなくundefが必要だ、という点に注意する必要があります。

標準のforkをオーバーライド

ここまで見てきた例はいずれも、forkを直接呼んでいるものでした。しかし、Parallel::ForkManagerやParalell::Preforkのようなプロセスを管理するモジュールを使ってコードを書いていると、forkの実行処理は隠蔽(いんぺい)されています。そのような場合には、Perlのfork関数(CORE::GLOBAL::fork)を差し替えることで、forkの呼び出し前後に任意の処理を追加できます[6]⁠。

BEGIN {
    no strict qw(refs);
    no warnings qw(redefine);
    *CORE::GLOBAL::fork = sub {
        my $pid = CORE::fork;
        if ($pid == 0) {
            # 子プロセス側の解放処理
            $dbh->{InactiveDestroy} = 1;
            undef $dbh;
        }
        $pid;
    };
}

また、XSモジュール内でforkが呼ばれている場合も、POSIX::AtForkモジュールを使うことで任意の処理を差し込むことができます。ただ、POSIX::AtForkはforkが呼ばれるあらゆる場面(たとえばPerl標準関数のsystem)で処理を差し込むことになるので、注意が必要です。

POSIX::_exitの利用

forkした際にファイルハンドルが親子の両プロセスで共有され、子プロセス終了時のリソース解放処理が走る結果、不具合が出てしまい、どうしてもそこを直せない(あるいは探す時間がない)というケースもあると思います。そのような場合のこずるい対処法として、子プロセス終了時にPOSIX::_exitを呼ぶ、という手法があります。

use POSIX qw(_exit);

my $pid = fork;
die "fork failed:$!"
    unless defined $pid;
if ($pid == 0) {
    # 子プロセス
    ...
    # グローバルデストラクションせずにexit(0)
    POSIX::_exit(0);
}

通常のPerlプログラムは終了時に、グローバルデストラクションという、すべての変数やオブジェクトを解放する処理が走ります。オブジェクトによってはこの際に実行されるメンバ関数DESTROYを定義していて、問題が発生するのはこのDESTROY関数が親プロセスと共有しているリソースを操作してしまうからです。

一方、POSIX::_exitは、プロセスの実行をいきなり終了する関数です。こちらを使えばグローバルデストラクションは走らないので、共有リソースへの操作は発生しません。また、プロセス終了時に開いていたファイルハンドル自体はOSによって閉じられるので、ファイルハンドルのリークも起こりません。ただ、この手法を使うとすべてのモジュールのグローバルデストラクションが走らなくなってしまいますので、一時ファイルが削除されずに残ってしまう、といったことがあるかもしれません。あくまでも一時しのぎの方法だと考えて、そのうえで使うようにしましょう。

まとめ

forkを使う際には、fork後のファイルハンドルが、いつ、どのような形で解放されるのかに注意すべきです。DBIなどのようにファイルハンドルがラップされていて直接触れない場合もあります。そのようなケースではモジュールのperldocに記載がないか確認しましょう。

おすすめ記事

記事・ニュース一覧