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

プロセスの生成 − fork

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(3T)

fork1 モデル

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

これは、子プロセスが生成後ただちに exec() を呼び出すような場合に利用します。実際、多くの場合に fork() を呼び出した後行われることです。この場合、子プロセスは fork() を呼び出したスレッド以外のスレッドの複製は必要としません。

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

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

共有データのロックのような通常の考慮事項に加えて、次のような問題があります。実行されているスレッドが 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() を使用していない状態で行うようにしなければなりません。

ライブラリでロックを管理するには、次の操作を実行します。

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

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

pthread では、pthread_atfork(f1, f2, f3) の呼び出しをライブラリの .init() セクションに追加できます。f1f2f3 の定義は次のとおりです。

f1() /* このプロセスが fork する前に実行される */
{
 mutex_lock(L1); |
 mutex_lock(...); | -- ロックの順に並べる
 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 をロックしようとする子プロセス内のスレッドは永久に待つことになります。

仮想 fork − vfork(2)

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

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

たとえば、vfork() を呼び出してから exec() を呼び出すまでの間に、新しいスレッドを生成することは大変危険です。これが問題となるのは、fork1 モデルを使用した場合と、子プロセスが exec() の呼び出しの他にも何か行う場合だけです。ほとんどのライブラリは「fork - 安全」ではないので、pthread_atfork() を使用することによって fork に対する安全性を実装してください。

解決策 − pthread_atfork(3T)

pthread_atfork() を使用すれば、fork1 モデルを使用したときのデッドロックが防止されます。

#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 ハンドラがそれらを解放するといった具合です。このようにすると、プロセスが fork される「前」に、関係するロックがすべて fork 関数を呼び出すスレッドによって保持されるので、子プロセスでのデッドロックが防止されます。

汎用 fork モデルを使用すれば、「fork1 モデルにおける安全性の問題とその解決策」で述べたデッドロックの問題は回避されます。

戻り値

正常終了時は 0 です。それ以外の戻り値は、エラーが発生したことを示します。以下の条件が検出されると、この関数は失敗し、次の値を戻します。


ENOMEM

テーブル空間が足りないので、fork ハンドラのアドレスを記録できません。

汎用 fork モデル

Solaris の fork(2) 関数は、子プロセスにアドレス空間とすべてのスレッド (および LWP) の複製を作成します。この方法を使用するのは、子プロセスで exec(2) をまったく呼び出さないが親のアドレス空間のコピーを使用するなどの場合です。汎用 fork 機能は POSIX スレッドにはありません。

なお、プロセス内のあるスレッドが Solaris の fork(2) を呼び出すと、割り込み可能なシステムコール処理中にブロックされたスレッドは EINTR を戻すので注意してください。

また、親プロセスと子プロセスの両方に保持されるロックを作成しないよう十分注意してください。このような状況が生じる可能性があるのは、ロックを共有可能なメモリー上に割り当てている (つまり、mmapMAP_SHARED フラグを指定した) 場合です。fork1 モデルを使用する場合、これは問題になりません。

正しい fork の選択

アプリケーションの中での fork() のセマンティクスが「汎用 fork」と「fork1」のどちらであるかは、該当するライブラリとリンクすることによって決定されます。-lthread を指定してリンクすると、fork() のセマンティクスは「汎用 fork 」になり、-lpthread を指定してリンクすると、fork() のセマンティクスは「fork1」になります (コンパイルオプションの説明は、図 7-1 を参照してください)。

すべての fork に関係する注意事項

どの fork() 関数についても、呼び出した後で広域的状態を使用するときには注意が必要です。

たとえば、あるスレッドがファイルを逐次的に読み取っているときに同じプロセス内の別のスレッドが fork() 関数を 1 つ呼び出すと、両方のプロセスにファイル読み取り中のスレッドが存在することになります。fork() 後はファイル記述子のシークポインタが共有されるため、親プロセス内のスレッドがデータを読み取ると、子プロセス内のスレッドは残りのデータを読み取ります。この結果、連続読み取りアクセスに切れ目ができます。