OSにおけるスレッドの動作原理と並行制御の基礎

スレッドは、単一のプロセス空間内で独立して実行フローを保持する実行コンテキストです。OSのスケジューラが直接対象とする最小の単位であり、親プロセスのヒープやデータセグメント、ファイルディスクリプタテーブルなどを共有しつつ、独自のスタックやレジスタ状態を保持します。これにより、コンテキストスイッチのオーバーヘッドを抑制しながら並列処理を実現します。

1. 実行コンテキストとメモリ構成

各スレッドは以下のような独立した状態情報を保持します:

  • スレッド識別子(TID):実行単位を一意に識別するための番号。
  • プログラムカウンタ(PC):次に実行すべき命令のアドレスを記録。
  • 個別スタック:ローカル変数や関数コールフレームを格納。スレッド間で分離されるため、再帰呼び出しや中断状態の保存に利用されます。
  • レジスタセット:実行中の演算結果やポインタ値を一時的に保持。

一方で、以下のリソースは親プロセスと共有されます:

  • コードセグメント(実行可能な機械語命令列)
  • データセグメントおよびヒープ(静的変数、動的メモリ割り当て領域)
  • オープンファイルディスクリプタ一覧とネットワークソケット
  • プロセスレベルのシグナルハンドラ設定

2. 状態遷移とライフサイクル

スレッドの生存期間は、OSスケジューラによる状態遷移によって制御されます。

  • 生成期(New):制御ブロックが作成された直後。まだスケジューリング対象ではありません。
  • 実行可能期(Runnable):CPU時間割り当てを待ち、スケジューリングキューに登録されている状態。
  • 実行中(Running):プロセッサの指令発行ユニットを占有し、命令実行サイクルを進めている状態。
  • 待機中(Blocked):ブロックI/O、メモリページング、ロック競合、またはシグナル待ちにより実行を一時停止。CPUリソースをシステムに返却します。
  • 終了期(Terminated):実行関数の正常リターン、明示的な終了呼び出し、または未処理例外によりクリーンアップされる状態。

3. プロセスとの技術的差異

比較項目 プロセス スレッド
メモリ分離 独立した仮想アドレス空間を持つ 親プロセスのアドレス空間を共有
コンテキストスイッチ ページテーブルやTLBフラッシュなど重い スタックポインタとレジスタの保存のみで軽量
通信コスト システムコールを経由したIPCが必要 メモリ共有による直接アクセスが可能
障害伝播 境界が明確で孤立している 未処理例外やメモリ破壊がプロセス全体に影響する
生成コスト 高い(メモリ確保やマッピングが必要) 低い(スタック領域の割り当てのみ)
実装スケール 数〜数千程度 数万〜数十万まで拡張可能

4. カーネルにおける実装戦略

OSレベルでのスレッド管理には主に3つのパラダイムが存在します。

  1. ユーザー空間スレッド(M:1モデル):ライブラリ层面で管理され、カーネルは1プロセスしか認識しない。コンテキストスイッチが速い反面、ブロックIOが発生するとプロセス全体が停止する欠点がある。
  2. カーネル空間スレッド(1:1モデル):各スレッドがカーネルスレッドと1対1でマッピングされる。並列性が最大限発揮されるが、システムコールの呼び出しコストが伴う。
  3. 多段マッピング(ハイブリッドモデル):LinuxのNPTLなどが採用する手法。ユーザースレッドを軽量プロセス(LWP)へバインドし、スケジューラがLWPを管理する。柔軟性と並列性のバランスが取れた現代的なアーキテクチャです。

5. 競合制御と同期プリミティブ

共有メモリへの同時書き込みはデータ整合性を損なうため、以下のような同期機構を導入します。

  • ミューテックス(Mutex):排他制御の基盤。クリティカルセクションへの進入を1つに制限し、不正な競合を防止します。
  • 条件変数(Condition Variable):状態変化を監視し、特定の条件が満たされるまでスレッドをスリープさせます。通常ミューテックスと組み合わせて使用します。
  • セマフォ(Semaphore):リソースの残数を整数で管理し、複数のスレッドが許容される範囲内でアクセスを許可します。
  • 読書・書き込みロック(Read-Write Lock):読み取りは並列に許可しつつ、書き込み時には排他制御をかける。リードヘビーなワークロードに最適です。

6. 設計上のメリットとリスク

メリット側面では、コンテキスト切り替えの低コスト化、メモリ共有による高速なデータ連携、およびマルチコアアーキテクチャとの親和性の高さが挙げられます。Webサーバーやリアルタイムデータ処理など、高スループットを要求する環境で効果を発揮します。

一方で留意すべきリスクには、データ競合(Race Condition)、デッドロック(循環待機状態)、プリエンプションによる非決定論的振る舞い、およびスレッドセーフでないライブラリの呼び出しが含まれます。特に非同期環境でのデバッグは、問題の再現性が低いため、静的解析やログ記録の充実が不可欠です。

7. 実装事例:範囲分割による並列加算

以下は、計算範囲を分割し複数のスレッドで並列実行したのちに結果を統合する実装例です。動的メモリ割り当てとループ制御を用いてスレッド生成処理を一般化しています。

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

#define WORKER_COUNT 2

typedef struct {
    int lower_bound;
    int upper_bound;
} CalculationRange;

int global_accumulator = 0;
pthread_mutex_t data_lock = PTHREAD_MUTEX_INITIALIZER;

void* worker_routine(void* args) {
    CalculationRange* bounds = (CalculationRange*)args;
    int local_total = 0;

    for (int i = bounds->lower_bound; i <= bounds->upper_bound; ++i) {
        local_total += i;
    }

    pthread_mutex_lock(&data_lock);
    global_accumulator += local_total;
    pthread_mutex_unlock(&data_lock);

    free(args);
    return NULL;
}

int main(void) {
    pthread_t workers[WORKER_COUNT];
    int ranges[][2] = {{1, 50}, {51, 100}};

    for (int i = 0; i < WORKER_COUNT; ++i) {
        CalculationRange* task = malloc(sizeof(CalculationRange));
        task->lower_bound = ranges[i][0];
        task->upper_bound = ranges[i][1];

        if (pthread_create(&workers[i], NULL, worker_routine, task) != 0) {
            fprintf(stderr, "スレッド生成エラー\n");
            exit(EXIT_FAILURE);
        }
    }

    for (int i = 0; i < WORKER_COUNT; ++i) {
        pthread_join(workers[i], NULL);
    }

    printf("最終計算結果: %d\n", global_accumulator);
    return EXIT_SUCCESS;
}

コンパイルおよび実行には以下のコマンドを使用します。

gcc -o parallel_sum parallel_sum.c -lpthread
./parallel_sum

タグ: スレッド pthread 並行プログラミング コンテキストスイッチ 同期プリミティブ

5月24日 05:08 投稿