Cプログラマへの道 #13 - マルチスレディング

Last Edited: 8/19/2024

このブログ記事では、C言語におけるマルチスレディングについて紹介します。

C Multithreading

通常の日本語で並行処理(Concurrency)と並列処理(Parallelism)は混同され使われますが、プログラミングに おいては明確に違う意味を持ちます。並行処理が複数の処理を同時かに関わらず行うことを指す一方、並列処理は複数の プロセッサが処理を同時に行うことを指します。それがどうしたと思うかもしれませんが、この違いはプログラミングに おいて大きな意味を持ちます。今回の記事では、その違いの意味を明らかにして行きます。

マルチスレディング

並行処理や並列処理を行うには、プロセスを複数のサブプロセス(スレッド)に分割し、それらのプロセスからの結果を上手く統合する 方法を考える必要があります。これがマルチスレッドプログラミングの主な作業です。言葉では分かりづらいのでC言語における最も簡単 なマルチスレッドの例を早速見てみましょう。

print.c
#include <stdio.h>
#include <pthread.h>
 
void *print (void *input) {
    int *value = (int *) (input);
    for (int i = 0; i < 1000000000; i++) {}
    printf("%d\n", *value);
    return NULL;
}
 
int main () {
    pthread_t thread1;
    pthread_t thread2;
 
    int value1 = 3;
    int value2 = 5;
 
    pthread_create(&thread1, NULL, print, (void *) &value1);
    pthread_create(&thread2, NULL, print, (void *) &value2);
 
    pthread_join(thread1, NULL);
    pthread_join(thread2, NULL);
 
    return 0;
}

上記のコードは、pthread_create関数を使ってthread1thread2という2つのスレッドを作成し、pthread_join関数を使って実行後にそれらのスレッドをmain関数の スレッドに戻すものです。pthread_create関数は4つの引数を取ります; pthread_tオブジェクト、スレッドの属性(スタックサイズやスレッドのアドレスなど。NULLはデフォルト属性を意味します)、 スレッド内で実行する関数、そしてその関数の引数です。

2つのスレッドを作成した後に結果を結合することで、利用可能な計算資源があればそれらは並列に実行されます。このプログラムの実行時間を、gcc -o print print.cでコンパイルし、 time ./printを使って計測したところ、私の環境では約1.3秒でした。(ぜひ自分の環境でも試してみてください。)次のコードの実行時間を計測すると、並列処理による速度の向上が確認できます。

print.c
int main () {
    pthread_t thread1;
    pthread_t thread2;
 
    int value1 = 3;
    int value2 = 5;
 
    pthread_create(&thread1, NULL, print, (void *) &value1);
    pthread_join(thread1, NULL);
 
    pthread_create(&thread2, NULL, print, (void *) &value2);
    pthread_join(thread2, NULL);
 
    return 0;
}

前のコードと異なるのは、pthread_joinを行うタイミングだけです。最初のスレッドが結合されてから2つ目のスレッドが作成されるため、それらは並列ではなく、 逐次的に、並行に実行されます。上記のコードの実行時間を測定したところ、約2秒かかりました。これも実際に試してみて、その影響を確認してみてください。

レースコンディション

以下の例のように複数のスレッドで同じ変数を操作したい場合があります。

int value;
 
void *increment () {
    for (int i = 0; i < 1000000000; i++) {
        value += i;
    }
    return NULL;
}
 
int main () {
    pthread_t thread1;
    pthread_t thread2;
 
    value = 0;
 
    pthread_create(&thread1, NULL, increment, NULL);
    pthread_create(&thread2, NULL, increment, NULL);
 
    pthread_join(thread1, NULL);
    pthread_join(thread2, NULL);
 
    printf("Result: %d\n", value);
 
    return 0;
}

両方のスレッドがivalueに10億回加算しようとしていますが、これが成功すると1808348672という結果が得られるはずです。しかし、このプログラムを実行すると、 1808348672よりも小さいランダムな数値が得られます。その理由は、両方のスレッドが同時に変数にアクセスし、加算して結果を保存しようとするため、 片方のスレッドがもう一方のスレッドの加算結果を上書きしてしまう可能性があるからです。

これを具体的に考えてみましょう。例えば、valueが0に設定されているとします。両方のスレッドが同時にvalueにアクセスし、0を取得します。何らかの理由で、 スレッド1がこの操作をより早く行い、1をvalueに保存します。しかし、スレッド2はスレッド1が既にvalueを1に増やしたことを知らないため、スレッド2もvalueを1に増やし、 その結果をvalueに保存しようとします。これでは、valueが1に設定されるだけで、正しく2にはなりません。

このように、プログラムの実行の成功が実行タイミングに依存する場合、それはレースコンディションが発生したと言います。上記は、複数のスレッドが同じ変数にアクセスしようとして起こる 典型的なレースコンディションの例です。

ミューテックス

レースコンディションを防ぐ一つの方法は、ミューテックスを使用することです。これにより、1つのスレッドだけが操作を実行できるように制限できます。概念的には、 ミューテックスは鍵のように機能し、スレッドが最初にそれに到達してプロセッサをロックし、そのスレッドが実行を終了してプロセッサをアンロックするまで他のスレッド を締め出します。これにより、共有変数が1回ずつ確実に更新されるようになります。

int value;
 
pthread_mutex_t mutex;
 
void *increment () {
    for (int i = 0; i < 1000000000; i++) {
        pthread_mutex_lock(&mutex);
        value += i;
        pthread_mutex_unlock(&mutex);
    }
    return NULL;
}
 
int main () {
    pthread_t thread1;
    pthread_t thread2;
 
    value = 0;
 
    pthread_mutex_init(&mutex, NULL);
 
    pthread_create(&thread1, NULL, increment, NULL);
    pthread_create(&thread2, NULL, increment, NULL);
 
    pthread_join(thread1, NULL);
    pthread_join(thread2, NULL);
 
    pthread_mutex_destroy(&mutex);
 
    printf("Result: %d\n", value);
 
    return 0;
}

ミューテックスを使用するにはまずpthread_mutex_t変数を作成し、スレッドの実行前後にそれを初期化し、破棄します。そして、 スレッドがプロセッサをロックおよびアンロックするべき場所にpthread_mutex_lockpthread_mutex_unlockを配置します。 上記のプログラムを実行すると、毎回1808348672が得られることが保証されます。

しかし、実行した際アルゴリズムが大幅に遅くなったことに気づくかもしれません。これは、上のアルゴリズムでのいかなる操作も並列で行われておらず、 ロックおよびアンロックのプロセスが20億回も行われているためです。(マルチスレディングを使用せずに単にforループを2回実行するよりも遅いです。 これも試してみてください。)では、このような場合マルチスレディングを使用しないほうが良いのでしょうか?実はそうではありません。よく考えてみると、 加算は結合的であるため、順序に依存しません。したがって、それぞれのスレッドでローカル変数を同時に増やし、その後、共有変数をミューテックスを使って、 共有変数を安全に一度に更新することができます。

void *increment () {
    int local = 0;
    for (int i = 0; i < 1000000000; i++) {
        local += i;
    }
    pthread_mutex_lock(&mutex);
        value += local;
    pthread_mutex_unlock(&mutex);
    return NULL;
}

私の環境では、前のアルゴリズムは実行に24秒もかかりましたが、更新されたバージョンでは約1.3秒で完了しました。(マルチスレッドを使わないで2回forループを回す と約4秒かかりました。)したがって、マルチスレディングを行う際にはプログラムを慎重に分析する必要があります。また、他にもスレッド間の異なる種類の通信を扱うための 様々な機能(条件変数、ミューテックスバリア、デタッチされたスレッド、セマフォなど)があり、これについては将来の記事で取り上げるかもしれません。 (これらについて興味がある方は、CodeVaultのUnix Threads in Cのビデオシリーズを 強くお勧めします。)

非並列の並行処理は悪か

以上の例の数々は、全て並列処理と並行処理の違いの重要性を示しています。並列処理は複数のプロセッサを活用してより高速に実行できますが、非並列な並行処理は高速にはならず、 場合によっては単一スレッドのプログラムよりも遅くなることがあります。それでは、非並列の並行処理は常に避けるべきなのでしょうか? これについて特別詳しいわけではありませんが、私の知る限り、ネットワーキングやログのように、タスクの性質がリアルタイムのモニタリングを必要とする場合には、 並行処理が並列性に関係なく依然として重要です。また、特にOSは複数のアプリケーションを限られたプロセッサの数で実行処理する必要があるため、 並行処理はとても重要な処理と言えます。

[注]: セマフォとデッドロックについては、記事Haskellerへの道 #24 - セマフォ で取り上げました。Haskellシリーズを追っていてこれらに興味がある方は、チェックしてみて下さい。

リソース