この章では、マルチスレッドと Solaris オペレーティング環境との関係について説明します。また、マルチスレッドをサポートするために Solaris オペレーティング環境に、どのような変更が加えられたかについても説明します。
Solaris オペレーティング環境における fork() 関数のデフォルト処理は、POSIX スレッドでの fork() の処理方法とはいくらか違っています。ただし、Solaris オペレーティング環境は両方の機構をサポートしています。
表 5–1 は、Solaris と pthread での fork() の処理について、相違点と類似点を示しています。POSIX スレッドまたは Solaris スレッドの側に相当するインタフェースがない項目については、「-」が記入されています。
表 5–1 POSIX と Solaris での fork() の処理の比較
|
Solaris オペレーティング環境のインタフェース |
POSIX スレッドのインタフェース |
---|---|---|
fork1 モデル |
fork1(2) |
fork(2) |
汎用 fork モデル |
fork(2) |
— |
fork - 安全 |
— |
pthread_atfork(3THR) |
表 5–1 で示すように、pthread の fork(2) 関数の動作は、Solaris の fork1(2) 関数の動作と同じです。pthread の fork(2) 関数と Solaris の fork1(2) 関数はどちらも新しいプロセスを生成し、子プロセスに完全なアドレス空間の複製を作成しますが、スレッドについては呼び出しスレッドのみを複製します。
これは、子プロセスが生成後ただちに exec() を呼び出すような場合に利用します。実際、多くの場合に fork() を呼び出した後行われることです。この場合、子プロセスは fork() を呼び出したスレッド以外のスレッドの複製は必要としません。
子プロセスでは、fork() を呼び出してから exec() を呼び出すまでの間に、ライブラリ関数を呼び出さないようにします。ライブラリ関数の中には、fork() 呼び出し時に親の中で保持されているロックを使用するものがあるからです。子プロセスは exec() ハンドラの 1 つが呼び出されるまで、「非同期シグナル安全」操作しか行えません。
共有データのロックのような通常の考慮事項に加えて、次のような問題があります。実行されているスレッドが 1 つ (fork() を呼び出したスレッド) しかないときに、ライブラリは子プロセスを fork することに関して上手に処理しなくてはなりません。この場合の問題は、子プロセスの唯一のスレッドが、その子プロセスに複製されなかったスレッドによって保持されているロックを占有しようとする可能性があることです。
これは、多くのプログラムが遭遇するような問題ではありません。ほとんどのプログラムは、fork() から復帰した直後に子プロセス内で exec() を呼び出します。しかし、子プロセス内で何かの処理を行なってから exec() を呼び出す場合、または exec() をまったく呼び出さない場合、子プロセスはデッドロックに遭遇するでしょう。
ライブラリの作成者は安全な解決策を提供してください。もっとも、fork に対して安全なライブラリを提供しなくても (このような状況が稀であるため) 大きな問題にはなりません。
たとえば、T1 が何かを出力している途中で (その間、printf() のためにロックを保持している)、T2 が新しいプロセスを fork すると仮定します。この場合、子プロセス内で唯一のスレッド (T2) が printf() を呼び出せば、すぐさまデッドロックに陥ります。
POSIX の fork() と Solaris の fork1() は、それを呼び出したスレッドのみを複製します。Solaris の fork() を呼び出せば、すべてのスレッドが複製されるので、この問題は生じません。
デッドロックを防ぐには、fork 時にこのようなロックが保持されないようにしなければなりません。そのための最も明瞭なやり方は、fork を行うスレッドに、子プロセスによって使われる可能性のあるロックをすべて獲得させることです。printf() でそのようなことはできないので (printf() は libc によって所有されているため)、fork() の呼び出しは printf() を使用していない状態で行うようにしなければなりません。
ライブラリでロックを管理するには、次の操作を実行します。
そのライブラリで使用するすべてのロックを明確に指定します。
そのライブラリで使用するロックのロック順序を明確に指定します。厳密なロック順序を使用しない場合は、ロックの獲得を管理する上で細心の注意が必要です。
fork 呼び出し時にそれらのロックを獲得できるよう段取りします。Solaris スレッドでは、これを手作業で行わなければなりません。fork1() を呼び出す直前にロックを獲得し、その後ただちに解放します。
次の例では、ライブラリによって使用されるロックのリストは {L1,...Ln} で、これらのロックのロック順序も L1...Ln です。
mutex_lock(L1); mutex_lock(L2); fork1(...); mutex_unlock(L1); mutex_unlock(L2); |
pthread では、pthread_atfork(f1, f2, f3) の呼び出しをライブラリの .init() セクションに追加できます。f1、f2、f3 の定義は次のとおりです。
f1() /* このプロセスが fork する前に実行される */ { mutex_lock(L1); | mutex_lock(...); | -- ordered in lock order mutex_lock(Ln); | } V f2() /* プロセスが fork した後、子で実行される */ { mutex_unlock(L1); mutex_unlock(...); mutex_unlock(Ln); } f3() /* プロセスが fork した後、親で実装される */ { mutex_unlock(L1); mutex_unlock(...); mutex_unlock(Ln); } |
デッドロックのもう 1 つの例として、mutex をロックした、親プロセス内のスレッド (Solaris の fork1(2) を呼び出したものではない) が考えられます。この mutex はロック状態で子プロセスにコピーされますが、その mutex をロック解除するためのスレッドはコピーされません。このため、その mutex をロックしようとする子プロセス内のスレッドは永久に待つことになります。
標準の vfork(2) 関数は、マルチスレッドプログラムでは危険です。vfork(2) は、呼び出しスレッドだけを子プロセスにコピーする点が fork1(2) に似ています。ただし、スレッドに対応した実装ではないので、vfork() は子プロセスにアドレス空間をコピーしません。
子プロセス内のスレッドで、exec(2) を呼び出す前にメモリーを変更しないよう十分注意してください。vfork() では、親プロセスのアドレス空間が子プロセスにそのまま渡されます。子プロセスが exec() を呼び出すか終了すると、親プロセスにアドレス空間が戻されます。したがって、子プロセスが、親プロセスの状態を変更しないようにすることが大切です。
たとえば、vfork() を呼び出してから exec() を呼び出すまでの間に、新しいスレッドを生成することは大変危険です。
fork1 を使用するときは必ず pthread_atfork() を使用して デッドロックを防止してください。
#include <pthread.h> int pthread_atfork(void (*prepare) (void), void (*parent) (void), void (*child) (void) ); |
pthread_atfork() 関数は、fork() を呼び出したスレッドのコンテキストで fork() の前後に呼び出される fork のハンドラを宣言します。
prepare ハンドラは fork() の起動前に呼び出されます。
parent ハンドラは fork() の復帰後に親の中で呼び出されます。
child ハンドラは fork() の復帰後に子の中で呼び出されます。
これらのどれでも NULL に設定できます。連続する pthread_atfork() 呼び出しの順序が重要です。
たとえば、prepare ハンドラが、必要な相互排他ロックをすべて獲得し、次に parent ハンドラと child ハンドラがそれらを解放するといった具合です。このようにすると、プロセスが fork される「前」に、関係するロックがすべて fork 関数を呼び出すスレッドによって保持されるので、子プロセスでのデッドロックが防止されます。
汎用 fork モデルを使用すれば、fork1 モデルにおける安全性の問題とその解決策で述べたデッドロックの問題は回避されます。
正常終了時は 0 です。それ以外の戻り値は、エラーが発生したことを示します。以下の条件が検出されると、この関数は失敗し、次の値を戻します。
ENOMEM
テーブル空間が足りないので、fork ハンドラのアドレスを記録できません。
Solaris の fork(2) 関数は、子プロセスにアドレス空間とすべてのスレッド (および LWP) の複製を作成します。この方法を使用するのは、子プロセスで exec(2) をまったく呼び出さないが親のアドレス空間のコピーを使用するなどの場合です。汎用 fork 機能は POSIX スレッドにはありません。
なお、プロセス内のあるスレッドが Solaris の fork(2) を呼び出すと、割り込み可能なシステムコール処理中にブロックされたスレッドは EINTR を戻すので注意してください。
また、親プロセスと子プロセスの両方に保持されるロックを作成しないよう十分注意してください。このような状況が生じる可能性があるのは、ロックを共有可能なメモリー上に割り当てている (つまり、mmap() で MAP_SHARED フラグを指定した) 場合です。fork 1 モデルを使用する場合、これは問題になりません。
アプリケーションの中での fork() のセマンティクスが「汎用 fork」と「fork 1」のどちらであるかは、該当するライブラリとリンクすることによって決定されます。-lthread を指定してリンクすると、fork() のセマンティクスは「汎用 fork」になり、-lpthread を指定してリンクすると、fork() のセマンティクスは「fork 1」になります (コンパイルオプションの説明は、図 7–1を参照してください)。
どの fork() 関数についても、呼び出した後で広域的状態を使用するときには注意が必要です。
たとえば、あるスレッドがファイルを逐次的に読み取っているときに同じプロセス内の別のスレッドが fork() 関数を 1 つ呼び出すと、両方のプロセスにファイル読み取り中のスレッドが存在することになります。fork() 後はファイル記述子のシークポインタが共有されるため、親プロセス内のスレッドがデータを読み取ると、子プロセス内のスレッドは残りのデータを読み取ります。この結果、連続読み取りアクセスに切れ目ができます。
システムコール exec(2) と exit(2) の動作は、アドレス空間内のすべてのスレッドを削除する点を除いて、シングルスレッドのプロセスの場合と変わりません。どちらのシステムコールも、スレッドを含むすべての実行リソースが削除されるまでブロック状態になります。
exec() は、プロセスを再構築するときに LWP を 1 つ生成します。さらにプロセス起動時に初期スレッドを生成します。通常、初期スレッドが処理を終えると exit() を呼び出し、プロセスは削除されます。
プロセス内のすべてのスレッドが終了すると、そのプロセスも終了します。複数のスレッドをもつプロセスから exec() 関数が呼び出されると、すべてのスレッドが終了し、新しい実行可能イメージがロードされ実行されます。デストラクタ関数は呼び出されません。
LWP ごとのタイマー (timer_create(3RT) を参照) とスレッドごとのアラーム (alarm(2) または setitimer(2) を参照) についての「サポート中止」のご案内が Solaris 2.5 リリースでされています。どちらの機能も、この節で説明するプロセスごとの代替物によって置き換えられています。
各 LWP は、その LWP に結合されているスレッドが使用できるリアルタイムインターバルタイマーとアラームを持っています。このタイマーとアラームは、一定時間が経過すると 1 つのシグナルをスレッドに送ります。
各 LWP は、その LWP に結合されているスレッドが使用できる仮想時間インターバルタイマー、またはプロファイル用のインターバルタイマーも持っています。このインターバルタイマーは一定時間が経過すると、それを所有している LWP に SIGVTALRM シグナルまたは SIGPROF シグナルを送ります。
Solaris 2.3 と 2.4 リリースでは、timer_create(3RT) 関数が戻すタイマーオブジェクトは、そのタイマー ID が呼び出し LWP の中だけで意味をもち、その期限切れシグナルが呼び出し LWP に送られるというものでした。このため、POSIX タイマ機能を使用できるスレッドは、結合スレッドに限られていました。
さらに、この制限された使用方法でも、Solaris 2.3 と 2.4 リリースのマルチスレッドアプリケーションでの POSIX タイマーは、生成されるシグナルのマスキングおよび sigvent 構造体からの関連値の送信について信頼性に欠けるところがありました。
Solaris 2.5 以降のリリースでは、マクロ _POSIX_PER_PROCESS_TIMERS を定義してコンパイルされたアプリケーション、あるいはシンボル _POSIX_C_SOURCE に対して 199506L より大きな値を指定してコンパイルされたアプリケーションは、プロセスごとのタイマーを作成できます。
Solaris 9 オペレーティング環境では、仮想時間およびプロファイルのインターバルタイマーを除いて、すべてプロセスごとのタイマーが使用されます (ITIMER_VIRTUAL と ITIMER_PROF については setitimer (2) を参照)。仮想時間およびプロファイルのタイマーは、LWP ごとになっています。
プロセスごとのタイマーのタイマー ID は、どの LWP からでも使用できます。期限切れシグナルは、特定の LWP に向けられるのではなく、そのプロセスに対して生成されます。
プロセスごとのタイマーは、timer_delete(3RT) の呼び出し時またはそのプロセスの終了時にのみ削除されます。
Solaris オペレーティング環境 2.3 と 2.4 リリースでは、alarm(2) または setitimer(2) の呼び出しは、呼び出し LWP の中だけで意味をもっていました。生成した LWP が終了すると、こうしたタイマーは自動的に削除されました。このため、alarm() や setitimer() を使用できるスレッドは、結合スレッドに限られていました。
さらに制限された使用方法でも、Solaris オペレーティング環境 2.3 と 2.4 のマルチスレッドアプリケーションでの alarm() タイマーと setitimer() タイマーは、これらの呼び出しを行なった結合スレッドからのシグナルのマスキングについて信頼性に欠けるところがありました。このようなマスキングが必要とされなければ、結合スレッドから出された、これら 2 つのシステムコールの動作は信頼できるものでした。
Solaris オペレーティング環境 2.5 以降のリリースでは、-lpthread (POSIX) スレッドとリンクしたアプリケーションは、alarm() を呼び出したときにプロセスごとの SIGALRM 通知を受け取ります。alarm() で生成される SIGALRM は、特定の LWP に向けられるのではなく、そのプロセスに対して生成されます。このアラームは、そのプロセスの終了時にリセットされます。
Solaris オペレーティング環境 2.5 リリースより前のリリースでコンパイルされたアプリケーション、あるいは -lpthread とリンクされていないアプリケーションは、alarm() または setitimer() で生成されるシグナルの、LWP ごとの送信を引き続き行います。
Solaris 9 オペレーティング環境では、 alarm() または setitimer(ITIMER_REAL) を呼び出すと、SIGALRM シグナルが戻り値としてプロセスに送信されます。
Solaris 2.6 より前の リリースでは、profil() をマルチスレッドプログラムから呼び出すと、呼び出した LWP にだけ適用されます。プロファイルの状態は、LWP の作成時には継承されません。 グローバルプロファイルバッファを使用してマルチスレッドプログラムにプロファイルを適用するには、各スレッドを起動するときに profil() を呼び出す必要があります。また、各スレッドは、結合スレッドでなければなりません。これは面倒で、プロファイルの動的な切り替えは簡単ではありませんでした。Solaris 2.6 以降のリリースでは、マルチスレッドプロセスから profil() システム呼び出しを行うと、大域的に適用されます。つまり、profil() を呼び出すと、プロセス内のすべての LWP およびスレッドに適用されます。 これにより、以前の LWP ごとの方式に依存したアプリケーションは、使用できなくなることがあります。しかし、実行時に動的にプロファイルを切り替えたい場合の状況を改善するものと期待されます。
setjmp() と longjmp() の有効範囲は、1 つのスレッド内だけに制限されます。この制限は、ほとんどの場合は問題となりません。しかし、この制限は、シグナルを扱うスレッドが longjmp() を使用できるのは、setjmp() が同一スレッド内で実行されている場合だけであることを意味します。
リソースの制限は、そのプロセス全体に課せられ、プロセス内のすべてのスレッドが全体でどれだけリソースを使用しているかによって決まります。リソースの弱い制限値を超えた場合は、制限に違反したスレッドにシグナルが送られます。プロセス内で使用されているリソースの合計は、getrusage(3C) で調べることができます。
Solaris のカーネルには、プロセスのスケジューリングに関する 3 つのクラスがあります。最も優先順位が高いスケジューリングクラスは、リアルタイム (RT) クラスです。その次はシステムクラスで、ユーザプロセスには適用されません。最も低いのはタイムシェア (TS) クラスで、デフォルトのスケジューリングクラスです。
スケジューリングクラスは、LWP ごとに維持管理されます。プロセスが生成されると、そのプロセスの初期 LWP は、親プロセスのスケジューリングクラスと作成元の LWP の優先順位を継承します。その後に、非結合スレッドを実行させるために生成される LWP も、このスケジューリングクラスと優先順位を継承します。
スレッドは、関連付けられている LWP と同じスケジューリングクラスおよび優先順位を持ちます。プロセス内の各 LWP は、カーネルから参照される固有のスケジューリングクラスおよび優先順位を持つことができます。結合スレッドは、常に同じ LWP に関連付けられます。
同期オブジェクトへの競合は、スレッドの優先順位によって調節されます。デフォルトでは、LWP はタイムシェアクラスに属します。 計算が大きな比率を占めるマルチスレッドの場合、スレッドの優先順位はあまり役立ちません。MT ライブラリを使って多くの同期を行うマルチスレッドアプリケーションでは、スレッドの優先順位がより意味をもちます。
スケジューリングクラスは、システムコール priocntl(2) で設定します。最初の 2 つの引数で、この設定の適用範囲を呼び出し側の LWP に限定したり、1 つ以上のプロセスのすべての LWP にしたりすることが可能です。3 番目の引数はコマンドで、次のいずれか 1 つを指定できます。
priocntl() は、呼び出しスレッドに関連付けられた LWP のスケジューリングを制御します。 非結合スレッドの場合、priocntl() への呼び出しから制御が戻ったときに、呼び出しスレッドが元の LWP に関連付けられる保証はありません。
タイムシェアスケジューリングでは、このスケジューリングの LWP に処理リソースが公平に配分されます。カーネルのそれ以外の部分は、ユーザーに対する応答時間に悪影響を与えないようにプロセッサを短時間ずつ使用します。
システムコール priocntl(2) は、1 つ以上のプロセスの nice() レベルを設定します。priocntl() による nice() レベルの変更は、そのプロセス内のタイムシェアクラスのすべての LWP に適用されます。nice() レベルの範囲は通常は 0〜+20 で、スーパーユーザー特権をもつプロセスの場合は -20〜+20 です。この値が小さいほど優先順位が高くなります。
タイムシェアクラスの LWP をディスパッチする優先順位は、LWP のその時点での CPU 使用率と nice() レベルに基づいて計算されます。タイムシェアスケジューラにとって、nice() レベルは、LWP 間の相対的な優先順位を表します。
LWP の nice() レベルが大きいほど、その LWP に配分される CPU 時間は少なくなりますが 0 になることはありません。多くの CPU 時間をすでに消費している LWP は、CPU 時間をほとんど (あるいは、まったく) 消費していない LWP よりも優先順位が下げられます。
リアルタイム (RT) クラスは、プロセス全体またはプロセス内の 1 つ以上の LWP に適用できます。ただし、スーパーユーザ特権が必要です。
タイムシェアクラスの nice(2) レベルとは異なり、リアルタイムクラスを指定された LWP には、個々の LWP 単位または複数の LWP 単位で優先順位を設定できます。priocntl(2) システムコールで、プロセス内のリアルタイムクラスのすべての LWP の属性を変更できます。
スケジューラは、最も高い優先順位を持つリアルタイムクラスの LWP をディスパッチします。優先順位の高い LWP が実行可能状態になると、それよりも優先順位の低い LWP は、実行リソースを横取りされます。実行リソースを横取りされた LWP は、そのレベルの待ち行列の先頭に置かれます。
リアルタイムクラスの LWP は、実行リソースが横取りされたり、一時停止したり、リアルタイム優先順位が変更されたりしない限り、プロセッサの制御を保持し続けます。リアルタイムクラスの LWP には、タイムシェアクラスのプロセスよりも絶対的に高い優先順位が与えられます。
新しく生成された LWP は、親プロセスまたは親 LWP のスケジューリングクラスを継承します。リアルタイムクラスの LWP は、親のタイムスライス (リソース割り当て時間) を有限または無限指定に関係なく継承します。
有限タイムスライスを指定された LWP は、処理が終了するか、入出力イベント待ちなどによってブロックされるか、より優先順位の高い実行可能なリアルタイムプロセスによって実行リソースを横取りされるか、またはタイムスライスが満了するまで実行を続けます。
無限タイムスライスを指定された LWP が実行を停止するのは、LWP が終了するか、ブロックされるか、または実行リソースが横取りされたときだけです。
公平配分スケジューラ (FSS) のスケジューリングクラスを使用すると、配分に基づいて CPU 時間を割り当てることができます。
デフォルトでは、FSS スケジューリングクラスでは、TS および対話型 (IA) スケジューリングクラスと同じ範囲の優先順位 (0 - 59) が使用されます。 プロセス内の LWP は、すべて同じスケジューリングクラスで実行する必要があります。 FSS クラスでは、プロセス全体ではなく、個々の LWP のスケジュールを設定します。 FSS および TS/IA のクラスを同時に使用すると、どちらのクラスも予期しないスケジュールで動作することがあります。
複数のプロセッサセットを使用する場合、それぞれのプロセッサセット上で動作するすべてのプロセスが、プロセッサごとに TS/IA または FSS スケジューリングクラスであれば、それらは同じ CPU 群に対して競合しないので、TS/IA と FSS を同時に 1 つのシステム上で使用できます。
固定優先順位スケジューリングクラス (FX) では、優先順位および時間量に固定値を割り当てます。この値は、リソースの消費に応じて変化しません。プロセスの優先順位は、そのプロセス自体、または適切な特権が割り当てられたほかのプロセスだけが変更できます。FX については、priocntl(1) および dispadmin(1M) のマニュアルページにも記述されています。
このクラスのスレッドは、TS および 対話型 (IA) のスケジューリングクラスと同じ範囲の優先順位 (0 - 59) を共有します。 通常は、TS がデフォルトです。 FX は通常、TS といっしょに使用します。
UNIX の従来のシグナルモデルが、スレッドに対しても自然な方法で使用できるように拡張されています。この拡張の主な特徴は、シグナルに対する処置がプロセス全体に適用され、シグナルマスクはスレッドごと適用されることです。プロセス全体に適用されるシグナル処置は、signal(3C)、sigaction(2) などの従来の機構を使って設定します。
シグナルハンドラが SIG_DFL または SIG_IGN に対して設定されている場合、シグナル (終了、コアダンプ、停止、継続、無視) を受け取ると、対象となるプロセス全体に対して指示された動作を行います。つまり、プロセス内のすべてのスレッドが対象となります。これらのシグナルでハンドラをもたないものについては、どのスレッドがシグナルを拾うかという問題は重要ではありません。これは、シグナルの受信による処置はプロセス全体に行われるからです。シグナルについては、signal(5) のマニュアルページを参照してください。
各スレッドは、スレッド専用のシグナルマスクを持っています。これによって、スレッドが使用するメモリーまたはその他の状態をシグナルハンドラも使用する限りは、スレッドは特定のシグナルをブロックできます。同じプロセス内のすべてのスレッドは、sigaction(2) またはそれに相当する機能によって設定されるシグナルハンドラを共有します。
あるプロセス内のスレッドが、別のプロセス内の特定のスレッドにシグナルを送ることはできません。kill(2)、sigsend(2) 、または sigqueue (3RT) からプロセスに送信されたシグナルは、プロセス内の受け入れ可能な任意のスレッドによって処理されます。
シグナルは、次の 2 つに大別されます。トラップや例外条件の同期シグナルと、割り込み非同期シグナル。
従来の UNIX と同様、シグナルが保留状態のときに同じシグナルが再度発生しても通常は無視されます。保留状態のシグナルは、カウンタではなく 1 ビットで表現されるからです。しかし、sigqueue(3RT) インタフェースを使用してシグナルを送信すれば、複数の同じシグナルのインスタンスを、プロセスのキューに格納することができます。
シングルスレッドのプロセスのときと同様、スレッドがシステムコールを呼び出してブロックされている間にシグナルを受け取ると、そのシステムコールは EINTR エラーを返すか、あるいはそれが入出力のシステムコールの場合には要求したバイト数が全部転送されないで戻ることがあります。
マルチスレッドプログラムでは、特に pthread_cond_wait(3THR) に対するシグナルの影響に注意する必要があります。この関数は、pthread_cond_signal(3THR) または pthread_cond_broadcast(3THR) に反応した場合は、通常はエラーを返しません (戻り値が 0)。 しかし、待機中のスレッドが従来の UNIX シグナルを受信すると、誤って呼び起こされた場合でも値 0 を返します。この場合、Solaris スレッドの cond_wait (3THR) 関数は EINTR を返します。 詳細は、条件変数上で割り込まれた待機を参照してください。
トラップ (SIGILL、 SIGFPE、 SIGSEGV など) は、ゼロ除算を行なったり、存在しないメモリーを参照したりすることによって、スレッド自体が発生させるものです。トラップは、そのトラップを発生させたスレッドだけが処理します。プロセス内の複数のスレッドが、同じ種類のトラップを同時に発生させて処理することもできます。
同期的に生成されたシグナルに関しては、シグナルの概念を個々のスレッドに簡単に拡張できます。シグナルハンドラは、同期シグナルを生成したスレッド上で起動されます。
しかし、適切なシグナルハンドラが設定されていない場合、そのプロセスはトラップに対応できません。その場合は、トラップが発生すると、問題を起こしたスレッドが生成されたシグナルをブロックしても、デフォルトの処理が適用されます。このようなシグナルのデフォルトの処理では、プロセスの終了に通常コアダンプを伴います。
同期シグナルは通常、スレッドだけでなく、プロセス全体に悪影響を及ぼすような重大な事態を意味するので、プロセスを終了させた方がよい場合が多くあります。
割り込み (SIGINT、SIGIO など) は、あらゆるスレッドに対して、プロセス外部のなんらかの動作が原因で非同期的に発生します。非同期シグナルは、他のスレッドから明示的に送られてきたシグナルの場合も、ユーザーが Control-C キーを入力したなどの外部動作を表す場合もあります。
割り込みは、その割り込みを受け取るようにシグナルマスクが設定されている、どのスレッドでも処理できます。複数のスレッドが、割り込みを受け取ることができるように設定されている場合は、その中の 1 つのスレッドだけが選択されます。
複数の同じシグナルがプロセスに送られた場合、スレッドがそのシグナルをマスクしていなければ、それぞれのシグナルを別のスレッドで処理できます。また、すべてのスレッドがマスクしているときは、「保留」の印が付けられ、最初にマスク解除したスレッドによって処理されます。
継続セマンティクス法は、従来から行われてきたシグナル処理方法です。これは、シグナルハンドラから復帰したときに割り込みが発生した時点から実行を再開する方法です。この方法は、シングルスレッドのプロセスで非同期シグナルを扱うのに適しています (詳細は、例 5–1 を参照してください)。
また、PL/1 などの一部のプログラミング言語の例外処理機構でも使用されています。
unsigned int nestcount; unsigned int A(int i, int j) { nestcount++; if (i==0) return(j+1) else if (j==0) return(A(i-1, 1)); else return(A(i-1, A(i, j-1))); } void sig(int i) { printf("nestcount = %d\n", nestcount); } main() { sigset(SIGINT, sig); A(4,4); } |
pthread_sigmask(3THR) は、スレッドのシグナルマスクを設定するための関数です。つまり、sigprocmask(2) がプロセスに対して行うのと同じ操作をスレッドに対して行います。新しいスレッドが生成されると、その初期状態のシグナルマスクは生成元から継承されます。
マルチスレッドプロセス内で sigprocmask() を呼び出すのは、pthread_sigmask() を呼び出すのと同等です。詳細は、sigprocmask(2) のマニュアルページを参照してください。
pthread_kill(3THR) は、スレッド用の kill(2) で、特定のスレッドにシグナルを送ります。スレッドにシグナルを送った場合は、プロセスにシグナルを送った場合と異なります。プロセスに送られたシグナルは、プロセス内のどのスレッドでも処理できます。pthread_kill() で送られたシグナルは、指定されたスレッドだけが処理できます。
pthread_kill() でシグナルを送ることができるのは、現在のプロセス内のスレッドに限られることに注意してください。スレッド識別子
(thread_t
型) の有効範囲が局所的であるため、現在のプロセス以外のプロセス内のスレッドを指定できないからです。
宛先のスレッドでシグナルの受信時に行われる処置 (ハンドラ、SIG_DFL、SIG_IGN) は通常どおり広域的です。この意味は、たとえば、あるスレッドに SIGXXX を送信する場合、そのプロセスにとっての SIGXXX シグナル処置がそのプロセスを終了させることであれば、宛先スレッドがこのシグナルを受け取ったとき、そのプロセス全体が終了するということです。
マルチスレッドプログラムでは、sigwait(2) が好まれるインタフェースです。これは、非同期的に生成されるシグナルを正しく処理できるからです。
sigwait() は、set 引数に指定したシグナルが呼び出しスレッドに送られてくるまで、そのスレッドを待ち状態にします。スレッドが待っている間は、set 引数で指定したシグナルのマスクが解除され、復帰時に元のシグナルマスクが設定し直されます。
set 引数で識別されるすべてのシグナルは、呼び出しスレッドを含むすべてのスレッドでブロックする必要があります。そうしないと、sigwait() は正確に動作しません。
非同期シグナルからプロセス内のスレッドを隔離したい場合は、sigwait() を使用します。非同期シグナルを待つスレッドを 1 つ生成しておき、他のスレッドは、現在のプロセスに送られてくる可能性のある非同期シグナルをすべてブロックするように生成します。
Solaris オペレーティング環境 2.5 以降のリリースでは、Solaris オペレーティング環境 2.5 バージョンの新しい sigwait() と POSIX.1c バージョンの sigwait() の 2 種類を使用できます。新しいアプリケーションとライブラリでは、できるだけ POSIX 規格インタフェースを使用してください。Solaris オペレーティング環境バージョンは、将来のリリースではサポートされない可能性があるからです。
これら 2 つのバージョンの sigwait() の構文は下記のとおりです。
#include <signal.h> /* Solaris 2.5 バージョン */ int sigwait(sigset_t *set); /* POSIX.1c バージョン */ int sigwait(const sigset_t *set, int *sig); |
指定のシグナルが送られてくると、POSIX.1c sigwait() は保留されているそのシグナルを削除し、sig にそのシグナルの番号を入れます。同時に複数のスレッドから sigwait() を呼び出すこともできますが、受け取るシグナルごとに 1 つのスレッドだけの sigwait() だけが返ってきます。
sigwait() を使うと、非同期シグナルを同期的に扱うことができます。つまり、非同期シグナルを扱うスレッドから sigwait() だけを呼び出すと、シグナルが到着しだい戻ります。sigwait() の呼び出し側も含むすべてのスレッドで非同期シグナルをマスクすることによって、非同期シグナルを特定のシグナルハンドラだけに処理させることができます。非同期シグナルを安全に処理することが可能です。
すべてのスレッドですべてのシグナルを常にマスクし、必要なときだけ sigwait() を呼び出すようにすれば、アプリケーションはシグナルに依存するスレッドに対してはるかに安全になります。
通常はsigwait() を呼び出すスレッドを 1 つ以上作成して、シグナルを待機します。sigwait() はマスクされているシグナルであっても受け取るため、それ以外のスレッドでは誤ってシグナルを受け取ることがないように、対象となるシグナルをすべてブロックしてください。
シグナルを受け取ったスレッドは、 sigwait() から戻ってそのシグナルを処理し、sigwait() を再度呼び出して次のシグナルを待機します。 このシグナル処理スレッドでは、非同期シグナル安全関数以外の関数も使用でき、ほかのスレッドとも通常の方法で同期をとることができます。非同期シグナル安全カテゴリについては、マルチスレッドインタフェースの安全レベルを参照してください。
sigwait() は、同期的に生成されたシグナルを受け取ることができません。
sigtimedwait(3RT) は、指定時間が経過してもシグナルが送られてこなかったときにエラーで復帰する点を除いて、sigwait(2) と似ています。
UNIX のシグナル機構が、スレッド指定という考え方で拡張されています。これは、シグナルがプロセスではなく特定のスレッドに送られるという点を除いて、通常の非同期シグナルと似ています。
独立したスレッドで非同期シグナルを待つ方が、シグナルハンドラを実装して、そこでシグナルを処理するよりも安全で簡単です。
非同期シグナルを処理するよりよい方法は、非同期シグナルを同期的に処理することです。具体的には、sigwait(2)で説明した sigwait(2) を呼び出すことにより、スレッドはシグナルの発生を待つことができます。
main() { sigset_t set; void runA(void); int sig; sigemptyset(&set); sigaddset(&set, SIGINT); pthread_sigmask(SIG_BLOCK, &set, NULL); pthread_create(NULL, 0, runA, NULL, PTHREAD_DETACHED, NULL); while (1) { sigwait(&set, &sig); printf("nestcount = %d\n", nestcount); printf("received signal %d\n", sig); } } void runA() { A(4,4); exit(0); } |
この例は、例 5–1 のコードを修正したものです。メインルーチンは SIGINT シグナルをマスクし、関数 A を呼び出す子スレッドを生成し、最後に sigwait() を呼び出して SIGINT シグナルを待ちます。
対象となるシグナルが、計算を行うためのスレッドでマスクされていることに注目してください。計算を行うためのスレッドは、メインスレッドのシグナルマスクを継承するからです。メインスレッドは SIGINT から保護されており、sigwait() の内部でだけ SIGINT に対するマスクが解除されます。
また、sigwait() を使用しているとき、システムコールから割り込まれる危険性がないことも注目してください。
シグナルを処理するもう 1 つの方法に、完了セマンティクス法があります。
完了セマンティクス法を使用するのは、シグナルが重大な障害が発生したことを示しているために現在のコード部を継続実行しても意味がない場合です。問題の原因となったコード部を引き続き実行する代わりに、シグナルハンドラが実行されます。つまり、シグナルハンドラによって、当該コード部の処理が完了されます。
例 5–3 で、if 文の then 部分の本体が問題のコード部です。setjmp(3C) の呼び出しは、プログラムの現在のレジスタ状態を jbuf に退避して 0 で復帰します。そして、このコード部が実行されます。
sigjmp_buf jbuf; void mult_divide(void) { int a, b, c, d; void problem(); sigset(SIGFPE, problem); while (1) { if (sigsetjmp(&jbuf) == 0) { printf("Three numbers, please:\n"); scanf("%d %d %d", &a, &b, &c); d = a*b/c; printf("%d*%d/%d = %d\n", a, b, c, d); } } } void problem(int sig) { printf("Couldn't deal with them, try again\n"); siglongjmp(&jbuf, 1); } |
SIGFPE (浮動小数点例外条件) が発生すると、シグナルハンドラが呼び出されます。
シグナルハンドラは、siglongjmp(3C) を呼び出します。この関数は、jbuf に退避されていたレジスタ状態を復元し、プログラムを sigsetjmp() 部分から復帰させます (プログラムカウンタとスタックポインタも退避されています)。
このとき、sigsetjmp(3C) は siglongjmp() の第 2 引数である 1 を返します。その結果、問題のコード部はスキップされ、while ループの次の繰り返しに入ります。
sigsetjmp(3C) と siglongjmp(3C) をマルチスレッドプログラムで使うこともできますが、別のスレッドで呼び出された sigsetjmp() の結果を使って、siglongjmp() を呼び出すことはできません。
また、sigsetjmp() と siglongjmp() は、シグナルマスクを退避または復元しますが、setjmp(3C) と longjmp(3C) は、シグナルマスクを退避または復元しません。
シグナルハンドラでは、sigsetjmp() と siglongjmp() を使用してください。
完了セマンティクス法は、例外条件の処理でよく使用されます。特に Sun AdaTM プログラミング言語では、このモデルが使用されています。
同期シグナルに対して sigwait(2) を決して使用しないでください。
スレッドに対する安全性と似た概念に、「非同期シグナル安全」があります。「非同期シグナル安全」操作は、割り込まれている操作を妨げないことが保証されています。
「非同期シグナル安全」に関する問題が生じるのは、現在の操作がシグナルハンドラによる割り込みで動作を妨げる可能性があるときです。
たとえば、プログラムが printf(3S) を呼び出している最中にシグナルが発生し、そのシグナルを処理するハンドラ自体も printf() を呼び出すとします。その場合は、2 つの printf() 文の出力が混ざり合ってしまいます。これを避けるには、printf() がシグナルに割り込まれたときにシグナルハンドラが printf() を呼び出さないようにします。
この問題は、同期プリミティブでは解決できません。シグナルハンドラと同期対象操作の間で同期をとろうとすると、たちまちデッドロックが発生するからです。
たとえば、printf() が自分自身を相互排他ロックで保護していると仮定します。あるスレッドが printf() を呼び出している最中に、つまり相互排他ロックを保持した状態にある時に、シグナルにより割り込まれたとします。
(printf() 内部にいるスレッドから呼び出されている) ハンドラ自身が printf() を呼び出すと、すでに相互排他ロックを保持しているスレッドが、もう一度相互排他ロックを獲得しようとします。その結果、即座にデッドロックとなります。
ハンドラと操作の干渉を回避するには、そうした状況が決して発生しないようにするか (通常は危険領域でシグナルをマスクする)、シグナルハンドラ内部では「非同期シグナル安全」操作以外は使用しないようにします。
POSIX が「非同期シグナル安全」を保証しているルーチンだけを表 5–2に示します。どのようなシグナルハンドラも、これらの関数を安全に呼び出すことができます。
表 5–2 「非同期シグナル安全」関数
_exit() |
fstat() |
read() |
sysconf() |
access() |
getegid() |
rename() |
tcdrain() |
alarm() |
geteuid() |
rmdir() |
tcflow() |
cfgetispeed() |
getgid() |
setgid() |
tcflush() |
cfgetospeed() |
getgroups() |
setpgid() |
tcgetattr() |
cfsetispeed() |
getpgrp() |
setsid() |
tcgetpgrp() |
cfsetospeed() |
getpid() |
setuid() |
tcsendbreak() |
chdir() |
getppid() |
sigaction() |
tcsetattr() |
chmod() |
getuid() |
sigaddset() |
tcsetpgrp() |
chown() |
kill() |
sigdelset() |
time() |
close() |
link() |
sigemptyset() |
times() |
creat() |
lseek() |
sigfillset() |
umask() |
dup2() |
mkdir() |
sigismember() |
uname() |
dup() |
mkfifo() |
sigpending () |
unlink() |
execle() |
open() |
sigprocmask() |
utime() |
execve() |
pathconf() |
sigsuspend() |
wait() |
fcntl() |
pause() |
sleep() |
waitpid() |
fork() |
pipe() |
stat() |
write() |
マスクされていない捕獲されたシグナルが条件変数上で待機しているスレッドに配信され、そのスレッドがシグナルハンドラから戻ると、そのスレッドは条件の待機状態からは戻りますが、これは誤って呼び起こされたもの (ほかのスレッドから送られた条件シグナルによらないもの) です。この場合、Solaris スレッドのインタフェース (cond_wait() と cond_timedwait()) は EINTR を返します。POSIX スレッドのインタフェース (pthread_cond_wait() と pthread_cond_timedwait()) は 0 を返します。 どちらのスレッドも、条件の待機状態から戻る前に、関連付けられている相互排他ロックを再度獲得します。
これは、スレッドがシグナルハンドラを実行しているときに相互排他ロックを獲得しているという意味ではありません。シグナルハンドラ内では、相互排他ロックの状態は不定です。
Solaris 9 より前のリリースに実装されている libthread では、シグナルハンドラ内の相互排他ロックの保持が保証されます。従来の動作に依存するアプリケーションは、Solaris 9 以降のリリースに合わせて修正する必要があります。
ハンドラのクリーンアップについて、例 5–4を使用して説明します。
int sig_catcher() { sigset_t set; void hdlr(); mutex_lock(&mut); sigemptyset(&set); sigaddset(&set, SIGINT); sigsetmask(SIG_UNBLOCK, &set, 0); if (cond_wait(&cond, &mut) == EINTR) { /* シグナルが発生し、ロックが保持される */ cleanup(); mutex_unlock(&mut); return(0); } normal_processing(); mutex_unlock(&mut); return(1); } void hdlr() { /* ロックの状態は未定義 */ ... } |
sig_catcher() が呼び出された時点では、すべてのスレッドで SIGINT シグナルがブロックされているものとします。さらに、sigaction(2) によって hdlr() が SIGINT シグナルのシグナルハンドラとして設定されているものとします。シグナルマスクが解除されて、キャッチされた SIGINT シグナルのインスタンスがスレッドに送信されたときに、スレッドが cond_wait() の状態だとします。スレッドは hdlr() を呼び出し、cond_wait() 関数に戻ります。このとき、相互排他ロックを必要に応じて再度獲得し、cond_wait() の EINTR を返します。
sigaction() で SA_RESTART フラグを指定したとしても、ここでは意味がないことに注意してください。cond_wait(3THR) はシステムコールではないため、自動的に再呼び出しされないからです。cond_wa でスレッドがブロックされているときにシグナルが送られてくると、cond_wait() は常に EINTR エラーで戻ります。
マルチスレッドプログラミングの利点の 1 つは、入出力の性能を高めることができることです。従来の UNIX の API では、この点に関してほとんどサポートされていませんでした。つまり、ファイルシステムに用意されている機構を利用するか、ファイルシステムを完全にバイパスするかのどちらかの選択肢しかありませんでした。
この節では、入出力の並行化やマルチバッファによって入出力の柔軟性を向上させるためのスレッドの使用方法を説明します。また、同期入出力 (スレッドを使用) と非同期入出力 (スレッドを使用することも使用しないこともある) の相違点と類似点についても説明します。
従来の UNIX のモデルでは、入出力は同期的に行われるように見え、入出力装置に対して、あたかも遠隔手続き呼び出しを行なっているように見えました。入出力の呼び出しが復帰した時点では、入出力は完了しているか、少なくとも完了しているように見えます (たとえば、書き込み要求はオペレーティング環境内のバッファにデータを転送しただけで戻ることがあります)。
このモデルの利点は、プログラマは手続き呼び出しの考え方に馴れているので、簡単に理解できることです。
従来の UNIX システムにはなかった代替モデルに非同期モデルがあります。このモデルでは、入出力要求は操作を開始させるだけで、プログラム側がなんらかの方法で操作の完了を検出しなければなりません。
この方法は同期モデルほど簡単ではありませんが、従来のシングルスレッドの UNIX プロセスでも並行入出力などの処理が可能であるという利点があります。
非同期入出力のほとんどの機能は、マルチスレッドプログラムによる同期入出力で実現できます。具体的には、要求を出した後でその要求の完了をチェックするという非同期入出力の操作を行う代わりに、独立したスレッドで同期入出力を実行します。メインスレッド側は、pthread_join(3THR) などによって入出力操作の完了を確認します。
各スレッドの同期入出力で同じ効果を実現できるため、非同期入出力が必要になることはほとんどありません。ただし、スレッドで実現できない非同期入出力機能もあります。
簡単な例は、ストリームとしてテープドライブへ書き込みを行う場合です。この場合、テープに書き込まれている間はテープドライブを停止させないようにし、テープに書き込むデータをストリームとして送っている間は高速にテープを先送りします。
これを行うためにカーネル内のテープドライバは、以前のテープへの書き込み操作が完了したことを知らせる割り込みに応答する時に、待ち行列に入っている書き込み要求を発行する必要があります。
スレッドでは、書き込み順序を保証できません。スレッドの実行される順序が不定だからです。たとえば、テープに対して順番どおり書き込みを行おうとしても不可能です。
aioread(3AIO) と aiowrite(3AIO) の形式は、pread(2) と pwrite(2) の形式にそれぞれ似ています。違いは、引数リストの最後に引数が 1 つ追加されていることです。aioread() または aiowrite() を呼び出すと、入出力操作が開始されます (あるいは、入出力要求が待ち行列に入れられます)。
この呼び出しはブロックされずに復帰し、resultp の指す構造体に終了状態が戻されます。これは aio_result_t 型の項目で、次のフィールドで構成されています。
int aio_return; int aio_errno; |
呼び出しが失敗すると、aio_errno にエラーコードが設定されます。そうでない場合は、このフィールドには操作要求が正常に待ち行列に入れられたことを示す AIO_INPROGRESS が設定されます。
非同期入出力操作の完了は、aiowait(3AIO) で待つことができます。この関数は、最初の aioread(3AIO)、または aiowrite(3) で指定した aio_result_t 構造体へのポインタを返します。
この時点で aio_result_t には、read(2) または write(2) のどちらかが非同期バージョン以外で呼ばれた時と同じ情報が設定されます。この read または write が正常終了した場合、aio_return には読み書きされたバイト数が設定されます。異常終了した場合、aio_return には -1、aio_errno にはエラーコードが設定されます。
aiowait() には timeout 引数があり、呼び出し側の待ち時間を設定できます。ここに NULL ポインタを指定すれば、無期限に待つという意味になります。また、値 0 が設定されている構造体を指すポインタの場合は、まったく待たないという意味になります。
非同期入出力操作を開始し別の処理を行なって aiowait() で操作の完了を待つ、あるいは操作完了時に非同期的に送られてくる SIGIO を利用するという方法もあります。
保留状態の入出力操作を取り消すときは、aiocancel() を使用します。このルーチンを呼び出すときは、取り消そうとする非同期入出力操作の結果を格納するアドレスを引数で指定します。
複数のスレッドが同じファイル記述子を使って同時に入出力操作を行う場合、従来の UNIX の入出力インタフェースがスレッドに対して安全ではない場合があります。この問題が生じるのは、入出力が逐次的に行われない場合です。システムコール lseek(2) でファイルオフセットを設定し、そのオフセットで次の read(2) または write(2) を呼び出してファイル内の操作開始位置を指定する場合です。このとき、同じファイル記述子に対して複数のスレッドが lseeks() を実行してしまうと矛盾が生じます。
この矛盾は、新しいシステムコール pread(2) と pwrite(2) で回避できます。
#include <sys/types.h> #include <unistd.h> ssize_t pread(int fildes, void *buf, size_t nbyte, off_t offset); ssize_t pwrite(int filedes, void *buf, size_t nbyte, off_t offset); |
これらのシステムコールの動作は、ファイルオフセットを指定するための引数が追加されていることを除いて、read(2) と write(2) とそれぞれ同じです。lseek(2) の代わりに、この引数でオフセットを指定すれば、複数のスレッドから同じファイル記述子に対して安全に入出力操作を実行できます。
標準入出力に関して、もう 1 つ問題があります。getc(3C) や putc(3C) などは、マクロとして実装されているため非常に高速に動作するという理由でよく使用されています。プログラムのループ内で使うときも、効率を気にする必要がないからです。
しかし、これらは、スレッドに対して安全になるよう変更されたため、以前よりも負荷が大きくなっています。変更後、(少なくとも) 2 つの内部サブルーチンが、相互排他のロックと解除のために呼び出されています。
この問題を回避するために、これらの代替マクロとして getc_unlocked(3C) と putc_unlocked(3C) が提供されています。
これらの代替マクロは mutex をロックしないので、スレッドに対して安全ではない元の getc(3C) および putc(3C) と同程度に高速です。
しかし、それらをスレッドに対して安全な方法で使うためには、標準入出力ストリームを保護する mutex を flockfile(3C) および funlockfile(3C) で明示的にロックまたは解除しなければなりません。ループの外側を flockfile() と funlockfile() で囲み、ループの内側で getc_unlocked() と putc_unlocked() を呼び出します。