Linuxのマルチスレッド:基礎概念とPOSIXスレッドの実践

スレッドの基礎概念

スレッドとは

プロセスを生成する際、カーネルはプロセス制御ブロック(task_struct)、メモリ空間(mm_struct)、およびページテーブルなどを独自に作成し、仮想アドレスと物理アドレスのマッピングを構築する。一方、スレッドの作成時には新たなtask_structが作成されるが、アドレス空間やページテーブルは親プロセスと共有される。

  • プロセス内の実行経路がスレッドである。つまり、プロセス内部の独立した制御フロー(実行分岐)と言える。
  • どのようなプロセスも、最低1つの実行スレッドを持つ。
  • スレッドはプロセスのアドレス空間内で動作するため、そのプロセスが確保したリソースはほぼ全てのスレッドで共有される。
  • Linuxカーネルから見ると、スレッドは従来のプロセスよりも軽量なPCBとして認識され、軽量プロセス(LWP: Light Weight Process)とも呼ばれる。

「プロセス」という概念について再考すると、プロセスは単なるtask_structではなく、アドレス空間、ファイルディスクリプタ、シグナルハンドラなど総合的なリソースの集合体である。カーネルの観点では、システムリソースを割り当てる基本単位をプロセスと呼ぶ。内部に制御フローが1つしかないものを単一スレッドプロセス、複数存在するものをマルチスレッドプロセスと呼ぶ。

Linuxにおいて、CPUはプロセスとスレッドを区別しない。CPUが認識するのは独立した実行フローのみであり、常にtask_structをスケジューリングの単位として扱う。したがって、スレッドはCPUスケジューリングの最小単位となる。

Linuxには厳密な意味での「スレッド」は存在せず、軽量プロセスによってシミュレートされている。もしOSが真のスレッドをサポートするなら、スレッドの生成・終了・スケジューリング・リソース管理など、プロセスとは別の管理モジュールを設計する必要があり、OSの複雑化を招く。そのためLinuxでは、プロセス制御ブロックをそのまま再利用し、全ての実行フローを軽量プロセスとして扱う設計を採用している。

この設計のため、Linuxには真のスレッドシステムコールは存在しないが、代わりに軽量プロセスを作成するvforkなどのインターフェースが提供されている。

#include <iostream>
#include <cstdlib>
#include <sys/types.h>
#include <unistd.h>

int shared_state = 50;

int main() {
    pid_t child_pid = vfork();
    if (child_pid == 0) {
        shared_state = 999;
        std::cout << "Child PID: " << getpid() << " | shared_state: " << shared_state << std::endl;
        exit(0);
    }
    sleep(2);
    std::cout << "Parent PID: " << getpid() << " | shared_state: " << shared_state << std::endl;
    return 0;
}

vforkで作成された子プロセスは親プロセスとアドレス空間を共有するため、子プロセスでの変更が親プロセスに反映される。

ページテーブルの仕組み

32ビット環境では4GB(2^32)のアドレス空間を持つ。もし単一の巨大なページテーブルで仮想アドレスと物理アドレスのマッピングを管理すると、エントリ数が膨大になり、メモリに収まらない(数十GB必要になる)。そこでLinuxは2階層のページテーブル構造を採用している。

  • 仮想アドレスの上位10ビットを使って、ページディレクトリ(1階層目)から対応するページテーブル(2階層目)を特定する。
  • 次の10ビットを使って、ページテーブル内のエントリを検索し、物理メモリ上のページフレーム(4KB単位)の先頭アドレスを取得する。
  • 残りの12ビットをオフセットとして、ページフレーム内の正確な物理アドレスを決定する。

物理メモリは4KBのページフレームに分割されており、ディスクからのI/Oもこの4KB単位で行われる。2階層構造にすることで、ページテーブルに必要なメモリ容量は10MB程度に抑えられる。この仮想アドレスから物理アドレスへの変換処理は、CPU内蔵のMMU(Memory Management Unit)によってハードウェア的に実行される。

スレッドのメリットとデメリット

メリット

  • 新規プロセスの生成と比較して、スレッドの生成コストが非常に低い。
  • コンテキストスイッチのコストが低い(キャッシュやレジスタのリロードが不要なため)。
  • メモリ消費が少ない。
  • マルチコアプロセッサの並列処理能力を活かせる。
  • I/O待ちの間に他の処理を進めることができる。
  • CPUバウンドな処理を複数スレッドに分割してパフォーマンスを向上させたり、I/Oバウンドな処理で複数のI/Oをオーバーラップさせたりできる。

デメリット

  • 計算密度の高いスレッドがプロセッサを独占し、スケジューリングオーバーヘッドが増大するとパフォーマンスが低下する。
  • 堅牢性が低い。共有変数の不適切なアクセスやタイミングのズレによりバグが発生しやすい。
  • アクセス制御が困難。ひとつのスレッドからのOS関数呼び出しがプロセス全体に影響を及ぼす。
  • デバッグや開発の難易度が上がる。

スレッドの異常

あるスレッドでゼロ除算や無効なポインタアクセスが発生すると、そのスレッドだけでなくプロセス全体がクラッシュする。スレッドはプロセスの実行分岐であるため、スレッドの異常はプロセスの異常として扱われ、シグナル機構によってプロセス全体が終了させられる。

プロセスとスレッドの比較

スレッドは同じアドレス空間を共有するため、コードセグメントやデータセグメントに加え、ファイルディスクリプタテーブル、シグナルハンドラ、カレントワーキングディレクトリ、ユーザーID/グループIDなども共有される。

プロセスがリソース分配の基本単位であるのに対し、スレッドはCPUスケジューリングの基本単位である。スレッドはプロセスのデータを共有しつつ、以下の独自データを持つ。

  • スレッドID
  • レジスタセット(実行コンテキスト)
  • スタック(独自の局所変数用)
  • errno(スレッドごとのエラー番号)
  • シグナルマスク
  • スケジューリング優先度

Linuxにおけるスレッド制御

POSIXスレッドライブラリ(pthread)

Linuxカーネルにはスレッド専用のシステムコールがなく、vforkなどの軽量プロセス用インターフェースが用意されているのみである。しかし、ユーザーはthread_createのような直感的なAPIを求めるため、ユーザー空間で軽量プロセスのシステムコールをラップした「POSIXスレッドライブラリ(pthread)」が提供されている。

  • このライブラリはサードパーティ製ではなく、ほぼ全てのLinuxディストリビューションに標準搭載されている。
  • 関数名はpthread_で始まるものが多い。
  • 利用時にはpthread.hをインクルードし、コンパイル時に-lpthreadオプションを指定する。
  • エラーハンドリングにおいて、従来のerrno変数を設定するのではなく、関数の戻り値としてエラーコードを返す設計になっている。

スレッドの生成

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

void* execute_task(void* argument) {
    while (true) {
        std::cout << "Running thread: " << static_cast<char*>(argument) << std::endl;
        sleep(1);
    }
    return nullptr;
}

int main() {
    pthread_t worker_id;
    pthread_create(&worker_id, nullptr, execute_task, reinterpret_cast<void*>(const_cast<char*>("Worker-Alpha")));

    while (true) {
        std::cout << "Main thread executing..." << std::endl;
        sleep(2);
    }
    return 0;
}

ps -aLコマンドを実行すると、LWP(軽量プロセスID)を確認できる。同じプロセス内のスレッドは同じPIDを持ち、異なるLWPを持つ。カーネルのスケジューラはPIDではなくLWPを基にスレッドを管理している。単一スレッドのプロセスでは、PIDとメインスレッドのLWPは一致する。

スレッドの待機

スレッドもゾンビプロセス同様、リソースリークを防ぐために待機(join)する必要がある。pthread_joinを使用すると、対象スレッドが終了するまで呼び出し元のスレッドがブロックされる。

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

void* compute_task(void* argument) {
    std::cout << static_cast<char*>(argument) << " is processing." << std::endl;
    sleep(2);
    return reinterpret_cast<void*>(42);
}

int main() {
    pthread_t worker_id;
    pthread_create(&worker_id, nullptr, compute_task, reinterpret_cast<void*>(const_cast<char*>("Worker-Beta")));

    void* exit_status = nullptr;
    int result = pthread_join(worker_id, &exit_status);

    if (result == 0) {
        std::cout << "Join successful. Exit code: " << reinterpret_cast<long long>(exit_status) << std::endl;
    } else {
        std::cout << "Join failed." << std::endl;
    }
    return 0;
}

スレッドの終了

returnによる終了

スレッドの開始関数からreturnすることでそのスレッドは終了する。ただし、メインスレッド(main関数)でreturnするとプロセス全体が終了してしまう。

pthread_exitによる終了

void pthread_exit(void *retval);
スレッドを終了させ、retvalpthread_joinに渡す。スタック上のローカル変数のアドレスをretvalとして渡してはならない。また、exit()はプロセス全体を終了させるため、スレッド終了目的では使用できない。

pthread_cancelによる終了

int pthread_cancel(pthread_t thread);
指定したスレッドをキャンセルする。キャンセルされたスレッドの終了ステータスはPTHREAD_CANCELED((void*)-1)となる。

スレッドの分離

デフォルトではスレッドはjoinableだが、終了ステータスを必要としない場合はpthread_detachによってスレッドを分離(detach)できる。分離されたスレッドは終了時に自動的にリソースが回収される。joinableとdetachedは相互に排他であり、分離されたスレッドに対してpthread_joinを呼び出すとエラーになる。

スレッドIDとメモリレイアウト

pthread_createの第一引数で取得するpthread_t型のスレッドIDは、カーネルのLWPとは異なる。LWPはカーネルのスケジューラが用いる識別子であるのに対し、pthread_tはユーザー空間のスレッドライブラリ(NPTL)が用いる識別子である。

NPTLは動的共有ライブラリとしてプロセスのアドレス空間の共有領域にマッピングされる。メインスレッドのスタックはプロセスの標準スタック領域が使われるが、その他のスレッドのスタックはこの共有領域内に確保される。また、各スレッドの属性情報(struct pthread)やスレッドローカルストレージも共有領域に配置される。pthread_tの実態は、この共有領域におけるstruct pthreadの先頭仮想アドレスであり、同一プロセス内で一意にスレッドを特定する。

補足:pthread_once

マルチスレッド環境で、初期化処理などを厳密に1回だけ実行したい場合がある。pthread_onceを使用すると、複数のスレッドから同時に呼び出されても、指定した初期化関数が1度だけ実行されることをスレッドセーフに保証できる。

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

pthread_once_t once_flag = PTHREAD_ONCE_INIT;
pthread_mutex_t console_lock = PTHREAD_MUTEX_INITIALIZER;

void init_config(void) {
    std::cout << "Configuration initialized by thread " << pthread_self() << std::endl;
}

void* task_alpha(void* arg) {
    pthread_mutex_lock(&console_lock);
    std::cout << "Task Alpha (" << pthread_self() << ") started." << std::endl;
    pthread_mutex_unlock(&console_lock);

    pthread_once(&once_flag, init_config);

    pthread_mutex_lock(&console_lock);
    std::cout << "Task Alpha (" << pthread_self() << ") finished." << std::endl;
    pthread_mutex_unlock(&console_lock);
    return nullptr;
}

void* task_beta(void* arg) {
    pthread_mutex_lock(&console_lock);
    std::cout << "Task Beta (" << pthread_self() << ") started." << std::endl;
    pthread_mutex_unlock(&console_lock);

    pthread_once(&once_flag, init_config);

    pthread_mutex_lock(&console_lock);
    std::cout << "Task Beta (" << pthread_self() << ") finished." << std::endl;
    pthread_mutex_unlock(&console_lock);
    return nullptr;
}

int main() {
    pthread_t t1, t2;
    pthread_create(&t1, nullptr, task_alpha, nullptr);
    pthread_create(&t2, nullptr, task_beta, nullptr);

    sleep(1);
    std::cout << "Main process terminating." << std::endl;
    return 0;
}

C++においては、標準ライブラリのstd::once_flagstd::call_onceを利用することで同等の機能をよりモダンな形で実現できる。

タグ: linux マルチスレッド posix pthread LWP

7月17日 20:02 投稿