前回の記事に引き続いて、マルチスレッドプログラムの性能についてです。前回は「コンテキストスイッチングや排他制御のオーバヘッドに見合う処理でなければ並列化は逆効果」ということがわかりました。今回はmutexのロック獲得と開放について簡単な実験します。
使うプログラム”mutex_test.c”はC言語で書かれたマルチスレッドプログラムで、起動すると10個のスレッドを起こし、それぞれのスレッドが共有変数を100万回カウントアップします。クリティカルセクションは当然短いに越したことはないという安直な考えで、カウントアップ毎にロックの獲得と開放を行っています。
#include <stdio.h> #include <pthread.h> struct params { int *i; pthread_mutex_t *mut; }; void *func(void *arg) { int j; struct params *prm = (struct params *) arg; for (j = 0; j < 1000000; j++) { pthread_mutex_lock(prm->mut); (*(int *)prm->i)++; // counts up pthread_mutex_unlock(prm->mut); } return NULL; } int main() { int n = 0, x = 0; pthread_t th[10]; pthread_mutex_t mut = PTHREAD_MUTEX_INITIALIZER; struct params prm = {&x, &mut}; for (n = 0; n < 10; n++) pthread_create(&th[n], NULL, func, &prm); for (n = 0; n < 10; n++) pthread_join(th[n], NULL); printf("%d\n", x); return 0; }
このコードをコンパイルし、timeコマンドを噛ませて実行すると、
$ gcc mutex_test.c -lpthread -Wall $ time ./a.out 10000000 real 0m2.117s user 0m1.272s sys 0m14.747s
1000万回のカウントアップに16秒ものCPU時間を消費してしまいます。さて、ここまでは前回と同じです。
確かに、ロック獲得のための待ち時間を少なくするためにはクリティカルセクションは短いほうがよいです。しかし、過激にクリティカルセクションを分断して何度もロックの獲得と開放を繰り返すのは誤りです。クリティカルセクション内の処理時間と排他制御自体に関わる処理時間を天秤にかける必要があります。このコードでは1000万回のカウントアップのために、10個のスレッドがそれぞれ100万回もロックを奪い合っているわけです。
そんなことをするくらいなら、10個のスレッドがはじめに1回だけロックを獲得して、100万回のカウントアップした後にロックを開放するほうがよほど効率的です。そのようにコードを書き換えて、
void *func(void *arg) { int j; struct params *prm = (struct params *) arg; pthread_mutex_lock(prm->mut); for (j = 0; j < 1000000; j++) { // pthread_mutex_lock(prm->mut); (*(int *)prm->i)++; // counts up // pthread_mutex_unlock(prm->mut); } pthread_mutex_unlock(prm->mut); return NULL; }
実行すると、
$ gcc mutex_test.c -lpthread -Wall $ time ./a.out 10000000 real 0m0.031s user 0m0.031s sys 0m0.000s
0.031秒。500倍高速になりました。クリティカルセクションは短い方がよいですが、短くするために分断すると排他制御のコストが増加することは頭に入れておきましょう。
次回は、インクリメントだけならmutexほどリッチな排他制御は必要ないよねというお話です。