お前らのThreadの使い方は間違っている

お前らのThreadの使い方は間違っている

すでに今までに10人くらいにこの話をしてきたのでいい加減ドキュメンテーションすることにしました.

そもそも

あなたが一流プログラマーを目指したいわけではないのであれば非同期処理を使ってはいけません. 非同期処理以外の方法を使って問題を解決しましょう.

ここからいくつかのトピックを記載しますが、全てのトピックを理解するまで弊社およびグループで非同期処理を使ってはいけません. 理解せずに利用したい場合、プロフェッショナルに依頼しましょう.

安全ではない例

まずはC 言語でスレッドの処理を記述してみます. この程度のプログラムを読むことができない人は非同期処理を使うべきではないので、C言語が読めない人は非同期処理を使うのをやめましょう

https://gist.github.com/katsusuke/6f7ebb9e8fb0acdcbb770cd4900d6f49

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

// 共有変数
int counter = 0;
const int LOOP_COUNT = 1000000;

// スレッド関数
void* increment_counter(void* arg) {
    int thread_id = *(int*)arg;

    printf("スレッド %d: 開始\n", thread_id);
    for (int i = 0; i < LOOP_COUNT; i++) {
        counter++; // カウンターをインクリメントするが、インクリメントはスレッドセーフではない
        printf("スレッド %d: カウンター = %d\n", thread_id, counter);
    }
    printf("スレッド %d: 終了\n", thread_id);
    return NULL;
}

int main() {
    pthread_t thread1, thread2;
    int id1 = 1, id2 = 2;

    printf("開始: カウンター = %d\n", counter);

    // 2つのスレッドを作成
    pthread_create(&thread1, NULL, increment_counter, &id1);
    pthread_create(&thread2, NULL, increment_counter, &id2);

    // 両方のスレッドが終了するのを待つ
    pthread_join(thread1, NULL);
    pthread_join(thread2, NULL);

    // 結果を表示
    printf("終了: カウンター = %d\n", counter);
    printf("期待される値は%dです\n", LOOP_COUNT * 2);

    return 0;
}

このコードを多くの環境で実行すると下記の様な実行結果になります.

スレッド 1: カウンター = 1999967
スレッド 1: カウンター = 1999968
スレッド 1: カウンター = 1999969
スレッド 1: カウンター = 1999970
スレッド 1: カウンター = 1999971
スレッド 1: 終了
終了: カウンター = 1999971
期待される値は2000000です

期待される値は2000000ですが、実際の値は1999971になっています.

スレッドとタイマー割り込み

今時のCPUは、マルチコアになっているので、複数のスレッドを同時に実行することができますが、スレッド自体はシングルコアの時代から存在していました. シングルコアCPUでは同時に1つの処理しか実行することができませんでしたが、タイマー割り込みを利用することで、擬似的に複数のスレッドを同時に実行しているように見せることができます. タイマー割り込みをシーケンス図で表すと以下のようになります.

sequenceDiagram
    participant CPU
    participant Thread1 as メイン処理
    participant Thread2 as Thread

    CPU->>+Thread1: メイン処理を起動
    Note right of Thread1: メイン処理を実行 実行時間は不定
    Thread1->>-CPU: タイマー割り込み 現在のスタックを退避
    CPU->>+Thread2: Threadを起動
    Note right of Thread2: スレッドの処理を実行 どれだけの時間で切り替わるかは不定
    Thread2->>-CPU: タイマー割り込み 現在のスタックを退避
    CPU->>+Thread1: スタックを復帰メイン処理に戻る
    Note right of Thread1: メイン処理を実行 実行時間は不定
    Thread1->>-CPU: タイマー割り込み 現在のスタックを退避
    CPU->>+Thread2: Threadを起動
    Note right of Thread2: スレッドの処理を実行 どれだけの時間で切り替わるかは不定
    Thread2->>-CPU: タイマー割り込み 現在のスタックを退避

スレッドは、CPUのタイマー割り込みによって実装されています. タイマー割り込みは、一定の時間間隔でCPUが現在実行中のタスクを中断し、他のタスクに切り替える仕組みです。 この仕組みによって、CPUは複数のスレッドを擬似的に同時に実行しているように見せることができます. スレッドは、タイマー割り込みによって実行されるため、スレッド切り替えがどのようなタイミングで実行されるかは予測できません.

この特性を理解した上で、先ほどのC言語のコードを見てみましょう. 特筆すべきはこの部分です. counter++; // カウンターをインクリメントするが、インクリメントはスレッドセーフではない このコードをアセンブラに翻訳すると下記のような処理になります.

mov eax, [counter]  ; カウンターの値をメモリーから汎用レジスタeaxにロード
add eax, 1          ; 汎用レジスタeaxの値を1増やす
mov [counter], eax  ; 増やした値をメモリーのカウンターに保存

このコードがマルチスレッドで実行された時理想的な状況ではアセンブラは下記のような処理を行います.

mov eax, [counter]  ; スレッド1: カウンターの値をメモリーから汎用レジスタeaxにロード
add eax, 1          ; スレッド1: 汎用レジスタeaxの値を1増やす (例: 1)
mov [counter], eax  ; スレッド1: 増やした値をメモリーのカウンターに保存 (例: 1)
; スレッド1がここでタイマー割り込みを受け、スレッド2に切り替わる
mov eax, [counter]  ; スレッド2: カウンターの値をメモリーから汎用レジスタeaxにロード (例: 1)
add eax, 1          ; スレッド2: 汎用レジスタeaxの値を1増やす (例: 2)
mov [counter], eax  ; スレッド2: 増やした値をメモリーのカウンターに保存 (例: 2)

しかし、前述した通り、スレッドの切り替えは予測できないため、実際には以下のような状況が発生する可能性があります。

mov eax, [counter]  ; スレッド1: カウンターの値をメモリーから汎用レジスタeaxにロード (例: 0)
add eax, 1          ; スレッド1: 汎用レジスタeaxの値を1増やす (例: 1)
; スレッド1がここでタイマー割り込みを受け、スレッド2に切り替わる
mov eax, [counter]  ; スレッド2: カウンターの値をメモリーから汎用レジスタeaxにロード (例: 0)
add eax, 1          ; スレッド2: 汎用レジスタeaxの値を1増やす (例: 1)
mov [counter], eax  ; スレッド2: 増やした値をメモリーのカウンターに保存 (例: 1)
; スレッド2がここでタイマー割り込みを受け、スレッド1に戻る
mov [counter], eax  ; スレッド1: 増やした値をメモリーのカウンターに保存 (例: 1)

このように、スレッド1とスレッド2が同時にカウンターを読み取り、同じ値をインクリメントしてしまうため、最終的なカウンターの値が2ではなく1になってしまいます. このような状態をレースコンディションと呼びます. レースコンディションは、複数のスレッドが同じデータに同時にアクセスし、予期しない結果を引き起こす問題です。 レースコンディションを防ぐためには、スレッド間でのデータの整合性を保つための適切な同期機構(ミューテックスやセマフォなど)を使用する必要があります。

安全な例

次に、C言語でスレッドの処理を記述してみます. 下記の例ではミューテックスを使用して、スレッド間の競合状態を防ぎます.

#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
// 共有変数
int counter = 0;
const int LOOP_COUNT = 1000000;
// ミューテックス
pthread_mutex_t mutex;
// スレッド関数
void* increment_counter(void* arg) {
    int thread_id = *(int*)arg;

    printf("スレッド %d: 開始\n", thread_id);
    for (int i = 0; i < LOOP_COUNT; i++) {
        pthread_mutex_lock(&mutex); // ミューテックスをロック
        counter++; // カウンターをインクリメント
        pthread_mutex_unlock(&mutex); // ミューテックスをアンロック
        printf("スレッド %d: カウンター = %d\n", thread_id, counter);
    }
    printf("スレッド %d: 終了\n", thread_id);
    return NULL;
}

またはatomic_fetch_add を使う方法もあります. こちらの方が高速です.

#include <stdio.h>
#include <pthread.h>
#include <stdatomic.h>
#include <unistd.h>
// 共有変数
int counter = 0;
const int LOOP_COUNT = 1000000;
// スレッド関数
void* increment_counter(void* arg) {
    int thread_id = *(int*)arg;

    printf("スレッド %d: 開始\n", thread_id);
    for (int i = 0; i < LOOP_COUNT; i++) {
        atomic_fetch_add(&counter, 1); // カウンターをインクリメント
        printf("スレッド %d: カウンター = %d\n", thread_id, counter);
    }
    printf("スレッド %d: 終了\n", thread_id);
    return NULL;
}

デッドロックについて

デッドロックは、複数のスレッドが互いにリソースを待ち合う状態で、どのスレッドも進行できなくなる問題です。 デッドロックは、スレッドがリソースを獲得する際に、他のスレッドが保持しているリソースを待つ状態で発生します。

デッドロックについて理解するために、以下の例を考えてみましょう。

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

// A さんの口座の残高
int account_a = 1000;
// A さんの残高をロックするためのミューテックス
pthread_mutex_t mutex_a;
// B さんの口座の残高
int account_b = 2000;
// B さんの残高をロックするためのミューテックス
pthread_mutex_t mutex_b;

void print_accounts(const char *msg) {
    printf("%s A さんの残高: %d Bさんの残高:%d\n", msg, account_a, account_b);  
}

// ロックの順序が異なる2つのスレッド関数を実行したことで両方のスレッドがデッドロックして、後続きの処理が進まなくなる
// A さんから B さんに 100 円送金するスレッド関数
void *move_a_to_b(void *arg) {
    print_accounts("A さんから引き落とし前にロック取得");
    pthread_mutex_lock(&mutex_a);
    account_a -= 100;
    print_accounts("A さんから100円引き落とし完了");
    sleep(1); // スリープを入れてデッドロックを発生
    print_accounts("B さんに入金前にロック取得");
    pthread_mutex_lock(&mutex_b);
    account_b += 100;
    print_accounts("B さんに100円入金完了");
    pthread_mutex_unlock(&mutex_b);
    pthread_mutex_unlock(&mutex_a);
    return NULL;
}

// B さんから A さんに 200 円送金するスレッド関数
void *move_b_to_a(void *arg) {
    print_accounts("B さんから引き落とし前にロック取得");
    pthread_mutex_lock(&mutex_b);
    account_b -= 200;
    print_accounts("B さんから200円引き落とし完了");
    sleep(1); // スリープを入れてデッドロックを発生
    print_accounts("A さんに入金前にロック取得");
    pthread_mutex_lock(&mutex_a);
    account_a += 200;
    print_accounts("A さんに200円入金完了");
    pthread_mutex_unlock(&mutex_a);
    pthread_mutex_unlock(&mutex_b);
    return NULL;
}

int main() {
    pthread_t thread1, thread2;
    pthread_mutex_init(&mutex_a, NULL);
    pthread_mutex_init(&mutex_b, NULL);
    pthread_create(&thread1, NULL, move_a_to_b, NULL);
    pthread_create(&thread2, NULL, move_b_to_a, NULL);
    pthread_join(thread1, NULL);
    pthread_join(thread2, NULL);
    pthread_mutex_destroy(&mutex_a);
    pthread_mutex_destroy(&mutex_b);  
    return 0;
}

この例ではAさんの口座残高と、Bさんの口座残高を2つのスレッドで同時に操作しています. 二つのスレッドで共通のリソースを操作するためには、ミューテックスを利用して排他制御を行う必要があります. そのため、Aさんの口座残高を操作する際には mutex_a をロックし、Bさんの口座残高を操作する際には mutex_b をロックしています.

実行結果は下記の通りです。このプログラムは残念ながらデッドロックが発生し、後続の処理が進まなくなります.

./a.out 
A さんから引き落とし前にロック取得 A さんの残高: 1000 Bさんの残高:2000
A さんから100円引き落とし完了 A さんの残高: 900 Bさんの残高:2000
B さんから引き落とし前にロック取得 A さんの残高: 1000 Bさんの残高:2000
B さんから200円引き落とし完了 A さんの残高: 900 Bさんの残高:1800
A さんに入金前にロック取得 A さんの残高: 900 Bさんの残高:1800
B さんに入金前にロック取得 A さんの残高: 900 Bさんの残高:1800

この場合、のデッドロックを防ぐためには、以下のような方法があります。

  1. 全体で単一のミューテックスを使用する: すべてのリソースに対して単一のミューテックスを使用し、同時に複数のリソースをロックしないようにします。ただしc さんd さんと人が増えるとパフォーマンスが低下します。
  2. ロックの順序を統一する: すべてのスレッドが同じ順序でリソースをロックするようにします。例えば、常に mutex_a を先にロックし、その後に mutex_b をロックするようにします。
  3. ロジックで工夫する: 要件次第ですが、もしこのシステムが、人と人の間のお金のやり取りのみを管理するシステムである場合、引き出し元だけロックするというようなルールで縛ることができます。ただし複数人でこのシステムが操作できて、また口座にお金が増える(全体のお金の総量が増える)ようなシステムはうまくいきません

1のケースの方が美しいコードになりますが、人数が増えるとパフォーマンスが低下します.

そもそもなぜ非同期処理を使うのか

非同期処理を使うべき正しい理由は、主に以下のようなものです。

  1. パフォーマンスの向上: 非同期処理を使用することで、I/O待ちや長時間実行されるタスクを他の処理と並行して実行でき、全体のパフォーマンスを向上させることができます。
  2. リソースの効率的な利用: 非同期処理を使用することで、CPUやメモリなどのリソースを効率的に利用できます。特に、I/O操作が多いアプリケーションでは、非同期処理が有効です。
  3. ユーザーエクスペリエンスの向上: ユーザーインターフェースを持つアプリケーションでは、非同期処理を使用することで、ユーザーが操作を行っている間にバックグラウンドで処理を実行し、スムーズな操作感を提供できます。
  4. スケーラビリティの向上: 非同期処理を使用することで、同時に多くのリクエストを処理できるようになり、アプリケーションのスケーラビリティが向上します。

しかし清水が見てきた多くの初心者プログラマーはみんな非同期処理を、状態遷移について考えたくないからという理由で使っています. しかしこの考え方は大きな間違いです. 状態遷移を考えずに非同期処理を使うことはできません。 状態遷移について考えず非同期処理を実装するとプログラムの挙動が予測できなくなり、メンテナンス不可能なコードが生成されます。

どうしても非同期が使いたい困った人のため非同期処理以外の方法

どうしても非同期処理を使いたい場合は、Golang のChannel 並びに、このエッセンスを使ったその他のプログラミング言語での非同期処理をまずは検討します. Golang のChannel はとてもクールな方法です. 他の言語で非同期処理を利用したと考えた時、まずはGolang のchannelについて学ぶ必要があります. Golang もついでに覚えましょう.スレッドについて理解するよりもGolang を覚える方が簡単です.

package main

import "fmt"

func sum(s []int, c chan int) {
	sum := 0
	for _, v := range s {
		sum += v
	}
	c <- sum // send sum to c
}

func main() {
	s := []int{7, 2, 8, -9, 4, 0}

	c := make(chan int)
	go sum(s[:len(s)/2], c)
	go sum(s[len(s)/2:], c)
	x, y := <-c, <-c // receive from c

	fmt.Println(x, y, x+y)
}

Channel だけでは実装できない時

Channel は素晴らしい方法ですが、現実世界で必要になる並列処理の全てのパターンをChannelだけで解決することはできません。

Unityなどのゲームエンジンなどで広く利用されているゲームループのようなパターンを利用するという方法があります.

この方法は設計の難易度が低く制御系のプログラミングにおいては非常に有効な方法です.

ゲームループの仕組み

ゲームループは、一つの大きなループと、外部通信など、遅延が発生する可能性がある処理は別のスレッドで実行し、メインループに結果を通知する仕組みです.

sequenceDiagram
    participant GameLoop as  ゲームループ
    participant Input as ゲームパッドなど入力処理スレッド
    participant External as 外部システム通信スレッド
    loop メインループ
        Input->>GameLoop: 入力されたコマンドを通知
        External->>GameLoop: 外部システムからのレスポンス受信結果
        GameLoop->>GameLoop: 入力コマンドと、レスポンスの内容を元に状態遷移
        GameLoop->>External: 外部システムからの受信があればゲームループに送信
        GameLoop->>GameLoop: 時間に余裕があれば描画処理、なければ描画をスキップ
    end

ゲームループとは、プログラムが一定の周期で「入力の処理」「状態の更新」「出力(描画など)」を繰り返す仕組みです。例えば、Unityなどのゲームエンジンでは、毎フレームこのループが実行されることで、リアルタイムな制御や描画が可能になります。制御系のプログラミングや、複雑な並列処理が必要な場面では、このようなループ構造を利用することで、状態管理や処理の流れをシンプルに保つことができます。 PLCではこの仕組みが強制されることで、複雑な現実の制御処理をシンプルに保つことが可能です。

それでも実装できないときは

おそらく、ここまで読んでも、多くの現場から出てくる要求を満たすことはできないでしょう. その場合、下記の本がおすすめです.

増補改訂版 Java言語で学ぶデザインパターン入門 マルチスレッド編 結城 浩 ISBN-13: 978-4797331622

ただし、この本はGolang のChannelなど新しい非同期処理を使った設計方法が書かれていません. おすすめの本があればぜひおしえてください.

参考サイト

この記事を描くにあたって参考にさせていただいたサイトです.

終わりに

非同期処理にはこれ以外に各プログラミング言語ごとの非同期の実装による違いなどがあり、とてもこんな短い記事では書ききれません.

気が向いたらこの辺りについても書いていきたいと思います.

  • 適当にプログラミング言語に非同期処理を実装しやがって async/await 地獄の巻
  • JavaScript の非同期処理はシングルスレッドで動くって聞いてたけど嘘じゃねーかの巻
この記事をシェア

弊社では、一緒に会社を面白くしてくれる仲間を募集しています。
お気軽にお問い合わせください!