(第13回)コンテキストスイッチを改良する

2007/11/11

あなたは 人目のお客様です.

今回はなんかわかりにくいタイトルだけど,前回は,シグナル発生時のコンテキスト 取得に sigaction(), SA_SIGINFO, ucontext_t を利用するように修正した. これは segmentation fault 発生時のまさに発生したその場所で gdb がブレーク するための対処だった.

しかしそもそも,現状ではコンテキストの管理に setjmp()/longjmp() を利用して いるが,これを getcontext()/setcontext() を利用したものに置き換えられるはずだ. 前回も書いたが,setjmp()/longjmp() を使うのは古くさい書き方のようで, 現在では getcontext()/setcontext() を使うべきのようだ. まあ確かに,setjmp()/longjmp() はスタックが残っていることを期待しているし (このため,setjmp()を呼び出した関数から return してはいけない.スタックが 解放されてしまうから),なんつーかスタックはそのままでレジスタの状態を 保存しておいて前回の場所に戻るという,目的よりも実装ありきな構造なので なんだかなあ,という部分がある.

ということで,getcontext/setcontext() を利用した構造に書き換えてみる. とりあえずは setjmp()/longjmp() の部分をそのまま setcontext()/getcontext() に 置き換えてみよう. まあいろいろと修正案はあって,将来的にはもっとシンプルな構造にしたいのだけど, とりあえずそんなかんじで修正する.

(2009/04/10 ライセンスに関する文書として,KL-01とLICENSEを追加. 詳しくは第43回を参照)

前回からの差分は,いつもどおり,上記ソースコードの diff.txt 参照.

では,修正点について説明しよう.

まずは thread.h で,setjmp() 用の jmp_buf を削除する.

@@ -36,7 +35,6 @@
   kz_membuf *messages;
 
   struct {
-    jmp_buf env;
     ucontext_t uap;
   } context;
 } kz_thread;
次に thread.c の修正.
@@ -23,13 +21,14 @@
 
 kz_thread threads[THREAD_NUM];
 kz_thread *readyque[PRI_NUM];
-static jmp_buf intr_env;
+static ucontext_t intr_env;
 static kz_timebuf *timers;
 static kz_thread *sigcalls[SIG_NUM];
 static int debug_sockt = 0;
 static sigset_t block;
こちらも jmp_buf を削除し,割り込み発生時のKOZOS動作用のコンテキストとして intr_env を ucontext_t で定義する.

次にスレッド生成の thread_run() だが,これは修正が多いので差分でなく ソースコードをそのまま見て説明しよう.

static int thread_run(kz_func func, char *name, int pri,
		      int argc, char *argv[])
{
  int i;
  kz_thread *thp;

  for (i = 0; i < THREAD_NUM; i++) {
    thp = &threads[i];
    if (!thp->id) break;
  }
  if (i == THREAD_NUM) return -1;

  memset(thp, 0, sizeof(*thp));

  thp->next = NULL;
  strcpy(thp->name, name);
  thp->id = thp;
  thp->func = func;
  thp->pri = pri;
  thp->stack = malloc(SIGSTKSZ);

  memset(thp->stack, 0, SIGSTKSZ);

  getcontext(&thp->context.uap);

  thp->context.uap.uc_stack.ss_sp = thp->stack;
  thp->context.uap.uc_stack.ss_size = SIGSTKSZ;
  thp->context.uap.uc_stack.ss_flags = 0;
  thp->context.uap.uc_link = NULL;

  makecontext(&thp->context.uap, (void (*)())thread_init, 3, thp, argc, argv);

  /* 起動時の初回のスレッド作成では current 未定なのでNULLチェックする */
  if (current) {
    putcurrent();
  }

  current = thp;
  putcurrent();

  return (int)current;
}
説明し忘れていたけど,getcontext()によるコンテキスト管理には, 以下の関数がある. まずスタックの獲得だが,今までは malloc() で獲得して setjmp() 用の jmp_buf に 直接設定していたが,今回の修正でコンテキストの作成に makecontext() を 利用するようになるので,malloc() して ucontext_t の ss_sp というメンバに 設定している.ちなみに SIGSTKSZ は正確には sigaltstack() の推奨スタックサイズ なのだが,まあ似たようなものだろうという安易な判断で, とりあえずここではスタックサイズを SIGSTKSZ としている.

さらに makecontext() でコンテキストを作成する. makecontext() は,getcontext() 呼び出し後にそのコンテキストに対して makecontext() を行うのが正しいようなので,そういうふうに書いてある. makecontext() 呼び出し時に,スタート用の関数として thread_init() を指定し, argc, argv も指定している. 前回まではスタックや jmp_buf に直接書き込むというおもいっきりCPU依存 (そしてライブラリ依存)な書き方であったが,makecontext() を利用すると,こんなに すっきりと書ける.前回までと比べると,うーん,実にすっきりしたなあ.

次に,スレッドのスタート用関数である thread_init() について説明.

static void thread_init(kz_thread *thp, int argc, char *argv[])
{
  sigprocmask(SIG_UNBLOCK, &block, NULL);

  thp->func(argc, argv);
  thread_end();
}
kz_start()内部で sigprocmask() によってシグナルをブロックしているので (これはKOZOSの内部処理中にシグナルが発生して再入するのを防止するため), スレッドのスタート時に sigprocmask() によってシグナルをアンブロックにして, シグナルを受け付けるようにしている. またスレッドのメイン関数から return してきた場合には, thread_end() によってスレッド終了している. スレッドの終了時の動作(というか,スレッドのメイン関数から return してきた ときの動作)はどうやら ucontext_t の uc_link メンバを適切に設定することで 制御可能なようなのだけど,面倒なのでとりあえず thread_end() を呼ぶように している.

次に,割り込みベクタ処理の thread_intrvec() の修正.

@@ -428,23 +410,14 @@
   }
   extintr_proc(signo);
   dispatch();
-  longjmp(current->context.env, 1);
+  setcontext(¤t->context.uap);
 }
スレッドのディスパッチ後(ていうか,これって言葉的にはディスパッチでなく スケジュールだね.そのうち修正しよう)に,setcontext() によりコンテキスト スイッチを行っている.従来は longjmp() により前回の setjmp() した場所 (これは,シグナルハンドラの内部)に戻っていた. しかし前回の修正で,シグナル発生時にシグナルハンドラの引数として ucontext_t 型でコンテキストが渡されるように修正された. 今回は上記シグナルハンドラに渡されたコンテキストに setcontext() でスイッチする ように修正しているので,シグナル発生したまさに直後から動作再開することになる.

実はシグナルハンドラに渡されたコンテキスト(ucontext_t)を setcontext() に 渡した場合の動作はOSに依存する?らしい?のだが, FreeBSD の setcontext のマニュアルを見ると

     If ucp was initialized by the invocation of a signal handler, execution
     continues at the point the thread was interrupted by the signal.
という記述があって,シグナルによって割り込まれた位置から動作再開する, という記述があるのでまあこれでいいんではないかな? とする. OSによっては動作が変わってくるかもしれない.Linux ではなんか対処が必要かも.

次にシグナルハンドラだ.

 static void thread_intr(int signo, siginfo_t *info, ucontext_t *uap)
 {
   memcpy(¤t->context.uap, uap, sizeof(ucontext_t));
-
-  /*
-   * setjmp()/longjmp() はシグナルマスクを保存し復元するが,
-   * _setjmp()/_longjmp() はシグナルマスクを保存しない.
-   * (レジスタセットとスタックしか保存および復元しない)
-   */
-  if (setjmp(current->context.env) == 0) {
-    longjmp(intr_env, signo);
-  }
-
-  setcontext(¤t->context.uap);
+  current_signo = signo;
+  setcontext(&intr_env);
 }
従来は intr_env に longjmp() することでKOZOSカーネル用コンテキストに スイッチしていた.これは,これをやらないとユーザスレッドのコンテキストが そのまま使われてしまうのを極力避けたい(とはいっても,シグナル処理に 利用されてしまうので根本解決ではなく,あくまで「極力」避けることしかできない) ためなのだが,同様の理由で setcontext() によりカーネル用コンテキストに 切替える.

まあ上にも書いたように,シグナル処理(シグナル発生して thread_intr() が 呼ばれてから setcontext(intr_env) が行われるまでの間)には やはりそのスレッドのスタックが利用されてしまう. これはスレッドからすると,スタックの未使用領域が突然書き変わることになり, 場合によってはタイミング依存のバグの原因になり得る.たとえば

char *getstr()
{
  char str[] = "sample";
  return str;
}
のようなスタック上の配列を返すようなプログラムだと, まあこういうのはバグではあるのだが,実際にはスタック上にデータがしばらくは 残っていて,問題なく動いてしまったりする. しかしKOZOS利用時には,シグナル発生でスタックの未使用領域が突然(予期せずに) 書き変わることで,上記のようなプログラムが場合によっては動かなかったり, タイミング依存で動いたり動かなかったり, 1回だけダウンしたがその後ダウン現象が発生しなかったり, というよくわからんバグの原因になる.

まあ上のプログラムは明らかにバグなので,そんなバギーなプログラムを書くほうが 悪いといえばそこまでなのだけど,OSがユーザスレッドの資源を勝手に書き換えると いうのも(たとえそこが未使用であるとしても)ちょっとなんだかなあという気はする. これについてはまた解決策があるので,後で修正する.

次はKOZOSの起動時の処理だ.

 static void thread_start(kz_func func, char *name, int pri, int argc, char *argv[])
@@ -460,7 +433,17 @@
   memset(&sa, 0, sizeof(sa));
   sa.sa_sigaction = (void (*)(int, siginfo_t *, void *))thread_intr;
   sa.sa_flags |= SA_SIGINFO;
+#if 0
   sa.sa_mask = block;
+#else
+  /*
+   * シグナル処理中のシグナル発生を受け付ける.
+   * これを有効にするとKOZOSの処理中に再入する可能性が発生するため問題有り
+   * なのだが,スタブ中での read() ブロック中に SIGALRM が発生した場合の
+   * 暫定対処とする.
+   */
+  sigemptyset(&sa.sa_mask);
+#endif
 
   sigaction(SIGSYS , &sa, NULL);
   sigaction(SIGHUP , &sa, NULL);
@@ -477,13 +460,22 @@
   current = NULL;
   current = (kz_thread *)thread_run(func, name, pri, argc, argv);
 
-  longjmp(current->context.env, 1);
+  swapcontext(&intr_env, ¤t->context.uap);
+  {
+    /* なぜか getcontext() しなおさないと正常動作しない...謎 */
+    static int f;
+    do {
+      f = 0;
+      getcontext(&intr_env);
+    } while (f);
+    f = 1;
+  }
+
+  thread_intrvec(current_signo);
 }
thread_start()では,まあコメントにある通りなのだが,前回の問題として gdbでのブレーク後に時刻表示が止まってしまうというものがあり, SIGALRMが捨てられてしまうことが原因なのだが,暫定対処として sa.sa_mask を空にすることでシグナル処理中のシグナル発生を受け付ける. これはコメントにもある通り,KOZOSの内部処理中に再度シグナルが発生して 再入する可能性が発生するため問題ありありなのだが (メッセージキューの張りかえ中にSIGALRM発生してメッセージキューの操作が入ると, メッセージのリンク構造に矛盾が発生して場合によってはダウンするだろう), まああくまで暫定ということで.

さらに,swapcontext()によりコンテキスト切替えを行い,カレントスレッドの コンテキストで動作開始する.これにより,直前の thread_run() で設定した 最初のスレッドが起動する. swapcontext() により現在のコンテキストが intr_env に保存されるので, 次回の割り込み発生時にシグナルハンドラから setcontext(intr_env) されると, swapcontext() の直後に動作が移る(つまり,カーネル用コンテキストに切り替わる). で,本来ならばそのまま thread_intrvec() を呼び出すことで割り込みハンドラに 移ればいいはずなのだが,なぜか getcontext() して intr_env を設定しなおさないと 正常動作しないので,そのようにしている.

最後に,KOZOSの起動処理.

 void kz_start(kz_func func, char *name, int pri, int argc, char *argv[])
 {
-  int signo;
-
   /*
    * setjmp()/longjmp()はシグナルマスクを保存/復旧するので,
    * intr_env の setjmp() 前にシグナルマスクを設定することでシグナル処理中の
@@ -502,15 +494,7 @@
   sigaddset(&block, SIGILL);
   sigprocmask(SIG_BLOCK, &block, NULL);
 
-  /*
-   * setjmp()は最低位の関数から呼ぶ必要があるので,本体は thread_start() に
-   * 置いて setjmp() 呼び出し直後に本体を呼び出す.
-   * (setjmp()した関数から return 後に longjmp() を呼び出してはいけない)
-   */
-  if ((signo = setjmp(intr_env)) == 0) {
-    thread_start(func, name, pri, argc, argv);
-  }
-  thread_intrvec(signo);
+  thread_start(func, name, pri, argc, argv);
 
   /* ここには返ってこない */
   abort();
kz_start() はKOZOSを起動する際に一番最初に呼ばれる関数だが, 従来はここで setjmp() してカーネル用コンテキストを作成していた. これはコメントにもあるように,setjmp()/longjmp() はスタックが そのまま残っていることを期待しているので,setjmp() を呼び出した関数から return してはいけないためにトップの関数で最初に setjmp() を行っているのだが, コンテキスト作成は swapcontext() により行うように,thread_start() に 移動したため,ここではただ thread_start() を呼び出すだけになっている. まあこのへんはもうちょっと整理できそうな気がするが,とりあえずはこれでいいと しよう.

注意として,setcontext() によるコンテキストスイッチでは, シグナルのマスクやブロック,ハンドリングの設定も同時に切り替わる. つまり,シグナル処理の設定内容も,コンテキストのひとつとして管理される (これは,至極もっともなことだ).このためカーネル用コンテキストの作成前に シグナルをブロックするように設定しておき(このためカーネル用コンテキストに 切り替わると,シグナルは自動的にブロックされる), スレッド作成後に thread_init() で再度シグナルをアンブロックにする, という構造になっている. このへんの設定位置を間違えると,シグナルを受けられなかったり本来受けては いけないシグナルを受けてしまったりして,正常に動作しない原因になる (そして,きちんと理解していないとデバッグできない)ので,注意してほしい.

では,実際に動かしてみよう. まず koz を起動し,gdbで接続し,telnet で接続するまでは前回と同じ.

Sun Nov 11 22:15:19 2007
Sun Nov 11 22:15:20 2007
Sun Nov 11 22:15:21 2007
Sun Nov 11 22:15:22 2007
Sun Nov 11 22:15:23 2007
...
時刻表示が正常に開始される.

telnet から down コマンドを発行して segmentation fault を発生させてみる.

% telnet 192.168.0.3 20001
Trying 192.168.0.3...
Connected to 192.168.0.3.
Escape character is '^]'.
> down
(この状態で停止)

nullp によるNULLポインタ参照でブレークしている.

nullp を(ていうか正確にはEAXレジスタを.これに関しては前回参照) telnetd_dummy に設定し直して continue する.

[$m8048088,1#3e](+)($55#6a)[+]
[$M8048088,1:cc#1e](+)($OK#9a)[+]
[$C0b#d5](+)($#00)[+]
[$c#63](+)Sun Nov 11 22:16:24 2007
Sun Nov 11 22:16:25 2007
Sun Nov 11 22:16:26 2007
Sun Nov 11 22:16:27 2007
Sun Nov 11 22:16:28 2007
Sun Nov 11 22:16:29 2007
...
時刻表示が復活している.SIGALRMが正常に受信再開できているということだ.

次に,break による強制ブレークと,ステップ実行を試してみよう.

telnet から break コマンドを実行すると,kz_break() が呼ばれ, 強制ブレークされる. これは実際には kill() により自分自身に SIGTRAP を発行している. 実際にやってみよう.

> break
(この状態で停止)

ブレークした.が,ソースコード表示(emacs の下半分ね)は以前のままだ. おそらくだが,SIGTRAPはkill()によって発行しているが, kill()はシステムコールでありアセンブラで記述されている. さらにシステムコールに関しては -g をつけてデバッグされているわけではないので, gdbがソースコードの当該の箇所を発見できず,ソースコードを表示できない (ので,以前のママ)でいるのだろう.

gdbからupを実行することで,スタックトレースを追ってひとつ上の関数呼び出しに 戻る.

kz_break()によりkill()した場所が表示された.

もう一度,upを実行してみよう.

break コマンドを受け付けて,kz_break()を呼び出している箇所が表示されている. うんうん,スタックはうまく追えている.

ここで gdb から next を実行して,ステップ実行してみよう.

さっきはkill()の位置に矢印があったのだが,kill()の次の行に進んでいることに 注目. kill()呼び出しを過ぎて,kz_break() から return しようとしているようだ.

さらに next を実行.

おー,kz_break() の呼び出しを過ぎて,次の処理に移っている.

さらに next を実行.

ふむふむ.さらに次の処理に移っているね.

最後に continue して,動作を再開してみる.

gdb は無応答になるので,うまく continue できたようだ. telnet は

> break
OK
> 
となっていて,telnet からの break コマンド実行が OK で正常終了し, プロンプトが表示されている.うまく動作再開できているようだ.
[$m8048088,1#3e](+)($55#6a)[+]
[$M8048088,1:cc#1e](+)($OK#9a)[+]
[$c#63](+)Sun Nov 11 22:19:40 2007
Sun Nov 11 22:19:41 2007
Sun Nov 11 22:19:42 2007
Sun Nov 11 22:19:43 2007
Sun Nov 11 22:19:44 2007
Sun Nov 11 22:19:45 2007
...
時刻表示も復活している.

今回は setjmp()/longjmp() を利用したコンテキストスイッチを getcontext()/setcontext() に書き換えた. またシグナルは signal() ではなく sigaction() 利用に,前回書き換えている. これらにより細かい動作のチューニングが可能になり,さらに(POSIXなので) 移植性も上がるはずだ.

さらに今回は,next によるステップ実行が確認できた. これでそこそこデバッガっぽくなってきた. まあ課題はまだまだあるのだが,けっこうそれっぽい動作なのではなかろうか. ちょっと感動だね.

次回は,KOZOSの内部をさらに改良してみたい.


メールは kozos(アットマーク)kozos.jp まで