Gaucheクックブック

Gauche (ゴーシュ)クックブックは動作する短いコードを一問一答形式で提示していくスタイルのプログラム解説ページです。毎週月曜、木曜に更新。

2007-03-22

長時間かかる処理にタイムアウトをつける

問題

ネットワーク経由の読み書き、ユーザからの入力待ち、非常に大量の計算など、必ずしも完了するまで待っていられない処理というものがある。そのような処理にタイムアウトする機能を付加して、指定された時間で処理を打ち切るようにしたい。

答え

未来のある時点にSIGALRMシグナルを受け取るようにタイマを設定しておけば、SIGALRMを受け取ったところで処理を中断できます。

下のwith-timeoutは3引数の手続きで、処理本体のサンク(0引数の手続き)、タイムアウトまでの秒数、タイムアウトしたときに返すデフォルト値を受け取ります。指定された秒数が経過するまでにサンクの評価が終わればその値を、タイムアウトすればサンクの評価を打ち切りデフォルト値を返します。

(define (with-timeout proc sec default)
  (let/cc k
    (with-signal-handlers
        ((SIGALRM (k default)))
      (lambda ()
        (dynamic-wind 
          (lambda () (sys-alarm sec))
          (lambda () (proc))
          (lambda () (sys-alarm 0)))))))

with-timeout以前にsys-alarmでセットされていたタイマがあれば、それは無効になります。

解説

上の短い手続きの中には、let/ccwith-signal-handlersdynamic-windsys-alarmといくつか要素がありますから、それを順に説明していきましょう。

let/ccはcall/ccに展開されるマクロで、(let/cc var body ...)は(call/cc (lambda (var) body ...))と書くのと同じです。ここではcall/ccを非局所脱出に使っています。

シグナルハンドラから単に返るとシグナルハンドラが呼び出されたときの処理に復帰するのですが、今回は処理を打ち切りたいので、元の場所ではなくその外側にリターンしなければいけません。let/ccが取り出した現在の継続を表す手続きkを呼ぶと、let/ccからリターンするので、それを利用して処理を中断します。Cでのシグナル処理に慣れたひとは、この処理を、シグナルハンドラからsiglongjmpするのとほぼ同じだと考えてください。

with-signal-handlersは、サンクを受け取り、そのサンクの実行中に限り一時的なシグナルハンドラをセットするマクロです。ここではkを呼び出して非局所脱出するハンドラをSIGALRMにセットしています。

dynamic-windはbefore、thunk、afterの3つのサンクを取る手続きです。まずbeforeを評価し、次にthunkを評価、最後にafterを評価します。返り値はthunkの返した値です。afterは、Javaのfinally節やRubyのensure節と似たようなものだと考えるとわかりやすいでしょう。Schemeにはcall/ccがあるので、一度リターンした手続きに再度突入したり、2度以上リターンすることがあります。そのときはbefore、afterがそのたびに呼び出されます。

最後にsys-alarmですが、これはシステムのalarm(2)のインタフェースで、指定した秒数後にSIGALRMシグナルを送ってもらうようカーネルに指示する手続きです。引数に0を渡すと現在のタイマが取り消されます。ここでは、本体を呼び出す前にタイムアウトを設定し、タイムアウトする前に本体の処理が終了したときには、そのあとにSIGALRMが送られてこないよう0を渡してタイマを取り消しています。

スクリプトの例

さて、with-timeoutを使ったサンプルプログラムを一つ作ってみましょう。下のスクリプトは、標準入力から1行読み取って、それをエコーするプログラムです。行の読み取りにはタイムアウトが設定されていて、3秒以内に行を入力しなければ「timeout」と表示して終了します。

#!/usr/bin/gosh
(define (with-timeout proc sec default)
  (let/cc k
    (with-signal-handlers
        ((SIGALRM (k default)))
      (lambda ()
        (dynamic-wind 
          (lambda () (sys-alarm sec))
          (lambda () (proc))
          (lambda () (sys-alarm 0)))))))

(define (main args)
  (cond ((with-timeout read-line 3 #f)
         => (cut format #t "got ~a\n" <>))
        (else (print "timeout")))
  0)

シグナルハンドラの遅延呼び出し

一部の手続きはシグナルがブロックされているかのように振舞うので、シグナルを使った割り込みで常に処理を中断できるわけではありません。下のコードでは長時間かかる正規表現マッチをwith-timeoutでくくっていますが、正規表現マッチはSIGALRMで中断できないため、この手続きは1秒でリターンしてくれません。(この正規表現マッチが長時間かかる理由はここでは置いておくことにしますが、正規表現マッチには指数的に長時間を要するものがあるのです。)

(with-timeout 
 (lambda () (#/a+*$/ "aaaaaaaaaaaaaaaaaaaaaaaaaaaab"))
 1 #f)

なぜSIGALRMが処理されないのでしょうか? その理由はGaucheのシグナルの扱いの仕組みにあります。シグナルハンドラは、処理系がシグナルを安全に処理できるタイミングまで呼び出しが遅延されます。具体的には、到着したシグナルはいったんシグナル受信の事実が記録され、VMのメインループか、明示的にシグナルの到着を確認している箇所から、Schemeのシグナルハンドラが呼び出されます。(シグナル処理の詳細についてはまた別の記事で改めて解説します。)

正規表現マッチの手続きはCで実装されていて、マッチが成功するか失敗するまでVMのメインループに戻らず、その中でシグナルハンドラを呼び出すこともしません。従って上のコードのSIGALRMハンドラは正規表現マッチから戻るまで呼び出されないことになります。これがSIGALRMで処理を中断できない理由です。

一般に、Cで実装されていて、システムコールを呼び出さない手続きは、その中でシグナル到着を確認しないと考えるべきです。そのような手続きはあまりありませんが、シグナルを扱うアプリケーションを書くときは上記のような事象があるということを覚えておくと、不思議な動作に出くわしたとき助けになるでしょう。

参照