時間のかかる処理を捕捉する ALRM シグナル

  • 6
    いいね
  • 0
    コメント
この記事は最終更新日から1年以上が経過しています。

時間のかかる処理を実行したいんだけど、ある程度の時間が経過したらタイムアウトしたいという要望に応えるために ALRM シグナルというものがあります。

例えば

  • シンプルなライブラリで通信したいんだけど、長時間かかる場合はタイムアウトして欲しい
    • ウェブサーバなどでユーザのリクエスト内容を元に DNS 問い合わせをしたいんだけど、1秒(1000ミリ秒)以上かかったらタイムアウトさせたい
  • バッチプログラムがいつまでも終わらなくて I/O でささっているっぽいんだけど、どこでささっているのか調査したい
    • ささっている系調査は、プログラミング言語のデバッガや strace なども併用するとよいです
  • n分おきに起動する cron プログラムなんだけど、処理するデータが多すぎてn分でおは終わらない場合には終了させないといけない(終了したら次の cron で起こされるプログラムが担当してくれるのでむしろそれで問題ない)

といった場合。

ときどき ALRM シグナルを使いたいと思って Qiita とかを検索するのですが、最近の情報共有サイトではあまり ALRM シグナルについて書かれていません。最近では便利なものが増えたこともありますし、良くも悪くも黒魔術的なものになってしまったのかもしれませんが、UNIX で普遍的に使えるテクニックなので覚えておくと結構役立つ局面もあると思います。

#!/usr/bin/perl

use strict;
use warnings;

my $second = 2;
local $@;
eval {
    local $SIG{ALRM} = sub { die "timeout\n"; };
    alarm $second;
    long_process();
    alarm 0;
};
if ( $@ ) {
    print "exception: $@\n";
}

sub long_process {
    for my $i (map { sprintf "%02d", $_ } 1..99) {
        system "ssh hostname$i command";
    }
}

長時間かかりそうな long_process サブルーチンの実行を2秒でタイムアウトしてしまうコードです。ALRM シグナルのフックで例外を投げて、それを eval で捕捉して後続に流すのがイディオムです。2秒以内に処理が終わった時には alarm 0 でアラームをオフにしています。

これの優秀なところは、I/O 待ちなどでも大丈夫なことです。これの対比としては、AnyEvent などのイベント駆動モジュールの場合は I/O などのブロッキング要素によってタイマーが無効になってしまうことを見れば、ALRM シグナルの強さが分かるでしょう。

#!/usr/bin/perl
# 2秒以上実行に時間が掛かるはずなのに die されないのは
# AnyEvent のループがブロッキング I/O によってブロックされているため

use strict;
use warnings;

use AnyEvent;
use LWP::UserAgent;

my $second = 2;
local $@;
my $cv = AnyEvent->condvar;
eval {
    my $timer = AnyEvent->timer(
        after => $second,
        cb => sub { die "timeout\n"; }
    );
    my $long_process = AnyEvent->timer(
        after => 0,
        cb => sub { long_process($cv); }
    );
    my $message = $cv->recv();
    print "message: $message\n" if $message;
};
if ( $@ ) {
    print "exception: $@\n";
}

sub long_process {
    for my $i (map { sprintf "%02d", $_ } 1..99) {
        system "ssh hostname$i command";
    }
    $cv->send("long_process end!");
}

だいたいのシステムコールの待ちを ALRM シグナルはキャンセルできるようです。詳しくは調べていませんが便利。

1秒より解像度の高いミリ秒(10^3)マイクロ秒(10^6)単位のアラームを仕掛けるために、Time::HiResualarm というサブルーチンが用意されています。使い方は組み込みの alarm 同様です(引数が秒ではなくマイクロ秒)。

これを使えば特定の場所で発行される SQL を補足してログするコードを書くこともできます。スロークエリログの簡易版みたいなやつです。

# ...
use Time::HiRes qw(ualarm);
local $@;
eval {
    my $sql = q{SELECTE * FROM tbl WHERE col1 = "foo" AND col2 LIKE ?};
    my $bind = ["bar%"];
    local $SIG{ALRM} = sub { system qq{logger -t slowquery "SQL is too slow: [$sql]"}; };
    ualarm 1.5 * 10**6;
    my $itr = $teng->search_by_sql($sql, $bind);
    ualarm 0;
};

特に処理を停止させる必要がなければ die しなくてもよくて、こっそりログを書くだけでも大丈夫です。複雑になりますが、ALRM シグナルで呼び出されるサブルーチンを汎用的にしておくことで、その中で alarm 関数を呼んで再度アラームを仕掛けるといったこともできます。

最近 LWP::UserAgent モジュールでダウンロードタイムアウトをしようと思ってこの手法を使ったのですが、LWP::UserAgent モジュールは根底でユーザの die を潰していたためにこれが使えなくて、しばらく考えこんでしまいました(対応するとすれば LWP::UserAgent#request メソッドのキーである :content_cb フックを使うと良さそうです)。

ちなみに die を潰す方法は以下の方法でできます。

# 方法1
local *CORE::die = sub {};
# 方法2
loacal $SIG{__DIE__};

ALRM はとても基礎的なものなので、他のLLでも同様のものがあるでしょう。また、シグナルを使っているので、prefork などの環境では大丈夫ですが、イベント駆動やスレッド環境では注意が必要です。

PHP 版:

<?php
$second = 2;
declare(ticks = 1); // シグナルを使うときには宣言必要

try {
    pcntl_signal(SIGALRM, function () {
        throw new Exception("timeout");
    });
    pcntl_alarm($second);
    long_process();
    pcntl_alarm(0);
} catch (Exception $e) {
    echo "exception: $e\n";
}

function long_process () {
    foreach( array_map(function($x){ return sprintf("%02d",$x); },
                       range(1,99))
             as $i) {
        system("ssh hostname$i command"); 
    }
}