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

スレッドの同期

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

問題となるのは、ある特定のオブジェクトの操作を複数のスレッドが呼び出すときです。シングルスレッド環境では、そのようなオブジェクトに対するアクセスの同期上の問題は生じませんが、例 10-3 に示すように、マルチスレッドでは注意する必要があります。(Solaris の printf(3S) は「MT-安全」です。この例では、printf() がマルチスレッドに対応していないと仮定したときに生じる問題を示しています。)


例 10-3 printf() の問題


/* スレッド 1: */
    printf("go to statement reached");

/* スレッド 2: */
    printf("hello world");


ディスプレイ上の表示:
    go to hello

シングルスレッド化

同期上の問題の解決策として、アプリケーション全域で 1 つの相互排他ロック (mutex ロック) を使用するという方法が考えられます。そのアプリケーション内で実行するスレッドは、実行時に必ず mutex をロックし、ブロックされた時に mutex を解除するようにします。このようにすれば、同時に複数のスレッドが共有データをアクセスすることはなくなるので、各スレッドから見たメモリーは整合性を保ちます。

しかし、これは事実上のシングルスレッドプログラムであり、この方法にはほとんど利点がありません。

リエントラント (再入可能)

よりよい方法として、モジュール性とデータのカプセル化の性質の利用があります。複数のスレッドから同時に呼び出されても正しく動作する関数を「リエントラント (再入可能)」関数と呼びます。再入可能な関数を作成するには、その関数にとって何が正しい動作なのかを把握することが必要です。

複数のスレッドから呼び出される可能性のある関数は、再入可能にしなければなりません。そのためには、関数のインタフェースまたは実装方法を変更する必要があります。

リエントラントの問題は、メモリーやファイルなどの広域的な状態におかれているものをアクセスする関数で生じます。それらの関数では、広域的なものをアクセスする場合、スレッドの適当な同期機構で保護する必要があります。

モジュール内の関数をリエントラントにする基本的な方法は、コードをロックする方法とデータをロックする方法の 2 通りがあります。

コードロック

コードロックは関数の呼び出しのレベルで行うロックで、その関数の全体がロックの保護下で実行されることを保証するものです。コードロックが成立するためには、すべてのデータアクセスが関数を通して行われることが前提となります。また、データを共有する関数が複数あるとき、それらを同じロックの保護下で実行することも必要です。

一部の並列プログラミング言語では、モニタという構造が用意されています。モニタは、その対象範囲内に定義されている関数に対して、暗黙の内にコードロックを行います。相互排他ロック (mutex ロック) によって、モニタを実装することも可能です。

同じ相互排他ロックの保護下にある関数または同じモニタの対象範囲内にある関数は、互いに原子操作的に実行されることが保証されます。

データロック

データロックは、データ集合へのアクセスが一貫性をもって行われることを保証します。データロックも、基本的な概念はコードロックと同じです。しかし、コードロックは共有される (広域的な) データのみへの参照を囲むようにかけます。相互排他ロックでは、各データ集合に対応する危険領域を同時に実行できるスレッドはせいぜい 1 つです。

一方、複数読み取り単一書き込みロックでは、それぞれのデータ集合に対して複数スレッドが同時に読み取り操作を行うことができ、1 つのスレッドが書き込み操作を行うことができます。複数読み取り単一書き込みロックのように、それぞれ異なるデータ集合を操作するか、同じデータ集合で衝突を起こさないようにすれば、同一モジュール内で複数のスレッドを実行できます。つまり、通常はコードロックよりもデータロックの方が、並行度を高くすることができます。(Solaris には読み取り / 書き込みロック機能が組み込まれていることに注意してください。)

プログラムで、(相互排他ロック、条件変数、セマフォなどの) さまざまなロックを使用するときの方針を説明します。できる限り並列性を高めるためにきめ細かくロックする、つまり必要なときだけロックして不要になったらすぐ解除するという方法と、ロックと解除に伴うオーバヘッドをできる限り小さくするため長期間ロックを保持する、つまりきめの粗いロックを行うという方法が考えられます。

ロックをどの程度きめ細かくかけるべきかは、保護の対象となるデータの量によって異なります。最もきめの粗いロックは、全データを 1 つのロックで保護します。保護対象のデータをいくつに分割してロックするかは、非常に重要な問題です。ロックのきめが細かすぎても、性能に悪影響を及ぼします。それぞれのロックと解除に伴うオーバヘッドは、ロックの数が多いと無視できなくなるからです。

通常、ロックを使用する方針は次のとおりです。最初は、きめを粗くロックします。次にボトルネックを特定したら、それが緩和されるようにロックのきめを細かくしていきます。これは妥当な解決策ですが、並列性を最大にすることとロックに伴うオーバヘッドを最小にすることのどちらをどの程度優先させるかは、ユーザが判断してください。

不変式

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

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

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


例 10-4 assert(3X) による不変式のテスト


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

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

他の例は、双方向リンクリストを管理するモジュールです。双方向リンクリストでは、直前の項目の前向きポインタと直後の項目の後ろ向きポインタが同じものを指すという条件が成立します。これは不変式のよい例です。

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