Perl Hackers Hub

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

シグナル制御

シグナルはUNIXにおけるプロセス間通信の手法の一つであり[7]⁠、プロセスへ一方向の単純な通知機能を提供すると同時に、子プロセスの死活管理にも使われる、とても身近なものです。Perlでは%SIGというグローバル変数を使って、シグナルを受信したときの処理をカスタマイズできます。本節では、シグナルに呼応して処理を行うプログラムの書き方を紹介します。

シグナルの種類とドキュメント

シグナルには特定の現象が発生した場合にOSが送信してくるものや、デーモンプロセスを制御するためにユーザが送信するものなど、さまざまなものが存在します。Perlでプログラムを書く際によく取り扱うシグナルは表1のとおりですが、これ以外にもPOSIXで規定されているもの、OSごとに独自のものがあり、manなどを参照することで確認できます[8]⁠。それぞれのシグナルを受信した場合のデフォルトの動作がなんであるかについても、これらのドキュメントに記載があります。また、Perlにおけるシグナルの取り扱い方について詳しくは、perldoc perlipcを参照してください。

表1 主なシグナル
シグナル名意味
SIGHUP主に設定ファイルの再読み込みを要求するために使用
SIGINT割り込みを要求(Ctrl-Cが押された場合など)
SIGKILLプロセスを強制終了
SIGPIPE閉じられたパイプやソケットに書き込もうとした
SIGALRMalarmで設定した時間が経過
SIGTERMプロセスの終了を要請
SIGCHLD子プロセスが終了

ネットワークプログラムとSIGPIPE

「私の書いたサーバが突然死するんです。どうしてでしょうか」という質問を受けることがあります。これは多くの場合、SIGPIPEの処理を忘れていることが原因です。SIGPIPEとは、切断されたネットワークソケットなどにデータを書き込もうとした際に送出されるUNIXシグナルです。特に設定しない限り、プロセスはSIGPIPEを受け取ると強制終了されます。そのため、通信が突然切断される可能性のあるTCPサーバにおいては、SIGPIPEを無視するよう設定する必要があります。

# デフォルトの動作(SIGPIPEの場合はプロセスの終了)に設定
$SIG{PIPE} = 'DEFAULT';

# SIGPIPEを無視するよう設定
$SIG{PIPE} = 'IGNORE';

# SIGPIPEを受信した際に実行するサブルーチンリファレンスを
# 設定
$SIG{PIPE} = sub {
    ...
};

# SIGPIPEを受信した際に実行するサブルーチン名を設定(古い
# スタイル)
$SIG{PIPE} = 'my_func';

シグナルとErrno::EINTR

シグナルハンドラ(%SIGの各要素の値)として'IGNORE'をセットした場合、シグナルは無視され、プロセスは何事もなかったかのように処理を継続します。

一方でサブルーチンを指定した場合は、シグナルを受信した段階でそのサブルーチンが呼び出されます。また、ネットワーク通信などを行うシステムコールが実行中だった場合は、その処理は中断されます。例として、次のコードを見てみましょう。

# SIGALRMのハンドラとして、何もしないサブルーチンをセット
$SIG{ALRM} = sub {};
# 1秒後にSIGALRMを送出するようセット
alarm(1);
# 10秒sleep
sleep(10);
# 実際は1秒後にSIGALRMを受信した段階でsleepが中断される

alarm関数は、指定した時間(単位:秒)が経過したあとに自分自身のプロセスに対してSIGALRMを送信するようOSに依頼する関数です。$SIG{ALRM}にサブルーチンがセットされているので、SIGALRMを受信した段階で、ただちにシグナルハンドラ(この例では空のサブルーチン)が実行され、そのままsleepの処理が中断されます。また、$!にはシグナルを受信したためにシステムコールの実行が中断されたことを示すエラーコードErrno::EINTRがセットされます。

ただし先述したように、シグナルハンドラが'IGNORE'だった場合は、sleepの実行は中断されないという点に注意してください。

alarmを使った通信のタイムアウト処理

sleepを中断するよりも実際的な例としては、ネットワーク通信のタイムアウト処理を挙げることができます。次のコードは、alarmを用いてソケットからの読み込みについてタイムアウト処理を行っています。

my $sock = IO::Socket::INET->new(...);
...
# localを用いて、このブロック限定のシグナルハンドラを設定
local $SIG{ALRM} = sub {};
# タイマーを設定
alarm($timeout);
# 読み込み開始。データが読み込まれるか、あるいはタイマー
# 時間が経過するまでブロック
my $len = $sock->read(my $buf, $maxlen);
# もしデータがなく、$!がEINTRだったらタイムアウト
if (! defined($len) && $! == Errno::EINTR) {
    warn 'timeout’;
    ...
}

alarmを使ったタイムアウト処理のメリット/デメリット

alarmを使ってタイムアウト処理を行うことのメリットは、ほとんどすべてのシステムコールに対してタイムアウトを設定できるという点にあります。一方、デメリットとしては、プロセス全体で1個のタイマーしか持てないため、階層化されたプログラムにおいて使いにくいという点が挙げられます[9]⁠。

そのため、alarm(とシグナルを使ったタイムアウト処理)はモジュールレベルではなくアプリケーションレベルでのタイムアウト処理に使われる一方、ネットワーク通信のようなモジュールレベルで実現可能なタイムアウト処理については、select関数を使って実装される場合が多いです。

シグナルとデーモンの終了処理

シグナルはタイムアウト処理以外にもさまざまな場面で使われます。多くのデーモンプログラムには、SIGHUPを受信すると設定ファイルを再読み込みする機能や、SIGTERMを受信すると適切な終了処理を行ったあとにシャットダウンする機能(一般に「Graceful Shutdown」などと呼ばれます)などが組み込まれています。

このような終了処理の実現方法に関する知識は、httpdサーバのようなモジュールを書いているプログラマに限らず、一般のWebアプリケーションプログラマにとっても有用です。たとえばGearman[10]のワーカプロセスを終了する場合、いきなりワーカプロセスを停止してしまうと、そのプロセスが処理中だったリクエストはどうなるでしょうか。クライアントにはエラーが返ることになります。ワーカプロセスに「SIGTERMを受信した場合、ハンドリング中のリクエストの処理が完了したら終了する」というコードを組み込めば、クライアントにエラーが返る心配をせずに、いつでもワーカプロセスを終了できるようになります[11]⁠。

GearmanのワーカをGraceful Shutdownする方法

ドキュメントには書いてありませんが、Gearmanのワーカには、メインループを中断して制御を呼び出し元に返すかどうか判断するためのコールバックが存在し、次の例のように、このコールバックを利用することでGraceful Shutdownが実現できるようになっています。

use Gearman::Worker;

my $worker = Gearman::Worker->new;
... # ワーカのセットアップ

# フラグを立てるシグナルハンドラを登録
my $stop_worker = undef;
$SIG{TERM} = sub {
    $stop_worker = 1;
};

while (! $stop_worker) {
    $worker->work(
        stop_if => sub {
            $stop_worker;
        },
    );
}

中断可能なコードの書き方

Gearmanのワーカ以外にもGraceful Shutdownが必要なケースはありますし、中には、中断可能なプログラムを自分で実装しなければいけない場合もあります。では、Gearmanのような中断可能なコードは、どのように書けばよいのでしょうか。その答えが次のコードです。

# 中断要請を管理するフラグを用意し、シグナルを受信したら
# セット
my $stop_worker = undef;
$SIG{TERM} = sub {
    $stop_worker = 1;
};

sub do_some_work {
    ...
    my $len;
    {
        # 処理の開始前に中断要請があったか確認し、
        # あったならreturn
        return if $stop_requested;
        # ブロックする処理を実行
        $len = $sock->read(my $buf, $maxlen);
        if (! defined($len) && $! == Errno::EINTR) {
            # シグナルを受信したのでリトライ
            redo;
        }
    }
    ...
}

このコードを理解するうえでのポイントは次の3点です。

  • 中断要請を管理するフラグ($stop_requested)を設け、シグナルを受信したらフラグをセットする
  • 重い処理(ここでは$sock->read)を実行する前に、必ず中断要請フラグを確認し、要請があればreturn
  • 重い処理がシグナルの受信が原因で失敗した場合には再実行

fork、waitとSIGCHLD

UNIXでは、子プロセスの実行が完了すると親プロセスがその終了ステータスを回収するのが基本原則になっています[12]⁠。そのために使われるのがwait関数です。次のコードは、親プロセスが子プロセスを作成し、それが終了するまで待つ例を示しています。

use POSIX;

# 子プロセスを生成
my $pid = fork;
die "fork failed:$!"
    unless defined $pid;
if ($pid == 0) {
    # 子プロセス
    ...
    exit(0);
}
# 親プロセスは子プロセスが終了するまでwait
while (wait == -1) {}
# 終了コード(あるいはシグナル)を表示
if (WIFEXITED($?)) {
    print "child exitted status: ", WEXITSTATUS($?),
          "\n";
} else {
    print "child was killed by signal ", WTERMSIG($?),
          "\n";
}

子プロセスの終了ステータスは親プロセスがwaitを呼び出して回収するまでOSの中に残り続け、システムを圧迫します(いわゆるゾンビプロセス⁠⁠。forkしたプロセスは必ずwaitを呼んで回収するようにしましょう。

あるいは、子プロセスの終了ステータスが不要なのであれば、$SIG{CHLD} = 'IGNORE'とすることで、waitを呼ばなくてもゾンビプロセスが溜まらないようにすることができます。

残念なことにPerlのwait関数は、シグナルを受信しても制御が返ってきません。ですから、先に紹介したようなシグナルとEINTRを使って中断可能なコードを書く場合は、組み込み関数のwaitの代わりに、Proc::Wait3モジュールが提供するwait3を使う必要があります。

まとめ

特にデーモンプログラムの場合、シグナル処理に対応しているかいないかによって、運用のコストが大きく変わります。もしGearmanワーカのような、シグナル処理に対応したモジュールを使ったデーモンを書いているのなら、迷わずシグナル対応にすべきでしょう。

また、自前のプログラムにおいても、可能な範囲でシグナル対応のコードを書いておくにこしたことはありません。

おすすめ記事

記事・ニュース一覧