unix のシグナル処理あれこれ

Linux で signal + マルチスレッド、というとハンドラであれこれしようとすると、これはもう悪夢のように大変でひとつ signal 処理用スレッドを用意するのが定石のようです。

さてはて、そんなことで FreeBSD もそういうコードを書いて fork した子供を回収してやろうとしたところ、全く動いていません… orz これはどうしたんだろう??と調べた結果 空の signal handler を signal でしこんでやると動きだすことがわかりました。

はてさて、これはカーネルにどんな実装の差があるのでしょうか? そして、他のBSDではどうなっているのでしょうか? これが今回の疑問です。

実験

ひとまずこんなコードを使って引数に 0-3 を渡して実験します。中身はこんな感じのプログラムです。


#include <stdio.h>
#include <signal.h>
#include <pthread.h>
#include <stdlib.h>
#include <unistd.h>

void sig_hand(int h) {}

void* signalWatch(void* p)
{
  int signo;
  sigset_t sigset;

  sigfillset(&sigset);

  while(sigwait(&sigset, &signo) == 0){
    printf("got signal %d\n", signo);
  }

  return NULL;
}

pthread_t signalSet(int which)
{
  int ret;
  sigset_t sigset;

  sigemptyset(&sigset);
  ret = sigaddset(&sigset, SIGCHLD);
  if(ret != 0) {perror("Cannot watch SIGCHLD ");exit(1);}

  ret = sigprocmask(SIG_BLOCK, &sigset, NULL);
  if (ret != 0) {perror("Cannot mask signal"); exit(1);}
  
  pthread_t tid;
  pthread_create(&tid, NULL, signalWatch, NULL);
  return tid;
}

int invokeChildren(int c)
{
  pid_t pid;
  if((pid = fork()) == 0){
    kill(getppid(), SIGWINCH);
    if(c & 2) kill(getppid(), SIGINT);
    exit(0);
  } 
  return 0;
}

int main(int argc, char *argv[])
{
  int c = 0;
  if(argc > 1) {
    c = argv[1][0] - '0';
  }
  if(c & 1)
    signal(SIGCHLD, sig_hand);

  pthread_t sigthread = signalSet(0);
  int cldthread = invokeChildren(c);

  sleep(5);
  
  return 0;
}

実験結果をまとめるとこんな感じ

Linux OpenBSD Solaris FreeBSD DragonFly NetBSD
handler なしで動く? o o x x x x
SIGINT があるとすぐ終了? o (出力もなし) o (got signal は出てくる) o (出力もなし) x x x
Ctrl-C すると終了なる? o x x x x x

いろいろと不思議な感じですねぇ…。

Linux

Linux だと SIGCHLD の配送はこんな感じに行なわれます。

まず、 sigwait() のほうからいきます。 kernel では、 kernel/signal.c の rt_sigtimedwait() によって実装されています。上の右側は、この rt_sigtimedwait() の大雑把な流れになっています。

deque_signal() は signal が来ているかどうかを確認する関数です。最初にこれが呼ばれてすでに signal が来てないかどうか確認します。(最初の画像だと赤の矢印のタイミングで来たらこっちで sigwait() を抜ける)

来てなかった場合は、一時的に signal の mask を解除します。これで signal が新しく届くようになるので、そのまま sleep に入ります。これは schedule_timeout_interruptible(timeout); で行なわれるので、 signal が配送されると起きます。するとあとは、 deque_signal() してやって送られてきた signal を返して、 sigwait() が終了します。この時 signal handler は実行されません。

ところで、これってほんとにうまく "sleep したところに" 配送されるものでしょうか?? SIGCHLD はプロセスに送られていますが、 sleep しているのは thread ですよね。

それは signal を配送する側を見るとわかります。 SIGCHLD は子プロセス終了時に親プロセスに送られます。上の画像の左側ですね。 do_notify_parent() まではささっと飛ばします。

do_notify_parent() の中では SIGCHLD を親が無視しているか、などなどが見られたりするわけですがまぁいいです。SA_NOCLDWAIT とかそこらへんの処理をあれこれしてます。 __group_send_sig_info() 経由で send_signal() に行きます。

send_signal() の中では signal を親の signal queue の中にいれておいて、 complete_signal() を呼びだします。この complete_signal() がうまく "sleep したところに" 配送していく肝です。

complete_signal() はこんなことをします。

  • メインのスレッドに配送するかチェック
    • block されてたら配送しない
    • 終了処理中なら配送しない
    • SIGKILL は配送する
    • 停止中またはトレース中なら配送しない
    • 現在実行中なら配送する (CPU変える必要ないからね!)
    • シグナルが処理必要 (pending) でなければ配送する
  • グループに送られる signal であり他のスレッドがいる
    • signal を配送可能な thread を探索 (配送ルールは上に同じ)

ということで、最初の画像にある通り最初に SIGCHLD をマスクすることで、全ての thread で SIGCHLD を block してしまいます。そして、 sigwait() の中ではさきほど書いたように「一時的に signal の mask を解除」して「sleep に入」っているので、みごと sigwait() していたスレッドだけが SIGCHLD を受信し wakeup するわけです! やったね!

(探索は「ラウンドロビン的に」最後に配送したとこから開始されてます。なるべく偏らないようにするわけですね。)

さて、今度は SIGINT のほう。 complete_signal() まで飛ばして、 complete_signal で上のチェックが行なわれます。ブロックされてないのでメインに配送されていきますね。

sig_fatal(p,SIGINT) (p は配送先 thread) が真になるので、プロセスの全てのスレッドに残酷にもここで SIGKILL が配送されて全てのスレッドが起こされていきます。

sig_fatal() は signal handler でデフォルトの動作をしていて、 signal のデフォルト動作が「無視」でも「停止」(Ctrl-z とかのあれ)でもなければ真を返すマクロです。

さて、これでもう Linux の動作は完全に納得できましたね! コード書く側としても自然だな、とぼくは思います。

FreeBSD

FreeBSD も sigwait() に関してはほとんど変わりません。kern_sigtimedwait() で実装されています。マスクを一時的に解除して sleep に入るという Linux とほとんど変わらない処理をしています。しかし、逆に言うと「それだけしか」していません。

では signal 送るところは? プロセスに signal を送るのは psignal() です、が結局実際の処理は tdsignal() に流れていきます。 tdsignal() の処理はざっとこんな感じ。

まず、注目すべきなのは「無視されてるので廃棄」の部分です。 FreeBSD ではスレッドごとに「無視されている signal の集合」を保持していて、送られてくる signal がこの中に入っていると即座に signal を廃棄してしまいます。この「signal の集合」は上で述べたように sigwait() が「それだけしか」していない、ので、たとえ sigwait() で指定されていたとしても、この集合から一時的に外されている、というようなことはないのです。

sigaction() (kernel では kern_sigaction()) を使えば、この「集合」ももちろんちゃんといじられるので、ダミーの signal handler を設定してやれば FreeBSD も sigwait() が期待の通りに動くようになります。

それでは、 FreeBSD は SIGINT はどうなってるでしょうか。最初のコードではマスクも action 明示されてないので action = DFL で、まぁデフォルトの動作になります。そして、 signotify() -> tdsigwakeup() と呼ばれていきます。

signotify() は処理すべき signal がキューにあるかを見て、 TDF_NEEDSIGCHK という signal きてますよ、というフラグと TDF_ASTPENDING という async event がありますよ、というフラグを立てるものです。

tdsigwakeup() の中ではプロセス停止な signal を受信スレッドが優先度が低ければ優先度上げたり、そのスレッドが寝ているなら起こしたり、ということをしています。 SIGCHLD の場合は、 sigtimedwait() で寝ているはずなので sleepq_abort() で起こしにいきます。…まぁとりたてて見るとこもない…ですかね、とりあえず。

全体的にはそういう感じですが、なぜか SIGINT が発行されてるのに全部 sigwait に拾われていきます。まぁ sigwait() が全部拾うようにしているので、 sigwait しているスレッドに配送されればそうなるわけですが…。さてなぜ全部 sigwait のスレッドにいってしまうのでしょう? これは配送先の探索方法とスレッドのデータ構造に秘密が隠されています。

まずは探索方法。探索は kern_sig.c の sigtd() で行なわれます。これは単純に

  • 実行中のスレッド宛ならそこに
  • プロセス内のスレッドを「頭から」なめてマスクしてない最初のスレッドに

配送します。常に「頭から」になるのがポイントですね。

さて、スレッドのデータ構造です。あるプロセスに所属する全てのスレッドのデータはリストにまとめられています。 kern_thread.c の thread_link() によって生成されたスレッドがプロセスのそのリストに追加されますが、この時に TAILQ_INSERT_HEAD を使うので「先頭に」入っていくことになります。

さっきのプログラムでは signal 処理のスレッドが最後に生成されたスレッドでしたし、 sigwait によって全てのシグナルのマスクが解除されてますから、全ての signal をこのスレッドが受け取ることになり SIGINT があっても終了しない、ということになります。

実際に、あのプログラムの sleep の前に適当なスレッドを生成しておくと、 Ctrl-C で終了するようになります! その新しく作ったスレッドに SIGINT が配送されて処理されてしまうためですね。

これで FreeBSD の謎も解けましたね!

DragonFly

まぁ… FreeBSD からの fork なので大まかには一緒ですね……。 DragonFly のが配送先選択が遅い程度。

おもしろいと思ったところを。

find_lwp_for_signal() で例の「配送先の選択」を行なっていますが、一端全てのスレッドを parse して running のもの、 stop してるもの、 sleep してるものをそれぞれ記録します。そして最後に run -> sleep -> stop の順に、記録されているかどうか(ようするに 非NULLかどうか)を見て、最初に見つかったものを返しています。

まぁ…スレッドいっぱい作ると遅くなりそうですよね……すでに3種ともに見つかってても全部 parse するコードになってます。

あと tree.h に377行のマクロがあります。少なくとも7個所以上使われて大量のコードを吐き出すようです :D

NetBSD

まず sigtimedwait1() から。 NetBSD ではプロセスに p_sigwaiters というものがあり、ここに登録しておくことで signal 待ちをすることができます! しかしながら、これがチェックされるのは、やっぱり「無視される集合」に入ってないことが確認された後ですし、 sigtimedwait1() の中で「無視される集合」から外していく、というような処理もなく、 handler なしの SIGCHLD はただただ無視されてしまいます。

では、 handler があると? さっきの p_sigwaiters に sigwait() してるスレッドが登録されてますから、 sigunwait() が走ってシグナル処理関数 kpsignal2() は return します。

さて、 SIGINT の行く末は? やっぱり他のBSDと同じように action が default に判定された後、 SIGINT のようなプロセスを止めてしまう系の signal をもらうと、優先度をある程度上げているようです。さっさと殺すためでしょうか。そこまできたところで p_sigwaiters がありますから、 SIGINT も sigwait() に持っていかれてしまいます。まぁこっちのほうが妥当ですかねぇ?

ちなみに、そこもすり抜けると、その後は素直にスレッド一覧をなめて配送先のスレッドを選んでいます。これもまた単純にリストの最初からなめているので偏りも出るでしょうね。リストは二回なめられていて、最初は idle でかつ signal 処理可能なスレッドを探して、無理であれば全てのスレッドから signal 投降先を探しにいきます。

これで NetBSD も(中身はちがえど)同じように動く理由が解りました。最後は OpenBSD です!

OpenBSD

sigwait() から行きましょう。 OpenBSD では kern_sig.c の sys_thrsigdivert() で実装されています。基本的な流れとしてはほとんど Linux とも他の *BSDとも同じです。 特筆すべきは p_sigdivert です。 NetBSD の p_sigwaiters と同様に p_sigdivert に待機する signal を登録しておいて寝ます。

これをふまえて、 signal 配送する ptsignal() を見ていきましょう。

メインの処理としては一番最初にプロセスに送られた signal ならそのプロセスの全てのスレッドをなめて、 p_sigdivert にその signal が登録されているかどうかチェックしています。 上のプログラムの SIGCHLD の場合だと sigwait() しているスレッドがこれに該当し、 process に送られていた signal が sigwait() のスレッド向けへと変換されます。

その後、早速 sigwait() していたスレッドを起こします。シンプルで速い! いいですね!

では SIGINT はどうなのでしょう? sigwait() による printf("got signal..") は走っていますが、その後すぐに終了しています。どこで終了が走るのでしょうか??

とりあえず sigwait() されてますから SIGCHLD と同様に p_sigdivert にひっかかっていきます。その後、すぐに return してしまうのでなく atomic_setbits_int(&p->p_siglist, mask); が呼ばれ、ようするに signal queue に SIGCHLD が来たことも SIGINT が来たことも記録されます。

そうすると、後ほどに issignal() がよばれ、ここで default の動作を行ない・その動作が無視である SIGCHLD は無視されます。 実際になにかしらの action がある SIGINT は postsig(SIGINT) として postsig() が呼ばれ、そこで sigexit() によってプロセスを終了させてしまいます。また、 p_sigdivert を使って待ちあわせをしていて、 signal の mask をいじっていないので SIGCHLD についてはたとえ handler があっても mask されていて handler が呼び出されることはありません。

個人的にこのOpenBSDの挙動が一番好きですね…。コードも結構読みやすいですね。

質問をください><

とこんな感じで自分でわかったことをまとめてみました。まだまだ自分でわかったこと、なのでまとめきれてない部分多いかと思います。 なにぶんカーネルですしわかりにくいところもあるかと思います。実際のソースにあたりつつ見てみてください。わからない・わかりにくい部分はどんどんご質問ください。せっかくだからもっといいまとめ、いい資料にしてみたいのです。

少し補足

多分 sigwait() するものはちゃんと全部マスクしないとだめとつっこみ入りそうです。はい、心得ております。まぁ、sigaction と sigwait 同時に使うのが未定義だったり…。ポータブルにするには

  • ちゃんとマスクして
  • デフォルトで無視されるものはダミーのハンドラいれて

おけばいいのでしょうね。未定義の部分にいろいろとカーネルの性格でて楽しいかな、とも思って書いた記事です。 :)