Ubuntu/Pythonの非同期シグナルセーフ関数のお話

最初に書いてしまうと、
ubuntuのsleep()は非同期シグナルセーフ関数で、実行中にシグナルを受信すると、ただちにシグナルハンドラへ処理が移り、シグナルハンドラが完了したあとで、指定秒数が経過していなくても、再開することなくただちに完了する。
ubuntuのread()とwrite()は非同期シグナル関数で、実行中にシグナルを受信すると、実行が完了してからシグナルハンドラへ処理が移り、シグナルハンドラが完了したあとは、read/writeの完了後から処理が再開する。つまりI/Oはシグナルを受信しなかったときと同じように実行される。
みたいだった。
非同期シグナルセーフ関数は他にもたくさんあるけど、今回はこれだけ。

pythonのsignalモジュールとmultiprocessingモジュールを試してるとき、どうも引っかかった。

子プロセスがtime.sleep(100)を実行して、time.sleepの次の行が実行されるまでの間にkill -sでシグナルを送ると、time.sleepへの指定秒数にかかわらず、ただちにシグナルハンドラへ処理が移り、シグナルハンドラの完了後はtime.sleepの次の行からただちに処理が再開された。

はて、read/writeの実行中にシグナルを送ると、どうなってしまうのやら?

リファレンスをよく見るとこんな説明が。
http://www.python.jp/doc/2.6/library/signal.html#signal-example

シグナルが I/O 操作中に到着すると、シグナルハンドラが処理を返した後に I/O 操作が例外を送出する可能性があります。これは背後にある Unix システムが割り込みシステムコールにどういう意味付けをしているかに依存します。

なんですって?

使用中のUnixシステムはUbuntu Server 12.04LTSだったので、いろいろ検索してると、これが出てきた。
http://manpages.ubuntu.com/manpages/jaunty/man7/signal.7.html
「Async-signal-safe functions」というリストに、read, write, sleep,ほか多数の関数が載っていた。
※このページは aptitude install manpages-ja; man 7 signalで日本語で読める

safeと聞いて、ちょっと期待。

さて、非同期シグナルセーフ関数とは…
http://d.hatena.ne.jp/yupo5656/20040712/p2 によると

最後に、念のために「非同期シグナルセーフ」とは何であるか説明しておきます。非同期シグナルセーフな関数とは、「関数内の任意の場所でシグナルに割り込まれても、次回の関数呼び出しに問題がない関数」の事です。その関数に静的に紐づけられたデータを更新するような関数(例: malloc)は、大抵の場合、非同期シグナルセーフにできません。ただし、静的なデータを持っていても、そのデータの処理中のシグナル受信を禁止(マスク)している場合は、特例的に非同期シグナルセーフ関数たりえます。

なるほど。
Pythonのsignalモジュールは一時的なシグナル受信の禁止をサポートしていないようなので*1後半は置いておいて、「関数内の任意の場所でシグナルに割り込まれても、次回の関数呼び出しに問題がないこと」が非同期シグナルセーフなのね。

期待していたのは、非同期シグナルセーフな関数は「関数内の任意の場所でシグナルに割り込まれても、関数はシグナルに割り込まれなかったときと同様に動作する」ということなんだけど、sleepはとてもそう見えなかったし、readやwriteについては、試してみるしかないみたいだった。

次のように試した。

500MBのテキストファイルを用意

>>> open("bigtext.txt", "w").write("a" * 500 * 1024 ** 2)

次のモジュール test.py を用意

import signal
import time

sig_term_received = False

def sig_term_handler(signum, frame):
  print "sig term handler"
  global sig_term_received
  sig_term_received = True

def f(second):
  signal.signal(signal.SIGTERM, sig_term_handler) #5
  global sig_term_received
  while True:
    if sig_term_received:
      print "sig term received"
      break
    print "f = open r 1"; f = open("bigtext.txt")
    print "f.read()";   text = f.read() #1
    print len(text)
    print "f.close()";  f.close()

    print "f = open w"; f = open("bigtext.txt", "w")
    print "f.write()";  f.write(text) #2
    print "f.close()";  f.close()

    print "f = open r 2"; f = open("bigtext.txt")
    print "f.read()";   text = f.read()
    print len(text)                       #3
    print "f.close()";  f.close()

    print "time.sleep 1"; time.sleep(second) #4
    print "time.sleep 2"; time.sleep(second)
  print "while loop end"

f()を実行

>>> import test; from multiprocessing import Process
>>> p = Process(target=test.f, args=(5,))
>>> p.start()
>>> p.pid

タイミングを見計らってシグナルを送る

$ kill -s TERM <pid>

#1の実行中にシグナルを送っても、シグナルを送らないときと同じlen(text)が得られた。
#2の実行中にシグナルを送っても、#3で得られるlen(text)はシグナルを送らないときと同じだった。
#4の実行中にシグナルを送ると、ただちに"sig term handler"がプリントされ、ただちに"time.sleep 2"がプリントされた。
#5のシグナルをsignal.SIGUSR1に変更して、USR1シグナルを送っても、同じ結果が得られた。

式の評価中にシグナルを受信したらどうなるんだろう?という心配はあるけど、リファレンスには

Python のシグナルハンドラは Python のユーザが望む限り非同期で呼び出されますが、呼び出されるのは Python インタプリタの “原子的な (atomic)” 命令実行単位の間です。したがって、 (巨大なサイズのテキストに対する正規表現の一致検索のような) 純粋に C 言語のレベルで実現されている時間のかかる処理中に到着したシグナルは、不定期間遅延する可能性があります。

とあって、Pythonの式がatomicならたぶんシグナルは式と式の間に割り込むので、大丈夫だと思っておく。
Pythonだから、きっとそうしてくれるだろう。

非同期シグナルセーフ関数が実行中にシグナルを受信したとき、シグナルを受信しなかったときと同じように動作するかどうかは、どこに書いてあるんだろう?
open()やclose()やfcntl()はどうなんだろう?

追記:
非同期シグナルセーフ関数の作動中にシグナルを受信したとき何が起こるかは、man 7 signalに全部書いてあった。
apaitude install manpages-jaで日本語になる。
式の評価中、文字列なんかを整形中にシグナルを受信したらどうなるかは、まだ謎。I/Oが安全でも、そこが分からない。

man 7 signal
シグナルハンドラによるシステムコールやライブラリ関数への割り込み
システムコールやライブラリが停止 (block) している間にシグナルハンドラが
起動されると、以下のどちらかとなる。

* シグナルが返った後、呼び出しは自動的に再スタートされる。

* 呼び出しはエラー EINTR で失敗する。

これらの二つの挙動のうちどちらが起こるかは、インターフェイスにより依存
し、 シグナルハンドラが SA_RESTART フラグ (sigaction(2) 参照) を使って設
定されていたかにも依存する。 詳細は UNIX システムによって異なる。 Linux
における詳細を以下で説明する。

以下のインターフェイスのいずれかの呼び出しが停止している間に シグナルハン
ドラにより割り込まれた場合、 SA_RESTART フラグが使用されていれば、シグナ
ルハンドラが返った後に その呼び出しは自動的に再スタートされることになる。
それ以外の場合は、その呼び出しはエラー EINTR で失敗することになる。

* read(2), readv(2), write(2), writev(2), ioctl(2) の「遅い
(slow)」デバイスに対する呼び出し。 ここでいう「遅い」デバイス
は、I/O 呼び出しが無期限に停止 (block) する 可能性のあるデバイス
ことで、例としては端末、パイプ、ソケットがある (この定義では、ディ
スクは遅いデバイスではない)。 遅いデバイスに対する I/O 呼び出しが、
シグナルハンドラにより割り込まれた時点までに何らかのデータを すでに
転送していれば、呼び出しは成功ステータス (通常は、転送されたバイト
数) を返すことだろう。

* 停止 (block) する可能性のある open(2) (例えば、FIFO のオープン時;
fifo(7) 参照)。

* wait(2), wait3(2), wait4(2), waitid(2), waitpid(2).

* ソケットインターフェイス: accept(2), connect(2), recv(2),
recvfrom(2), recvmsg(2), send(2), sendto(2), sendmsg(2). 但し、ソ
ケットにタイムアウトが設定されていない場合 (下記参照)。

* ファイルロック用インターフェイス: flock(2), fcntl(2) F_SETLKW.

* POSIX メッセージキューインターフェイス: mq_receive(3),
mq_timedreceive(3), mq_send(3), mq_timedsend(3).

* futex(2) FUTEX_WAIT (Linux 2.6.22 以降; それ以前は常に EINTR で失敗
していた)。

* POSIX セマフォインターフェイス: sem_wait(3), sem_timedwait(3)
(Linux 2.6.22 以降; それ以前は常に EINTR で失敗していた)。

以下のインターフェイスは、 SA_RESTART を使っているどうかに関わらず、シグ
ナルハンドラにより割り込まれた後、 再スタートすることは決してない。 これ
らは、シグナルハンドラにより割り込まれると、常にエラー EINTR で失敗する。

* setsockopt(2) を使ってタイムアウトが設定されているソケットインター
フェース: accept(2), recv(2), recvfrom(2), recvmsg(2) で受信タイム
アウト (SO_RCVTIMEO) が設定されている場合と、 connect(2), send(2),
sendto(2), sendmsg(2) で送信タイムアウト (SO_SNDTIMEO) が設定されて
いる場合。

* シグナル待ちに使われるインターフェイス: pause(2), sigsuspend(2),
sigtimedwait(2), sigwaitinfo(2).

* ファイルディスクリプタ多重インターフェイス: epoll_wait(2),
epoll_pwait(2), poll(2), ppoll(2), select(2), pselect(2).

* System V IPC インターフェイス: msgrcv(2), msgsnd(2), semop(2),
semtimedop(2).

* スリープ用のインターフェイス: clock_nanosleep(2), nanosleep(2),
usleep(3).

* inotify(7) ファイルディスクリプタからの read(2).

* io_getevents(2).

sleep(3) 関数も、ハンドラにより割り込まれた場合、決して再スタートされるこ
とはない。 しかし、成功となり、残っている停止時間を返す。

どちらも非同期シグナルセーフ関数なのにsleepとread/writeとが違う動きに見えたのは、そういうわけだったんだ。

PythonではSA_RESTARTはsignal.siginterrupt(signalnum, flag)で設定するそうな。
http://stackoverflow.com/questions/5844364/linux-blocking-signals-to-python-init

リファレンスによるとsignal.signal(signalnum, handler)は暗黙のうちにsiginterrupt(signalnum, True)を呼び出す(SA_RESTARTをOFFにする)そうだけど、それだと今度は上記の実験でSA_RESTARTが暗黙のうちにOFFになっていたにもかかわらず、まるでSA_RESTARTがONになっていたかのような動作になっていたことの説明がつかない。
リファレンスの間違いじゃないかと、わざわざsignterrupt(signalnum, True/False)を両方試しても、動作は同じように見える。謎。

もはやおまじないの域だけど、ひとまずリファレンスに従って、SA_RESTARTは明示的に指定するだけして、祈ることにした。

*1:http://www.python.jp/doc/2.6/library/signal.html: クリティカルセクションから一時的にシグナルを”ブロック”することはできません。この機能をサポートしない Unix 系システムも存在するためです。