🇯🇵 日本語 | 🇺🇸 English
広告スペース

C言語 pthread入門

POSIXスレッドで並行処理を書く。create/join の基本から、データ競合・mutex による排他制御まで。

スレッドとプロセス

プロセスは「実行中のプログラム」の独立した単位。スレッドはその中の「並行に動ける実行の流れ」です。
POSIXスレッド (pthread): Linux/macOS で標準的な API。#include <pthread.h> で使える。コンパイル時は -pthread を忘れずに。

pthread_create / join

2つのスレッドで別々のメッセージを出力する最小サンプル。
#include <stdio.h>
#include <pthread.h>

// スレッドが実行する関数: 引数も戻り値も void*
void *worker(void *arg) {
    int id = *(int *)arg;
    printf("hello from thread %d\n", id);
    return NULL;
}

int main(void) {
    pthread_t t1, t2;
    int a = 1, b = 2;

    pthread_create(&t1, NULL, worker, &a);
    pthread_create(&t2, NULL, worker, &b);

    pthread_join(t1, NULL);  // t1が終わるまで待つ
    pthread_join(t2, NULL);

    printf("main done\n");
    return 0;
}
$ gcc -pthread hello_pt.c -o hello_pt $ ./hello_pt hello from thread 1 hello from thread 2 main done # 実行ごとに順序が変わるかも(並行実行のため)
必ず join: join しないと main が先に終わってプログラム全体が終了してしまい、スレッドが処理を完了できない。また、join しないスレッドはリソースリークになる(detached threadにする場合は別)。

戻り値を受け取る

void *compute(void *arg) {
    int x = *(int *)arg;
    int *result = malloc(sizeof(int));
    *result = x * x;
    return result;
}

// main側:
void *ret;
pthread_join(t, &ret);
printf("result = %d\n", *(int *)ret);
free(ret);

データ競合(実演)

複数スレッドが同じ変数を同時に書き換えると、結果が不定になります。これがデータ競合 (race condition)
#include <stdio.h>
#include <pthread.h>

#define N 4
#define LOOPS 1000000

long counter = 0;         // 全スレッドで共有

void *worker(void *arg) {
    for (int i = 0; i < LOOPS; i++) {
        counter++;              // これが原子的ではない!
    }
    return NULL;
}

int main(void) {
    pthread_t th[N];
    for (int i = 0; i < N; i++)
        pthread_create(&th[i], NULL, worker, NULL);
    for (int i = 0; i < N; i++)
        pthread_join(th[i], NULL);

    printf("expected: %d\n", N * LOOPS);
    printf("actual:   %ld\n", counter);
    return 0;
}
$ ./race expected: 4000000 actual: 2841739 # 毎回違う値!
なぜ壊れるか: counter++ は見た目は1命令だが、CPUレベルでは「読む→加算→書く」の3ステップ。2スレッドがほぼ同時にやると、片方の更新が失われる。

mutex で修正

mutex (mutual exclusion) は「同時に1スレッドしか入れない錠前」。共有データを書き換える区間を mutex で囲みます。
#include <stdio.h>
#include <pthread.h>

#define N 4
#define LOOPS 1000000

long counter = 0;
pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;

void *worker(void *arg) {
    for (int i = 0; i < LOOPS; i++) {
        pthread_mutex_lock(&mtx);
        counter++;                       // 保護された区間
        pthread_mutex_unlock(&mtx);
    }
    return NULL;
}

int main(void) {
    pthread_t th[N];
    for (int i = 0; i < N; i++)
        pthread_create(&th[i], NULL, worker, NULL);
    for (int i = 0; i < N; i++)
        pthread_join(th[i], NULL);

    printf("counter = %ld\n", counter);  // 常に4000000
    pthread_mutex_destroy(&mtx);
    return 0;
}
粒度: ロックする範囲は小さいほど並列性が上がる。ただし小さすぎるとオーバーヘッドで逆に遅くなる。測って決める。

簡単な場合は atomic の方が速い

#include <stdatomic.h>

atomic_long counter = 0;

// worker内:
atomic_fetch_add(&counter, 1);   // ロックなしで安全
<stdatomic.h> は C11 で追加。単純なカウンタなら atomic の方が高速。複雑な不変条件を守るなら mutex。

デッドロック回避

2つのmutexを2つのスレッドが逆順でロックすると、互いに相手を待ち続けて永久に止まることがあります。
// NG: スレッドごとにロック順序が違う
// Thread A: lock(m1) → lock(m2)
// Thread B: lock(m2) → lock(m1)
// → 両方が片方取った瞬間にデッドロック

回避策

  1. 順序を統一する: mutex に番号を付け、必ず若い番号から取る。全スレッドで同じ順序なら循環が起きない。
  2. trylock を使う: pthread_mutex_trylock は取れなかったら即座に失敗を返す。失敗したら自分の持っているロックを解放して再試行。
  3. ロックを細分化しすぎない: そもそも1個で済むなら1個にまとめる。
検出ツール: helgrind(valgrindツールの1つ)でデータ競合とデッドロックを検知できる。
valgrind --tool=helgrind ./app

チャレンジ課題

課題1: 並列和
配列 a[1000000] の総和を、4スレッドで250000要素ずつ担当して計算せよ。各スレッドはローカル変数で集計し、最後に mutex で全体に加える(mutexの競合を減らす)。
課題2: 生産者-消費者
10スロットの循環バッファを作り、生産者スレッドが数を入れ、消費者スレッドが取り出すプログラムを書け。pthread_cond_t(条件変数)を使って「空なら待つ」「満タンなら待つ」を実装する。
課題3: atomic との性能比較
counter++ を mutex 版と atomic 版で実装し、実行時間を clock_gettime で測って比較。スレッド数を 1, 2, 4, 8 と変えて傾向を観察。
課題4: デッドロックを作って検出
2つの mutex を逆順でロックするプログラムを書き、valgrind helgrind を使って実際に警告を出させよ。その後、順序統一で修正。