mutex というちょっとしたもの
Daniel Robbins
President/CEO
Gentoo Technologies, Inc.
2000年 8月 01日 POSIX スレッドは、コードの応答性とパフォーマンスを向上させる優れた方法です。3 回シ リーズの第 2 回である今回の記事では、mutex というちょっとした優れた手段により、スレッ ド化されたコードの整合性を保つ方法について、Daniel Robbins が説明します。
わたしを mutex してください
前回の記事では、予想外の異常なことを行うスレッド化されたコードについて説明しました。2 つのスレッドが 1 つのグローバル変数をそれぞれ 20 回ずつ増分するというものでした。予想で は、変数は最終的に値 40 になるはずでしたが、実際には値 21 で終わってしまいました。何が 起こったのでしょうか。一方のスレッドが実行した増分を、もう一方のスレッドが繰り返し「無 効にした」ため、問題が起こったのです。mutex を使って問題を解決した修正コードを見てみま しょう。thread3.c
#include <pthread.h> #include <stdlib.h> #include <unistd.h> #include <stdio.h> int myglobal; pthread_mutex_t mymutex=PTHREAD_MUTEX_INITIALIZER; void *thread_function(void *arg) {int i,j;
for ( i=0; i<20; i++ ) {
pthread_mutex_lock(&mymutex); j=myglobal; j=j+1; printf("."); fflush(stdout); sleep(1); myglobal=j; pthread_mutex_unlock(&mymutex); } return NULL; } int main(void) { pthread_t mythread; int i;
if ( pthread_create( &mythread, NULL, thread_function, NULL) ) { printf("error creating thread.");
abort(); }
for ( i=0; i<20; i++) {
pthread_mutex_lock(&mymutex); myglobal=myglobal+1; pthread_mutex_unlock(&mymutex); printf("o"); fflush(stdout); sleep(1); }
if ( pthread_join ( mythread, NULL ) ) { printf("error joining thread."); abort();
}
printf("\nmyglobal equals %d\n",myglobal); exit(0); }
説明の時間
上記のコードと前回の記事のバージョンを比較するなら、pthread_mutex_lock() と pthread_mutex_unlock() という呼び出しが追加されていることに気付くでしょう。これらの呼び 出しは、スレッド化されたプログラムにとって大いに必要な機能を実行します。これらの呼び出 しは相互的排他 (mutual exclusion) を実現します (これが名前の由来です)。2 つのスレッドが同時 に同じ mutex にロックをかけることはできません。 mutex は次のようにして機能します。スレッド "b" がある mutex をロックしている間に、スレッ ド "a" が同じ mutex にロックをかけようとすると、スレッド "a" はスリープ状態に入ります。 スレッド "b" が (pthread_mutex_unlock() 呼び出しを介して) mutex を解放すると、即座にス レッド "a" が同じ mutex をロックできるようになります (つまり、ロックされた mutex とともに pthread_mutex_lock() が戻ります)。同様に、スレッド "a" の保留中にスレッド "c" が mutex に ロックをかけようとすると、スレッド "c" も一時的にスリープ状態に入ります。すでにロックさ れている mutex に対して pthread_mutex_lock() を呼び出すスレッドはすべて、スリープ状態に入 り、その mutex にアクセスする順番を「列を作って待つ」ことになります。 pthread_mutex_lock() と pthread_mutex_unlock() は通常、データ構造体を保護するために使用さ れます。つまり、スレッドをロックまたはロック解除することにより、あるデータ構造にアクセ スできるスレッドを 1 つだけに限定することができます。お気付きかもしれませんが、POSIX ス レッド・ライブラリーでは、ロック解除されている mutex をロックしようとするスレッドは、ス リープ状態に入らずにロックを許可されます。読者に楽しんでいただけるよう、4 人の znurt が上記の pthread_mutex_lock() 呼び
出しのシーンを再現します。
このイメージの中で mutex をロックしているスレッドは、自分がアクセスしているのと同じ 複合データ構造体に、他のスレッドが同時に手を出しているのではないかと心配する必要は ありません。データ構造体は、mutex のロックが解除されるまで、事実上「凍結」していま す。pthread_mutex_lock() および pthread_mutex_unlock() 呼び出しは、変更中または読み取り中 の特定の共用データの前後に掲げられた「工事中」の標識のようなものだと言えます。これらの 呼び出しは、他のスレッドに対し、スリープ状態に入って、自分の番が来るまで mutex をロック するのを待つよう指示する警告のようなものです。当然、これは、特定のデータ構造体を読み書 きするたび 、その前後に pthread_mutex_lock() と pthread_mutex_unlock() への呼び出しを配し た場合にのみ当てはまります。一体なぜ mutex なのか
面白いですね。しかし、なぜ、スレッドをよりによってスリープ状態にしなければならないので しょうか。スレッドの最大の利点は、それぞれ独立して働けること、多くの場合は同時にそうで きるということではなかったでしょうか。そう、そのとおりです。しかし、ちょっとしたスレッ ド・プログラムであれば、必ず mutex を少しは使う必要があるのです。その理由を理解するため に、もう一度プログラムの例を見てみましょう。 thread_function() を一見すれば、mutex がループの最初でロックされ、ループの最後で解放 されていることに気付かれるでしょう。このプログラム例では、mymutex は myglobal の値を 保護するために使用されています。thread_function() を注意深く調べてみると、増分コード が、myglobal をローカル変数にコピーし、ローカル変数を増分し、1 秒間スリープし、その後 で初めてローカル値を myglobal に書き戻していることが分かります。mutex を使わないとし たら、thread_function() が眠っている 1 秒の間にメイン・スレッドが myglobal を増分した場 合、thread_function() は、目覚めてから、増分された値を上書きしてしまうでしょう。mutex を 使えば、そうしたことは起こりません。(読者が不思議に思うことのないよう、1 秒の遅延を組み 入れて、間違った結果が生じるようにしました。thread_function() が myglobal にローカル値を書 き戻す前に 1 秒間スリープしなければならない理由は、特にありません。) mutex を使った新しい プログラムでは、次に示す望ましい結果が得られます。$ ./thread3 o..o..o.o..o..o.o.o.o.o..o..o..o.ooooooo myglobal equals 40 この極めて重要な概念をさらに調べるため、プログラムの中の増分コードを見てみましょう。 thread_function() ######: j=myglobal; j=j+1; printf("."); fflush(stdout); sleep(1); myglobal=j; ##############: myglobal=myglobal+1; このコードがシングル・スレッドのプログラムであれば、thread_function() のコードはその全体 が実行されるものと期待できます。その実行の後で、メイン・スレッドのコードが実行される (あ るいはその逆) ことになります。mutex を使わないスレッド化プログラムの場合、コードが次の順 序で実行される可能性があります (sleep() のおかげで、実際にこのように実行されます)。 thread_function() #### ######## j=myglobal; j=j+1; printf("."); fflush(stdout); sleep(1); myglobal=myglobal+1; myglobal=j; 上に示した順序でコードが実行されると、メイン・スレッドが myglobal に加えた変更は上書きさ れてしまいます。プログラムの終了時点で得られる値は、間違ったものになります。ポインター を操作している場合だったら、segfault を生じるところです。thread_function() スレッドは、その すべての命令を正しい順序で実行する必要があるのです。thread_function() 自体が何か間違った ことをするというわけではありません。問題は、事実上同時に同じデータ構造体に対して別の変 更を加えるスレッドが、もう 1 つあるということです。
スレッドの内部 1
mutex を使うべき場所を割り出す方法を説明する前に、スレッド内部の働きについて少し説明し ます。最初の例を挙げます。 たとえば、1 つのメイン・スレッドが 3 つの新しいスレッド ("a"、"b"、"c") を作成するとしま す。これらのスレッドは、"a"、"b"、"c" の順番に作成されるものとします。pthread_create( &thread_a, NULL, thread_function, NULL); pthread_create( &thread_b, NULL, thread_function, NULL); pthread_create( &thread_c, NULL, thread_function, NULL);
最初の pthread_create() 呼び出しが完了したなら、スレッド "a" について、それが存在している か、あるいはすでに完了して現在は停止中であるかのどちらかであると見なせます。2 番目の pthread_create() 呼び出しが完了したなら、メイン・スレッドとスレッド "b" は両方とも、スレッ ド "a" は存在している (あるいは停止している) ものと見なせます。
しかし、2 番目の create() 呼び出しが戻るやいなや、メイン・スレッドは、最初に実行を開始した のが (a と b の) どちらのスレッドであるかを想定できなくなります。スレッドは両方とも存在し ているものの、それらのスレッドに CPU 時間をスライスして与えるのは、カーネルおよびスレッ ド・ライブラリーの責任になります。そして、どのスレッドが先行するかということに関するき ちんとした規則はありません。この場合、スレッド "a" がスレッド "b" に先立って実行を開始す る可能性が大ですが、その保証があるわけではありません。これは、マルチプロセッサー・マシ ンの場合に特にそう言えます。スレッド "b" に先立ってスレッド "a" が実際にそのコードの実行 を開始する、との仮定の元にコードを書くなら、出来上がるプログラムは時間を 99% 使用するも のとなるでしょう。さらに悪いことに、自分のマシンで時間を 100% 使用し、クライアントの 4 プ ロセッサー・サーバーで使用する時間は 0% ということになりかねません。 この例から学べるもう 1 つの点は、スレッド・ライブラリーは、それぞれのスレッドごとにコー ド実行の順番を保持するということです。つまり、上記の 3 つの pthread_create() 呼び出しは、 実際には、その出現の順番で実行されるということです。メイン・スレッドから見れば、これら のコードはすべて順番に実行されます。これを利用して、スレッド化されたプログラムの一部を 最適化できる場合があります。たとえば、上記の例において、スレッド "c" は、スレッド "a" およ び "b" を、実行中、あるいはすでに終了したものと見なすことができます。スレッド "a" および "b" がまだ作成されていない可能性を心配する必要はありません。この論理を使って、スレッド化 されたプログラムを最適化できます。
スレッドの内部 2
よろしい、では、仮想の例をもう 1 つ挙げます。次のコードを実行する一群のスレッドがあると します。 myglobal=myglobal+1; 増分のたびに、事前に mutex をロックし、後からロックを解除する必要があるでしょうか。 「必要ない」とおっしゃる方もあるでしょう。結局のところ、コンパイラーが上記の指定を 単一のマシン・インストラクションにコンパイルする可能性が大です。ご存じのとおり、単一 のマシン・インストラクションの「途中」に割り込むことはできません。ハードウェア割り 込みでさえ、マシン・インストラクションの原子性に沿って行われます。この傾向を考える と、pthread_mutex_lock() 呼び出しと pthread_mutex_unlock() 呼び出しをまったく省きたくなり ます。しかし、そうしてはなりません。 わたしは臆病なのでしょうか。そうではありません。まず、自分自身でマシン・コードを調べる のでない限り、上記の指定が単一のマシン・インストラクションにコンパイルされると決めてか かるべきではありません。増分が原子的に行われるようインライン・アセンブリーを挿入するに せよ、コンパイラーを自分で書くにせよ、問題が起こる可能性は依然として残ります。 理由は次のとおりです。単一のインライン・アセンブリー命令コードは、単一プロセッサー・マ シンではすばらしい働きをするでしょう。増分はそれぞれ原子的に生じ、望む結果を得られる可 能性が大きいと言えます。しかし、マルチプロセッサー・マシンとなると話は別です。マルチ CPU マシンの場合、2 つのプロセッサーが上記の指定をほとんど同時に (あるいはまったく同時 に) 実行することになります。そして、このメモリーの変更は、L1 から L2 へ、そしてメイン・ メモリーへと順番に伝わっていかなければなりません。(対称型マルチプロセッサー・マシンとは、単にプロセッサーが追加されているだけのマシンではありません。RAM へのアクセスを仲 裁するための、特別なハードウェアも備えたマシンです。) 結局のところ、どの CPU がメイン・ メモリーへの書き込みのレースに「勝つ」のかは分かりません。予測可能なコードを書くために は、mutex を使用することです。mutex は「メモリーのバリア」を挿入します。これは、スレッ ドが mutex をロックする順番に従って、メイン・メモリーへの書き込みが行われるようにしま す。 メイン・メモリーを 32 ビット・ブロックに更新する対称型マルチプロセッサー・アーキテク チャーを考えてみてください。64 ビット整数を mutex なしで増分する場合、最初の 4 バイト は 1 つの CPU から来て、次の 4 バイトは別の CPU から来るということが起こりえます。何てこ とでしょう。中でも最悪なのは、技法がお粗末だと、長時間かけたプログラムが一瞬で飛んだ り、午前 3 時に重要なクライアント・システムでプログラムが飛んだりすることがある、とい うことです。David R. Butenhof は、mutex を使用しない置換として利用可能なものを、その著書 「Programming with POSIX Threads」の中で説明しています (この記事の末尾の「参考文献」を参 照してください)。
多数の mutex
mutex が多すぎると、コードに並行性がまったくなくなり、単一スレッドのソリューションより も実行速度が遅くなるでしょう。逆に少なすぎれば、コードに奇妙で厄介なバグが現れます。あ りがたいことに、その間を取るという方法があります。まず第一に、mutex は「共用データ」へ のアクセスを直列化するために使用するものです。非共用データのために mutex を使用しないで ください。また、プログラムの論理により、特定のデータ構造体に一度にアクセスできるスレッ ドが 1 つだけに限られている場合にも、使用しないでください。 第二に、共用データを使用する場合は、読み取りと書き込みの両方に mutex を使用し てください。読み取りセクションと書き込みセクションを pthread_mutex_lock() と pthread_mutex_unlock() で囲むか、プログラムのインバリアントが一時的に崩れるときにいつ でもこれらを使用してください。コードを単一のスレッドの観点から眺めることを学び、プロ グラム内の個々のスレッドがメモリーを一貫した分かりやすい方法で見られるようにしてくださ い。mutex のこつをつかんで自分のコードを書けるようになるには時間かかかるかもしれません が、それはすぐに身に着き、「あまり」考えないでもそれを適正に使えるようになるでしょう。呼び出しの使用: 初期化
よろしい、それではここで、mutex を使用するさまざまな方法すべてを調べることにしましょ う。まず、初期化から始めることにします。thread3.c の例では、静的な初期化の方式を使用しま した。これには、pthread_mutex_t 変数を宣言して、これに定数 PTHREAD_MUTEX_INITIALIZER を 割り当てることが含まれます。 pthread_mutex_t mymutex=PTHREAD_MUTEX_INITIALIZER; これは非常に簡単です。しかし、mutex を動的に作成することもできます。コードが malloc() を 使用して新しい mutex を割り振る場合は必ず、この動的な方式を使用してください。この場合、 静的な初期化の方式ではなく、ルーチン pthread_mutex_init() を使用するべきです。ご覧のとおり、pthread_mutex_init は、mutex として初期化する、割り振り済みのメモリー領域 を指すポインターを受け入れます。2 番目の引数として、オプショナルの pthread_mutexattr_t ポ インターを受け入れることもできます。この構造体は、mutex のさまざまな属性を設定するため に使用できます。しかし、通常、そのような属性は必要ないので、NULL を指定するのが普通で す。
pthread_mutex_init() を使って初期化した mutex は、必ず pthread_mutex_destroy() を使って 破棄する必要があります。pthread_mutex_destroy() は pthread_mutex_t へのポインターを受 け入れ、mutex の作成時に割り振られたあらゆるリソースを解放します。pthread_mutex_t を保管するときに使用されたメモリーを、pthread_mutex_destroy() が解放することはないこ とに注意してください。プログラマー自身の責任で、メモリーを free() する必要がありま す。pthread_mutex_init() と pthread_mutex_destroy() は両方とも、成功するとゼロを戻すという ことにも留意してください。
呼び出しの使用: ロック
pthread_mutex_lock(pthread_mutex_t *mutex)pthread_mutex_lock() は、ロックする mutex への単一のポインターを受け入れます。mutex が既 にロックされている場合、呼び出し元はスリープ状態に入ります。関数が戻ると、呼び出し元は (当然) 目を覚まし、ロックを保持することになります。この呼び出しも、成功するとゼロ、失敗 すると非ゼロのエラー・コードを戻します。
pthread_mutex_unlock(pthread_mutex_t *mutex)
pthread_mutex_unlock() は pthread_mutex_lock() を補完し、スレッドがすでにロックした mutex のロックを解除します。ロックした mutex は、安全な範囲でできるだけ早くロック解除する必要 があります (パフォーマンスの向上のため)。ロックしていない mutex は絶対にロック解除しては なりません (さもないと、pthread_mutex_unlock() 呼び出しが失敗し、非ゼロの EPERM 戻り値が 戻されます)。 pthread_mutex_trylock(pthread_mutex_t *mutex) この呼び出しは、スレッドが (mutex がその時点でロックされているために) 何か別のことをして いる間に、mutex をロックする場合に便利です。pthread_mutex_trylock() を呼び出すと、mutex のロックが試みられます。mutex が現在ロック解除されている状態なら、ロックがかけられ、こ の関数はゼロを戻します。しかし、mutex がロックされている場合、この呼び出しが妨害を行う ことはありません。むしろ、非ゼロの EBUSY エラー値を戻します。そうしたら、自分の仕事に取 り掛かって、ロックは後で試行することができます。
条件の待機
mutex はスレッド化されたプログラムにとって必要なツールですが、何でもできるわけではあり ません。たとえば、スレッドが共用データに特定の条件が生じるのを待っている場合、何が起こ るでしょうか。コードが、値に何か変更が加えられることがないか検査しながら、mutex のロッ クとロック解除を繰り返すかもしれません。それと同時に、他のスレッドが必要な変更を加えることができるよう、mutex のロック解除も素早く行います。しかし、これは恐ろしいアプローチ です。なぜなら、このスレッドは、妥当な時間フレームの間、変更を検出するために絶えずルー プを回す必要があるからです。 呼び出し側のスレッドを、検査と検査の合間に少しだけ (たとえば 3 秒間) スリープ状態にする こともできるかもしれませんが、この場合、スレッド化されたコードが最良の仕方で責任を果た しているとは言えないでしょう。ここで本当に必要なのは、ある条件が満たされるまでの間、ス レッドをスリープ状態にしておく方法です。いったん条件が満たされたなら、今度は、その特定 の条件が真になるのを待つスレッドを目覚めさせるための方式が必要になります。それができる なら、スレッド化されたコードは本当に効果的なものになり、貴重な mutex ロックを占有するこ とはなくなるでしょう。これこそが、POSIX の条件変数が行えることです。 POSIX 条件変数は、次の記事で取り上げる主題であり、その詳細な使用法は次の記事で説明しま す。その時、作業班や組み立てラインなどのモデリングを行う、高機能なスレッド化プログラム を作成するための資材がすべて揃うことになります。読者もスレッドに精通してこられたことで すし、次の記事ではペースを上げることにします。そうして、次回の記事の最後までに、ある程 度の機能を備えたスレッド化プログラムを作成できるようにしたいと望んでいます。さらに、条 件の待機についても説明します。では、またお目にかかりましょう。
著者について
Daniel Robbins
Daniel Robbins 氏は、ニューメキシコ州アルバカーキーに住んでいます。彼 は、Gentoo プロジェクトのチーフ・アーキテクト、Gentoo Technologies Inc. の社長 / CEO です。著書に、Macmillan から出版されている Caldera OpenLinux Unleashed、SuSE Linux Unleashed、Samba Unleashed があります。Daniel 氏は、小 学 2年のとき初めて Logo プログラム言語や、中毒になる恐れのあった Pac Man に出 会って以来、何らかの形でコンピューターに関係してきています。これで、彼がなぜ SONY Electronic Publishing/Psygnosis でリード・グラフィック・アーチストを務めて いるかが分かるでしょう。愛妻 Mary さんや、生まれたばかりの愛娘 Hadassah ちゃ んとの時間をとても大切にしています。 © Copyright IBM Corporation 2000 (www.ibm.com/legal/copytrade.shtml) 商標 (www.ibm.com/developerworks/jp/ibm/trademarks/)