futexの内部動作とユーザ空間ロックの実装
ロックは取れて当たり前。競合しない普通の場合はシステムコールを一切呼ばずユーザ空間で完結し、本当に待つときだけカーネルへ落ちる――その「速いロックの正体」futexを原理からつかめます。
- 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_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_WAIT の expected 引数が、この設計の肝です。これは 「私はさっき値がXだと見て、競合していると判断した。本当にまだXのままなら眠る」 という条件付きの眠りです。なぜこの確認が要るのか——次節の取りこぼし問題が答えです。
カーネル側は uaddr(物理ページ+オフセットから導く一意なキー)をハッシュして、グローバルなハッシュテーブル上のバケットにある待ち行列へスレッドを並べます。futexごとにカーネル内オブジェクトを常駐させるわけではなく、待ち手が現れて初めてカーネルに記録が作られる のも、軽さの一因です。
「値を読む→競合とみなす→眠る」の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 もカーネルに触れません。
slow pathで xchg(&val, 2) を使うのは、「自分は眠るつもりだ」という事実を値に刻むためです。これにより、いま保持しているスレッドが解放する際、xchg(&val,0) の戻り値が2となり「待ち手がいる」と分かります。仮にここを2にせず1のまま眠ると、保持者は解放時に「待ち手なし」と誤判定してWAKEを撃たず、眠った自分は永久に起こされません。FUTEX_WAIT の expected を2に指定するのも、この「2=眠る合図」と整合させ、取りこぼしを防ぐためです。
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)——眠らずに済みます。ミューテックスと条件変数を必ずペアで使う作法は、この原子性を成立させるための前提です。排他の全体像は 排他制御とデッドロック も参照してください。
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_WAITが expected値を取る理由=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、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。