この章では、スレッドを使ったプログラミングのための指針を示します。ほとんどの内容は Solaris スレッドと POSIX スレッドの両方に当てはまりますが、ユーティリティーが異なる場合は、その動作を明記します。この章では、シングルスレッドとマルチスレッドの考え方の違いを中心に説明します。この章の内容は次のとおりです。
従来のほとんどのコードは、シングルスレッドプログラム用に設計されています。このコード設計は、特に C プログラムから呼び出されるライブラリルーチンによく見られます。シングルスレッド用のコードでは、次のように仮定していました。
変数への並行アクセスは起こらないので、同期は必要ない
次に、上記の仮定が原因で生じるマルチスレッドプログラム上の問題とその対処例を示します。
従来のシングルスレッドの C および UNIX では、システムコールで検出されたエラーの処理について一定の決まりがあります。システムコールは、関数値として任意の値を戻すことができます。たとえば、write() は、転送されたバイト数を返します。ただし、値 -1 は、エラーが生じたことを示すために予約されています。つまり、システムコールから -1 が戻された場合は、システムコールが失敗したことを意味します。
extern int errno; ... if (write(file_desc, buffer, size) == -1) { /* the system call failed */ fprintf(stderr, “something went wrong, “ “error code = %d\n”, errno); exit(1); } ...
通常の戻り値と混同されがちですが、実際のエラーコードは返されず、大域変数 errno に格納されます。システムコールが失敗した場合は、errno を調べればエラーの内容を知ることができます。
ここで、マルチスレッド環境において 2 つのスレッドが同時に失敗し、異なるエラーが発生したと仮定します。このとき、両方のスレッドがエラーコードは errno に入っていると期待しても、1 つの errno に両方の値を保持することは不可能です。このように、マルチスレッドプログラムでは、大域変数による方法は使用できません。
スレッドは、この問題を、新しい概念の記憶領域クラスであるスレッド固有データを使用することで解決します。この記憶領域は大域記憶領域と似ています。スレッド固有データは、スレッドの実行が可能な任意の手続きからアクセスできます。ただし、スレッド固有データは特定のスレッド専用です。2 つのスレッドが同じ名前のスレッド固有データを参照した場合、これらのスレッドはそれぞれ異なる記憶領域を参照していることになります。
したがって、スレッドを使用しているときは、スレッドごとに errno の専用のコピーが与えられるので、errno の参照がスレッドに固有なものとなります。この実装では、errno をマクロにして関数呼び出しを拡張することで、スレッド固有のものとして errno を参照できるようになっています。
例 9–2 は、errno と同様の問題を示します。ただし、ここでは大域的な記憶領域ではなく静的な記憶領域が問題となります。関数 gethostbyname(3NSL) は、コンピュータ名を引数として与えられて呼び出されます。その戻り値はある構造体のポインタで、その構造体には指定したコンピュータと、ネットワークを通して通信するために必要な情報が入っています。
struct hostent *gethostbyname(char *name) { static struct hostent result; /* Lookup name in hosts database */ /* Put answer in result */ return(&result); }
一般に、局所変数へのポインタを返すのはよい方法ではありません。この例では、変数が静的なため、ポインタは正常に動作します。しかし、2 つのスレッドが異なるコンピュータ名で同時に関数を呼び出すと、静的記憶領域の衝突が生じます。
静的記憶領域の代わりとして、errno の問題の場合と同様にスレッド固有データを使用できます。しかし、この方法では、動的に記憶領域が割り当てられるため、呼び出しの負荷が高くなります。
このような問題を解決する方法は、gethostbyname() の呼び出し側が結果を戻すための記憶領域を呼び出し時に指定してしまうことです。このルーチンに出力引数を 1 つ追加して、呼び出し側から記憶領域を提供できるようにします。出力引数を追加するには、gethostbyname() 関数に新しいインタフェースが必要です。
この方法は、上記のような問題の多くを解決するために使用されています。通常、新しいインタフェース名は、末尾に「_r」を付けたものです。たとえば、gethostbyname(3NSL) は、gethostbyname_r(3NSL) となります。
アプリケーション内のスレッドは、データやプロセスリソースを共有するときに相互に同期をとりながら連携して動作しなければなりません。
複数のスレッドが、オブジェクトを操作する関数を呼び出すと、問題が発生します。シングルスレッドの場合、こうしたオブジェクトへのアクセスの同期は問題にはなりません。しかし、例 9–3 のように、マルチスレッドコードでの同期については考慮が必要です。printf(3S) 関数は、マルチスレッドプログラムの呼び出しに対して安全です。この例では、printf() がマルチスレッドに対して安全でないと仮定したときに生じる問題を示しています。
/* thread 1: */ printf("go to statement reached"); /* thread 2: */ printf("hello world"); printed on display: go to hello
同期上の問題の解決策として、アプリケーション全域で 1 つの相互排他ロック (mutex ロック) を使用するという方法が考えられます。そのアプリケーション内で実行するスレッドは、実行時に必ず mutex をロックし、ブロックされた時に mutex を解除するようにします。このようにすれば、同時に複数のスレッドが共有データをアクセスすることはなくなるので、各スレッドから見たメモリーは整合性を保ちます。
しかし、これは事実上のシングルスレッドであり、この方法にはほとんど利点がありません。
よりよい方法として、モジュール性とデータのカプセル化の性質の利用があります。再入可能な関数は、複数のスレッドに同時に呼び出されても、正常に動作します。再入可能な関数を作成するときには、その関数にとって何が正しい動作なのかを把握しておく必要があります。
複数のスレッドから呼び出される可能性のある関数は、再入可能にしなければなりません。再入可能な関数にするためには、関数のインタフェースまたは実装方法を変更する必要があります。
再入可能の問題は、メモリーやファイルなど、大域的な状態におかれているものをアクセスする関数で生じます。それらの関数では、大域的なものをアクセスする場合、スレッドの適当な同期機構で保護する必要があります。
モジュール内の関数を再入可能な関数にする方法として、コードロックとデータロックの 2 つがあります。
コードロックは関数の呼び出しのレベルで行うロックで、その関数の全体がロックの保護下で実行されることを保証するものです。コードロックが成立するためには、すべてのデータアクセスが関数を通して行われることが前提となります。また、データを共有する関数が複数あるとき、それらを同じロックの保護下で実行することも必要です。
一部の並列プログラミング言語では、「モニター」という構造が用意されています。モニターは、そのスコープに定義されている関数に対して、暗黙的にコードロックを行います。相互排他ロック (mutex ロック) によって、モニターを実装することも可能です。
同じ相互排他ロックの保護下にある関数または同じモニターのスコープにある関数は、互いに不可分操作的に実行されることが保証されます。
データロックは、データ集合へのアクセスが一貫性をもって行われることを保証します。データロックも、基本的な概念はコードロックと同じです。しかし、コードロックは共有される (大域的な) データのみへの参照を囲むようにかけます。相互排他ロックでは、各データ集合に対応する危険領域を同時に実行できるスレッドはせいぜい 1 つです。
一方、複数読み取り単一書き込みロックでは、それぞれのデータ集合に対して複数スレッドが同時に読み取り操作を行うことができ、1 つのスレッドが書き込み操作を行うことができます。複数のスレッドが個別のデータ集合を操作する場合には、1 つのモジュール内で複数のスレッドを実行できます。特に、複数読み取り単一書き込みの場合には、1 つのデータ集合に対するスレッドの競合は発生しません。つまり、通常はコードロックよりもデータロックの方が、多重度を高くすることができます。
プログラムで mutex、条件変数、セマフォーなどのロックを使用するときに、どのような方法を使用すればよいかを説明します。できる限り並列性を高めるためにきめ細かくロックする、つまり必要なときだけロックして不要になったらすぐ解除するという方法がよいでしょうか。それとも、ロックと解除に伴うオーバヘッドをできる限り小さくするため長期間ロックを保持する、つまりきめの粗いロックを行うほうがよいでしょうか。
ロックをどの程度きめ細かくかけるべきかは、ロックによって保護されるデータの量によって異なります。もっともきめの粗いロックは、全データを 1 つのロックで保護します。保護対象のデータをいくつに分割してロックするかは、非常に重要な問題です。ロックのきめが細かすぎても、性能に悪影響を及ぼします。それぞれのロックと解除に伴うオーバーヘッドは、アプリケーション内のロックの数が多いと無視できなくなるからです。
一般には、きめの粗いロックから始めてボトルネックを特定し、その軽減のために必要な場所に、きめの細かいロックを追加します。これは妥当な解決策ですが、並列性を最大にすることとロックに伴うオーバーヘッドを最小にすることのどちらをどの程度優先させるかは、ユーザーが判断してください。
コードロックでもデータロックでも、複雑なロックを制御するためには「不変式」が重要な意味をもちます。不変式とは、常に真である条件または関係のことです。
不変式は、並行実行環境に対して次のように定義されます。 すなわち不変式とは、関連するロックが行われるときに条件や関係が真になっていることです。ロックが行われた後は偽になってもかまいません。ただし、ロックを解除する前に真に戻す必要があります。
あるロックが行われるときに真となるような条件または関係も不変式です。条件変数では、条件という不変式を持っていると考えることができます。
mutex_lock(&lock); while((condition)==FALSE) cond_wait(&cv,&lock); assert((condition)==TRUE); . . . mutex_unlock(&lock);
この assert() 文は、不変式を評価しています。cond_wait() 関数は、不変式を保存しません。このため、スレッドが戻ったときに不変式をもう一度評価しなければなりません。
ほかの例は、双方向リンクリストを管理するモジュールです。双方向リンクリストの各項目で、直前の項目の前向きポインタは不変式のよい例になります。この前向きポインタは、直後の項目の後ろ向きポインタと同じ要素を指します。
このモジュールでコードロックを使用するものと仮定し、1 つの大域的な相互排他ロックでモジュールを保護することにします。項目を削除したり追加したりするときは相互排他ロックを獲得し、ポインタの変更後に相互排他ロックを解除します。明らかに、この不変式はポインタ操作中のある時点で偽になります。しかし、相互排他ロックを解除する前に真に戻されています。
ある一組のスレッドが一連のリソースの獲得で競合したまま、永久にブロックされた状態に陥っているとき、その状態をデッドロックと呼びます。実行可能なスレッドがあるからといって、デッドロックが発生していないという証拠にはなりません。
代表的なデッドロックは、「自己デッドロック」(「再帰的なデッドロック」) です。自己デッドロック (再帰的なデッドロック) は、すでに保持しているロックをスレッドがもう一度獲得しようとしたとき発生します。これは、ちょっとしたミスで簡単に発生してしまいます。
たとえば、一連のモジュール関数で構成されるコードモニターについて考えてみましょう。このモジュール関数は、呼び出しの間 mutex ロックを保持します。この場合、このモジュール内の相互排他ロックの保護下にある関数間で呼び出しが行われると、たちまちデッドロックが発生します。ある関数がこのモジュールの外部のコードを呼び出し、そこから再び同じ相互排他ロックで保護されているモジュールを呼び出した場合にもデッドロックが発生します。
この種のデッドロックを回避するには、モジュールの外部の関数が、元のモジュールに依存している可能性がある場合、この関数を呼び出さないようにします。特に、不変式を再設定しないで再び元のモジュールを呼び出すような関数を呼び出さないことです。さらに、呼び出しの前にすべてのモジュールロックを解除するようにします。次に、その呼び出しを終了してもう一度ロックを獲得した後、所定の状態を評価して、意図している操作がまだ有効であるか確認します。
もう 1 つ別の種類のデッドロックがあります。スレッド 1、2 がそれぞれ mutex A、B のロックを獲得しているものと仮定します。スレッド 1 が mutex B を、スレッド 2 が mutex A をロックしようとすると、スレッド 1 は mutex B を待ったままブロックされ、スレッド 2 は mutex A を待ったままブロックされます。何も変化しない状態になります。つまり、これらのスレッドが永久にブロックされ、デッドロック状態となります。
この種のデッドロックを回避するには、ロックを獲得する順序、「ロック階層」を設定します。すべてのスレッドが常に一定の順序でロックを行う限り、この種のデッドロックは生じません。
しかし、ロックを行う順序を一定に保つという規則を守っていればよいとは必ずしも言えません。たとえば、スレッド 2 が mutex B を保持している間に、モジュールの状態に関して多くの仮定条件を立てた場合、次に mutex A のロックを獲得するために mutex B のロックを解除し、mutex A のロックを獲得したあとにもう一度 mutex B のロックを獲得すると、先の仮定が無効になります。モジュールの状態をもう一度評価しなければならなくなります。
通常、ブロックを行う同期プリミティブには、ロックを獲得しようとしてできなかった場合にエラーとなるプリミティブが用意されています。一例として、pthread_mutex_trylock() があります。このプリミティブの動作によって、競合が発生していないときには、スレッドによるロック階層違反が許可されます。競合があるときは、通常は保持しているロックをいったん解除してから、順番にロックを実行しなければなりません。
ロックが獲得される順序が保証されていないため、特定のスレッドがロックを獲得できないという問題が発生します。
通常、この問題は次のような状況で起こります。スレッドが、保持していたロックを解除し、少し時間をおいてからもう一度ロックを獲得するものとします。このとき、ロックはいったん解除されたので、ほかのスレッドがロックを獲得したように見えます。しかし、ロックを保持していたスレッドはブロックされません。その結果、このスレッドは、ロックを解除してからロックを再度獲得する間も引き続き実行されます。この場合、ほかのスレッドは実行されません。
通常、この種の問題を解決するには、ロックを再度獲得する呼び出しの直前に sched_yield(3C)() を呼び出します。sched_yield() 関数は、その他のスレッドに実行とロックの獲得を許可します。
必要なタイムスライスの大きさはアプリケーションに依存するため、システムでは特に制限していません。sched_yield() を呼び出して、スレッドが共有する時間を設定してください。
ロックを長期間保持しないでください。たとえば、入出力時にロックを保持したままにすると性能が低下することがあります。
モジュールから外部の関数を呼び出す場合、その関数が元のモジュールを呼び出す可能性があるときはロックを解除してください。
一般に、初めは大まかに調べるというやり方で臨み、ボトルネックを見つけます。そして、ボトルネックを軽減するのに必要なら、きめ細かなロックを追加していきます。ロックが保持される時間は通常はそれほど長くなく、競合もめったに起こりません。したがって、実際に競合のあったロックだけを調整してください。
複数のロックを使用する場合は、デッドロックを回避するために、すべてのスレッドで同じ順序でロックを獲得するようにしてください。
Sun Studio Thread Analyzer は、プログラム内のデッドロックを検出するために使用できるツールです。Thread Analyzer は、実際のデッドロックだけでなく、潜在的なデッドロックも検出できます。潜在的なデッドロックは、特定の実行で常に発生するわけではありませんが、スレッドのスケジューリングやスレッドによるロック要求のタイミングによっては、プログラムの任意の実行で発生する可能性があります。実際のデッドロックは、プログラムの実行中に発生して、関連するスレッドをハングアップさせますが、これによってプロセス全体をハングアップさせるかどうかは場合によって異なります。
『Sun Studio 12: スレッドアナライザユーザーズガイド』を参照してください。
外部から手続きなどを流用する場合、その安全性を確認してください。
マルチスレッドプログラムから、マルチスレッド化されていないコードをそのまま呼び出すことはできません。
マルチスレッドプログラムでは、初期スレッドからのみ「MT-安全ではない」コードに安全にアクセスできます。
この方法で安全でないコードを参照すれば、初期スレッドに対応する静的記憶領域が、初期スレッドによってだけ使用されることが保証されます。
ライブラリを「MT-安全」にする場合、プロセスの大域的な操作はスレッド化しないでください。
大域的な操作 (または大域的な副作用のある処理) をスレッド化しないでください。たとえば、ファイル入出力をスレッド単位の操作に変更しても、複数のスレッドがファイルに同時にアクセスできません。
スレッド特有の動きやスレッドとして認識される動きは、スレッド機能を使って実現してください。たとえば、main() の終了時に main() のスレッドだけを終了したい場合は、次のようにします。
pthread_exit(); /*NOTREACHED*/
Sun から提供されるライブラリは、「安全」であると明記されていなければ、「安全ではない」とみなされます。
リファレンスマニュアルのエントリにインタフェースが「MT-安全」であると明示的に記載されていない場合は、そのインタフェースは「安全ではない」と考えるべきです。
コンパイルフラグでソースのバイナリレベルでの非互換性を解消してください。詳細は、第 8 章コンパイルとデバッグを参照してください。
-mt を使用すると、マルチスレッドが有効になります。
-mt オプションで使用される -lpthreads は、POSIX スレッドの関数にリンクします。このフラグは、プログラムで pthread の関数を使用する場合にのみ必要です。
-mt を使用した場合は、Solaris スレッドの API が自動的にリンクされます。-lthread を明示的に指定する代わりに、常に -mt オプションを使用してください。libpthread ライブラリによって libthread へのインタフェースが提供されるため、pthread を使用する場合も libthread が引き続き必要です。
スレッドパッケージは、スレッドのデータ構造およびスタックをキャッシュするので、スレッドを繰り返し生成しても負荷は大きくなりません。ただし、必要に応じてスレッドを生成したり削除したりする方が、専用の処理要求を待つスレッドを維持管理するより負荷が大きくなります。たとえば、RPC サーバーがよい例です。RPC サーバーは要求が送られてきたらスレッドを生成し、応答を返したらスレッドを削除します。
スレッドの生成にかかるオーバーヘッドは、プロセスの生成にかかるオーバーヘッドほど高くありません。しかし、スレッドの生成は、いくつかの命令を生成するのにかかる負荷に比べると、効率的ではありません。少なくとも数千の機械語命令が続くような処理を対象にして、スレッドを生成してください。
マルチスレッドでは、主に並列性とスケーラビリティーという点でマルチプロセッサ (マルチコアプロセッサやマルチスレッドプロセッサを含む) を活用できます。プログラマは、マルチプロセッサと単一プロセッサのメモリーモデルの違いを考慮に入れておかなければなりません。
このマニュアルでは、特に断りのない限り、マルチプロセッサの説明はマルチコアプロセッサとマルチスレッドプロセッサにも該当します。
メモリーの一貫性は、メモリーを問い合わせるプロセッサと直接的な相関関係にあります。単一プロセッサの場合、メモリーを参照するプロセッサは 1 つしかないのでメモリーは一貫しています。
マルチプロセッサの性能を高めようとすると、メモリーの一貫性が緩められることになります。あるプロセッサによるメモリーへの変更が、ほかのプロセッサから見たメモリーイメージにただちに反映されるとは限りません。
共有される大域変数を使用するときに同期変数を使用すれば、この複雑さを回避できます。
メモリーバリアー同期を使用すると、マルチプロセッサ上での並列性をうまく制御できる場合があります。
マルチプロセッサに関して、もう 1 つの問題があります。共通の実行ポイントに到達するまで全スレッドが待たなければならないようなケースでは、同期の効率が問題となります。
共有メモリーの場所へのアクセスに常にスレッド同期プリミティブを使用する場合、ここで言及した内容は重要ではありません。第 4 章同期オブジェクトを使ったプログラミングを参照してください。
スレッドは、スレッド同期ルーチンを使用して共有記憶領域へのアクセスを同期します。スレッドを同期することによって、共有メモリー型のマルチプロセッサ上でプログラムを実行することは、単一プロセッサ上でプログラムを実行することと同じことになります。
しかし、プログラマはあえてマルチプロセッサ特有の機能を活用したり、同期ルーチンを迂回する「トリック」を使用したりすることがあります。例 9–5 と例 9–6 に示すように、こうしたトリックは危険性をはらんでいます。
通常のマルチプロセッサアーキテクチャーがサポートしているメモリーモデルを理解することは、この危険性を理解する助けとなります。
マルチプロセッサの主な構成要素は、次のとおりです。
プログラムを実行するプロセッサ (コアとそれらのスレッドを含む)
ストアバッファー (プロセッサとキャッシュを接続する)
メモリー (全プロセッサによって共有される主記憶領域)
従来の単純なモデルでは、各プロセッサがメモリーに直接接続されているかのように動作します。 つまり、あるプロセッサが特定の位置にデータを格納すると同時に別のプロセッサが同じ位置からデータをロードした場合、2 番目のプロセッサは最初のプロセッサが格納したデータをロードします。
キャッシュを使って、平均メモリーアクセス時間を高速化できます。キャッシュ間の整合性が維持されているときは、データの整合性も保たれます。
この単純なモデルの問題点は、データの整合性を保つためプロセッサをしばしば遅延させなければならないことです。最新のマルチプロセッサでは、各種の手法でそうした遅延を回避していますが、メモリーデータの整合性を失わせています。
次の 2 つの例で、それらの手法と効果を説明します。
例 9–5 は、「生産者 / 消費者」問題の代表的な解決方法です。
このプログラムは、現在の SPARC ベースのマルチプロセッサでは正しく動作しますが、すべてのマルチプロセッサが強く順序付けられたメモリーをもつことを想定しています。したがって、このプログラムには移植性がありません。
このプログラムは、生産者と消費者がそれぞれ 1 つしか存在せず、かつ共有メモリー型のマルチプロセッサ上で動作するときは正しく動作します。in と out の差が、バッファー内のデータ数となります。
生産者はバッファーに新しい項目のための空きができるまで、この差を繰り返し計算しながら待ちます。消費者は、バッファーに項目が入れられるのを待ちます。
強く順序付けられたメモリーでは、一方のプロセッサへの変更が、もう一方のプロセッサのメモリーにただちに伝えられます。強く順序付けられたメモリーでは、in と out が最終的にオーバーフローしても、このソリューションが成立します。このオーバーフローは、BSIZE が 1 ワードで表現できる最大の整数より小さい場合に発生します。
共有メモリー型のプロセッサは、必ずしも強く順序付けられたメモリーをもつ必要はありません。つまり、あるプロセッサによるメモリーへの変更が、ほかのプロセッサにただちに伝わるとは限りません。あるプロセッサによって、異なったメモリー領域 2 箇所に変更が加えられたとき、どうなるか考えてみましょう。もう一方のプロセッサには、メモリーの変更がただちに伝わらないので、このプロセッサから検出できる変更の順序は最初の順序と同じであるとは限りません。
変更内容は、まず「ストアバッファー」に入れられます。このストアバッファーは、キャッシュからは参照できません。
プロセッサは、プログラムの整合性を保証するために、これらのストアバッファーをチェックします。しかし、ほかのプロセッサから、このストアバッファーは参照できません。このため、あるプロセッサが書き込んだ内容は、キャッシュに書き込まれるまで、ほかのプロセッサから参照できません。
同期プリミティブは、特別な命令でストアバッファーの内容をキャッシュにフラッシュしています。第 4 章同期オブジェクトを使ったプログラミングを参照してください。したがって、共有データをロックで保護すればメモリーの整合性が保証されます。
メモリーの順序付けが非常に弱い場合は例 9–5 で問題が発生します。消費者は、対応するバッファースロットへの変更を知る前に、生産者によって in が 1 つ増やされたことを知る場合があります。
あるプロセッサのストア順序が、別のプロセッサからは違った順序で見えることがあるため、このメモリーの順序付けを「弱い順序付け」と呼びます。ただし、同じプロセッサから見たメモリーは常に整合性を保っています。この問題を解決するには、相互排他ロックを使用して、ストアバッファーの内容をキャッシュにフラッシュしなければなりません。
最近は、メモリーの順序付けが弱くされる傾向にあります。このため、プログラマは大域データや共有データをロックで保護するよう注意する必要があります。
例 9–6 のコードは、2 つのスレッド間の相互排他を処理する Peterson のアルゴリズムの実装例です。このコードは、危険領域に同時に複数のスレッドが存在しないことを保証しようとしています。スレッドは、mut_excl() を呼び出すと、「すばやく」危険領域に入ります。
ここで、スレッドは危険領域に入るとすばやく抜け出るものとします。
void mut_excl(int me /* 0 or 1 */) { static int loser; static int interested[2] = {0, 0}; int other; /* local variable */ other = 1 - me; interested[me] = 1; loser = me; while (loser == me && interested[other]) ; /* critical section */ interested[me] = 0; }
このアルゴリズムは、マルチプロセッサのメモリーが強く順序付けられているときは成立します。
SPARC ベースのマルチプロセッサを含む一部のマルチプロセッサは、ストアバッファーを装備しています。スレッドがストア命令を実行すると、データがストアバッファーに入れられます。このバッファーの内容は最終的にキャッシュに送られますが、すぐに送られるとは限りません。各プロセッサのキャッシュはデータの整合性を維持していますが、変更されたデータはキャッシュにすぐには送られません。
複数のデータが格納されたとき、その変更はキャッシュ (およびメモリー) に正しい順序で伝わりますが、通常は遅延を伴います。SPARC ベースのマルチプロセッサでは、この性質のことを「トータルストア順序 (TSO) をもつ」と言います。
あるプロセッサが A 番地にデータを格納して次に B 番地からデータをロードして、別のプロセッサが B 番地にデータを格納して次に A 番地からデータをロードした場合、「最初のプロセッサが B 番地の新しい値を得る」と「2 番目のプロセッサが A 番地の新しい値を得る」の一方または両方が成立します。しかし、「両方のプロセッサが以前の値を得る」というケースは起こりえないはずです。
さらに、ロードバッファーとストアバッファーの遅延が原因で、上記の起こりえないケースが起こることがあります。
このとき Peterson のアルゴリズムでは、それぞれ別のプロセッサで実行されている 2 つのスレッドが両方とも危険領域に入ります。各スレッドは特定の配列の自分のスロットにデータを格納し、別のスロットからデータをロードします。両方のスレッドは以前の値 (0) を読み取り、相手がいないものと判定し、両方が危険領域に入ってしまいます。この種の問題は、プログラムのテスト段階では発生せず、あとになって発生する可能性があります。
この問題を回避するには、スレッドの同期プリミティブを使用します。それらのプリミティブには、ストアバッファーをキャッシュに強制的にフラッシュする特別な命令が含まれています。第 4 章同期オブジェクトを使ったプログラミングを参照してください。
多くのアプリケーション、特に数値計算関係のアプリケーションでは、ほかの部分が本質的に逐次的であっても、while 部分のアルゴリズムを並列化できます。このアルゴリズムでは、バリアー同期を使用して並列的な部分と逐次的な部分を調整できます。
表 9–1 バリアー同期を使用したマルチスレッドの調整
逐次実行 |
並列実行 |
---|---|
Thread 1 |
Thread 2 through Thread n |
while(many_iterations) { sequential_computation --- Barrier --- parallel_computation } |
while(many_iterations) { --- Barrier --- parallel_computation } |
たとえば、完全な線型計算で一組の行列を作成し、それらの行列に対する操作を並列アルゴリズムで実行します。次に、これらの操作結果からもう一組の行列を作成し、それらの行列を並列的に操作するといった処理が考えられます。
こうした計算の並列アルゴリズムの特徴は、計算中はほとんど同期をとる必要がない点にあります。しかし、並列計算を始める前に逐次計算が終了していることを確認するために、すべてのスレッドの同期をとる必要があります。
バリアーには、並列計算を行なっているすべてのスレッドを、関係しているすべてのスレッドがバリアーに達するまで待たせるという働きがあります。スレッドは全部がバリアーに達したところで解放され、一斉に計算を開始します。
このマニュアルでは、スレッドのプログラミングに関する重要な問題を幅広く取り上げて説明しました。付録 A 拡張の例: スレッドプールの実装では、これまでに紹介した機能や方法の多くを使用した pthread プログラムの例を掲載しています。
マルチスレッドについてさらに詳しく知りたい場合は、『Programming with Threads』 (Steve Kleiman、Devang Shah、Bart Smaalders 共著、Prentice-Hall 発行、1995 年) を参照してください。この書籍には Solaris OS の変更が反映されていませんが、その概念的な情報の多くは引き続き有効です。