マルチスレッドのプログラミング

第 5 章 Solaris ソフトウェアを使ったプログラミング

この章では、マルチスレッドと Solaris ソフトウェアとの関係について説明します。また、マルチスレッドをサポートするために Solaris ソフトウェアに加えられた変更点についても説明します。

プロセスの生成時の fork の問題

Solaris 9 製品とそれ以前の Solaris リリースでは、fork() のデフォルトの処理方法が、POSIX スレッドでの fork() の処理方法と少し異なっていました。Solaris 9 以降のリリースでは、fork() は、常に POSIX スレッドの fork と同様に動作します。

表 5–1 に、Solaris スレッドと POSIX スレッドの fork() の処理の相違点と共通点を示します。POSIX スレッドまたは Solaris スレッドの側に相当するインタフェースがない項目については、「-」が記入されています。

表 5–1 POSIX と Solaris での fork() の処理の比較
 

Solaris のインタフェース 

POSIX スレッドのインタフェース 

fork1 モデル 

fork1(2)

fork(2)

fork(2)

汎用 fork モデル 

forkall(2)

forkall(2)

fork - 安全 

— 

pthread_atfork(3C)

fork1 モデル

表 5–1 で示すように、POSIX スレッドの fork(2) 関数の動作は、Solaris の fork1(2) 関数の動作と同じです。POSIX スレッドの fork(2) 関数も Solaris の fork1(2) 関数も新しいプロセスを生成し、子プロセスに完全なアドレス空間の複製を作成します。しかし、どちらの関数も、子プロセス内に呼び出しスレッドのみを複製します。

呼び出しスレッドを子プロセス内に複製する処理は、子プロセスが生成後ただちに exec() を呼び出すような場合によく使用します。実際、このような状況は、fork() を呼び出したあとによく発生します。この場合、子プロセスは fork() を呼び出したスレッド以外のスレッドの複製は必要としません。

子プロセスでは、fork() を呼び出してから exec() を呼び出すまでの間に、ライブラリ関数を呼び出さないようにします。ライブラリ関数の中には、fork() 呼び出し時に親に保持されているロックを使用するものがあります。子プロセスは exec() ハンドラの 1 つが呼び出されるまで、「非同期シグナル安全」操作しか行えません。「非同期シグナル安全」関数の詳細については、「シグナルハンドラと「非同期シグナル安全」」を参照してください。

fork1 モデルにおける安全性の問題とその解決策

共有データのロックのような一般的な考慮事項に加えて、次のような問題があります。ライブラリは、fork() を呼び出したスレッドだけが実行されているとき、子プロセスを fork することに上手に対処する必要があります。この場合の問題は、子プロセスの唯一のスレッドが、その子プロセスに複製されなかったスレッドによって保持されているロックを占有しようとする可能性があることです。

ほとんどのプログラムでは、この問題は発生しません。ほとんどのプログラムは、fork() から復帰した直後に子プロセス内で exec() を呼び出します。しかし、子プロセス内で何らかの処理を行なってから exec() を呼び出す場合、または exec() をまったく呼び出さない場合、子プロセスはデッドロックに遭遇する可能性があります。ライブラリの作成者は安全な解決策を提供してください。もっとも、fork に対して安全なライブラリを提供しなくても (このような状況が稀であるため) 大きな問題にはなりません。

たとえば、T1 が何かを出力している途中であり、printf() のためにロックを保持しているとします。この状態で、T2 が新しいプロセスを fork すると仮定しましょう。この場合、子プロセス内で唯一のスレッド (T2) が printf() を呼び出せば、すぐさまデッドロックに陥ります。

POSIX の fork() と Solaris の fork1() は、それを呼び出したスレッドのみを複製します。()()Solaris の forkall() を呼び出した場合は、すべてのスレッドが複製されるので、この問題は発生しません。

しかし、forkall() では別の問題が発生する可能性があるので、使用する場合は注意が必要です。たとえば、あるスレッドが forkall() を呼び出した場合、ファイルへの入出力を実行する親スレッドが子プロセス内に複製されます。スレッドのコピーはどちらも同じファイルへの入出力を続行します。ただし、一方は親、他方は子に入出力を行うので、機能不全やファイルの破損が発生します。

fork1() の呼び出し時のデッドロックを防ぐには、fork 時に保持されているロックがないようにする必要があります。デッドロックを防ぐためのもっとも明瞭なやり方は、fork を行うスレッドに、子プロセスによって使われる可能性のあるロックをすべて獲得させることです。printf() に対してすべてのロックを獲得することはできないので (printf()libc() によって所有されているため)、fork() の呼び出しは printf を使用していない状態で行うようにしなければなりません。


ヒント –

Sun Studio ソフトウェアに含まれている Thread Analyzer ユーティリティーを使用すると、実行中のプログラム内のデッドロックを検出できます。詳細については、『Sun Studio 12: スレッドアナライザユーザーズガイド』を参照してください。


ライブラリ内でロックを管理するには、次の処理を行う必要があります。

次の例では、ライブラリによって使用されるロックのリストは { L1,...Ln} です。これらのロックのロック順序も L1...Ln です。

mutex_lock(L1);
mutex_lock(L2);
fork1(...);
mutex_unlock(L1);
mutex_unlock(L2);

Solaris スレッド、POSIX スレッドのどちらを使用する場合でも、pthread_atfork(f1, f2, f3) の呼び出しをライブラリの .init() セクションに追加できます。f1()f2()f3() の定義は次のとおりです。

f1() /* This is executed just before the process forks. */
{
 mutex_lock(L1); |
 mutex_lock(...); | -- ordered in lock order
 mutex_lock(Ln); |
 } V

f2() /* This is executed in the child after the process forks. */
 {
 mutex_unlock(L1);
 mutex_unlock(...);
 mutex_unlock(Ln);
 }

f3() /* This is executed in the parent after the process forks. */
 {
 mutex_unlock(L1);
 mutex_unlock(...);
 mutex_unlock(Ln);
 } 

仮想 fork–vfork

標準の vfork(2) 関数は、マルチスレッドプログラムでは危険です。vfork(2) は、呼び出しスレッドだけを子プロセスにコピーする点が fork1(2) に似ています。ただし、スレッドに対応した実装ではないので、vfork() は子プロセスにアドレス空間をコピーしません。

スレッドが exec(2) を呼び出す前に、子プロセス内のスレッドによってメモリーが変更されないように注意してください。vfork() では、親プロセスのアドレス空間が子プロセスにそのまま渡されます。子プロセスが exec() を呼び出すか終了すると、親プロセスにアドレス空間が戻されます。子プロセスが親プロセスの状態を変更してはなりません。

たとえば、vfork() を呼び出してから exec() を呼び出すまでの間に、新しいスレッドを生成すると非常に危険です。

解決策: pthread_atfork

fork 1 モデルを使用するときは必ず、pthread_atfork() を使ってデッドロックを防ぎます。

#include <pthread.h>

int pthread_atfork(void (*prepare) (void), void (*
parent) (void),
    void (*child) (void) );

pthread_atfork() 関数は、fork() を呼び出したスレッドのコンテキストで fork() の前後に呼び出される fork() のハンドラを宣言します。

すべてのハンドラ引数は NULL に設定できます。連続する pthread_atfork() 呼び出しの順序が重要です。

たとえば、prepare ハンドラが、必要な相互排他ロックをすべて獲得することがあります。次に、parent および child ハンドラがこれらの相互排他ロックを解放することがあります。必要なすべての相互排他ロックを獲得する prepare ハンドラは、プロセスが fork される前に、関連するすべてのロックが、fork 関数を呼び出すスレッドによって保持されているようにする必要があります。この方法で、子プロセスでのデッドロックを防ぐことができます。

詳細は、pthread_atfork(3C) のマニュアルページを参照してください。

汎用 fork モデル

Solaris forkall(2) 関数は、アドレス空間とすべてのスレッドを子プロセスに複製します。アドレス空間の複製は、子プロセスで exec(2) をまったく呼び出さないが親のアドレス空間のコピーを使用する場合などに使用します。

プロセス内のあるスレッドが Solaris の forkall(2) を呼び出すと、割り込み可能なシステムコール処理中にブロックされたスレッドは EINTR を返します。

親プロセスと子プロセスの両方に保持されるようなロックは作成しないでください。MAP_SHARED フラグを指定して mmap() を呼び出すことにより、共有メモリー内にロックを割り当てると、親プロセスと子プロセスの両方に保持されるロックが生成されます。fork 1 モデルを使用すると、この問題は発生しません。

正しい fork の選択

Solaris 10 リリースから、fork() の呼び出しは fork1() の呼び出しと等価になりました。すなわち、子プロセスには、呼び出しスレッドだけが複製されます。この動作は、POSIX の fork() の動作と同じです。

以前の Solaris ソフトウェアのリリースでは、fork() の動作は、アプリケーションが POSIX スレッドライブラリにリンクされているかどうかによって決定されていました。-lthread (Solaris スレッド) とリンクされていても -lpthread (POSIX スレッド) とリンクされていなければ、fork()forkall() と等価でした。-lpthread とリンクされていれば、-lthread とリンクされているかどうかに関係なく、fork()fork1() と等価でした。()

Solaris 10 リリースから、マルチスレッドアプリケーションには、-lthread-lpthread のどちらも不要になりました。マルチスレッドアプリケーションをコンパイルしていることを示すには、-mt オプションを使用します。どちらのアプリケーションプログラムインタフェースを使用する場合でも、標準 C ライブラリがすべてのスレッド機能を提供します。アプリケーションで、すべての fork セマンティクスを複製する必要がある場合は、forkall() を呼び出してください。

プロセスの作成: execexit の問題

exec(2)exit(2) は、次の点を除いて、シングルスレッドのプロセスの場合と変わりません。マルチスレッドアプリケーションでは、これらの関数は、アドレス空間内のすべてのスレッドを削除します。どちらのシステムコールも、スレッドを含むすべての実行リソースが削除されるまでブロック状態になります。

exec() は、プロセスを再構築するときに LWP を 1 つ生成します。()さらにプロセス起動時に初期スレッドを生成します。通常、初期スレッドが処理を終えると exit() を呼び出し、プロセスは削除されます。

プロセス内のすべてのスレッドが終了すると、そのプロセスも終了します。複数のスレッドをもつプロセスから exec() 関数が呼び出されると、すべてのスレッドが終了し、新しい実行可能イメージがロードされ実行されます。デストラクタ関数は呼び出されません。

タイマー、アラーム、およびプロファイル

Solaris OS は、このいくつかのリリースの間に、アラーム、インターバルタイマー、およびプロファイルに関してプロセスごとのモードに展開してきました。

タイマー

LWP ごとであるリアルタイムプロファイルのインターバルタイマーを除き、すべてのタイマーがプロセスごとです。ITIMER_REALPROF タイマーについては、setitimer(2) のマニュアルページを参照してください。

プロセスごとのタイマーのタイマー ID は、どの LWP からでも使用できます。期限切れシグナルは、特定の LWP に対してではなく、そのプロセスに対して生成されます。

プロセスごとのタイマーは、timer_delete(3RT) の呼び出し時またはそのプロセスの終了時にのみ削除されます。

アラーム

アラームは、スレッドレベルではなくプロセスレベルで動作します。alarm() 関数は、シグナル SIGALRM を呼び出しスレッドではなく呼び出しプロセスに送信します。

マルチスレッドプログラムのプロファイリング

マルチスレッドプロセスの profil() システムコールは、そのプロセス内のすべての LWP およびスレッドに大域的な影響力を持ちます。スレッドが、個々のスレッドのプロファイリングのために profil() を使用することはできません。詳細は、profil(2) のマニュアルページを参照してください。


ヒント –

Sun Studio ソフトウェアに含まれている Performance Analyzer ツールを使用すると、マルチスレッドプログラムやシングルスレッドプログラムの広範囲なプロファイリングを行うことができます。このツールでは、指定した任意の時点でのスレッドの動作を詳細に表示できます。詳細は、Sun Studio Web ページおよび Sun Studio Information Center を参照してください。


大域ジャンプ: setjmplongjmp

setjmp()longjmp() のスコープは、1 つのスレッドに制限されています。この制限は、ほとんどの場合問題になりません。しかし、このスコープの制限は、シグナルを扱うスレッドが longjmp() を実行できるのは、setjmp() が同一スレッド内で実行されている場合だけであることを意味します。

リソースの制限

リソースの制限は、そのプロセス全体に課せられ、プロセス内のすべてのスレッドが全体でどれだけリソースを使用しているかによって決まります。リソースの弱い制限値を超えた場合は、制限に違反したスレッドにシグナルが送られます。プロセス内で使用されているリソースの合計は、getrusage(3C) で調べることができます。

LWP とスケジューリングクラス

Solaris のカーネルには、ディスパッチ優先順位の 3 つの範囲があります。もっとも高い優先順位の範囲 (100 - 159) は、リアルタイム (RT) スケジューリングクラスに対応しています。中間の優先順位の範囲 (60 - 99) は、システム (SYS) スケジューリングクラスに対応しています。システム (system) クラスは、ユーザープロセスには適用されません。もっとも低い優先順位の範囲 (0 - 59) は、タイムシェアリング (TS)、対話型 (IA)、公平配分 (FSS)、および固定優先順位 (FX) スケジューリングクラスで共有されます。

スケジューリングクラスは、LWP ごとに管理されます。プロセスが生成されると、そのプロセスの初期 LWP は、親プロセスのスケジューリングクラスと作成元の LWP の優先順位を継承します。スレッドが生成されていく過程で、関連 LWP はこのスケジューリングクラスと優先順位を継承します。

スレッドは、関連付けられている LWP と同じスケジューリングクラスおよび優先順位を持ちます。プロセス内の各 LWP は、カーネルから参照される固有のスケジューリングクラスおよび優先順位を持つことができます。

同期オブジェクトへの競合は、スレッドの優先順位によって調節されます。デフォルトでは、LWP はタイムシェアクラスです。計算が大きな比率を占めるマルチスレッドの場合、スレッドの優先順位はあまり役立ちません。MT ライブラリを使って頻繁に同期を行うマルチスレッドアプリケーションでは、スレッドの優先順位はより意味を持ちます。

スケジューリングクラスは、priocntl(2) によって設定されます。最初の 2 つの引数で、この設定の適用範囲を呼び出し側の LWP に限定したり、1 つ以上のプロセスのすべての LWP にしたりすることが可能です。3 番目の引数はコマンドで、次のいずれかのコマンドを指定できます。()

LWP のユーザーレベルの優先順位は、そのディスパッチ優先順位ではなく、そのクラス内の優先順位です。この優先順位は、priocntl() システムコールのアプリケーションによる場合を除き、時間が経っても変更されることはありません。カーネルは、LWP のディスパッチ優先順位を、そのスケジューリングクラス、そのクラス内での優先順位、さらには最近使用された CPU 時間などのほかの要因に基づいて決定します。

タイムシェアスケジューリング

タイムシェアスケジューリングは、プロセッサリソースをタイムシェアリング (TS) および対話型 (IA) スケジューリングクラス内の LWP の間で公平に配分しようとします。

priocntl(2) の呼び出しは、1 つ以上のプロセスまたは LWP のクラス優先順位を設定します。タイムシェアクラスの優先順位の標準の範囲は -60 - +60 です。この値が大きいほど、カーネルのディスパッチ優先順位は高くなります。タイムシェアクラスのデフォルトの優先順位は 0 です。

プロセスに対する nice 値の古い概念 (nice 値が小さいほど高い優先順位を示す) は、TS、IA、および FSS のすべてのスケジューリングクラスのために維持されています。古い nice ベースの setpriority(3C)nice(2) のインタフェースは、nice 値を優先順位の値に対応させることによって引き続き動作します。nice 値を設定すると優先順位が変更され、その逆も同様です。nice 値の範囲は -20 - +20 です。0 の nice 値が 0 の優先順位に対応し、-20 の nice 値が +60 の優先順位に対応します。

タイムシェアクラスの LWP のディスパッチ優先順位は、LWP のその時点での CPU 使用率と、その LWP のクラス優先順位から計算されます。タイムシェアスケジューラにとって、クラス優先順位は、LWP 間の相対的な優先順位を表します。

LWP のクラス優先順位が低いほど、その LWP に配分される CPU 時間は少なくなりますが 0 になることはありません。多くの CPU 時間をすでに消費している LWP は、CPU 時間をほとんど (あるいは、まったく) 消費していない LWP よりもディスパッチ優先順位が下げられます。

リアルタイムスケジューリング

リアルタイムクラス (RT) は、プロセス全体、または単一プロセス内の 1 つ以上の LWP に適用できます。リアルタイムクラスを使用するには、スーパーユーザー特権が必要です。

リアルタイムクラスの優先順位の標準の範囲は 0 - 59 です。リアルタイムクラス内の LWP のディスパッチ優先順位は、そのクラス優先順位プラス 100 に固定されています。

スケジューラは、もっとも高い優先順位を持つリアルタイムクラスの LWP をディスパッチします。もっとも優先順位の高いリアルタイム LWP は、優先順位の高い LWP が実行可能になると、優先順位の低いほうの LWP を横取りします。横取りされた LWP は、レベルキューの先頭に配置されます。

リアルタイム LWP は、LWP が横取りされたり、LWP が一時停止したり、リアルタイム優先順位が変更されたりしないかぎり、プロセッサを制御し続けます。リアルタイムクラスの LWP には、タイムシェアクラスのプロセスよりも絶対的に高い優先順位が与えられます。

新しく生成された LWP は、親プロセスまたは親 LWP のスケジューリングクラスを継承します。リアルタイムクラスの LWP は、親のタイムスライス (リソース割り当て時間) を有限または無限指定に関係なく継承します。

有限タイムスライスを指定された LWP が実行を停止するのは、LWP が終了するか、入出力イベントでブロックされるか、より優先順位の高い実行可能なリアルタイムプロセスによって横取りされるか、タイムスライスが期限切れになったときだけです。

無限タイムスライスを持つ LWP は、LWP が終了するか、ブロックされるか、または横取りされるまで実行されます。

公平配分スケジューリング

公平配分スケジューラ (FSS) スケジューリングクラスでは、配分に基づいて CPU 時間を割り当てることができます。

公平配分スケジューラクラスの優先順位の標準の範囲は -60 - 60 です。この範囲は、スケジューラによって、TS および IA スケジューリングクラスと同じ範囲 (0 - 59) のディスパッチ優先順位に対応付けされます。プロセス内の LWP は、すべて同じスケジューリングクラスで実行する必要があります。FSS クラスでは、プロセス全体ではなく、個々の LWP のスケジュールを設定します。FSS および TS/IA のクラスを同時に使用すると、どちらのクラスも予期しないスケジュールで動作することがあります。

TS/IA と FSS のスケジューリングクラスプロセスが、同じ CPU を奪い合うことはありません。複数のプロセッサセットを使用する場合、TS/IP と FSS を同一システム内に混在させることができます。ただし、各プロセッサセット内のプロセスはすべて、TS/IA または FSS のどちらかのスケジューリングクラスでなければなりません。

固定優先順位スケジューリング

固定優先順位のスケジューリングクラス (FX) は、リソースの消費量とは無関係に、優先順位および時間量に固定値を割り当てます。プロセスの優先順位は、そのプロセス自体、または適切な特権が割り当てられたほかのプロセスだけが変更できます。FX については、priocntl(1) および dispadmin (1M) のマニュアルページを参照してください。

固定優先順位スケジューラクラスの優先順位の標準の範囲は 0 - 60 です。この範囲は、スケジューラによって、TS および IA スケジューリングクラスと同じ範囲 (0 - 59) のディスパッチ優先順位に対応付けされます。

シグナルの拡張

従来の UNIX のシグナルモデルが、スレッドでも使用できるように、自然な方法で拡張されています。この拡張の主な特徴は、シグナルに対する処置がプロセス全体に適用され、シグナルマスクはスレッドごと適用されることです。プロセス全体に適用されるシグナル処置は、signal(3C)sigaction(2) などの従来の機構を使って設定します。

シグナルハンドラが SIG_DFL または SIG_IGN に設定されている場合、シグナルを受け取ると、受け取り側のプロセス全体に対して処理が実行されます。これらのシグナルには、終了、コアダンプ、停止、継続、無視が含まれます。これらのシグナルを受け取ったときの処理は、プロセス内のすべてのスレッドに対して実行されます。したがって、どのスレッドがシグナルを受け取るかという問題は重要ではありません。終了、コアダンプ、停止、継続、無視のシグナルは、ハンドラを持ちません。シグナルに関する基本的な情報については、signal.h(3HEAD) のマニュアルページを参照してください。

各スレッドに、固有のシグナルマスクがあります。スレッドとシグナルハンドラがメモリーまたはほかの状態を共有しているときに、スレッドはシグナルマスクに基づいて特定のシグナルをブロックします。同じプロセス内のすべてのスレッドは、sigaction(2) またはそれに相当する機能によって設定されるシグナルハンドラを共有します。

あるプロセス内のスレッドが、別のプロセス内の特定のスレッドにシグナルを送ることはできません。kill(2)sigsend(2)、または sigqueue(3RT) からプロセスに送信されたシグナルは、そのプロセス内の受け入れ可能な任意のスレッドによって処理されます。

シグナルは、トラップ、例外、および割り込みに分類されます。 トラップと例外は、同期的に生成されるシグナルです。割り込みは、非同期的に生成されるシグナルです。

従来の UNIX と同様、シグナルが保留状態のときに同じシグナルが再度発生しても通常は無視されます。保留状態のシグナルは、カウンタではなくビットで表現されます。しかし、sigqueue(3RT) インタフェースを使用してシグナルを送信すれば、複数の同じシグナルのインスタンスを、プロセスのキューに格納することができます。

シングルスレッドのプロセスのときと同様、スレッドがシステムコールを呼び出してブロックされている間にシグナルを受け取ると、早期終了することがあります。スレッドが早期終了すると、EINTR エラーコードが返されます。また、入出力呼び出しの場合、要求したバイト数の一部が転送されません。

マルチスレッドプログラムでは、特に pthread_cond_wait(3C) へのシグナルの影響に注意する必要があります。この関数は通常、pthread_cond_signal(3C) または pthread_cond_broadcast(3C) への応答の場合に限り、エラー (戻り値が 0) を返さずに終了します。しかし、待機中のスレッドが従来の UNIX シグナルを受信した場合、pthread_cond_wait() は、誤って呼び起こされた場合でも値 0 を返します。

同期シグナル

SIGILLSIGFPESIGSEGV などのトラップは、スレッド上でゼロ除算や存在しないメモリーの参照などの操作が行われたときに発生します。トラップは、そのトラップを発生させたスレッドだけが処理します。プロセス内の複数のスレッドが、同じ種類のトラップを同時に発生させて処理することもできます。

同期シグナルに関しては、シグナルの概念を個々のスレッドに簡単に拡張できます。ハンドラは、同期シグナルを生成したスレッド上で起動します。

しかし、適切なシグナルハンドラが設定されていない場合、そのプロセスはトラップに対してデフォルトの処理を実行します。デフォルトの処理は、問題を起こしたスレッドが生成されたシグナル上でブロックされた場合も適用されます。このようなシグナルのデフォルトの処理では、プロセスの終了に通常コアダンプを伴います。

こうした同期シグナルは、通常、特定のスレッドだけでなくプロセス全体に悪影響を及ぼすような重大な事態を表します。このような場合には通常、プロセスを終了するのが適切です。

非同期シグナル

SIGINTSIGIO などの割り込みは、あらゆるスレッドに対して、プロセス外部の何らかの処理が原因で非同期的に発生します。これらの割り込みは、ほかのスレッドから明示的に送られてきたシグナルである場合もあれば、ユーザーによる Control - C キーの入力など、外部の処理を表す場合もあります。

割り込みは、その割り込みを受け取るようにシグナルマスクが設定されているどのスレッドでも処理できます。複数のスレッドが、割り込みを受け取ることができるように設定されている場合は、その中の 1 つのスレッドだけが選択されます。

同じシグナルが複数発生し、1 つのプロセスに送られた場合、それぞれのシグナルを別のスレッドで処理できます。ただし、それらのスレッドがシグナルをマスクしていない場合に限られます。また、すべてのスレッドがマスクしているときは、「保留」の印が付けられ、最初にマスク解除したスレッドによって処理されます。

継続セマンティクス法

継続セマンティクス法は、従来から行われてきたシグナル処理方法です。シグナルハンドラが終了すると、割り込みが発生した時点からプロセスの実行が再開されます。このような制御の復帰方法は、シングルスレッドプロセスで非同期シグナルを扱うのに適しています。例 5–1 を参照してください。

PL/1 などの一部のプログラミング言語の例外処理機構でも、このような制御の復帰方法が使用されています。


例 5–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(3C) は、sigprocmask(2) がプロセスに対して行うのと同じ処理を、スレッドに対して行います。pthread_sigmask() は、スレッドのシグナルマスクを設定します。新しいスレッドが生成されると、その初期状態のシグナルマスクは生成元から継承されます。

マルチスレッドプロセス内で sigprocmask() を呼び出すのは、pthread_sigmask() を呼び出すのと同等です。詳細は、sigprocmask(2) のマニュアルページを参照してください。

特定のスレッドへのシグナルの送信

pthread_kill(3C) は、スレッド用の kill(2) です。pthread_kill() の呼び出しは、特定のスレッドにシグナルを送ります。特定のスレッドに送られるシグナルは、プロセスに送られるシグナルとは異なります。プロセスに送られたシグナルは、プロセス内のどのスレッドでも処理できます。pthread_kill() で送られたシグナルは、指定されたスレッドだけが処理できます。

pthread_kill() でシグナルを送ることができるのは、現在のプロセス内のスレッドに限られます。スレッド識別子 (thread_t 型) のスコープは局所的であるため、現在のプロセスのスコープにないスレッドは指定できません。

宛先スレッドでシグナルの受信時に行われる処理 (ハンドラ、SIG_DFL、または SIG_IGN) は、通常どおり大域的です。あるスレッドに、あるプロセスを終了させる SIGXXX を送信する場合、宛先スレッドがこのシグナルを受け取った時点でプロセス全体が終了します。

特定のシグナルの待機

マルチスレッドプログラムでは、sigwait(2) が優先インタフェースです。これは、非同期シグナルを正しく処理できるからです。()

sigwait() は、sigwait() 関数の set 引数に指定したシグナルが呼び出しスレッドに送られてくるまで、そのスレッドを待ち状態にします。スレッドが待っている間は、set 引数で指定したシグナルのマスクが解除され、復帰時に元のシグナルマスクが設定し直されます。

set 引数で識別されるすべてのシグナルは、呼び出しスレッドを含むすべてのスレッドでブロックする必要があります。そうしないと、sigwait() は正確に動作しません。

非同期シグナルからプロセス内のスレッドを隔離したい場合は、sigwait() を使用します。非同期シグナルを待つスレッドを 1 つ生成しておき、ほかのスレッドは、現在のプロセスに送られてくる可能性のある非同期シグナルをすべてブロックするようにすることができます。

次の例は、sigwait() の構文を示しています。

#include <signal.h>
int sigwait(const sigset_t *set, int *sig
);

指定のシグナルが送られてくると、sigwait() は保留されているそのシグナルを削除し、sig にそのシグナルの番号を入れます。同時に複数のスレッドから sigwait() を呼び出すこともできますが、受け取るシグナルごとに 1 つのスレッドだけの sigwait だけが返ってきます。

sigwait() では、非同期シグナルを同期的に扱うことができます。こうしたシグナルを扱うスレッドは、sigwait() を呼び出したあと、シグナルが到着するとすぐ終了します。sigwait() の呼び出し側を含むすべてのスレッドで非同期シグナルをマスクすることによって、非同期シグナルを特定のシグナルハンドラだけに安全に処理させることができます。

すべてのスレッドですべてのシグナルを常にマスクし、必要なときだけ sigwait() を呼び出すようにすれば、アプリケーションはシグナルに依存するスレッドに対してはるかに安全になります。

通常はsigwait() を呼び出すスレッドを 1 つ以上作成して、シグナルを待機します。sigwait() はマスクされているシグナルであっても受け取るため、それ以外のスレッドでは誤ってシグナルを受け取ることがないように、対象となるシグナルをすべてブロックしてください。

シグナルを受け取ったシグナル処理スレッドは、sigwait() から復帰してそのシグナルを処理したあと、sigwait() を再度呼び出して次のシグナルを待機します。このシグナル処理スレッドは、非同期シグナル安全関数以外にも使用できます。このシグナル処理スレッドは、ほかのスレッドとも通常の方法で同期をとることができます。非同期シグナル安全カテゴリについては、「マルチスレッドインタフェースの安全レベル」を参照してください。


注 –

sigwait() は、非同期シグナルを受け取れません。


特定のシグナルを指定された時間だけ待機

sigtimedwait(3RT)sigwait(2) に似ていますが、sigtimedwait() は指定時間内にシグナルを受け取らなかった場合に失敗してエラーを返すという点が異なっています。詳細は、sigtimedwait(3RT) のマニュアルページを参照してください。

スレッド指定シグナル

UNIX のシグナル機構が、スレッド指定シグナルの概念で拡張されています。スレッド指定シグナルは、プロセスではなく特定のスレッドに送られるという点を除いて、通常の非同期シグナルと似ています。

独立したスレッドで非同期シグナルを待つほうが、シグナルハンドラを実装して、そこでシグナルを処理するよりも安全で簡単です。

非同期シグナルを処理するよりよい方法は、非同期シグナルを同期的に処理することです。sigwait(2) を呼び出すことにより、スレッドはシグナルの発生まで待機できます。「特定のシグナルの待機」を参照してください。


例 5–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 で復帰します。そして、このコード部が実行されます。


例 5–3 完了セマンティクス法

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(3C) の呼び出し中にシグナルが発生し、このシグナルのハンドラが printf() を呼び出すとします。その場合は、2 つの printf() 文の出力が混ざり合ってしまいます。この問題を避けるには、printf() がシグナルに割り込まれる可能性があるときは、シグナルハンドラが printf() を呼び出さないようにします。

この問題は、同期プリミティブを使用しても解決できません。シグナルハンドラと同期対象の操作の同期をとろうとすると、たちまちデッドロックが発生します。

たとえば、printf() が自分自身を相互排他ロックで保護していると仮定します。あるスレッドが printf() を呼び出している最中に、つまり相互排他ロックを保持した状態にある時に、シグナルにより割り込まれたとします。

このハンドラが printf() を呼び出す場合、すでに相互排他ロックを保持するスレッドが、新たに相互排他ロックを保持しようとします。その結果、即座にデッドロックが発生します。

ハンドラと操作の干渉を回避するには、そうした状況が決して発生しないようにします。たとえば、危険領域でシグナルをマスクしたり、シグナルハンドラ内部では「非同期シグナル安全」操作以外は使用しないようにする方法があります。

表 5–2 に、POSIX が「非同期シグナル安全」を保証しているルーチンを示します。どのようなシグナルハンドラも、これらの関数を安全に呼び出すことができます。

表 5–2 「非同期シグナル安全」関数

_Exit()

fpathconf()

read()

sigset()

_exit()

fstat()

readlink()

sigsuspend()

abort()

fsync()

recv()

sockatmark()

accept()

ftruncate()

recvfrom()

socket()

access()

getegid()

recvmsg()

socketpair()

aio_error()

geteuid()

rename()

stat()

aio_return()

getgid()

rmdir()

symlink()

aio_suspend()

getgroups()

select()

sysconf()

alarm()

getpeername()

sem_post()

tcdrain()

bind()

getpgrp()

send()

tcflow()

cfgetispeed()

getpid()

sendmsg()

tcflush()

cfgetospeed()

getppid()

sendto()

tcgetattr()

cfsetispeed()

getsockname()

setgid()

tcgetattr()

cfsetospeed()

getsockopt()

setpgid()

tcsendbreak()

chdir()

getuid()

setsid()

tcsetattr()

chmod()

kill()

setsockopt()

tcsetpgrp()

chown()

リンク()

setuid()

time()

clock_gettime()

listen()

shutdown()

timer_getoverrun()

close()

lseek()

sigaction()

timer_gettime()

connect()

lstat()

sigaddset()

timer_settime()

creat()

mkdir()

sigdelset()

times()

dup()

mkfifo()

sigemptyset()

umask()

dup2()

open()

sigfillset()

uname()

execle()

pathconf()

sigismember()

ulink()

execve()

pause()

sleep()

utime()

fchmod()

pipe()

signal()

wait()

fchown()

poll()

sigpause()

waitpid()

fcntl()

posix_trace_event()

sigpending ()

write()

fdatasync()

pselect()

sigprocmask()

 

fork()

raise()

sigqueue()

 

条件変数上で割り込まれた待機

マスクされていない捕獲されたシグナルが条件変数上で待機しているスレッドに配信された場合、シグナルハンドラから復帰すると、スレッドは誤った呼び起こしによって条件待ち関数から復帰します。 pthread_cond_wait() および pthread_cond_timedwait() は、pthread_cond_signal() または pthread_cond_broadcast() の呼び出しが別のスレッドによって実行されなかった場合でも 0 を返します。この場合、sigaction() へのフラグとして SA_RESTART が指定されたかどうかは無視されます。pthread_cond_wait() および pthread_cond_timedwait() 関数は、自動的には再起動されません。いずれの場合も、条件待ちから復帰する前に、関連付けられている相互排他ロックを再度獲得します。

関連付けられている相互排他ロックを再度獲得しても、スレッドがシグナルハンドラを実行しているときに相互排他ロックを獲得しているという意味ではありません。シグナルハンドラ内では、相互排他ロックの状態は不定です。

入出力の問題

マルチスレッドプログラミングの利点の 1 つは、入出力の性能を高めることができることです。従来の UNIX の API では、この点に関してほとんどサポートされていませんでした。つまり、ファイルシステムに用意されている機構を利用するか、ファイルシステムを完全にバイパスするかのどちらかの選択肢しかありませんでした。

この節では、入出力の並行化やマルチバッファーによって入出力の柔軟性を向上させるためのスレッドの使用方法を説明します。また、同期入出力 (スレッドを使用) と非同期入出力 (スレッドを使用することも使用しないこともある) の相違点と類似点についても説明します。

遠隔手続き呼び出しとしての入出力

従来の UNIX のモデルでは、あたかも入出力装置に対して遠隔手続き呼び出しを行なっているかのように、入出力が同期した状態に見えました。呼び出しが復帰した時点では、入出力は完了しているか、少なくとも完了しているように見えます。たとえば、書き込み要求は、オペレーティング環境内のバッファーにデータを転送するだけで終了することがあります。

このモデルの利点は、よく使われる手続き呼び出しの考え方が利用されている点です。

従来の UNIX システムにはなかった代替モデルに非同期モデルがあります。このモデルでは、入出力要求は操作を開始させるだけです。プログラム側がなんらかの方法で操作の完了を検出しなければなりません。

非同期モデルは同期モデルほど簡単ではありません。しかし、従来のシングルスレッドの UNIX プロセスでも並行入出力などの処理が可能であるという利点があります。

非同期性の管理

非同期入出力のほとんどの機能は、マルチスレッドプログラムによる同期入出力で実現できます。非同期入出力では、要求の発行後、入出力がいつ完了したかをチェックします。同期入出力では、独立したスレッドで入出力を同期的に実行できます。メインスレッド側は、pthread_join(3C) の呼び出しなどによって、あとで入出力操作の完了を確認できます。

非同期入出力

各スレッドの同期入出力で同じ効果を実現できるため、非同期入出力が必要になることはほとんどありません。ただし、スレッドで実現できない非同期入出力機能もあります。

簡単な例は、ストリームとしてテープドライブへ書き込みを行う場合です。この場合、テープに書き込まれている間は、テープドライブを停止させないようにします。テープに書き込むデータをストリームとして送っている間は、テープは高速で先送りされます。

ストリーミングをサポートするには、カーネル内のテープドライバはスレッドを使用する必要があります。カーネル内のテープドライバは、割り込みに応答するときに、待ち行列に入っている書き込み要求を発行する必要があります。この割り込みは、以前のテープへの書き込み操作が完了したことを示すものです。

スレッドでは、書き込み順序を保証できません。スレッドの実行される順序が不定だからです。たとえば、テープへの書き込み順序を指定することはできません。

非同期入出力操作

#include <aio.h>

int aio_read(struct aiocb *aiocbp);

int aio_write(struct aiocb *aiocbp);

int aio_error(const struct aiocb *aiocbp);

ssize_t aio_return(struct aiocb *aiocbp);

int aio_suspend(struct aiocb *list[], int nent,
    const struct timespec *timeout);

int aio_waitn(struct aiocb *list[], uint_t nent, uint_t *nwait,
    const struct timespec *timeout);

int aio_cancel(int fildes, struct aiocb *aiocbp);

aio_read(3RT)aio_write(3RT) は、概念において pread(2)pwrite(2) に似ています。ただし、入出力操作のパラメータが、aio_read() または aio_write() に渡される非同期入出力制御ブロック (aiocbp) に格納される点が異なります。

    aiocbp->aio_fildes;    /* file descriptor */
    aiocbp->aio_buf;       /* buffer */
    aiocbp->aio_nbytes;    /* I/O request size */
    aiocbp->aio_offset;    /* file offset */

さらに、必要に応じて、「struct sigevent」のメンバーで非同期通知タイプ (一般には、待ち行列に入れられたシグナル) を指定することができます。

    aiocbp->aio_sigevent;  /* notification type */

aio_read() または aio_write() を呼び出すと、入出力操作が開始されます (または、入出力要求が待ち行列に入れられます)。この呼び出しは、ブロックされずに復帰します。

非同期操作の進行中のエラー状態や復帰状態を判定するために、aiocbp の値を aio_error(3RT) および aio_return(3RT) の引数として使用できます。

入出力操作の完了の待機

1 つ以上の未処理の非同期入出力操作の完了を、aio_suspend() または aio_waitn() を呼び出すことによって待機することができます。入出力操作の成功または失敗を判定するには、完了した非同期入出力制御ブロックに対して aio_error() および aio_return() を使用します。

aio_suspend() および aio_waitn() 関数には、呼び出し側の待ち時間を示す timeout 引数があります。NULL ポインタを指定すれば、呼び出し側が無期限に待つという意味になります。値 0 が設定されている構造体を指すポインタは、呼び出し側がまったく待たないという意味になります。

非同期入出力操作を開始し別の処理を行なって aio_suspend() または aio_waitn() で操作の完了を待つことができます。あるいは、aio_sigevent() で指定された非同期通知イベントを発生させて操作の完了を通知するという方法もあります。

最後に、保留状態の非同期入出力操作を取り消すときは、aio_cancel() を呼び出します。この関数を呼び出すときは、入出力操作を開始するために使用された入出力制御ブロックのアドレスを指定します。

共有入出力と新しい入出力システムコール

複数のスレッドが同じファイル記述子を使って同時に入出力操作を行う場合、従来の UNIX の入出力インタフェースがスレッドに対して安全ではない場合があります。この問題は、lseek(2) システムコールがファイルオフセットを設定するとき、入出力が逐次的に行われない場合に発生します。ファイルオフセットは、次の read(2) または write(2) 呼び出しでファイル内の操作開始位置を指定するときに使用されます。このとき、同じファイル記述子に対して複数のスレッドが lseek() を実行してしまうと矛盾が生じます。

この問題を回避するには、システムコール pread()pwrite() を使用します。

#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);

pread(2)pwrite(2) は、read(2)write(2) と同様に機能しますが、ファイルオフセットの引数が追加されている点が異なります。lseek(2) の代わりに、この引数でオフセットを指定すれば、複数のスレッドから同じファイル記述子に対して安全に入出力操作を実行できます。

getcputc の代替

標準入出力に関して、もう 1 つ問題があります。getc(3C)putc(3C) などのルーチンは、マクロとして実装されていて非常に高速に動作するという理由でよく使用されています。getc(3C)putc(3C) が高速であるため、これらのマクロは、プログラムのループ内でも効率を気にせずに使用できます。

しかし、getc(3C)putc(3C) は、スレッドに対して安全になるように変更されたため、以前よりも負荷が大きくなっています。これらのマクロは、相互排他のロックと解除のために、少なくとも 2 つの内部サブルーチンの呼び出しを必要とします。

この問題を回避するため、代替マクロとして getc_unlocked(3C) および putc_unlocked(3C) が用意されています。

getc_unlocked(3C)putc_unlocked(3C) は、相互排他ロックを獲得しません。これらの getc_unlocked() または putc_unlocked() マクロは、スレッドに対して安全ではない元の getc(3C) および putc(3C) と同程度の処理速度を実現しています。

しかし、これらのマクロをスレッドに対して安全な方法で使うためには、flockfile(3C) funlockfile(3C) を使って、標準入出力ストリームを保護する mutex を明示的にロックし、解放する必要があります。これらのルーチンは、ループの外で呼び出します。getc_unlocked()putc_unlocked() はループ内で呼び出します。