地方エンジニアの学習日記

興味ある技術の雑なメモだったりを書いてくブログ。たまに日記とガジェット紹介。

【Linux】pthreadのTLSのオーバーヘッド

この記事は「渡部 Advent Calendar 2025」の7日目の記事です。


ja.wikipedia.org

pthread の TLS(Thread-Local Storage) を扱う API(pthread_key_create, pthread_setspecific, pthread_getspecific)が “重い” と言われる理由は、実装上の構造・呼び出しコスト・同期コスト が複合して効いているためです。

この記事は、CPU アーキテクチャTLS(__thread / thread_local)との比較を交えながら整理していきます。

結論:pthread TLS が重い理由

  • 関数呼び出しベースであり、インライン化できない
    • → CPU ネイティブ TLS は 1 命令。pthread TLS は複数関数呼び出し+間接参照。
  • 内部にハッシュテーブルまたは配列アクセスがあり、間接参照が多い
    • → pthread_getspecific() は「テーブルの lookup」が必要。
  • 削除・destructor 実行をサポートするため複雑なライフサイクル管理が必要
    • → 高速化が難しい。
  • 一部実装ではロックやメモリバリアが必要
    • → 特に key の生成や destructor 実行周り。
  • glibc では可変長の key → index → per-thread TSD 配列の lookup 構造になっている
    • → CPU が最適化しにくい。

簡単に書くと

pthread TLS は “ランタイムが管理する辞書アクセス”。
CPU ネイティブ TLS は “レジスタからの O(1) 直接アクセス”。

という感じです。

もう少し詳しく書く

ここからは、先ほどざっくり箇条書きで挙げたポイントを、もう少し丁寧に見ていきます。

1. key = integer ID(最大 PTHREAD_KEYS_MAX ≧ 1024)

まず、pthread の TLS は「整数のキー(ID)で値にアクセスする仕組み」になっています。

pthread_key_t key;
pthread_key_create(&key, destructor);  // key に整数 ID が入る

pthread_key_create() を呼ぶと、ランタイム側で「次の空きスロット」を探して、そこにキーを割り当てます。アプリケーション側は、この整数 ID を使って pthread_setspecific() / pthread_getspecific() を叩きます。glibc の実装イメージを超ざっくり書くと、pthread_getspecific(key) の中ではこんなことをしています。

  1. 「そのスレッド用の TLS テーブル(TSD: Thread-Specific Data)」を取り出す
  2. そのテーブルの中から、key に対応するスロットを探す
  3. 見つかったスロットに保存されているポインタを返す

ポイントは、毎回「テーブルの中から探す」というステップが挟まることです。CPU ネイティブ TLS のように「このオフセットを見れば必ず目的の値がある」という形にはなっていません。

2. CPU ネイティブ TLS は 1 命令でアクセスできる

それに対して、CPU ネイティブ TLS(__thread / thread_local)は、そもそも仕組みが別物です。x86-64 では、TLS の領域に FS/GS レジスタを使ってアクセスすることがよくあります。例えばこんなイメージです。

mov %fs:0x1234, %rax

ここでは、fs セグメントベース + 0x1234 というオフセット。その位置に「そのスレッド用の TLS 変数」があるという前提で、たった 1 命令で値を読み込んでいますコンパイル時に「この TLS 変数は FS ベースでオフセット何バイト」という情報が決まってしまうので、lookup という概念自体が存在しません。一方、pthread TLS を使うと、こうなります。

void *p = pthread_getspecific(key);

内部では、

call pthread_getspecific   ; 関数呼び出し
  ↓
スレッドごとの TSD テーブルを探す
  ↓
key をインデックスにしてエントリを取得
  ↓
値(ポインタ)を返す

という処理が毎回走ります。つまり、

関数呼び出し(スタック操作)

分岐(キー範囲チェックやテーブル状態のチェック)

メモリアクセス(TSD テーブルの実体を辿る)

が必ず入ります。この構造の時点で、CPU ネイティブ TLS の「1 命令」と同じ速度まで落とすのは構造的に無理がある、というのが「高速化が物理的に不可能」という話のニュアンスです。

3. destructor(スレッド終了時のクリーンアップ)が構造を複雑にする

pthread の TLS が便利なのは、destructor を指定できる点です。

pthread_key_create(&key, destructor);

とすると、そのスレッドが終了するときに destructor が呼ばれ、TLS に紐づいたリソースを自動的に開放できます。ただし、この仕組みを支えるために、ランタイム側では次のようなことをしています。

スレッド終了時に 全ての key をスキャン する

それぞれの key について、値がセットされていて destructor が登録されていれば destructor を呼ぶ

destructor 内でまた pthread_setspecific() が呼ばれて値が更新される可能性があるため、値が変わった場合は再度スキャンを繰り返す(最大 PTHREAD_DESTRUCTOR_ITERATIONS 回)このように、「全部スキャンする」「値が変わるかもしれないので繰り返す」といった処理が必要になるため、内部構造をシンプルで固定的な形にしにくいのです。結果として、「高速アクセスに全振りした設計」にすることが難しくなっています。

4. key の管理はグローバル → 同期が必要

pthread_key_create() は「プロセス内で一意なキー」を新たに割り当てる API です。このキーは 全スレッドで共有されるグローバル資源なので、割り当て処理には同期が必要になります。ざっくり言うと、

グローバルな key テーブルを持っていて

「空いている番号を探して、そこを新しい key として予約する」

という処理を、複数スレッドから安全に呼べるようにしておく必要があるわけです。そのため、実装によっては「key テーブルの更新時にロックを取る」あるいは「メモリバリアを張って可視性を担保する」といった処理が入ります。

pthread_getspecific() 自体は通常ロックレスで実装されていますが、グローバルな管理構造を前提にしているため、キャッシュ効率の良い「ローカル固定配列」にしづらいという制約を抱えています。

5. glibc は「柔軟性」を優先した実装になっている

glibc の実装は、POSIX の仕様を満たしつつ、「いくつ key が増えても動く」「動的に key を生成・破棄できる」ことを重視しています。そのために、「可変長の TSD 配列を使う」「必要に応じて配列を拡張する」「key の数が増えても破綻しないように設計する」といった柔軟な構造を取っています。

ただし、これは性能面ではデメリットもあります。「配列が大きくなりやすい」「アクセスするたびにポインタを辿る距離が伸びる」「キャッシュ局所性が悪くなりやすい」結果として、CPU ネイティブ TLS のような、「この変数は FS ベースで +0x1234 バイトのところに常にある」という 固定オフセット前提の高速アクセスと比べると、どうしても不利になってしまいます。

こんな感じで、「キーを整数で管理していること」「destructor や動的生成をサポートしていること」「グローバルな key 管理と柔軟な TSD 配列」という複数の要因が重なり、pthread の TLS API は機能的には便利だが、構造的に重くなりがちという性格を持っています。

まとめ

pthread TLS API が重い理由

  • 関数呼び出しベース
  • 辞書的な lookup
  • 可変長 key 管理
  • destructor の仕組み
  • メモリバリア/キャッシュミス

などにより、CPU ネイティブ TLS のような「1 命令アクセス」ができないため。なので重い処理と言える。TLS領域が必要かどうかはちゃんと考える必要があるものであると言えそうです。

参考

www.isus.jp

david-grs.github.io