スレッド間でカウンタを共有する

C言語では、複数のスレッドが共有する変数を単純にインクリメントすると期待通りの結果になりません。あるいは簡単なテストでうまく動いたとしても、高負荷になるとおかしな結果を返します。この手のバグは再現条件を揃えること自体が難しいので、はじめから安全なコードを書けるように挙動を知っておくことが大事です。

まずうまくいかないサンプルコードがこちら。10個のスレッドを起こして、それぞれのスレッドが共有変数をナイーブに100万回カウントアップします。

#include <stdio.h>
#include <pthread.h>

void *func(void *i) {
    int j;

    for (j = 0; j < 1000000; j++)
        (*(int *)i)++;  // <- counts up

    return NULL;
}

int main() {
    int n = 0, x = 0;
    pthread_t th[10];

    for (n = 0; n < 10; n++)
        pthread_create(&th[n], NULL, func, &x);

    for (n = 0; n < 10; n++)
        pthread_join(th[n], NULL);

    printf("%d\n", x); 

    return 0;
}

すると、こんな結果が返ります。(あとで性能についても考えるので、timeコマンドを噛ませます。)

$ time ./a.out
2871703

real	0m0.037s
user	0m0.207s
sys	0m0.000s

期待する結果1000万(10スレッド*100万回カウントアップ)に比べて、出力された値は遥かに少ないです。インクリメントはCでは1行で済むのでアトミック(不可分)な処理に思いがちですが、Read-Modify-Writeという3つの処理に分けて実行されるのが普通です。

  1. メモリからレジスタに読み込み
  2. レジスタに入れた値に対して命令された演算を行う
  3. 結果の入ったレジスタの値をメモリに書き込む

という流れの処理です。こんな感じ。

read a
>------+
       |
       * modify a
write a|
<------+

read b
>------+
       |
       * modify b
write b|
<------+

この流れに則って処理されていれば問題ないのですが、メモリアクセスは非常に時間がかかるハイコストな操作なので、マルチスレッドだと次のようなレースコンディション(競合状態)が頻発します。たとえば、クロスオーバー。

read a
>------+
       |
       |
       * modify a
read b |
>-----------+
       |    |
       |    |
write a|    * modify b
<-------    |
            |
write b     |
<-----------+

あるいは、オーバーラップ。

read a
>-----------+
            |
read b      * modify a
>------+    |
       |    |
       * modify b
       |    |
write b|    |
<-------    |
            |
read c      |
>------+    |
       |    |
       * modify c
       |    |
write c|    |
<-------    |
            |
write a     |
<-----------+

このように処理が割り込まれると、インクリメントに失敗します。対処としてたまに見かける誤った例は、共有される変数の宣言にvolatile修飾子を付けるというものです。

#include <stdio.h>
#include <pthread.h>

volatile int i = 0;

void *func() {
    int j;

    for (j = 0; j < 1000000; j++)
        i++;

    return NULL;
}

int main() {
    int n = 0;
    pthread_t th[10];

    for (n = 0; n < 10; n++)
        pthread_create(&th[n], NULL, func, NULL);

    for (n = 0; n < 10; n++)
        pthread_join(th[n], NULL);

    printf("%d\n", i); 

    return 0;
}

C言語ではvolatile修飾子の役割はコンパイラの最適化を抑止することです。たとえば、メモリマップトI/Oのような唐突に特定のアドレスへ書き込みを行うようなコードをコンパイラが不要とみなして勝手に消してしまうことを抑止するような用途に使います。修飾された変数への処理がスレッド安全になるわけではありません。むしろアプリケーション・プログラミングではほとんどなにも保証してくれないと思ったほうがいいです。ばっちり割り込まれます。(POS03-C. volatile を同期用プリミティブとして使用しない)

$ time ./a.out 
2546514

real	0m0.040s
user	0m0.225s
sys	0m0.000s

解決として一般的な方法はmutex(MUTual EXclusion)を使って複数のスレッドが同時にクリティカルセクションへ入ることを防ぐというものです。

#include <stdio.h>
#include <stdatomic.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)++;
        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 ./a.out 
10000000

real	0m2.117s
user	0m1.272s
sys	0m14.747s

問題は、mutexは非常にコストがかかるということです。1000万回のインクリメントに実時間で2秒以上かかり、CPU時間では16秒もかかっています。

次回はこの性能劣化について考えてみます。

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です

*