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

スレッドの同期

アプリケーション内のスレッドは、データやプロセスリソースを共有するときに相互に同期をとりながら連携して動作しなければなりません。

複数のスレッドが、オブジェクトを操作する関数を呼び出すと、問題が発生します。シングルスレッドの場合、こうしたオブジェクトへのアクセスの同期は問題にはなりません。しかし、例 9–3 のように、マルチスレッドコードでの同期については考慮が必要です。printf(3S) 関数は、マルチスレッドプログラムの呼び出しに対して安全です。この例では、printf() がマルチスレッドに対して安全でないと仮定したときに生じる問題を示しています。


例 9–3 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 つのロックで保護します。保護対象のデータをいくつに分割してロックするかは、非常に重要な問題です。ロックのきめが細かすぎても、性能に悪影響を及ぼします。それぞれのロックと解除に伴うオーバーヘッドは、アプリケーション内のロックの数が多いと無視できなくなるからです。

一般には、きめの粗いロックから始めてボトルネックを特定し、その軽減のために必要な場所に、きめの細かいロックを追加します。これは妥当な解決策ですが、並列性を最大にすることとロックに伴うオーバーヘッドを最小にすることのどちらをどの程度優先させるかは、ユーザーが判断してください。

不変式とロック

コードロックでもデータロックでも、複雑なロックを制御するためには「不変式」が重要な意味をもちます。不変式とは、常に真である条件または関係のことです。

不変式は、並行実行環境に対して次のように定義されます。 すなわち不変式とは、関連するロックが行われるときに条件や関係が真になっていることです。ロックが行われた後は偽になってもかまいません。ただし、ロックを解除する前に真に戻す必要があります。

あるロックが行われるときに真となるような条件または関係も不変式です。条件変数では、条件という不変式を持っていると考えることができます。


例 9–4 assert(3C) による不変式のテスト

    mutex_lock(&lock);
    while((condition)==FALSE)
        cond_wait(&cv,&lock);
    assert((condition)==TRUE);
      .
      .
      .
    mutex_unlock(&lock);

この assert() 文は、不変式を評価しています。cond_wait() 関数は、不変式を保存しません。このため、スレッドが戻ったときに不変式をもう一度評価しなければなりません。

ほかの例は、双方向リンクリストを管理するモジュールです。双方向リンクリストの各項目で、直前の項目の前向きポインタは不変式のよい例になります。この前向きポインタは、直後の項目の後ろ向きポインタと同じ要素を指します。

このモジュールでコードロックを使用するものと仮定し、1 つの大域的な相互排他ロックでモジュールを保護することにします。項目を削除したり追加したりするときは相互排他ロックを獲得し、ポインタの変更後に相互排他ロックを解除します。明らかに、この不変式はポインタ操作中のある時点で偽になります。しかし、相互排他ロックを解除する前に真に戻されています。