(注意)このブログは本家のほうの文章部分のみの転載です.ソースコードの配布,画像などについては本家のほうを参照してください.文章中のリンク先は面倒なのですべて本家のほうに変換してしまっているのでご注意ください.

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

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

結局のところこれらの問題は,スレッド間で共通の資源をどうするかということなのだけど,実は共通資源の扱いに関しては,一番良いというか,まあだいたいこーしとけば間違いは無いというまともな対策がある.それは,
  • 共通資源をアクセスするための専用スレッドを作成し,その資源へのアクセスはそのスレッドに限らせる.
というものである.もうひとつの方法として,
  • 共通資源の管理はすべてOSにまかせる.
というものがある.

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

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

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

ではここで,上記の前者,後者の設計で,前回の fprintf() による文字列表示プログラムを書き換えてみよう.まず,以下のような設計にする.
  • 文字列の表示用に outlog というスレッドを起動し,文字列を出力したい場合にはoutlog スレッドに kz_send() によってメッセージを送って「お願い」する.
  • メモリ獲得・解放用に kz_memalloc()/kz_memfree() を追加する.上記メッセージの送信時には,kz_memalloc()によってメモリを獲得する.
メモリの獲得は 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 が追加されている.以下は前回からの差分.すでにいっぱい説明してしまったので,ソースについてあまり説明する部分は無い.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のタイマ処理にはバグがあって,タイマがすでに動作中の状態で kz_timer() を呼び出すと,ualarm()しなおすために現在動作中のタイマも再起動されてしまう.

これを修正するためには,前回のタイマ起動時からの経過時間を測定してその差分を考慮する必要があるのだが,まああまり難しい修正ではないのでちょっと直してみた.以下は前回からの差分.まず,kz_timer() の処理用関数である thread_timer() に以下の修正が入っている.

+static struct timeval alarm_tm;
+
static int thread_timer(int msec)
{
kz_timebuf **tmpp;
kz_timebuf *tmp;
+ struct timeval tm;
+ int diffmsec;

tmp = malloc(sizeof(*tmp));
tmp->next = NULL;
tmp->thp = current;

+ gettimeofday(&tm, NULL);
+ if (timers) {
+ diffmsec = (tm.tv_sec - alarm_tm.tv_sec) * 1000 +
+ (tm.tv_usec - alarm_tm.tv_usec) / 1000;
+ if (timers->msec > diffmsec)
+ timers->msec -= diffmsec;
+ else
+ timers->msec = 0;
+ }
+
for (tmpp = &timers; *tmpp; tmpp = &((*tmpp)->next)) {
if (msec < (*tmpp)->msec) {
(*tmpp)->msec -= msec;
break;
}
msec -= (*tmpp)->msec;
}

if (msec == 0) msec++;
tmp->msec = msec;
tmp->next = *tmpp;
*tmpp = tmp;

- ualarm(timers->msec * 1000, 0);
+ alarm_tm = tm;
+ if (tmpp == &timers)
+ ualarm(timers->msec * 1000, 0);

putcurrent();
return 0;
}

gettimeofday()により現在時刻を取得し,前回 ualarm() が行われた時刻(現在カウント中のタイマが設定された時刻)からの差分を計算し,現在カウント中のタイマ資源の時刻を修正するというものだ.従来は例えば100msのタイマが70ms経過したところで150msのタイマを設定すると,
  • 現在のタイマがまた ualarm() により再設定されるため,ゼロからカウントされなおす.このため最終的には 100ms でなく 170ms の時間でタイマ満了されることになる.
  • 150msのタイマ設定時に現在起動しているタイマの時間を減算するが,150msから100msをそのまま減算しているため,100msのタイマ満了時(実際には上の理由により170ms後)のさらに50ms後にタイマ満了する.実際には100msのタイマが70ms経過した時点(残り30ms)で150msのタイマをかけているので,100msタイマ満了後の120ms後にタイマ満了するのが正しい.
という動作になっていた.

修正後の処理では前回設定時刻からの経過時間が差分として計算に入れられるため,上のような問題は無い.さらにタイマが先頭に挿入されたときのみ,ualarm()によるタイマ設定を行うようになっている(タイマが動作中でタイマキューの後ろのほうにタイマが挿入されるならば,現在動作中のタイマを上書きする意味は無いので).

SIGALRM発生時の処理は以下のようになる.

void alarm_handler()
{
kz_timebuf *tmp;

sendmsg(timers->thp, 0, 0, NULL);
tmp = timers;
timers = timers->next;
free(tmp);
if (timers) {
+ gettimeofday(&alarm_tm, NULL);
ualarm(timers->msec * 1000, 0);
}
}

ualarm()によるタイマ設定時には,後々の経過時間測定用に,その時点の時刻を保存しておくようにしてある.

サンプルプログラムによって動作確認する.前回のサンプルプログラムに対して,スレッドを3つにして,100ms,150ms,151msの間隔でタイマをかけて文字列表示を行うようになっている.

実行結果は以下のようになる.

% ./koz
func1
func2
func3
func1
func2
func3
func1
func2
func1
func3
func1
func2
func3
func1
^C
%

まあそれっぽく表示されているようだ.
(注意)このブログは本家のほうの文章部分のみの転載です.ソースコードの配布,画像などについては本家のほうを参照してください.文章中のリンク先は面倒なのですべて本家のほうに変換してしまっているのでご注意ください.

前回は主にスレッド間でのメッセージ通信を実装した.今回は,タイマを実装してみよう.

タイマは以下のように実装してみる.
  • kz_timer()でタイマを設定する.(ミリ秒で指定)
  • タイマ満了時にはKOZOSからスレッドに対してメッセージが投げられる.
つまり,一定時間待ちたいような場合には,kz_timer()で待ち時間を指定して,直後に kz_recv() でメッセージ待ちに入ればよい.kz_timer() だけではタイマをかけるだけでメッセージ待ちは行わないので,スレッドはそのまま走り続けてしまうことになる.kz_timer() は即時終了する,ということだ.

実はタイマの実装方法についてはちょっといろいろ考えたところがあって,KOZOSにシグナルのような機構を導入しタイマ満了時にはシグナルが送信されるようにする(kz_recv()はシグナルによって終了する)とか,kz_timer()が即終了するのでなくタイマ満了時に kz_timer() が終了する(つまり,kz_timer()はタイマ設定と同時にメッセージ待ちに入る)とかいった実装も考えたのだけど,まあメッセージの送受信をすでに実装しているので,その作りを流用するのが楽でいいかな,と.

で,作成したのが以下.以下は前回からの差分.システムコールの追加方法に関しては前回と同様なのでとくに説明しない.タイマの実装は thread.c にまとまっているので,そこだけ説明しよう.

タイマは複数設定されることがあり得るので,タイマ構造体によってリンクリスト管理する.このためには thread.c の先頭付近で

diff -ruN -U 10 kozos03/thread.c kozos04/thread.c
--- kozos03/thread.c Sun Oct 21 22:32:45 2007
+++ kozos04/thread.c Sun Oct 21 22:32:45 2007
@@ -5,23 +5,30 @@
#include
#include

#include "kozos.h"
#include "syscall.h"
#include "thread.h"

#define SIG_NUM 32
#define STACK_SIZE 0x8000

+typedef struct _kz_timebuf {
+ struct _kz_timebuf *next;
+ int msec;
+ kz_thread *thp;
+} kz_timebuf;
+
kz_thread threads[THREAD_NUM];
kz_thread *readyque[PRI_NUM];
static jmp_buf intr_env;
+static kz_timebuf *timers;

kz_thread *current;

static void getcurrent()
{
readyque[current->pri] = current->next;
current->next = NULL;
}

static int putcurrent()

のようにして構造体 kz_timebuf と,そのリンクリスト timers を定義してある.構造体 kz_timebuf には,タイマの起動時間(ミリ秒)とタイマをかけたスレッドのID(タイマ満了時にメッセージを送信する先)を保持している.

kz_timer()によるタイマ設定時には,thread_timer() が呼ばれる.タイマでちょっと難しいのは,たとえば
  • 100ms,250msという順に(時間差無しで連続で)タイマが設定された場合には,100ms後,さらに150ms後にタイマが起動する.(つまり100ms後に1番目のタイマ満了した後,2番目のタイマを設定する際には,250ms - 100ms = 150ms 後にタイマが起動するように設定する必要がある)
  • 200ms,300ms,250msという順に(時間差無しで連続で)タイマが設定された場合には,300msのタイマより先に250msのタイマをキューに挿入する必要がある.(キューのソートを行わなければならない)
  • 200ms,100msという順に(時間差無しで連続で)タイマが設定された場合,現在動作している200msのタイマをいったん削除し,100msのタイマを起動し直さなければならない.(タイマ設定を上書きする必要がある)
という点だ.たいていハードウエアはいくつかのタイマ資源(起動時間を設定しておくと,その時間が経過したときに割り込みが入る)を持っていたりするものだが,タイマをひとつしか使わないとすると,このように時間の減算やソート,タイマ設定の上書きなどが必要になってくる.そういう心づもりで,以下の thread_timer() を見てほしい.

static int thread_timer(int msec)
{
kz_timebuf **tmpp;
kz_timebuf *tmp;

tmp = malloc(sizeof(*tmp));
tmp->next = NULL;
tmp->thp = current;

for (tmpp = &timers; *tmpp; tmpp = &((*tmpp)->next)) {
if (msec < (*tmpp)->msec) {
(*tmpp)->msec -= msec;
break;
}
msec -= (*tmpp)->msec;
}

if (msec == 0) msec++;
tmp->msec = msec;
tmp->next = *tmpp;
*tmpp = tmp;

ualarm(timers->msec * 1000, 0);

putcurrent();
return 0;
}

タイマ設定時には,現在のタイマキューを検索し,常にソートされた状態でキューに挿入される.

さらに kz_timer() に引数に渡されるのは「現在から何ミリ秒後にタイマが起動するか?」という値だが,キューへの挿入時には「前回のタイマの満了時から何ミリ秒後にタイマ起動するか?」という値にして保存する(キューの next ポインタをたどるたびに,タイマ時間で msec を減算している).

キューへの接続が終ったら,タイマキューの先頭にあるタイマ情報を見て ualarm() を起動する.これにより,一番最初に満了すべきタイマ値で ualarm() が行われ,そのタイマ時間が経過したときに SIGALRM が発生することになる.タイマが既にかかっている状態で新しい(もっと短い時間の)タイマが設定された場合には,新しいタイマ値でタイマの設定を上書きする必要があるが,ここで無条件で ualarm() による設定を行っているので,必要ならばタイマの上書きが行われることになる.

※ あー,っていうか今気がついたのだけど,これってタイマが既に起動している状態で kz_timer() が呼ばれると,ualarm() の再設定が行われてタイマ時間が戻ってしまうってことだよね...(例えば100msのタイマを仕掛けて50msたった状態で200msのタイマを設定すると,100msタイマが再設定されてまたゼロからカウントされることになる.よって100msタイマなのに実際にタイマ起動するのは150ms後,ということになる)これを防止するには上書きを止めるだけではダメで,thread_timer()呼び出し時にはgettimeofday() とかで現在時刻を取得して,前回のタイマ起動時間からの差分を考慮しないといけないということだ...うーん面倒ではないけど,もうここまで文章書いちゃったので,まあそのうち対処しよう.

ということで,タイマ満了時には SIGALRM が発行される.これをハンドリングするために,KOZOSの起動部分で SIGSYS と同様にハンドラを登録しておく.

@@ -301,21 +355,24 @@
static void thread_start(kz_func func, char *name, int pri, int argc, char *argv[])
{
memset(threads, 0, sizeof(threads));
memset(readyque, 0, sizeof(readyque));

+ timers = NULL;
+
signal(SIGSYS, thread_intr);
+ signal(SIGALRM, thread_intr);

/*
* current 未定のためにシステムコール発行はできないので,
* 直接関数を呼び出してスレッド作成する.
*/
current = NULL;
current = (kz_thread *)thread_run(func, name, pri, argc, argv);

longjmp(current->context.env, 1);
}

さらに SIGALRM 発生時には,タイマ処理のために alarm_handler() が呼ばれるように割り込みベクタの疑似処理である thread_intrvec() に SIGALRM を追加しておく.

static void thread_intrvec(int signo)
{
switch (signo) {
case SIGSYS: /* システムコール */
syscall_proc();
break;
+ case SIGALRM: /* タイマ割込み発生 */
+ alarm_handler();
+ break;
case SIGBUS: /* ダウン要因発生 */
case SIGSEGV:
case SIGTRAP:
case SIGILL:
{
fprintf(stderr, "error %s\n", current->name);
/* ダウン要因発生により継続不可能なので,スリープ状態にする*/
getcurrent();
}
break;

最後にタイマ満了時のメッセージ送信処理だ.タイマ満了時には,タイマをかけたスレッドに対してメッセージが投げられる.これは alarm_handler() で行われる.

void alarm_handler()
{
kz_timebuf *tmp;

sendmsg(timers->thp, 0, 0, NULL);
tmp = timers;
timers = timers->next;
free(tmp);
if (timers) {
ualarm(timers->msec * 1000, 0);
}
}

sendmsg()によりメッセージ送信(当該スレッドのメッセージキューにメッセージが積まれ,スレッドが kz_recv() による受信待ちならば wakeup 処理が行われる)し,満了したタイマの資源を解放する.さらに次のタイマ設定が存在するならば,ualarm() によってタイマを再設定する.sendmsg() への引数はスレッドへのポインタの後に 0,0,NULL と続いているので,メッセージを受信するスレッドは,送信元スレッドのIDはゼロ,サイズはゼロ,データへのポインタはNULLというメッセージを受信することになる.KOZOSではメッセージ主体の制御になるのだが(今回のタイマにしても,満了時にはシグナルが発生するのでなく,OSからメッセージが投げられてくる),OSから投げられるメッセージは送信元スレッドIDをゼロという設計になっている.

では,サンプルプログラムによって動作確認してみよう.サンプルプログラムでは,2つのスレッドが

while (1) {
kz_timer(100);
fprintf(stderr, "func1\n");
kz_recv(NULL, NULL);
}

のようにしてタイマをかけてから kz_recv() でタイマ満了を待つ,という処理になっている.kz_timer() によるタイマの設定値は,100msと150msだ.kz_recv() では引数に NULL,NULL を渡しているが,これはタイマ満了時にKOZOSから送信される空メッセージの受信待ちのため,送信元スレッドIDとデータへのポインタを返してもらう必要が無いからだ.

実行結果は以下のようになる.

% ./koz
func1
func2
func1
func2
func1
func2
func1
func1
func2
func1
func2
func1
^C
%

2つのスレッドが交互にメッセージを受信している.func1 のほうが連続して受信している場合があるが,これはタイマ設定に100ms,150msという歳があるためだ.だったらメッセージの出力数が 3:2 の割合になってもいい気がするが,前述したように kz_timer() によるタイマ設定が行われると,現在起動中のタイマまでリスタートしてしまうという問題があるため,比率がずれてしまっているのだろう.
(注意)このブログは本家のほうの文章部分のみの転載です.ソースコードの配布,画像などについては本家のほうを参照してください.文章中のリンク先は面倒なのですべて本家のほうに変換してしまっているのでご注意ください.

前回まででKOZOSのスレッドディスパッチ機能がだいたい動いたのだけど,システムコールでスレッドの起動と終了,スリープとwakeupしかないのでさすがに寂しすぎる.ていうかこれではぜんぜん使いものにならん.

スレッドどうしが同期して動作できるように,とりあえずスレッド間通信の機能をもたせたい.というわけで今回は,スレッド間通信としてメッセージ通信を実装してみよう.ついでにスレッドIDの取得と優先度変更も実装してみる.

というわけで今回実装するシステムコールは以下の4つ.
  • kz_getid() ... スレッドIDの取得
  • kz_chpri() ... スレッド優先度の変更
  • kz_send() ... 別スレッドにメッセージを送信する
  • kz_recv() ... 別スレッドからのメッセージを受信する
まずKOZOSでのシステムコール追加なのだけど,システムコールを追加するには以下の手順が必要になる.
  • 外部公開用のサービス関数を kozos.h に追加
  • syscall.c にサービス関数の本体を追加
  • syscall.h の kz_syscall_type_t の定義に新システムコールを追加
  • syscall.h の kz_syscall_param_t の定義に新システムコール用のパラメータを追加
  • thread.c にシステムコールの処理を追加.
  • thread.c の syscall_proc() にシステムコール処理の呼び出しを追加.
で今回の4つのシステムコールなのだけど,kz_getid()とkz_chpri()は単体で動作するシステムコールなので,実はそれほど難しくはない.問題は,kz_send() と kz_recv() だ.
  • kz_send()
    • 送信先のスレッドが kz_recv() で受信待ち状態ならば,受信処理を行って wakeup する必要がある.
    • そうでないなら送信先スレッドのメッセージキューにメッセージを繋げる.
  • kz_recv()
    • メッセージキューにメッセージがすでに存在するならば,受信処理を行う.
    • そうでないならスリープしてメッセージ受信待ちに入る.
というように,送受信の相手の状態に応じて処理内容が変わってくる.

実装したソースは以下のようになる.ちなみに以下は前回からの修正の差分.上で書いた,システムコールの新規追加時の必要作業と見比べてほしい.では,システムコールの追加について,上記の差分に対して修正内容を説明していこう.

まず,kozos.h に外部公開用のサービス関数を追加している.

diff -ruN -U 10 kozos01/kozos.h kozos03/kozos.h
--- kozos01/kozos.h Sun Oct 21 20:07:33 2007
+++ kozos03/kozos.h Sun Oct 21 20:07:33 2007
@@ -4,15 +4,19 @@
#include "configure.h"

typedef int (*kz_func)(int argc, char *argv[]);

/* syscall */
int kz_run(kz_func func, char *name, int pri, int argc, char *argv[]);
void kz_exit();
int kz_wait();
int kz_sleep();
int kz_wakeup(int id);
+int kz_getid();
+int kz_chpri(int pri);
+int kz_send(int id, int size, char *p);
+int kz_recv(int *idp, char **pp);

/* library */
void kz_start(kz_func func, char *name, int pri, int argc, char *argv[]);

#endif



kz_getid() は引数無しで自分自身のスレッドIDが返る.kz_chpri() は優先度を引数に取り,スレッドの優先度を切替える.

kz_send()は指定したスレッドIDのスレッドに,サイズとして数値をひとつ,データとしてポインタをひとつ送信する.kz_recv() では戻り値としてサイズ,さらに送信してきたスレッドのIDと,データを指すポインタが返ってくる.送受信の方法をもうちょっと具体的に説明すると,送信側では

int size;
char *data;
...
kz_send(size, data);

のようにすることで,サイズとデータ(へのポインタ)を送信する.受信側では

int size, id;
char *data;
size = kz_recv(&id, &data);

のようにすることで,戻り値としてサイズが返され,第1引数のポインタが指す先に送信元スレッドID,第2引数のポインタが指す先にデータへのポインタが格納されて返ってくる.第1引数,第2引数にはNULLを指定することでとくに値を取得しないことも可能だ.ただしデータの内容はコピーされるわけではなく,ポインタ値がそのまま渡されるだけだ.

システムコールの呼び出しにはそれなりの手順が必要(パラメータを用意して自分自身に対して SIGSYS を送信する)だが,システムコール呼び出しのたびにこういったことをいちいち行うのは面倒なので,syscall.c でサービス関数が用意されている.たとえばUNIXではシステムコールはたいてい引数を用意してからシステムコール用のソフトウエア割り込みを行うことになるが,これらはアセンブラで書く必要があり,定型の操作でもあるため,関数化されていて,ユーザは通常はその関数を呼び出せばいいことになる(アセンブラを知る必要は無い).これと同じようにシステムコールを関数化している.

で,kozos.h ではこれらの関数を外部に対して公開している.つまり KOZOSを使う場合には,ユーザは kozos.h のみをインクルードすればいいことになる.

サービス関数は syscall.c に書いてある.これが以下の部分だ.

diff -ruN -U 10 kozos01/syscall.c kozos03/syscall.c
--- kozos01/syscall.c Sun Oct 21 20:07:33 2007
+++ kozos03/syscall.c Sun Oct 21 20:07:33 2007
@@ -47,10 +47,44 @@
return param.un.sleep.ret;
}

int kz_wakeup(int id)
{
kz_syscall_param_t param;
param.un.wakeup.id = id;
kz_syscall(KZ_SYSCALL_TYPE_WAKEUP, &param);
return param.un.wakeup.ret;
}
+
+int kz_getid()
+{
+ kz_syscall_param_t param;
+ kz_syscall(KZ_SYSCALL_TYPE_GETID, &param);
+ return param.un.getid.ret;
+}
+
+int kz_chpri(int pri)
+{
+ kz_syscall_param_t param;
+ param.un.chpri.pri = pri;
+ kz_syscall(KZ_SYSCALL_TYPE_CHPRI, &param);
+ return param.un.chpri.ret;
+}
+
+int kz_send(int id, int size, char *p)
+{
+ kz_syscall_param_t param;
+ param.un.send.id = id;
+ param.un.send.size = size;
+ param.un.send.p = p;
+ kz_syscall(KZ_SYSCALL_TYPE_SEND, &param);
+ return param.un.send.ret;
+}
+
+int kz_recv(int *idp, char **pp)
+{
+ kz_syscall_param_t param;
+ param.un.recv.idp = idp;
+ param.un.recv.pp = pp;
+ kz_syscall(KZ_SYSCALL_TYPE_RECV, &param);
+ return param.un.recv.ret;
+}

syscall.c では自動変数としてパラメータを用意して kz_syscall() を呼び出す.kz_syscall() の内部では kill() によって SIGSYS を自分自身に発行することで,ソフトウエア割り込みもどきの処理を行っている.

syscall.h には,kz_syscall_type_t に新システムコールを追加し,さらにkz_syscall_param_t の定義に新システムコール用のパラメータを追加する必要がある.

diff -ruN -U 10 kozos01/syscall.h kozos03/syscall.h
--- kozos01/syscall.h Sun Oct 21 20:07:33 2007
+++ kozos03/syscall.h Sun Oct 21 20:07:33 2007
@@ -2,20 +2,24 @@
#define _KOZOS_SYSCALL_H_INCLUDED_

#include "kozos.h"

typedef enum {
KZ_SYSCALL_TYPE_RUN,
KZ_SYSCALL_TYPE_EXIT,
KZ_SYSCALL_TYPE_WAIT,
KZ_SYSCALL_TYPE_SLEEP,
KZ_SYSCALL_TYPE_WAKEUP,
+ KZ_SYSCALL_TYPE_GETID,
+ KZ_SYSCALL_TYPE_CHPRI,
+ KZ_SYSCALL_TYPE_SEND,
+ KZ_SYSCALL_TYPE_RECV,
} kz_syscall_type_t;

typedef struct {
union {
struct {
kz_func func;
char *name;
int pri;
int argc;
char **argv;
@@ -27,16 +31,34 @@
struct {
int ret;
} wait;
struct {
int ret;
} sleep;
struct {
int id;
int ret;
} wakeup;
+ struct {
+ int ret;
+ } getid;
+ struct {
+ int pri;
+ int ret;
+ } chpri;
+ struct {
+ int id;
+ int size;
+ char *p;
+ int ret;
+ } send;
+ struct {
+ int *idp;
+ char **pp;
+ int ret;
+ } recv;
} un;
} kz_syscall_param_t;

void kz_syscall(kz_syscall_type_t type, kz_syscall_param_t *param);

#endif

kz_syscall_type_t の定義はシステムコール発行時のシステムコール番号であり,実際にはシステムコールの呼び出しには kz_getid() などのサービス関数を用いるので,ユーザが知る必要は,まあ,無い.ただ追加すればいいだけのものだ.

kz_syscall_param_t の定義では,新システムコールで必要になるパラメータを追加する.パラメータの個数や種類はシステムコールごとに異なるので,共用体を使ってポインタによってシステムコール処理側に渡すような構造になっている.

ここで,システムコールのパラメータ中に,戻り値の返却用として ret というメンバがあることに注目してほしい.システムコールの結果というか戻り値は,このメンバを用いて呼び出し側に通知する.パラメータはシステムコールの呼び出し時に,呼び出し用のサービス関数によって(自動変数として)スタック上に確保されるので,戻り値も同じ場所に確保されることになる.

システムコールの呼び出しには kill() によるソフトウエア割り込み(のようなもの)を利用しているので,通常の関数コールのように戻り値を返すことができない.この対策として実は初期の KOZOS では,システムコールの戻り値は外部変数によって返していた.しかしこれで,実は以下の問題が出たのだ.
  • 外部変数を経由して戻り値を返すので,戻り値はスレッド単位でなくKOZOS に1個,という管理になる.
  • メッセージ通信の実装では,kz_recv() をすると実際にメッセージが送信されてくるまで受信待ちでスリープする.
  • 別のスレッドが kz_send() によりメッセージを送信すると,kz_recv() しているスレッドには wakeup がかかり,動作再開する.
  • この際 kz_send() した側のスレッドは,kz_send() の戻り値として,戻り値格納用の外部変数を参照することになる.
  • しかし kz_recv() した側のスレッドも,kz_recv() の戻り値として戻り値格納用の外部変数を参照することになる.しかしここには kz_send() の戻り値が格納されている.結果として kz_recv() したスレッドは,戻り値として正しい値を受け取ることができない.
ということで,戻り値はパラメータ領域の中に埋め込んで,スレッド単位で管理できるようになっている.

次に,順番はちょっと逆になるが,thread.h の修正について説明する.システムコールの追加の際,thread.h も修正を行う必要は必ずしもないのだが,今回はメッセージ通信のためにメッセージキューと,kz_recv() による受信待ちの際の引数の保存領域が追加されている.

diff -ruN -U 10 kozos01/thread.h kozos03/thread.h
--- kozos01/thread.h Sun Oct 21 21:12:59 2007
+++ kozos03/thread.h Sun Oct 21 21:12:59 2007
@@ -5,31 +5,40 @@
#include
#include

#include "kozos.h"
#include "syscall.h"

#define THREAD_NUM 16
#define PRI_NUM 32
#define THREAD_NAME_SIZE 16

+typedef struct _kz_membuf {
+ struct _kz_membuf *next;
+ int id;
+ int size;
+ char *p;
+} kz_membuf;
+
typedef struct _kz_thread {
struct _kz_thread *next;
char name[THREAD_NAME_SIZE + 1];
struct _kz_thread *id;
kz_func func;
int pri;
char *stack;

struct {
kz_syscall_type_t type;
kz_syscall_param_t *param;
} syscall;
+
+ kz_membuf *messages;

struct {
jmp_buf env;
} context;
} kz_thread;

extern kz_thread *current;

#endif

メッセージは構造体 kz_membuf によって管理され,構造体 kz_thread の messages メンバにリンクリストとして連結される.

最後に,thread.c へのシステムコールの処理の追加だ.ここはひとつひとつ見ていこう.

static int thread_getid()
{
putcurrent();
return (int)current->id;
}

まず kz_getid() の処理は簡単だ.自分自身のIDを返すだけだ.さらに前回に説明したとおり,putcurrent()によってスレッドをレディーキューに繋げている.これをやらないと kz_getid() の呼び出し後にスレッドがスリープしてしまうことになる.

次に優先度の変更だ.

static int thread_chpri(int pri)
{
int old = current->pri;
if (pri >= 0)
current->pri = pri;
putcurrent();
return old;
}

引数として渡された値に優先度を変更するだけなのだが,以前の優先度を戻り値として返し,さらに優先度として負の値が渡された場合には,優先度の変更は行わないようになっている.つまり自身の優先度が知りたいだけならば,

pri = kz_chpri(-1)

を実行すればいいようになっている.

次に kz_send(),kz_recv() によるメッセージの送受信だ.

static void recvmsg()
{
kz_membuf *mp;

mp = current->messages;
current->messages = mp->next;
mp->next = NULL;

current->syscall.param->un.recv.ret = mp->size;
if (current->syscall.param->un.recv.idp)
*(current->syscall.param->un.recv.idp) = mp->id;
if (current->syscall.param->un.recv.pp)
*(current->syscall.param->un.recv.pp) = mp->p;
free(mp);
}

static void sendmsg(kz_thread *thp, int id, int size, char *p)
{
kz_membuf *mp;
kz_membuf **mpp;

current = thp;

mp = (kz_membuf *)malloc(sizeof(*mp));
if (mp == NULL) {
fprintf(stderr, "cannot allocate memory.\n");
exit(1);
}
mp->next = NULL;
mp->size = size;
mp->id = id;
mp->p = p;
for (mpp = &current->messages; *mpp; mpp = &((*mpp)->next))
;
*mpp = mp;

if (putcurrent() == 0) {
/* 受信する側がブロック中の場合には受信処理を行う */
recvmsg();
}
}

static int thread_send(int id, int size, char *p)
{
putcurrent();
sendmsg((kz_thread *)id, (int)current, size, p);
return size;
}

static int thread_recv(int *idp, char **pp)
{
if (current->messages == NULL) {
/* メッセージが無いのでブロックする */
return -1;
}

recvmsg();
putcurrent();
return current->syscall.param->un.recv.ret;
}

kz_send() 実行時には thread_send() が呼ばれる.thread_send() からは sendmsg() が呼び出されている(これを関数化しているのは,今後タイマやシグナル受信時の通知処理を実装する際に,メッセージ送信を行う必要があるので,メッセージの送信部分だけ関数化しておきたかったため.これらはまた今後説明する).sendmsg()では,以下の処理を行っている.
  • メッセージ管理用に構造体 kz_membuf をひとつ,malloc()によって確保する.さらにパラメータを保存する.
  • kz_membuf を送信先のスレッドのメッセージキューのお尻に接続する.
  • 送信先のスレッドをレディーキューに繋ぐ(putcurrent()).putcurrent()の戻り値がゼロの場合には,受信側は kz_recv() による受信待ちだったということなので,recvmsg()を呼び出してさらに受信処理を行う.
kz_recv() 実行時には thread_recv() が呼ばれる.ここでは以下の処理が行われている.
  • メッセージキューに何もない場合には受信待ちを行う必要があるので,putcurrent() を行わずにそのまま返ることでスリープ状態になる.
  • メッセージキューにメッセージがあった場合には,recvmsg()により受信処理を行い,putcurrent() でスレッドをレディーキューに繋ぐ.
  • recvmsg()ではメッセージキューの先頭からメッセージを取り出し,kz_recv() の戻り値としてデータのサイズを格納し,さらに受信するスレッドが kz_recv() に渡した引数(送信元IDやデータを指すポインタの格納先ポインタ)の指す先に,戻り値としてそれらの情報を格納する.


kz_recv()で受信待ちには入った際には,他スレッドがメッセージを送信してくるまで,受信処理を行うことはできない.で,メッセージが送信されると,受信処理として送信元スレッドIDの格納と,データへのポインタの格納が行われる.

ところで,kz_send()の実行時には,引数としてサイズとデータへのポインタを渡していた.kz_recv()では,それらを戻り値として返しているだけである.つまり kz_send() によりデータ(へのポインタ)を渡したとしても,kz_recv() する側にそのデータがコピーされて渡されるわけではない.たとえば kz_recv() 側でデータを書き換えたりすると,kz_send() した側ではデータが突然書き変わって見えるようになる.

このため malloc() によって獲得したデータを渡すような場合には注意が必要だ.基本的には kz_send() 側で malloc() を行い,kz_recv() 側で free() を行うべきだろう.また kz_send() 側では,kz_send() の後にはそのデータを参照してはいけないことになる.kz_recv() 側でいつ free() されるかわからないからだ.(それらの注意点をどうしても破る必要があるならば,スレッドの優先度を工夫して,データ参照される前にfree()されることは絶対に無いような優先度設計にするなどの考慮が必要)

またサイズは kz_send() の引数として渡された値を kz_recv() の戻り値として返すだけなので,数値としての意味はとくに無い.たぶんデータのサイズを渡したいことが多いだろうから size というパラメータ名にしているだけなので,任意の数値を渡したり,不要ならばゼロとか負の値にしてしまうことも可能だ.

さて,システムコールの処理用関数の呼び出しは以下のようになる.

static void syscall_proc()
{
/* システムコールの実行中にcurrentが書き換わるのでポインタを保存しておく */
kz_syscall_param_t *p = current->syscall.param;

getcurrent();

switch (current->syscall.type) {
case KZ_SYSCALL_TYPE_RUN:
p->un.run.ret = thread_run(p->un.run.func, p->un.run.name, p->un.run.pri,
@@ -153,20 +229,32 @@
thread_exit();
break;
case KZ_SYSCALL_TYPE_WAIT:
p->un.wait.ret = thread_wait();
break;
case KZ_SYSCALL_TYPE_SLEEP:
p->un.sleep.ret = thread_sleep();
break;
case KZ_SYSCALL_TYPE_WAKEUP:
p->un.wakeup.ret = thread_wakeup(p->un.wakeup.id);
+ break;
+ case KZ_SYSCALL_TYPE_GETID:
+ p->un.getid.ret = thread_getid();
+ break;
+ case KZ_SYSCALL_TYPE_CHPRI:
+ p->un.chpri.ret = thread_chpri(p->un.chpri.pri);
+ break;
+ case KZ_SYSCALL_TYPE_SEND:
+ p->un.send.ret = thread_send(p->un.send.id, p->un.send.size, p->un.send.p);
+ break;
+ case KZ_SYSCALL_TYPE_RECV:
+ p->un.recv.ret = thread_recv(p->un.recv.idp, p->un.recv.pp);
break;
default:
break;
}
return;
}

これは syscall_proc() に単に追加しているだけだ.

ここまでが今回の機能追加のための修正内容なのだけど,次にサンプルプログラムによって動作確認してみよう.今回用意したサンプルプログラムは以下.前回同様,コンパイルして実行してみよう.まずはコンパイル.上記の main.c を KOZOS のディレクトリに置いてmake を行う.

% make
cc -c thread.c -g -Wall -static
cc -c syscall.c -g -Wall -static
cc -c main.c -g -Wall -static
cc thread.o syscall.o main.o -o koz -g -Wall -static
%

で,これが実行結果.

% ./koz
main start (08062da0)
main start2 pri(1)
func1 start
main start3 pri(3)
message sending
func1 recv 18 "message sample 1."
func1 send
func2 send
func2 start
func2 recv 18 "message sample 2."
func1 recv 0 "message sample 3."
%

まず main.c を簡単に説明するが,kz_run() により"func1", "func2" という2つのスレッドを起動する.これらのスレッドは,kz_recv() によりメッセージ受信待ちに入り,メッセージを受信したらその内容を表示する,という動作を行う."func2" はメッセージ内容の表示後,さらに "func1" に対してメッセージを送信する.

実行結果を見てみよう.最初に "main start" というメッセージが出力され,スレッドIDとして 0x08062da0という値が表示されている.スレッドIDは kz_getid() により取得した値だ.さらに kz_run() により "func1","func2" というスレッドを起動し,自身の優先度を kz_chpri() により1→3に変更している.

ここで最初に起動した "main" スレッドの優先度は1,"func1"の優先度は2,"func2"の優先度は4である.このため kz_run() によるスレッド生成を行っても,優先度が1である"main" スレッドがそのまま走行する.しかし kz_chpri() により優先度が3になると,"main" よりも "func1" のほうが優先順位が高くなるので,その直後から動作は"func1" に切り替わる.このため "func1 start" というメッセージが直後に出力されている("main start3 pri(3)"のメッセージよりも先に表示されていることに注意."func1"のほうが優先順位が高いので,"main" によるメッセージ出力よりも先に"func1 start" が表示されている).

"func1" は動作を開始すると,kz_recv() による受信待ちに入る.このためスリープするので,動作は再び "main" に戻る."main" は kz_send() により"func1"と"func2"にメッセージ送信を行う.

"func1","func2"は"main"から送られてきたメッセージを受信し内容を表示するのだが,ここでも各スレッドの優先度が効いている.まずは "main" は "func1" に対して kz_send() によりメッセージを送信しているのだが,"func1" のほうが優先順位が高いので,kz_send()の実行直後に動作は "func1" に切り替わり,"func1" が受信を行って,メッセージの内容を出力している."main" が "func1 send" というメッセージを出力するのはその後ということになる.

さらに "main" は "func2" に対してメッセージを送信し,"func2 send" というメッセージを出力している."func2" はここでようやくスレッドの動作が開始し,"func2 start" という文字列を表示した後に "main" からのメッセージを受信して出力している.これらの送信側,受信側の動作の順番は "func1" のときとは逆になっているが,これらは優先度関係の違いによるものだ.さらに "func2" は "func1" にメッセージを投げ,"func1" 側で再びメッセージが表示されている.

"func1" 側の受信は,"func1" が kz_recv() により受信待ち状態に入ってからメッセージが送信されてくるが,"func2" 側の受信は,まず "main" の kz_send() によりメッセージが既に送信されている状態で,"func2" が kz_recv() による受信を行うことになる.前者は kz_recv() によるスリープとその後の wakeup 処理,後者はメッセージを受信されるまで保持しておくためのキューイング処理が必須になってくる.どちらも,正常に動作しているようだ.

ちなみにメッセージとして投げる文字列は,

char p[] = "message sample 1."; /* スタック上の文字列 */
...
kz_send(id1, 18, p);

のようにしてスタック上に確保したものと,

kz_send(id2, 18, "message sample 2.");

のようにして,文字列リテラルとして静的に確保したものの両方を使っているが,どちらも問題なく表示できている.

あと "func2" が最後に "func1" にメッセージを投げるときは実は size をゼロとして投げているのだが,"func1" ではメッセージを正常に受信して,kz_recv() の戻り値はゼロとして表示している.kz_send() の size メンバにはとくに意味は無く,渡された数値をそのまま受信側に渡すだけだからだ.
(注意)このブログは本家のほうの文章部分のみの転載です.ソースコードの配布,画像などについては本家のほうを参照してください.文章中のリンク先は面倒なのですべて本家のほうに変換してしまっているのでご注意ください.

えーと,前回はKOZOSの動作の流れについて説明しただけでサンプルのアプリの動作説明をしてませんでした.ということで今回は,サンプルアプリの説明.

まず,前回紹介した main1.c をもう一度.こちらは実行結果.

% koz
main start
thread 1 started
thread 2 started
mainfunc loop 0
mainfunc loop 1
mainfunc end
func1 start 1 ./koz
func1 loop 0
func2 start 1 ./koz
func2 loop 0
func1 loop 1
func2 loop 1
func1 end
func2 end
%

では,main1.c の動作を順に説明しよう.

まず,C言語の常識に違わず main() が実行されるのだけど,kz_start() によっていきなり KOZOS が起動され,スレッド "main" が生成される.スレッド "main" は,mainfunc() をメイン関数として実行開始する.

int main(int argc, char *argv[])
{
kz_start(mainfunc, "main", 1, argc, argv);
return 0;
}

で,mainfunc() はこんなふうになっている.

int mainfunc(int argc, char *argv[])
{
int i;
int id1, id2;

fprintf(stderr, "main start\n");

id1 = kz_run(func1, "func1", 2, argc, argv);
fprintf(stderr, "thread 1 started\n");

id2 = kz_run(func2, "func2", 2, argc, argv);
fprintf(stderr, "thread 2 started\n");

for (i = 0; i < 2; i++) {
fprintf(stderr, "mainfunc loop %d\n", i);
kz_wait();
}

fprintf(stderr, "mainfunc end\n");

return 0;
}

最初に kz_run() によって "func1" スレッドが生成される.これは func1() をメイン関数として実行開始する.で,kz_run() の呼び出し後にはどうなるかというと,なんだか func1() がそのまま呼ばれそうな気がするのだけど,ここで注意しなければならないのはスレッドの優先度だ.

kz_start() でスレッド "main" を起動したときの優先度(kz_start()の第3引数)は「1」となっている.これに対して,kz_run()によってスレッド "func1" を起動したときの優先度(kz_run)の第3引数)は「2」だ.つまり kz_run() 実行後のディスパッチ処理によってカレントスレッドになるのは,優先度の高い(=優先度の数値の小さい)スレッド "main" ということになる.なので,mainfunc() 内の処理がそのまま継続される.

mainfunc()で次に行われるのは,kz_run() によるスレッド "func2" の生成だ.これに関しても,優先度「2」で生成されるので,スレッド "main" はスレッド "func2" を生成したままさらに処理を進めることになる.

ここで,func1(),func2() の先頭と実行結果を見てほしい.

int func1(int argc, char *argv[])
{
int i;

fprintf(stderr, "func1 start %d %s\n", argc, argv[0]);
...


int func2(int argc, char *argv[])
{
int i;

fprintf(stderr, "func2 start %d %s\n", argc, argv[0]);
...


% koz
main start
thread 1 started
thread 2 started
mainfunc loop 0
...

func1(), func2() とも,先頭で fprintf() によりメッセージを出力しているのに,実際の実行結果では mainfunc() 内部のメッセージ出力が行われている.つまり,スレッド "func1","func2" は実際には動作開始せず,スレッド "main" がそのまま動作していることがわかる.

スレッド "main" はさらに for ループに入り,メッセージを出力する.

for (i = 0; i < 2; i++) {
fprintf(stderr, "mainfunc loop %d\n", i);
kz_wait();
}

fprintf(stderr, "mainfunc end\n");

実行結果をもう一度,今度は全体を見てみよう.

% koz
main start
thread 1 started
thread 2 started
mainfunc loop 0
mainfunc loop 1
mainfunc end
func1 start 1 ./koz
func1 loop 0
func2 start 1 ./koz
func2 loop 0
func1 loop 1
func2 loop 1
func1 end
func2 end
%

mainfunc() のループが終了した後で,ようやく func1(),func2() 先頭のメッセージ出力が行われている.mainfunc()のループ内では kz_wait() によるディスパッチが行われているのだが,結局のところスレッド "main" の優先度が一番高いので,"main" が動作している限りはカレントスレッドは "main" のままとなり,"main" が終了してからはじめて "func1","func2" が動き出していることになる.

func1()とfunc2()ではどちらもループしながらメッセージ出力しているが,実際にはメッセージは交互に出力されている.これはどちらもループ内でkz_wait() により処理を相手に渡しあっているからだ.

int func1(int argc, char *argv[])
{
int i;

fprintf(stderr, "func1 start %d %s\n", argc, argv[0]);

for (i = 0; i < 2; i++) {
fprintf(stderr, "func1 loop %d\n", i);
kz_wait();
}

fprintf(stderr, "func1 end\n");

return 0;
}


int func2(int argc, char *argv[])
{
int i;

fprintf(stderr, "func2 start %d %s\n", argc, argv[0]);

for (i = 0; i < 2; i++) {
fprintf(stderr, "func2 loop %d\n", i);
kz_wait();
}

fprintf(stderr, "func2 end\n");

return 0;
}

このように,アプリの動作は優先度に左右される.この「優先度」というのがイマイチピンとこないひとも多いと思うし,それってそんなに重要なの? といったように思うひとも多いと思うのだが,すっげー重要です.この優先度の設計によって,動作ががらりと変わったり,おかしなバグが出たり解決したり,排他の問題が出たりやんだり,リアルタイム性があったりなくなったりという,とにかく組み込みOSのスレッド設計の根幹となると言ってもいいくらいのものです.

優先度を理解するために,ちょっと優先度を変更して実行してみましょう.main1.c の kz_start() 実行時の優先度を1→3に変更してみます.

--- main1.c Fri Oct 19 00:49:30 2007
+++ main2.c Fri Oct 19 00:49:36 2007
@@ -60,6 +60,6 @@

int main(int argc, char *argv[])
{
- kz_start(mainfunc, "main", 1, argc, argv);
+ kz_start(mainfunc, "main", 3, argc, argv);
return 0;
}

上記の main2.c を main.c にリネームして make することで,スレッド "main" の優先度を1→3に変更した実行形式を作成できます.で,実行してみます.

% koz
main start
func1 start 1 ./koz
func1 loop 0
func1 loop 1
func1 end
thread 1 started
func2 start 1 ./koz
func2 loop 0
func2 loop 1
func2 end
thread 2 started
mainfunc loop 0
mainfunc loop 1
mainfunc end
%

どうです? ぜんぜん違うでしょ?

注目すべきは,スレッド "func1" がまっさきに動作してループ処理して終了してしまっている点です.スレッド "main" の優先度は3ですが,スレッド "func1" の優先度は2です.なので kz_run() によりスレッド "func1" が生成されると,その後のディスパッチによってスレッド "func1" が動き出してしまいます.この間,スレッド "main" の動作は待たされます.func1() のループの内部では kz_wait() によるディスパッチが行われますが,スレッド "main" が待たされているためにスレッド "func2" がまだ起動していません.よってスレッド "func1" がそのまま動き続け,終了します.スレッド "func1" が終了すると,ようやくスレッド "main" が動き出します.ここで,ようやく「thread 1 started」という,スレッド "func1" を開始した旨のメッセージが出力されます.さらに kz_run() により今度はスレッド "func2" が起動されるのですが,スレッド "func1" と同様に優先度がスレッド "main" よりも高いので,そのままループに入り,終了するまで突き進みます.スレッド "func2" も終了した後に,ようやくスレッド "main" がループ処理を進めて終了することになります.

今度は,main1.c に対してスレッド "func2" の優先度を変えてみましょう.

--- main1.c Fri Oct 19 00:49:30 2007
+++ main3.c Fri Oct 19 00:49:43 2007
@@ -45,7 +45,7 @@
id1 = kz_run(func1, "func1", 2, argc, argv);
fprintf(stderr, "thread 1 started\n");

- id2 = kz_run(func2, "func2", 2, argc, argv);
+ id2 = kz_run(func2, "func2", 3, argc, argv);
fprintf(stderr, "thread 2 started\n");

for (i = 0; i < 2; i++) {


% koz
main start
thread 1 started
thread 2 started
mainfunc loop 0
mainfunc loop 1
mainfunc end
func1 start 1 ./koz
func1 loop 0
func1 loop 1
func1 end
func2 start 1 ./koz
func2 loop 0
func2 loop 1
func2 end
%

スレッド "main" は優先度1,スレッド "func1" は優先度2,スレッド "func2" は優先度3になっています.まずスレッド "main" が走りきり,次にスレッド "func1",最後にスレッド "func2" が走っていますね.

ここまではスレッドの優先順位による単純な処理でしたが,main2.c に対して,スレッドのスリープと再起動(wakeup)を入れてみましょう.

--- main2.c Fri Oct 19 00:49:36 2007
+++ main4.c Fri Oct 19 00:49:56 2007
@@ -8,6 +8,8 @@
int i;

fprintf(stderr, "func1 start %d %s\n", argc, argv[0]);
+ kz_sleep();
+ fprintf(stderr, "func1 wakeup\n");

for (i = 0; i < 2; i++) {
fprintf(stderr, "func1 loop %d\n", i);
@@ -52,6 +54,9 @@
fprintf(stderr, "mainfunc loop %d\n", i);
kz_wait();
}
+
+ fprintf(stderr, "thread 1 wakeup\n");
+ kz_wakeup(id1);

fprintf(stderr, "mainfunc end\n");

実行結果は次のようになります.

% koz
main start
func1 start 1 ./koz
thread 1 started
func2 start 1 ./koz
func2 loop 0
func2 loop 1
func2 end
thread 2 started
mainfunc loop 0
mainfunc loop 1
thread 1 wakeup
func1 wakeup
func1 loop 0
func1 loop 1
func1 end
mainfunc end
%

さて,main2.c の実行結果に対してどのように変化しているでしょうか?main2.c ではスレッド "main" の優先度が最も低かったためにスレッド "func1" が生成されるとまずは "func1" が走りきり,次にスレッド "func2" が生成されると今度は "func2" が走りきり,最後に "main" が走り終っていました.

しかし今回の結果では,同様に "func1" が走ろうとしますが,起動直後にループの手前で "func2" に切り替わっています.これは func1() の先頭で kz_sleep() によりスレッドがスリープしているためです.スレッド "func1" はスリープしてしまうために今度は "func2" が動作を始め,止まる部分が無いためにそのまま終了まで突き進んでいます.

"func2" が終了した後にはどうなるでしょうか? "func1" は相変わらずスリープ中であるために動作できません.よって優先度は低いのですが,"main" が動作を始めます."main" は mainfunc() 内部でのループ処理を行った後,kz_wakeup() によってスレッド "func1" を起こします.これによりスレッド "func1" が動作を再開し,優先度の高い "func1" のほうが,今度は終了まで突き進みます."func1" の終了後は再び "main" が動作を始め,終了しています.

優先度をいろいろ変えたり,sleep と wakeup の位置を変えたりしていろいろ試してみてください.数値をひとつ変えるだけで,こんなにも動作が変わるものなのかということが実感できると思います.