(第6回)再入について考える

2007/10/22

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

突然だけど,Toppersの最新カーネルをちょっと興味本位で読んでみた. そしたらLinux用シミュレータの実装で,setjmp()/longjmp() を利用して スレッドのディスパッチをしていてびっくり. 使い方もほとんど同じ.うーん,誰でも考えることは同じなんだなあ... (けっこう良いアイディアだと思っていたのだが, 誰でも考えつくようなものってことか...)

さて,ここまではあまり難しいことは考えずにてきとーーーに作ってきていて, なんだこんなの楽勝じゃんっていうか, なにも考えなくてもよさそうに思えてしまうのだけど, 実際には排他とか再入とかいったことを考えなければならない.

たとえば 前々回の実装では, 複数のスレッドから何も考えずに fprintf() を呼び出していたが, これは問題は無いのか?

また前々々回の実装では, kz_send() によるメッセージ送信でも, スタック上に確保している文字列を平気で送ってしまっていた. これは問題は無いのか?

結論から言うとどちらも問題がある.というより,問題がある可能性がある. まあでも「問題がある可能性がある」というその時点で職業プログラマなら 「ほんとうに問題が無いのか?」を調査しなければならなくなる. つまり,扱いはバグと同じになってしまう.

たとえば fprintf() だが,複数のスレッドから呼ばれてしまっている. ということは,fprintf() の呼び出し中に別のスレッドがタイマ割り込みとかで ディスパッチして fprintf() が再度呼ばれる,ということがありうるわけだ. この際に,もしも fprintf() 関数の内部で

int fprintf(FILE *fp, const char *fmt, ...)
{
  static char buf[1024];
  ...
のようにして,文字列の処理用のバッファを静的に(static に)確保していたら どうなるだろうか? こういうのは,スタックサイズを節約したい組み込み機器では十分にあり得ることだ.

あるスレッドが fprintf() による処理中にタイマ割り込みが入り,別のスレッドが ディスパッチされ,そちらのスレッドでも fprintf() が行われてしまう. この際に上記バッファの内容は上書きされてしまうため,最初に動いていたスレッドが 再度ディスパッチされたときには,バッファの内容が書き変わってしまっている. 結果として,最初に動いていたスレッドからすれば,とつぜんバッファの内容が 壊れてしまって見えることになる.これはまんまバグに直結するが, 症状が症状なので,非常に原因を特定しにくいバグの原因になる.

このように,ある関数の処理中に再度その関数が(別スレッドなどから)呼ばれてしまう ことは関数の再入と呼ばれる.関数の内部で静的変数の書き換えをしていると, 上で説明したように,関数が再入された際に誤動作することになる.

プロセスモデルだと,仮想メモリ機構によってプロセス単位で資源が確保されるので このような問題は起きない(注:シグナル処理を除く.このためシグナルハンドラから ライブラリ関数を無闇に呼んではいけない)のだが, スレッドプログラミングではスレッドのディスパッチが頻繁に起きるのと, 資源は共通化されるので,このような再入はあたりまえのように起きることになる. で,いくつか対策はあるのだが,まず代表的な対策として

使われるのがローカル変数だけならば,ローカル変数はスレッドごとにスタック上に 確保されるので,再入されたときの資源の衝突の問題は無くなる. また静的変数も,書き込みを行わずに読むだけなら問題は無い. このようにローカル変数のみ利用することで再入を可能にした関数を, 「再入可能」もしくは「リエントラント」と呼ぶ.

関数を再入可能にするならば,その関数だけでなく,その関数の内部から呼ばれる ライブラリ関数も芋ヅル式に再入可能になっていないと意味が無い.よって 再入可能にするためには,関数単位でなくライブラリ単位での対処が必要になる. とはいっても最近のライブラリはスレッドでの利用を考慮して, ライブラリ全体として再入可能になっているものも多い. こーいうのは「このライブラリはスレッドセーフになっているため, スレッドプログラミングで利用することも可能である」 などと説明されることもある.

もうひとつの方法として,

というものもある.まあ方法としてはローカル変数使うのに似ているが, 実装的にはまた独特な感じになるし場合によっては大改造が必要になるので, これはこれで別の解決策ととらえてもいいだろう. たとえば2つのスレッドから fprintf() が呼ばれていて, SIGALRM のような非同期シグナルが利用されていないならば, fprintf()の処理中にスレッドのディスパッチが発生することは無いはずだ. このようにスレッド構成の設計によってそもそも再入が起こらないようにする, という回避策もある.ただこれは,わりと慎重に設計しないといけないし, 機能追加とかでスレッドやメッセージが追加されたり, 優先度が変更されたりすると問題が発生するようになってしまう (別のいいかたをすると,スレッドやメッセージの追加,優先度の変更のたびに, 再入されることが無いか,設計を見直さなければならなくなる)という面倒もある. なので,あまり複雑なスレッド構成になっている場所では,もっときちんとした 安全な対策をとったほうがいいだろう. たとえば fprintf() 呼び出し時にはスレッドの優先度を一時的に最高にして 他のスレッドが割り込めないようにするという方法もある. こーいうのを「スレッド間で排他を取る」という. また,そもそもその間だけ一時的に割り込み禁止にしてしまうという方法もある. fprintf()全体で優先度を上げてしまうのが嫌(割り込みに対する反応が鈍くなるし, リアルタイム性に問題が出てくるので)ならば,共通の資源にアクセスするときのみ 優先度を上げるとかしてもよい.もしくはセマフォを使って排他してもいいだろう.

で,ここまではまあ対策の方法で思いつくものを書いてみたのだけど...

まず FreeBSD の fprintf() がスレッドセーフかどうかという点なのだけど, ちょっとよくわからない.こーいうのは実装によって異なるので, FreeBSD と Linux ではとーぜん違うことが考えられるし, まあ調べればわかることなのだけど,調べてないしよく知らん. またスレッドセーフでないとしても, 現状の実装では,上で説明したような優先度などの関係で, 構造的に再入が起きない,ということもあり得る. このへんはほんとはちゃんと検証しなければならないのだけど, 面倒なのでここでは検証していない. そーいう意味で,冒頭では 「問題がある」ではなく「問題がある*可能性がある*」という表現をしている.

でも「問題がある可能性がある」ということは,言い方を変えれば, 「問題が無いことを調べてはっきりさせるか,別の実装方法に変更する必要がある」 ということだ.

結局のところこれらの問題は,スレッド間で共通の資源をどうするかということ なのだけど,実は共通資源の扱いに関しては,一番良いというか, まあだいたいこーしとけば間違いは無いというまともな対策がある.それは,

というものである.もうひとつの方法として, というものがある.

まず前者について説明しよう.fprintf() による表示をすべてのスレッドで 許可するのではなく,fprintf() を行う専用のスレッドを作成し, 各スレッドはメッセージの出力が行いたいならば,自分で fprintf() を呼び出すのでは なく,その専用スレッドにメッセージを投げて,出力を「お願いする」というものだ.

次に後者だが,これは簡単だ.そのサービスを行うためのシステムコールを作成し, OSに行わせる,というものだ.ただしこれをやるとOSがサービス過剰で肥大化しがちに なるので,注意と取捨選択が必要だ.

前者では共通資源の利用は専用スレッドにお願いしたが, 後者ではOSにお願いすることになる. いずれにしても,共通資源をアクセスするコンテキストはひとつに絞る, というのがミソになる.そもそも資源がひとつならば,それをアクセスするひとも ひとつであるべき(そしてそのひとにお願いするべき)という考え方だ.

ではここで,上記の前者,後者の設計で, 前回の fprintf() による文字列表示プログラムを書き換えてみよう. まず,以下のような設計にする.

メモリの獲得は malloc()/free() でも行えるのだが,これらにもやはり再入の 問題があり得る.文字列表示を outlog スレッドに任せるのと同様に, malloc()/free() を行う専用のスレッドを立ち上げる,という方法もあるのだが, 実は malloc()/free() は KOZOS の内部でも行っているので,専用スレッドと KOZOSの間で再入が起きる可能性が構造上あるため,ボツ仕様にした.

ここで,outlogスレッドになげるメッセージを kz_memalloc() によって獲得している (そしてoutlogスレッド側で kz_memfree() により解放している) という点に注目してほしい.冒頭で言及したように, 前々々回の実装では, スタック上に確保している文字列を送っていた. しかしこれは実は問題があって, たとえば文字列表示スレッドにデータとしてスタック上の文字列を送った場合, もしも文字列表示スレッドのほうが優先度が低くて,さらに送信もとのほうが 関数から抜けてしまったりすると,スタックが解放されてしまい, 実際の文字列表示の際にスタック上の文字列が残っているかどうか保証できない. ていうか,たまになんかおかしな文字列が表示されるとかいった, これはこれで原因が特定しにくいバグの原因になり得る. こーいうのはスレッドの優先度構成が変わったり, スレッド間のメッセージのやりとりの手順が変わったり, 関数の呼び出し構造を変えたり関数呼び出しを新たに追加したりすることで 突然発生したりするのでやっかいだ (なのでやはりこれも,冒頭では「問題がある」ではなく 「問題がある可能性がある」という表現をしている). なので outlog スレッドに送る文字列データは静的なものにするか, kz_memalloc()によって確保したものにする(そして kz_memfree() 側で解放する)かの どちらかになる.しかし文字列データを静的なものにするとして, outlog スレッドを利用する立場(ユーザー側の立場)からすると,

char *p = "message"; /* これは静的領域なのでOK */
kz_send(outlog_id, strlen(p), p);
のような使い方はOKなのだけど,
char p[] = "message"; /* これはスタック上なのでNG */
kz_send(outlog_id, strlen(p), p);
のような使い方はNG(かもしれない)ということになる.正確には, 後者の書き方で優先度がoutlogよりも高くて関数から抜けるとスタック解放されるので アウト,ということになる(スタック上書きされなくても,シグナル処理で非同期に スタックが汚れる可能性がある).

で,前者のような書き方はいいのだけれど,それを見て,何も考えずに 後者のような書き方をするひとが必ず出てくる. 複数人での大規模開発だとこーいうミスはすごくあり得る.

まあそいつの無知といってしまうとそこまでだし, 心配ならば仕様書に注意書きしておけばいいといえばそこまでなのだけど, みなさんはこーいうミスについて,どう考えるでしょうか?

こーいうミスに対して「そんなの,スレッド構成もちゃんと理解せずに, スタックを使ってしまう無知な輩が悪い」 「仕様書に書いてないのが悪い」「仕様書をちゃんと読まないのが悪い」 というのは簡単だし, 実際そーいうことを平気で言ったりする人もいたりするのだけど, ぼくのコーディング時のテーマは「いかに安全に書くか?(自分も,他人も)」 ということであり, (無知の人が無知のままでもいいとは思わないが)たとえ無知だとしても, それによりバグが出ることが予想されるような設計をわざわざすることもない. というか,そーいう設計は積極的に避けるべきだ. 近頃はどこでも開発サイクルはどんどん短縮化されているし,人の出入りも激しい. しかしソースコードの規模はどんどん肥大化している (数ヵ月で数10万行なんて,ザラだと思う). そのような開発環境で,新しく参加したメンバーでも極力バグを出さなくてすむ, 注意しなければならない事柄を減らす,調べなければならない範囲を狭めるような 考慮というのは,非常に大切だ.

まあこのへんは個人的な考えになってしまうが,ここでちょっとぼくのバグに対する 考え方をだらだらと書いてみるけど,たとえば自動車の運転がうまいというのは, ハンドリングがうまいとか交差点でのコーナリングがうまいとか駐車車両よけるのが うまいとか加速がうまいとか単にクルマの操作がうまいとかそーいうことよりも, 周りの状況がきちんと見れて交通の流れを適切に先読みできる, あの車や歩行者はどうしたいんだろうとか,人の考えていることを読める (良い意味で読める,ということ.勝手に憶測して決めつけるということではない), 危険を予測できる(駐車車両よけるのが下手ならば,そもそも駐車車両があるだろう ことを予測して,そこには行かないとかあらかじめ距離を取るとかができる), 前や隣を走っているのがあまり運転のうまくない人でも,事故が起こらないように うまく位置取りができる(もしくはそういう位置取りをさせてあげられる)ように 流れを作れる,とかいったことだと思うのだな. あ,もちろんレースとかでなく,公道で,の話ね. だって公道では,前や隣を走っているのがみんな運転うまい人だとは限らない わけだし,ひょっとしたら初心者かもしれないわけじゃないですか. それでもしも事故ってしまって,万が一大怪我なんてしてしまったとしたら, もうどっちが悪いとか悪くないとか,運転がうまいとか下手だとかいった以前の 問題になってしまうわけです. で,プログラミングも共同作業であるわけで,同じことだと思うのですな.

スポーツでも,ファインプレーがあるうちはまだ2流で, 1流はそもそもファインプレーなんぞしなくていいように流れを作る, というじゃないですか. 個人的には,そういう考え方と言うか,周りを見た(他人に優しい)書き方が できないうちは上級プログラマにはなれん!と思う. (言語を扱えるようになっただけでその言語をマスターしたと思ってしまううちは, まだまだ上級ではないというか,先は長いと思う)

ということで,ここでは文字列表示を依頼する側で kz_memalloc() により メモリ獲得し,outlog スレッドで kz_memfree() によるメモリ解放する, という設計にする.文字列表示のたびにいちいち kz_memalloc() して strcpy() するのが面倒だというならば,そのためのライブラリ関数を一個作ればいいだけだ.

で,結局やることは outlog スレッドの追加と kz_memalloc()/kz_memfree() の 追加ということになる.修正後のソースは以下.今回は outlog.c が追加されている.

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

以下は前回からの差分.

すでにいっぱい説明してしまったので, ソースについてあまり説明する部分は無い. kz_memalloc()/kz_memfree()に関しては,実際には malloc()/free() を行うだけの システムコールを追加しただけだ.

outlogスレッドのメイン処理は outlog.c にあり,以下のようになっている.

int outlog_main(int argc, char *argv[])
{
  char *p;
  while (1) {
    kz_recv(NULL, &p);
    fprintf(stderr, "%s", p);
    kz_memfree(p);
  }
}
kz_recv()でメッセージの受信待ちをして, fprintf()で表示して,kz_memfree()で解放するだけだ.

サンプルプログラムによって動作確認する.

main.c は前回のものを以下のように改造している.
int mainfunc1(int argc, char *argv[])
{
  char *p, *mes = "func1\n";

  while (1) {
    p = kz_memalloc(strlen(mes) + 1);
    strcpy(p, mes);
    kz_send(outlog_id, 0, p);

    kz_timer(100);
    kz_recv(NULL, NULL);
  }

  return 0;
}
kz_memalloc()によりメモリ獲得し,strcpy()でデータをコピーし, kz_send()により outlog スレッドに送信する.メモリの解放は outlog スレッド側で 行われるので,ここで行う必要は無い.

なお outlog スレッドはログの表示用に今後も利用する. このように一般的に利用されるようなサービスに関してはスレッド化して, スレッドの機能として提供する,というのがKOZOSの方針でもある.

以下は実行結果.

% ./koz 
func1
func2
func3
func1
func2
func3
func1
func2
func1
func3
func1
func2
func3
func1
func2
func1
func3
^C
% 
前回と同様に,ちゃんと表示が行われている.
メールは kozos(アットマーク)kozos.jp まで