スレッド間でカウンタを共有する(再び)

これまでも話してきたとおり、スレッド間でオブジェクトを共有することはハイコストなので、可能な限りオブジェクトを共有しない設計にすることが性能においても品質においても重要です。しかし、どうしてもオブジェクトを共有したい場合は、昨今の処理系にはそれを保証する機能が用意されているのでそれを使って安全を担保します。

Cではvolatile修飾子をこの用途に使ってはいけません。なんの効果も得られないか、特定の環境下で効果があったとしてもそれは移植性がないコードで、より複雑なバグを埋め込むことになります。

Cでの第一選択肢はPOSIXスレッド(pthreads)のMUTEXです。ただし、MUTEXは他のスレッドからの割り込みに対しては安全ですが、非同期シグナル安全ではありませんので、これも同時に考慮して設計する必要があります。

それではスレッド間で共有する変数のカウントアップをMUTEXで排他制御したコードに、timeコマンドを噛ませて実行してみましょう。

#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;
}

このとき、主とする処理が単なる数値演算のようなOSの機能を必要としないものにも関わらず、やたらとシステムCPU時間を消費している場合には、排他制御のオーバーヘッドが大きいことを疑ってみてください。プロファイラにかけて重い処理を探ってみるのもよいですが、timeコマンドの出力するCPU時間は取得が簡単でとても参考になります。

$ gcc mutex_test.c -lpthread -Wall
$ time ./a.out 
10000000

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

このようにMUTEXでは役不足な処理の場合、排他制御の実現にOSが介在する上位のAPIではなく、メモリバリアを抽象化した下位のAPIを使う選択肢もあります。C11では Atomic operations library の定義が stdatomic.h に記述されています。

ではこのライブラリを使ってカウントアップ処理を保護するMUTEXを外し、インクリメントをatomic_fetch_add()に置き換えてみましょう。

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


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

    for (j = 0; j < 1000000; j++)
        atomic_fetch_add((int *)i, 1); // 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 ./a.out 
10000000

real	0m0.252s
user	0m1.776s
sys	0m0.000s

システムCPU時間を消費していないことがわかります。この例ではMUTEXよりも高速です。あるいは、_Atomic修飾子です。

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

_Atomic int i = ATOMIC_VAR_INIT(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;
}
$ time ./a.out 
10000000

real	0m1.292s
user	0m9.514s
sys	0m0.000s

こちらもシステムCPU時間が消費されていません。つまりこれらは、OSが提供する機能ではなく、アプリケーションの機械語レベルで非同期スレッド安全な処理にコンパイルされています。

では最後に、完全にシングルで書いたものを実行してみます。

#include <stdio.h>

int main() {
    int i = 0, j = 0;

    for (; j < 10000000; j++)
        i++;

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

    return 0;
}
$ time ./a.out 
10000000

real	0m0.028s
user	0m0.028s
sys	0m0.000s

これまでで最速の処理時間です。シングルの場合、非同期スレッド安全のために保証しなくてはならない、次に上げる事柄を一切考慮する必要がありません。

  • 不可分性
    クリティカルセクションに割り込ませないか、割り込まれても問題がないことの保証。
  • 可視性
    あるスレッドが行った操作の結果が、他のスレッドからも観測できることの保証。例えばコンパイラがある処理をレジスタ上で完結するように最適化してしまったり、CPUキャッシュの中で完結してメモリに書き込まれていなかったりすると、他のスレッドからはその値を参照できない場合がある。
  • 逐次性
    あるスレッドが行った処理の順序が、他のスレッドでも同じ順序で観測できることの保証。例えばコンパイラが不要と判断した処理を省略してしまったり、CPUのアウトオブオーダー実行によって命令の実行順序が入れ替わってしまうと、期待する条件が成立しない場合がある。

これらを考慮する必要が無い場合、極端に言えばコンパイラは最適化によってただ「10000000」と標準出力に書き込むバイナリを生成しても許されるわけで、実際そのような最適化が行われます。

一貫性のある設計のためには、なにを保証し、なにを保証しないのかを明確にしておくべきです。ときとして、これらの設計と実装はとても複雑になり、シングルで作ったほうがよい場合があります。そのことに早い段階で気づくための作業が設計フェーズです。

コメントを残す

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

*