TL

futexの内部動作とユーザ空間ロックの実装

ロックは取れて当たり前。競合しない普通の場合はシステムコールを一切呼ばずユーザ空間で完結し、本当に待つときだけカーネルへ落ちる――その「速いロックの正体」futexを原理からつかめます。

応用futexミューテックス同期システムコール並行処理pthread最終更新: 2026-06-21
TL;DR要点だけ先に
  • 1.futexの核心は分業。ロック状態はユーザ空間の1ワードに置きアトミック命令で操作し、競合して本当に眠る/起こす必要が出たときだけFUTEX_WAIT/FUTEX_WAKEでカーネルへ落ちる。
  • 2.競合がなければシステムコールはゼロ回。CASで0→1にできた瞬間にロック獲得が終わるため、非競合パスは数命令で済む。これが「速いミューテックス」の正体。
  • 3.pthread mutexは値0/1/2の3状態で「待ち手がいるか」を表現し、条件変数はミューテックス連携・取りこぼし防止・スプリアスwakeupをfutexの上に組んで実装される。

futexが解く問題──「速い」ミューテックスとは何か

ミューテックスに求められる性能には、相反する2つの要求があります。競合していないとき(ほぼ毎回)は限りなく軽く、しかし 競合して待つことになったときはCPUを焼かずに眠る こと。

素朴に作ると、このどちらかを諦めることになります。毎回カーネルにロックを依頼すれば確実に眠れますが、システムコール のたびにユーザ/カーネルモード遷移のコストを払い、競合していなくても遅くなります。逆にユーザ空間でひたすら spin すれば軽いものの、長く待つ場合にCPUを無駄に焼き続けます。

futex(fast userspace mutex) は、この対立を「分業」で解きます。発想は単純です。

  • ロックの状態そのものは、ユーザ空間の共有メモリ上の1ワード(int)に置く。獲得・解放はアトミック命令(CAS など)でユーザ空間だけで行う。
  • カーネルが受け持つのは 「眠る」と「起こす」だけ。すなわち、競合して本当に待たなければならなくなったとき、そのときに限ってシステムコールを呼ぶ。

つまり 非競合パスはユーザ空間で完結し、システムコールは1回も呼ばれません。カーネルは「待ち行列の管理人」に徹し、ロックの値の意味(0が空き、1が保持、など)すら知りません。値の解釈は完全にユーザ空間(libc)側の責任です。

futexはロックではない──ロックを作るための部品

よくある誤解として「futex=ミューテックス」がありますが、正確には違います。futexが提供するのは FUTEX_WAIT(この番地の値がXのままなら眠れ)FUTEX_WAKE(この番地で待つスレッドをN個起こせ) という2つの低レベル操作だけ。ミューテックス・条件変数・セマフォ・rwlockは、いずれもこの部品を土台にユーザ空間で組み上げられます。futexは「同期プリミティブを作るためのプリミティブ」です。

2つのコア操作──FUTEX_WAITとFUTEX_WAKE

futexシステムコールの中心は次の2つです。アドレス uaddr が指すint値を、ユーザとカーネルが共有して見ます。

FUTEX_WAIT(uaddr, expected):
  アトミックに {
    if (*uaddr == expected)   // 期待値とまだ一致しているなら
        この呼び出し元を uaddr のwait queueに入れて眠らせる
    else
        即座に戻る(EAGAIN)   // 値が変わっていた=寝る必要がない
  }

FUTEX_WAKE(uaddr, n):
  uaddr のwait queueで眠っているスレッドを最大n個起こす

FUTEX_WAITexpected 引数が、この設計の肝です。これは 「私はさっき値がXだと見て、競合していると判断した。本当にまだXのままなら眠る」 という条件付きの眠りです。なぜこの確認が要るのか——次節の取りこぼし問題が答えです。

カーネル側は uaddr(物理ページ+オフセットから導く一意なキー)をハッシュして、グローバルなハッシュテーブル上のバケットにある待ち行列へスレッドを並べます。futexごとにカーネル内オブジェクトを常駐させるわけではなく、待ち手が現れて初めてカーネルに記録が作られる のも、軽さの一因です。

expected確認がない素朴な実装は必ず壊れる──lost wakeup問題

「値を読む→競合とみなす→眠る」の3ステップに分けると、致命的な隙間が生まれます。スレッドAが「競合だ、眠ろう」と決めた直後、まだ実際に眠る前に、スレッドBがロックを解放して FUTEX_WAKE を撃ったとします。Bのwakeは「まだ眠っていないA」には届かず空振りし、その後Aが眠ると——起こす人がもういない状態で永久に眠り続けます。これが lost wakeup(wakeupの取りこぼし)です。futexは「値の確認と待ち行列への登録をカーネル内で原子的に行う」ことでこの隙間を閉じます。FUTEX_WAIT が値引数を取るのはまさにこのためです。

非競合パス:システムコールゼロでロックを取る

ここからが本題、ミューテックスの実装です。最小のロックは 1ワードで表せます。0 = 空き1 = 保持中 とします。

lock():
  if (CAS(&val, 0, 1))   // 0なら1にできた=獲得成功
      return            // ← ここで終わり。システムコールを呼んでいない
  ... (競合時のslow path) ...

unlock():
  val = 0                // 解放
  ... (待ち手がいれば起こす) ...

競合がなければ lock()CAS 1発で終わり、カーネルには一切触れません。これが「fast userspace mutex」の名の通り、futexの設計目標がそのまま現れる箇所です。ロックを取る圧倒的多数のケース(競合なし)で、モード遷移のコストがまるごと消えます。アトミックなCASがなぜ単独で正しく働くか、その土台は ロックフリー同期とCAS を参照してください。

問題は 解放側です。上の素朴な unlock() は無条件で FUTEX_WAKE を呼びたくなりますが、それでは「待ち手がいない普通のケース」でも毎回システムコールを撃つことになり、非競合パスの軽さが台無しです。待ち手がいるときだけ起こしたい。 そのために状態をもう1つ増やします。

3状態ミューテックス:0・1・2で「待ち手の有無」を表す

glibc の pthread mutex が採る古典的な実装が 3状態(Drepperの手法として知られる)です。1ワードに次の意味を持たせます。

意味解放時にWAKEが必要か
0ロックは空き(誰も保持していない)
1保持中・待ち手なし不要(誰も眠っていない)
2保持中・待ち手あり(1人以上が眠っている)必要(FUTEX_WAKEで起こす)

この1ビットぶんの情報(値が2かどうか)で、解放時に「システムコールを呼ぶべきか」を判定 できます。これが3状態の存在意義です。

lock():
  c = CAS(&val, 0, 1)
  if (c == 0) return               // 非競合:即獲得、syscallなし
  // ここからslow path(競合あり)
  if (c != 2)
      c = xchg(&val, 2)            // 「待ち手あり」を宣言(2に上書き)
  while (c != 0) {
      futex(&val, FUTEX_WAIT, 2)   // val がまだ2なら眠る
      c = xchg(&val, 2)            // 起きたら再び2にして取得を試みる
  }

unlock():
  if (xchg(&val, 0) == 2)          // 解放。直前が2=待ち手がいた
      futex(&val, FUTEX_WAKE, 1)   // そのときだけ1人起こす

注目すべきは unlock() の条件分岐です。直前の値が2だったときに限り FUTEX_WAKE を呼ぶ。値が1(待ち手なし)で保持していたなら、解放は単なる代入で済み、システムコールはゼロです。つまり 競合がなければ lock も unlock もカーネルに触れません

なぜ「2に上書き」してから眠るのか

slow pathで xchg(&val, 2) を使うのは、「自分は眠るつもりだ」という事実を値に刻むためです。これにより、いま保持しているスレッドが解放する際、xchg(&val,0) の戻り値が2となり「待ち手がいる」と分かります。仮にここを2にせず1のまま眠ると、保持者は解放時に「待ち手なし」と誤判定してWAKEを撃たず、眠った自分は永久に起こされません。FUTEX_WAIT の expected を2に指定するのも、この「2=眠る合図」と整合させ、取りこぼしを防ぐためです。

待ち手が複数いても解放時に起こすのは1人

FUTEX_WAKE の引数を1にしているのは無駄ではありません。ロックは1人しか取れないので、解放時に全員起こしても1人を除く全員が再び競合に敗れて眠り直す——サンダリングハード(thundering herd、群れの暴走) が起きるだけです。だから「1人だけ起こす」のが基本。ただし起こされた1人は再取得に成功すると値を2に戻すため、まだ残っている待ち手のために「待ち手あり」状態が維持され、次の解放で連鎖的に起こされていきます。

条件変数をfutexで組む──待ちの取りこぼしを防ぐ

pthread_cond_t(条件変数)も futex の上に作られますが、ミューテックスより一段繊細です。条件変数は必ず 「ミューテックスを解放しつつ眠り、起こされたら取り直す」 をアトミックに行う必要があり、ここに固有の取りこぼし問題があります。

cond_wait(cond, mutex):
  seq = cond->seq            // ① 現在のシーケンス番号を読む
  mutex_unlock(mutex)        // ② ミューテックスを離す(他者がsignalできるように)
  futex(&cond->seq, FUTEX_WAIT, seq)  // ③ seq がまだ同じなら眠る
  mutex_lock(mutex)          // ④ 起きたらミューテックスを取り直す

cond_signal(cond):
  atomic_inc(&cond->seq)     // シーケンス番号を進める(=状態が変わった印)
  futex(&cond->seq, FUTEX_WAKE, 1)

肝は シーケンス番号(世代カウンタ) です。cond_wait は②でミューテックスを離した瞬間から③で眠るまでの間に、別スレッドが cond_signal する隙があります。もし単純に眠ると、その signal を取りこぼして永久に眠りえます。そこで ①で読んだ seq を FUTEX_WAIT の expected に渡す。②〜③の間に signal が入れば seq が進んでおり、FUTEX_WAIT は「値が違う」と即座に戻る(EAGAIN)——眠らずに済みます。ミューテックスと条件変数を必ずペアで使う作法は、この原子性を成立させるための前提です。排他の全体像は 排他制御とデッドロック も参照してください。

スプリアスwakeupは仕様──条件はwhileで再確認する

cond_wait から戻ったからといって、待っていた条件が成立しているとは限りません。FUTEX_WAKE で起こされても、自分が取り直す前に別スレッドが条件を消費しうるからです(これを含め、理由なく起きることを spurious wakeup と呼ぶ)。だから条件変数の待ちは必ず while (条件が偽) cond_wait(...) のループで囲み、起床後に条件を再確認します。if で書くのは典型的なバグで、futexの取りこぼし対策とは別レイヤーの「論理の取りこぼし」を生みます。

PI futexとロバストfutex──運用で効く拡張

基本のfutexは「眠る・起こす」しか知らないため、優先度逆転や保持者の死に弱いままです。これを補うカーネル支援の変種があります。

変種解く問題仕組みの要点
PI futex(FUTEX_LOCK_PI)優先度逆転:低優先度の保持者が高優先度の待ち手を足止めカーネルが保持者スレッドを一時的に昇格(優先度継承)させ、早く解放させる
ロバストfutex保持者が解放せず死ぬとロックが永久に取れなくなる保持中futexをスレッドごとにリスト登録。スレッド終了時にカーネルが検知し、待ち手へ「保持者死亡」を通知
PrivateフラグFUTEX_PRIVATEプロセス間共有でないfutexのキー計算コストプロセス内限定と分かればキー導出を簡略化し、より高速

PI futexでは、ロックの値そのものにカーネルが意味を持たせ(待ち手の優先度継承を成立させるため、保持者TIDを値に書く規約になる)、基本futexより踏み込んだ協調が行われます。優先度逆転とその影響は プロセスとスレッド のスケジューリングと併せて押さえると理解が進みます。

試験・面接で問われる要点
  • futex=fast userspace mutex。ロック状態はユーザ空間の1ワードに置き、アトミック命令で操作。競合がなければシステムコールはゼロ回
  • カーネルが提供するのは FUTEX_WAIT(値が期待値なら眠る)FUTEX_WAKE(待ち手をn個起こす) の2操作のみ。
  • FUTEX_WAITexpected値を取る理由=lost wakeup(眠る直前のwakeup取りこぼし)の防止。値確認と待ち行列登録をカーネル内で原子的に行う。
  • pthread mutexは 0/1/2の3状態。値2(待ち手あり)のときだけ解放時に FUTEX_WAKE を呼び、非競合の解放はsyscallゼロ。
  • 条件変数は シーケンス番号 で signal の取りこぼしを防ぎ、待ちは必ず whileループ でスプリアスwakeupに備える。

まとめ──状態はユーザ空間、待機だけカーネル

まとめ

futexの本質は 役割分担 です。ロックの状態はユーザ空間の1ワードに置いてアトミック命令で操作し、カーネルは 眠る(FUTEX_WAIT)と起こす(FUTEX_WAKE)だけ を担います。これにより 競合しない圧倒的多数のケースではシステムコールが1回も呼ばれず、CAS数命令でロックの取得・解放が完結します。FUTEX_WAIT が期待値を取るのは lost wakeup を防ぐため の必須設計で、値確認と待ち行列登録をカーネル内で原子的に行います。この部品の上に、pthread mutexは 0/1/2の3状態(値2のときだけ解放でWAKE)として、条件変数は シーケンス番号+whileループ でsignalの取りこぼしとスプリアスwakeupに備えて組まれます。さらにPI futex(優先度継承)やロバストfutex(保持者の死を検知)が実運用の穴を埋めます。眠る/回すの使い分けという土台は カーネルのロック機構 が、モード遷移のコストは システムコール が補強します。

OS Article

futexの内部動作とユーザ空間ロックの実装を実務で読む

TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。

解決すること

futex

比較で見る軸

難易度: advanced / カテゴリ: OS / タグ数: 6

導入後に効く点

競合がなければシステムコールはゼロ回。CASで0→1にできた瞬間にロック獲得が終わるため、非競合パスは数命令で済む。これが「速いミューテックス」の正体。

先に潰すリスク

用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。

数字・仕様の読み方
難易度
advanced
カテゴリ
OS
タグ数
6

判断チェックリスト

  • 自社の用途が「futex / ミューテックス」に近いか確認する。
  • 強みである「futexの核心は分業。ロック状態はユーザ空間の1ワードに置きアトミック命令で操作し、競合して本当に眠る/起こす必要が出たときだけFUTEX_WAIT/FUTEX_WAKEでカーネルへ落ちる。」が本当に評価軸になるか確認する。
  • 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
  • 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
  • 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
  • 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。

次に確認する観点

futexミューテックス同期システムコール並行処理futexミューテックス同期
参考: 公式情報