LabHub

ブログ

[オペレーティングシステム] 07. 同期問題:生産者-消費者、哲学者の食事

한국어English日本語

古典的同期問題

同期ツールの正しい使い方を理解するために、オペレーティングシステム分野で伝統的に扱われる3つの古典的問題を見ていく。


1. 有界バッファ問題(Bounded-Buffer Problem)

生産者はデータを作ってバッファに入れ、消費者はバッファからデータを取り出して使用する。バッファのサイズが有限なので、バッファが満杯なら生産者は待機し、空なら消費者が待機しなければならない。

[有界バッファ構造]

生産者 -->  [  |  |  |  |  ]  --> 消費者
            0  1  2  3  4
            ^           ^
           out          in

セマフォ:
  mutex = 1      (バッファアクセスの相互排除)
  empty = N      (空きスロット数、初期値 = バッファサイズ)
  full  = 0      (埋まったスロット数、初期値 = 0
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <semaphore.h>
#include <unistd.h>

#define BUFFER_SIZE 5

int buffer[BUFFER_SIZE];
int in = 0, out = 0;

sem_t empty;    // 空きスロット数
sem_t full;     // 埋まったスロット数
pthread_mutex_t mutex;

void *producer(void *arg) {
    int id = *(int *)arg;
    for (int i = 0; i < 10; i++) {
        int item = rand() % 100;

        sem_wait(&empty);           // 空きスロットがあるまで待機
        pthread_mutex_lock(&mutex); // バッファアクセスをロック

        // クリティカルセクション:バッファにアイテムを追加
        buffer[in] = item;
        printf("生産者 %d: buffer[%d] = %d 生産\n", id, in, item);
        in = (in + 1) % BUFFER_SIZE;

        pthread_mutex_unlock(&mutex);
        sem_post(&full);            // 埋まったスロット数を増加

        usleep(rand() % 500000);
    }
    return NULL;
}

void *consumer(void *arg) {
    int id = *(int *)arg;
    for (int i = 0; i < 10; i++) {
        sem_wait(&full);            // 埋まったスロットがあるまで待機
        pthread_mutex_lock(&mutex); // バッファアクセスをロック

        // クリティカルセクション:バッファからアイテムを取り出す
        int item = buffer[out];
        printf("消費者 %d: buffer[%d] = %d 消費\n", id, out, item);
        out = (out + 1) % BUFFER_SIZE;

        pthread_mutex_unlock(&mutex);
        sem_post(&empty);           // 空きスロット数を増加

        usleep(rand() % 800000);
    }
    return NULL;
}

int main() {
    pthread_mutex_init(&mutex, NULL);
    sem_init(&empty, 0, BUFFER_SIZE);
    sem_init(&full, 0, 0);

    pthread_t prod[2], cons[2];
    int ids[] = {0, 1};

    for (int i = 0; i < 2; i++) {
        pthread_create(&prod[i], NULL, producer, &ids[i]);
        pthread_create(&cons[i], NULL, consumer, &ids[i]);
    }

    for (int i = 0; i < 2; i++) {
        pthread_join(prod[i], NULL);
        pthread_join(cons[i], NULL);
    }

    pthread_mutex_destroy(&mutex);
    sem_destroy(&empty);
    sem_destroy(&full);
    return 0;
}

セマフォの順序は任意ではない

上の生産者コードでは sem_wait(&empty)pthread_mutex_lock(&mutex) より先に来ている。この2行を入れ替えてもコンパイルは通り、数秒間は正常に動いた後で静かに停止する。なぜそうなるかはコードを睨んでいても見えず、2つのスレッドが時間軸でどう絡むかを一コマずつ追わなければ見えない。

まず順序を入れ替えたコードはこうなる。

// 誤った順序:先にロックを取ってから空きスロットを待つ
void *producer_broken(void *arg) {
    for (int i = 0; i < 10; i++) {
        int item = rand() % 100;

        pthread_mutex_lock(&mutex);   // (1) 先にロック
        sem_wait(&empty);             // (2) その後で空きスロット待ち <-- 危険

        buffer[in] = item;
        in = (in + 1) % BUFFER_SIZE;

        pthread_mutex_unlock(&mutex);
        sem_post(&full);
    }
    return NULL;
}

バッファサイズが5なので、生産者が5個埋めると empty は0になる。その状態で次のことが起きる。

[デッドロックに至るインターリービング]

時刻  生産者 P                          消費者 C
----  --------------------------------  --------------------------------
 t0   (バッファ満杯, empty=0, full=5)   (まだ未実行)

 t1   pthread_mutex_lock(&mutex) 成功
      -> P が mutex を保有

 t2   sem_wait(&empty) を呼ぶ
      empty が 0 なのでブロック
      -> P は mutex を握ったまま眠る

 t3                                     sem_wait(&full) 成功 (full=5)

 t4                                     pthread_mutex_lock(&mutex)
                                        P が握っているのでブロック

 t5   PCsem_post(&empty)CP が mutex を離すのを
      してくれるのを待つ                 待つ
----  --------------------------------  --------------------------------
結果:どちらのスレッドも永遠に目覚めない = デッドロック

核心は t2 である。生産者がロックを手に握ったまま眠ってしまった。消費者はバッファを空けられる唯一のスレッドだが、空けるにはまずロックが必要で、そのロックは消費者が空けてくれるのを待っている生産者が持っている。互いに相手の前進を条件にする循環待ちができあがったわけだ。

元の順序がこの循環を断ち切る理由は単純である。sem_wait(&empty) が先にあれば、生産者はロックを取る前に眠る。眠っている間ロックは誰も所有していないので、消費者はいつでも入ってバッファを空け、sem_post(&empty) で生産者を起こせる。規則としてまとめると、長くブロックしうる待機は必ずロックの外で行い、ロックの中では決して無期限に待たない。

逆に後ろ側の pthread_mutex_unlock(&mutex)sem_post(&full) は順序を入れ替えてもデッドロックにはならない。どちらの操作もブロックしないからである。ただし sem_post を先にすると、起きた消費者がすぐにロックでもう一度弾かれるためコンテキストスイッチが1回余分に発生する。こちらは正確性の問題ではなく性能の問題である。


2. 読者-書者問題(Readers-Writers Problem)

データベースを複数のプロセスが共有する場合、読み取りのみを行うプロセス(reader)は同時にアクセスしても安全だが、書き込みプロセス(writer)は排他的アクセスが必要である。

[読者-書者ルール]

Reader + Reader = 許可(同時読み取り可能)
Reader + Writer = 不許可(衝突)
Writer + Writer = 不許可(衝突)

第一変形:読者優先

読者がいる間、書者は待機する。読者が次々と入ってくると書者が飢餓状態に陥る可能性がある。

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

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_t rw_mutex = PTHREAD_MUTEX_INITIALIZER;
int read_count = 0;
int shared_data = 0;

void *reader(void *arg) {
    int id = *(int *)arg;

    pthread_mutex_lock(&mutex);
    read_count++;
    if (read_count == 1) {
        // 最初の読者が書者をブロック
        pthread_mutex_lock(&rw_mutex);
    }
    pthread_mutex_unlock(&mutex);

    // クリティカルセクション:読み取り
    printf("読者 %d: データ読み取り = %d(現在の読者数:%d)\n",
           id, shared_data, read_count);

    pthread_mutex_lock(&mutex);
    read_count--;
    if (read_count == 0) {
        // 最後の読者が書者のブロックを解除
        pthread_mutex_unlock(&rw_mutex);
    }
    pthread_mutex_unlock(&mutex);

    return NULL;
}

void *writer(void *arg) {
    int id = *(int *)arg;

    pthread_mutex_lock(&rw_mutex);  // 排他的アクセス

    // クリティカルセクション:書き込み
    shared_data++;
    printf("書者 %d: データ更新 = %d\n", id, shared_data);

    pthread_mutex_unlock(&rw_mutex);

    return NULL;
}

書者の飢餓を実際に追跡してみる

第一変形が書者を飢えさせるという話はよく出るが、正確にどんな条件で飢えるのかはあまり説明されない。read_count がどう動くかを追えば条件がそのまま見えてくる。

[書者が永遠に待機する流れ]

段階  出来事                            read_count  rw_mutex 保有者
----  --------------------------------  ----------  ---------------
 1    R1 が入る: read_count 0 -> 1          1        R1 がロック
 2    W 到着: rw_mutex を要求               1        R1 (W はブロック)
 3    R2 到着: read_count 1 -> 2            2        R1
      2番目なので rw_mutex に触れずそのまま通過
 4    R3 到着: read_count 2 -> 3            3        R1
 5    R1 退出: read_count 3 -> 2            2        R1
      0 ではないので unlock しない
 6    R2 退出: read_count 2 -> 1            1        R1
 7    R4 到着: read_count 1 -> 2            2        R1
----  --------------------------------  ----------  ---------------
read_count が一度も 0 にならなければ W は永遠に待つ

条件を一文で書くとこうなる。最後の読者が出る前に新しい読者が入ってくる状況が続けば、つまり読者の到着間隔が読者のクリティカルセクション滞在時間より短く保たれれば、read_count は0を踏まず書者は無期限に待つ。読者が非常に多いシステムや読み取りが遅いシステムでは、偶然ではなく通常負荷で起きる現象である。

注意すべきは、これがデッドロックではないという点だ。システム全体は前進し続け、読者は全員正常に仕事を終える。止まっているのは書者ひとつだけである。デッドロックは関係者全員が止まるが飢餓は一部だけが止まるので、モニタリングでスループットだけを見ていると絶対に見えない。書者側のレイテンシの最大値か上位パーセンタイルを別に測って初めて表に出る。

第二変形:書者優先

書者の準備ができたら新しい読者は入れない。既存の読者が全員出た後に書者が実行される。

この変形は先ほどの条件を正面から崩す。書者が待ち行列に並んだ瞬間から新しい読者の進入を阻む関門をもう1つ置くからである。そうすればすでに中にいる読者が抜けるだけでよく、read_count は有限時間内に必ず0になる。代わりに副作用が生じる。今度は書者が連続して到着すると読者が飢える。どちらも無料ではなく、2つの変形のどちらを使うかは読み取りと書き込みのどちらの遅延がサービスにとってより致命的かで決める。

両方の飢餓を防ぐには到着順序を保存する第三の設計が必要になる。待機者を1つのキューに入れ、到着した順に起こす方式であり、読み書きロックを提供するライブラリの中にはこの公平性を選べるようにしたものがある。ただし公平性を有効にすると順序を守るぶんスループットが落ちる。既定値は使用中のバージョンのドキュメントで確認してほしい。


3. 食事する哲学者問題(Dining Philosophers Problem)

5人の哲学者が円形テーブルに座っている。各哲学者の間に箸が1本ずつあり、食事するには両側の箸2本が必要である。

[食事する哲学者]

        P0
    C4      C0
  P4          P1
    C3      C1
        P3
      C2
        P2

P: 哲学者(Philosopher)
C: 箸(Chopstick)

哲学者 iは箸 iと箸 (i+1)%5を使用

セマフォを使った簡単な解法(デッドロックの危険)

// 注意:この解法はデッドロックが発生する可能性がある!
sem_t chopstick[5];

void *philosopher(void *arg) {
    int id = *(int *)arg;

    while (1) {
        printf("哲学者 %d: 考え中\n", id);
        usleep(rand() % 1000000);

        // 両側の箸を取る
        sem_wait(&chopstick[id]);           // 左
        sem_wait(&chopstick[(id + 1) % 5]); // 右

        printf("哲学者 %d: 食事中\n", id);
        usleep(rand() % 1000000);

        // 箸を置く
        sem_post(&chopstick[id]);
        sem_post(&chopstick[(id + 1) % 5]);
    }
}

// デッドロック:5人全員が同時に左の箸を取ると
// 誰も右の箸を取れない!

デッドロックを防止する解法

// 解法 1: 非対称 - 偶数哲学者は左から、奇数は右から取る
void *philosopher_asymmetric(void *arg) {
    int id = *(int *)arg;

    while (1) {
        printf("哲学者 %d: 考え中\n", id);

        if (id % 2 == 0) {
            sem_wait(&chopstick[id]);
            sem_wait(&chopstick[(id + 1) % 5]);
        } else {
            sem_wait(&chopstick[(id + 1) % 5]);
            sem_wait(&chopstick[id]);
        }

        printf("哲学者 %d: 食事中\n", id);
        usleep(rand() % 1000000);

        sem_post(&chopstick[id]);
        sem_post(&chopstick[(id + 1) % 5]);
    }
}

モニターベースの解法

// モニターベースの解法(Pthreads条件変数を使用)
#include <pthread.h>
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>

#define N 5
#define THINKING 0
#define HUNGRY   1
#define EATING   2

int state[N];
pthread_mutex_t monitor_mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t self[N];

// 左右の隣人インデックス
#define LEFT(i)  ((i + N - 1) % N)
#define RIGHT(i) ((i + 1) % N)

void test(int i) {
    // 自分が空腹で、両隣が食事中でなければ食べられる
    if (state[i] == HUNGRY &&
        state[LEFT(i)] != EATING &&
        state[RIGHT(i)] != EATING) {
        state[i] = EATING;
        pthread_cond_signal(&self[i]);
    }
}

void pickup(int i) {
    pthread_mutex_lock(&monitor_mutex);

    state[i] = HUNGRY;
    printf("哲学者 %d: 空腹\n", i);
    test(i);  // すぐに食べられるか確認

    while (state[i] != EATING) {
        pthread_cond_wait(&self[i], &monitor_mutex);
    }

    printf("哲学者 %d: 食事開始\n", i);
    pthread_mutex_unlock(&monitor_mutex);
}

void putdown(int i) {
    pthread_mutex_lock(&monitor_mutex);

    state[i] = THINKING;
    printf("哲学者 %d: 食事完了、考え始める\n", i);

    // 隣人が食べられるか確認
    test(LEFT(i));
    test(RIGHT(i));

    pthread_mutex_unlock(&monitor_mutex);
}

void *philosopher(void *arg) {
    int id = *(int *)arg;
    while (1) {
        usleep(rand() % 1000000);  // 考える
        pickup(id);                 // 箸を取る
        usleep(rand() % 1000000);  // 食事
        putdown(id);                // 箸を置く
    }
}

モニター解法がデッドロックしない理由

セマフォ解法とモニター解法はコードの長さが似ているのに、片方だけがデッドロックする。違いは箸を何本ずつ取るかにある。

セマフォ解法では哲学者が sem_wait(&chopstick[id]) で左を取った後、右を取るためにもう一度 sem_wait を呼ぶ。この2つの呼び出しの間には隙間があり、その隙間で哲学者は資源を1つ保有したまま別の資源を待つ状態にいる。5人全員が同時にこの状態に入ると循環待ちが完成する。

モニター解法にはその隙間がない。pickupmonitor_mutex を取ってから state[i] を HUNGRY に印を付け、test(i) を呼ぶ。test は両隣が食事中でないときにだけ状態を EATING に変える。つまり箸2本をまとめて得るか、まったく得ないかのどちらかであり、その判定はモニターロックの下で原子的に行われる。哲学者が箸を片方だけ握った中間状態はそもそも存在しない。

デッドロックの4条件のうち保持して待機(hold and wait)が成立しないので、デッドロックは構造的に不可能である。待機している哲学者は箸を1本も持っておらず、pthread_cond_waitmonitor_mutex まで手放すため、隣人が putdown に入るのを妨げることもない。

[状態配列が保証するもの]

state[] = [THINKING, EATING, HUNGRY, THINKING, HUNGRY]
             P0       P1      P2       P3        P4

P2 は左隣の P1EATING なので待機中
  -> 箸は0本保有。誰も妨げない。

P1putdown() を呼ぶ:
  state[1] = THINKING
  test(LEFT(1)=P0)  -> P0THINKING なので条件不成立
  test(RIGHT(1)=P2) -> P2HUNGRY, 両隣 P1/P3 とも EATING でない
                       -> state[2] = EATING に変えて P2 を起こす

P2 は目覚めた時点ですでに「食べてよい」が確定した状態

ただしこの解法が飢餓まで防いでくれるわけではない。test が順序をまったく考慮しないからである。P2 が長く待っていても、P1 と P3 が交互に食事し続ければ P2 の条件は永遠に成立しない可能性がある。デッドロックがないことと全員がいつか食べられることは別の保証であり、後者が欲しければ空腹になった時刻を記録し、長く待った哲学者を優先するロジックを test に直接入れる必要がある。


POSIX同期

POSIXミューテックス

#include <pthread.h>

pthread_mutex_t lock;

// 静的初期化
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;

// 動的初期化
pthread_mutex_init(&lock, NULL);

// 使用
pthread_mutex_lock(&lock);
// クリティカルセクション
pthread_mutex_unlock(&lock);

// ノンブロッキング試行
if (pthread_mutex_trylock(&lock) == 0) {
    // ロック獲得成功
    pthread_mutex_unlock(&lock);
} else {
    // 他のスレッドが保有中
}

// クリーンアップ
pthread_mutex_destroy(&lock);

POSIXセマフォ

POSIXは名前付き(named)セマフォと名前なし(unnamed)セマフォを提供する。

#include <semaphore.h>

// 名前なしセマフォ(スレッド間)
sem_t sem;
sem_init(&sem, 0, 1);  // 0: スレッド間、初期値1
sem_wait(&sem);
sem_post(&sem);
sem_destroy(&sem);

// 名前付きセマフォ(プロセス間)
sem_t *sem = sem_open("/my_sem", O_CREAT, 0644, 1);
sem_wait(sem);
sem_post(sem);
sem_close(sem);
sem_unlink("/my_sem");

POSIX条件変数

#include <pthread.h>

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
int data_ready = 0;

// 生産者
void *producer(void *arg) {
    pthread_mutex_lock(&mutex);

    // データ生成
    data_ready = 1;
    printf("生産者:データ準備完了\n");

    pthread_cond_signal(&cond);    // 待機中の消費者を起こす
    pthread_mutex_unlock(&mutex);
    return NULL;
}

// 消費者
void *consumer(void *arg) {
    pthread_mutex_lock(&mutex);

    while (!data_ready) {
        // 条件が満たされるまで待機
        // 待機中はmutexが自動的に解放される
        pthread_cond_wait(&cond, &mutex);
    }

    printf("消費者:データ消費\n");
    data_ready = 0;

    pthread_mutex_unlock(&mutex);
    return NULL;
}

while ループが if ではいけない理由

上の消費者コードでは pthread_cond_waitif ではなく while の中に入っている。これは好みの問題ではなく正確性の問題であり、条件変数を使うコードで最も頻繁に出るバグの原因である。

POSIX標準は2つのことを明示している。1つ目は、偽の目覚め(spurious wakeup)が起こりうるということだ。標準の文言は "Spurious wakeups from the pthread_cond_timedwait() or pthread_cond_wait() functions may occur" である。2つ目は、戻ってきたこと自体が条件について何も語らないということだ。"the return from pthread_cond_timedwait() or pthread_cond_wait() does not imply anything about the value of this predicate" と釘を刺している。戻りは誰かがシグナルを送ったかもしれないというヒントにすぎず、待っていた条件が真だという保証ではない。

偽の目覚めだけが理由ではない。シグナルが本物であっても、自分が起きたときには条件がすでに消えている場合がある。

[目覚めたが条件が消えていた場合]

待機者 A と待機者 B がともに data_ready を待つ。

 1  A: pthread_cond_wait に入り、mutex を解放して待機
 2  B: pthread_cond_wait に入り、mutex を解放して待機
 3  P: mutex 獲得, data_ready = 1, cond_broadcast, mutex 解放
 4  A: 目覚めて mutex を再獲得
 5  A: data_ready = 0 として消費し mutex 解放
 6  B: 目覚めて mutex を再獲得
 7  B: if だったらここをそのまま通過 -> data_ready が 0 なのに消費
       while だったら条件を再検査 -> 偽なので再び待機

4番と6番の間には必ず間隔が生じる。pthread_cond_wait は戻る前にミューテックスを取り直さなければならず("Upon successful return, the mutex shall have been locked and shall be owned by the calling thread")、ミューテックスは一度に1つのスレッドしか握れないからである。その間隔の間に世界は変わりうるのに、if は変わった世界を確認しない。

逆方向の事故もある。シグナルを取り逃がす場合だ。条件を真にする前に、しかもロックの外で pthread_cond_signal を呼ぶと、待機者がまだ待機状態に入っていない時点でシグナルが飛んで消えてしまう。条件変数には記憶がないので、待機者がいないときに送ったシグナルはただ消える。この場合は while があっても最初の検査で条件が偽なら眠り込み、起こしてくれるシグナルはもう存在しない。

// 危険:条件をセットする前に、しかもロックの外でシグナルを送る
pthread_cond_signal(&cond);      // 待機者がいなければこのシグナルは消える
pthread_mutex_lock(&mutex);
data_ready = 1;
pthread_mutex_unlock(&mutex);

// 安全:ロックの中で条件をセットしてからシグナルを送る
pthread_mutex_lock(&mutex);
data_ready = 1;                  // まず条件を真にする
pthread_cond_signal(&cond);      // その後シグナル
pthread_mutex_unlock(&mutex);

// 待機側は常に while
pthread_mutex_lock(&mutex);
while (!data_ready) {            // if に変えると上の保護がすべて崩れる
    pthread_cond_wait(&cond, &mutex);
}
data_ready = 0;
pthread_mutex_unlock(&mutex);

このバグが特に厄介なのは、症状がクラッシュではなく停止だからである。スタックトレースもなくコアダンプも残らない。プロセスは生きていて CPU 使用率は0に近く、ログはただ途切れる。テストではタイミングが合わず再現せず、本番で数時間に一度出る。条件変数を使うとき while は交渉の余地がないと覚えておくほうがよい。


Java同期

synchronizedキーワード

public class Counter {
    private int count = 0;

    // メソッドレベルの同期
    public synchronized void increment() {
        count++;
    }

    public synchronized int getCount() {
        return count;
    }

    // ブロックレベルの同期
    public void incrementBlock() {
        synchronized (this) {
            count++;
        }
    }
}

ReentrantLock

synchronizedよりも柔軟なロックメカニズムである。

import java.util.concurrent.locks.ReentrantLock;
import java.util.concurrent.locks.Condition;

public class BoundedBufferLock {
    private final Object[] buffer;
    private int count = 0, in = 0, out = 0;

    private final ReentrantLock lock = new ReentrantLock();
    private final Condition notFull = lock.newCondition();
    private final Condition notEmpty = lock.newCondition();

    public BoundedBufferLock(int size) {
        buffer = new Object[size];
    }

    public void produce(Object item) throws InterruptedException {
        lock.lock();
        try {
            while (count == buffer.length) {
                notFull.await();    // バッファが空くまで待機
            }
            buffer[in] = item;
            in = (in + 1) % buffer.length;
            count++;
            notEmpty.signal();      // 消費者を起こす
        } finally {
            lock.unlock();
        }
    }

    public Object consume() throws InterruptedException {
        lock.lock();
        try {
            while (count == 0) {
                notEmpty.await();   // データが来るまで待機
            }
            Object item = buffer[out];
            out = (out + 1) % buffer.length;
            count--;
            notFull.signal();       // 生産者を起こす
            return item;
        } finally {
            lock.unlock();
        }
    }
}

代替アプローチ

トランザクショナルメモリ(Transactional Memory)

データベースのトランザクション概念をメモリアクセスに適用する。

// 概念的コード(実際の文法は実装により異なる)
atomic {
    // このブロック内のすべてのメモリ操作はアトミックに実行
    // 衝突発生時は自動的にリトライ
    account1.balance -= amount;
    account2.balance += amount;
}

関数型プログラミング

不変(immutable)データ構造を使用すれば共有状態が変更されないため、同期が不要になる。Erlang、Scala、Haskellなどの関数型言語がこのアプローチを活用している。


失敗事例と落とし穴

同期のバグは症状が3つの形にしか現れない。症状を先に分類すれば、どの道具を出すかは自動的に決まる。

症状1:停止しているのに CPU 使用率が0に近い

すべてのスレッドがブロックされているという意味である。デッドロックか、取り逃がしたシグナルか、誰も埋めてくれないセマフォを待っている状態だ。診断の順序はこうなる。

  1. topps でそのプロセスの CPU 使用率が本当に0に近いか確認する。100パーセントに張り付いていれば症状2へ進む。
  2. 実行中のプロセスにデバッガを接続し、全スレッドの呼び出しスタックを取る。
  3. スタックからロック待ちのフレームを探し、どのスレッドがどのロックを待っているか対応を付ける。

GDB のドキュメントによれば、全スレッドにコマンドを適用する構文は thread apply [thread-id-list | all [-ascending]] [flag]… command であり、スレッド一覧は info threads で見る。

# 停止したプロセスの PID を探して接続する
pgrep -a myprogram

gdb -p 12345

出力例である。

(gdb) info threads
  Id   Target Id                        Frame
* 1    Thread 0x7f2a1c0 (LWP 12345)     __lll_lock_wait () at ...
  2    Thread 0x7f2a1c1 (LWP 12346)     futex_wait (...) at ...
  3    Thread 0x7f2a1c2 (LWP 12347)     futex_wait (...) at ...

(gdb) thread apply all backtrace

Thread 3 (Thread 0x7f2a1c2):
#0  futex_wait (...)
#1  __new_sem_wait_slow (...)
#2  producer (arg=0x0) at bb.c:22        <-- sem_wait(&empty) で停止
#3  start_thread (...)

Thread 2 (Thread 0x7f2a1c1):
#0  __lll_lock_wait (...)
#1  pthread_mutex_lock (...)
#2  consumer (arg=0x0) at bb.c:41        <-- mutex を待っている
#3  start_thread (...)

読み方は単純である。__lll_lock_waitpthread_mutex_lock が上のほうに見えればミューテックス待ちであり、futex_wait がセマフォや条件変数の関数の下に敷かれていればシグナル待ちである。フレーム番号2あたりに出る自分のコードのファイルと行番号が実際の犯人だ。上の例は先ほど見たデッドロックそのもので、生産者は empty を、消費者は mutex を待っている。

症状2:停止しているようだが CPU 使用率が100パーセントである

ブロックではなく回転である。スピンロックで回っているか、条件を満たせないまま互いに譲り合うだけのライブロックだ。ライブロックはデッドロックと違ってスレッドが状態を変え続けるので、スタックを一度取っただけではわからない。数秒間隔で何度もスタックを取り、同じ関数区間を往復しているかを確認する。リトライループに上限もバックオフもないコードが原因であることが多い。

症状3:結果がときどき間違う

停止も異常終了もしないのに、カウンタがたまに狂う。ロックなしでアクセスしている共有変数があるという意味であり、目で探すのは時間の無駄である。競合検出器を使う。

GCC のドキュメントは -fsanitize=thread を "ThreadSanitizer, a fast data race detector" と説明している。メモリアクセス命令を計測してデータ競合を捕まえる。同じドキュメントによれば、このオプションは -fsanitize=address-fsanitize=leak と併用できず、出力を読めるものにするには -g も一緒に渡すことを勧めている。実行時の挙動は TSAN_OPTIONS 環境変数で制御する。

gcc -fsanitize=thread -g -O1 counter.c -o counter -lpthread
./counter

出力例である。

WARNING: ThreadSanitizer: data race (pid=4711)
  Write of size 4 at 0x55a1c8 by thread T2:
    #0 increment counter.c:14 (counter+0x1234)

  Previous write of size 4 at 0x55a1c8 by thread T1:
    #0 increment counter.c:14 (counter+0x1234)

  Location is global 'shared_counter' of size 4 at 0x55a1c8

SUMMARY: ThreadSanitizer: data race counter.c:14 in increment

読む順序は下から上である。SUMMARY の行がファイルと行番号を教えてくれ、その上の2つのブロックが衝突した2つのアクセスだ。同じ行が2回出るなら同じコードが2つのスレッドで同時に実行されたのであり、異なる行なら片方が読み取りで片方が書き込みである場合が多い。

コンパイルし直しにくい状況なら Valgrind の Helgrind を使う。マニュアルによれば --tool=helgrind で指定し、3種類のエラーを捕まえる。POSIX pthreads API の誤用、一貫しないロック順序、データ競合である。ロック順序の検査は --track-lockorders が制御し、既定値は yes である。過去のアクセス情報をどれだけ集めるかは --history-level が決め、既定値は full である。

valgrind --tool=helgrind --track-lockorders=yes ./counter

出力例である。

==5123== Possible data race during write of size 4 at 0x60105C by thread #3
==5123== Locks held: none
==5123==    at 0x4006B1: increment (counter.c:14)
==5123==
==5123== This conflicts with a previous write of size 4 by thread #2
==5123== Locks held: none
==5123==    at 0x4006B1: increment (counter.c:14)

決定的な手がかりは Locks held の行である。none なら、アクセスの時点でどのロックも握っていなかったという意味なので、その行を包むロックを入れればよい。2つのアクセスが異なるロックを握っていたなら、ロックはあるが間違ったロックを使っていたことになる。

3つの道具はいずれもプログラムを大きく遅くするということは、あらかじめ知っておいたほうがよい。再現シナリオを最小限まで削ってから走らせるほうが、実戦でははるかに速い。


使わないほうがよい場面

この章の3つの問題は同期を学ぶための教材であって、そのまま書き写すテンプレートではない。実務のコードでセマフォを手で組み立てなければならない場面は思ったより少ない。

まず、共有メモリではなくメッセージ受け渡しが合う場合がある。スレッドが実際にはデータを渡すだけで、同じデータ構造を同時にいじる必要がないなら、チャネルやキューで所有権をまるごと渡すほうがよい。こうするとクリティカルセクション自体が存在しなくなるので、デッドロックも競合状態も設計段階で消える。先ほど見た有界バッファ問題は、実はそのキューを自分で実装してみる練習であり、検証済みのキューが標準ライブラリにあるなら自作する理由はない。

アプリケーションのコードなら、たいていは標準ライブラリの並行コレクションが正解である。Java の java.util.concurrent パッケージや各言語のスレッドセーフなキューは、この章の問題をすでに解いた成果物であり、何年も実戦で検証されている。自作の有界バッファがそれより優れている確率は低い。

ロックフリーのデータ構造は最後の選択肢である。ロック競合が実際のボトルネックだと測定で確認してから手を付けるべきだ。ロックフリーのコードはメモリモデルとメモリ順序を正確に理解している必要があり、間違ったときの症状は特定の CPU の特定の負荷でしか出ない。測定なしにロックは遅いからという理由で始めると、動いていたコードをデバッグ不能なコードに変える結果になる。

そもそも同期を不要にする道も常に検討する価値がある。スレッドごとに自分の取り分のデータを持ち、最後に一度だけ合流させる構造、不変データ構造、単一スレッドのイベントループといったものだ。最も速いクリティカルセクションは存在しないクリティカルセクションである。

逆にこの章の内容が本当に必要な場所もはっきりある。スレッドプール、コネクションプール、レートリミッタのように個数の決まった資源を配る構造では、計数セマフォがまさに正しい道具である。オペレーティングシステムのカーネルや言語ランタイムを作る側なら、そもそも選択の余地がない。


参考資料


まとめ

有界バッファ、読者-書者、食事する哲学者問題は同期の核心的な課題を示す古典的な例題である。POSIXとJavaはミューテックス、セマフォ、条件変数など様々な同期ツールを提供する。トランザクショナルメモリや関数型プログラミングなどの代替アプローチも並行性問題を解決する方法として注目されている。

コメント

まだコメントはありません。

ログインするとコメントできます