
2025/12/08 9:38
Spinlocks vs. Mutexes: When to Spin and When to Sleep
RSS: https://news.ycombinator.com/rss
要約▶
Japanese Translation:
Summary:
スピンロックは、ロック変数をタイトなループで繰り返しテストするため、同期の中で最も高速です。しかし、ロックが占有されている場合でもCPUサイクルを消費し続けるため、キャッシュラインのコンテストが発生します。一方、ミューテックスは重い競合時に遅くなる傾向があります。これはカーネル支援(futex 呼び出し)に依存し、コンテキストスイッチを引き起こす可能性があるためです。しかし、CPU時間の浪費を避け、リアルタイムシステムで優先度逆転を防止するための優先度継承機能を提供します。どちらを選択するかは、クリティカルセクションの長さと競合頻度に依存します:非常に短い(<100 ns)場合はスピンロックが有利です;中程度(100 ns–10 µs)の場合は、最初にスピンし、必要に応じてミューテックスへフォールバックする適応型またはハイブリッドロックが最適です;長いセクションでは標準ミューテックスを使用すべきです。このガイドラインは、コンテキストスイッチ、キャッシュミス、futex 使用量に関するプロファイルデータで裏付けられており、Redis や PostgreSQL のような高性能システムの開発者がレイテンシとスループットを最適化しつつ、予測可能なリアルタイム動作を維持するのに役立ちます。
Summary Skeleton
What the text is mainly trying to say (main message)
スピンロックは高速ですが消費的であり、ミューテックスは競合時に遅くなるものの高CPU使用率や優先度逆転を回避できるため、クリティカルセクションの長さと競合レベルによって選択が決まります。
Evidence / reasoning (why this is said)
- スピンロックはタイトな
ループを使用し、待機中に100 % CPU を消費しキャッシュラインのバウンスを引き起こします。CMPXCHG - ミューテックスは競合時に futex システムコール(約500 ns)とコンテキストスイッチへフォールバックしますが、非競合時の高速パス読み取り(<50 ns)は安価です。
- プロファイル指標(コンテキストスイッチ数、キャッシュミス、futex カウント)がトレードオフを確認しています。
Related cases / background (context, past events, surrounding info)
- Redis は短いセクション(<50 ns)でスピンロックを使用し、PostgreSQL はナノ秒レベルの検索にスピンロック、I/O にはミューテックスを利用します。
- Linux の PREEMPT_RT カーネルは優先度継承ミューテックスを必要とし、無制限の遅延を防止します。
- 現代のメモリアロケータは、偽共有を防ぐためにロックオブジェクトを 64 バイトキャッシュラインにパディングしています。
What may happen next (future developments / projections written in the text)
推奨はハイブリッドまたは適応型ロッキング戦略の採用です:<100 ns のセクションにはスピンロック、100 ns–10 µs にはハイブリッド/適応ミューテックス、10 µs 超の場合は通常ミューテックスを使用。リアルタイムシステムは引き続き優先度継承ミューテックスを重視します。
What impacts this could have (users / companies / industry)
正しいロックタイプの選択は CPU 使用率を削減し、レイテンシを向上させ、リアルタイムアプリケーションで優先度逆転を防止します。Redis や PostgreSQL のような高性能サーバー開発者やカーネル作成者がこれらのガイドラインを活用してスループットと応答性を最適化できます。
本文
同期プリミティブ ― スピンする時とスリープする時
perf top を見ていると、CPU 時間の 60 % が pthread_mutex_lock に費やされていることに気づく。レイテンシが「良い」から「排泄室」に変わってしまう。
誰かが「ただスピンロックを使えばいい」と言ったら、16 コアのサーバは 100 % で無駄な作業をしてしまう。
これが 同期プリミティブの罠 だ。多くのエンジニアは、各プリミティブが実際にどんな場面で意味を持つかを説明されないまま飛び込むので起こる現象である。
実際に何が起きているか
| プリミティブ | 仕組み | コンテスト時のコスト | 一般的な使い道 |
|---|---|---|---|
| スピンロック | ユーザ空間で (atomic compare‑and‑swap)を繰り返し成功するまでループ。 | 待機中は 100 % CPU を消費。キャッシュラインのバウンスがあると 40–80 ns 程度かかる。 | 非常に短いクリティカルセクション(< 100 ns)、低コンテスト(2–4 スレッド)。 |
| ミューテックス | ファーストパスは atomic test‑and‑set。失敗すると システムコール(約 500 ns)→ コンテキストスイッチ(3–5 µs)。ロック解放時に が待機者を起こす。 | スリープ/ウェイクのオーバーヘッド、スケジューラが関与。 | 中〜長いクリティカルセクション(≥ 100 ns)、中~高コンテスト、CPU を他の作業に回したい場合。 |
なぜスピンロックは危険なのか
| リスク | 詳細 |
|---|---|
| プリエンプタブルなコンテキスト | ロックを保持しているスレッドがプリエンプトされると、他の全てのスレッドが 1 スライス(Linux では約 100 ms)分だけスピンし続ける。カーネルはスピンロック周辺でプリエンプションを無効化するが、ユーザ空間では不可能。 |
| 優先度逆転 | 低優先度のスレッドがスピンロックを保持すると、高優先度のスレッドは永遠にスピンし続ける。PI ミューテックスは保持者の優先度を一時的に上げて回避するが、スピンロックではできない。 |
| 偽共有 | 同じキャッシュライン上に無関係な 2 つのロックがあると不要なコンテストが発生する。現代のメモリアロケータは 64 バイト境界でパディング( / )している。 |
適切なプリミティブを選ぶ
| クリティカルセクション長 | コンテストレベル | 推奨ロック |
|---|---|---|
| < 100 ns | 2–4 スレッド | スピンロック(数クロックで解放) |
| 100 ns – 10 µs | 中程度 | ハイブリッドミューテックス(glibc の adaptive mutex: 短時間スピン後にスリープ)。PostgreSQL の LWLock も同様。 |
| > 10 µs または高コンテスト | どれでも | 通常のミューテックス(スケジューラに任せる) |
| リアルタイム / 境界付きレイテンシ | PREEMPT_RT カーネルで PI ミューテックス を使用。スピンロックは除外。 |
実践的診断
-
perf stat -e context-switches,cache-misses
高いコンテキストスイッチ+低 CPU → ミューテックスのオーバーヘッド。
高いキャッシュミス+100 % CPU → ロック競合 / 偽共有。 -
– システムコールをカウント。strace -c
すべての
がコンテスト中のミューテックスを示す。futex() -
– 自発的 vs 非自発的コンテキストスイッチを確認。/proc/PID/status
スピンロック保持時に非自発的スイッチがあると警告。
サンプルプログラム
以下は、先ほど説明した挙動を示す 2 つの最小限のテストプログラムです。
コンパイル方法:
gcc -Wall -Wextra -O2 -pthread spinlock_test.c -o spinlock_test gcc -Wall -Wextra -O2 -pthread mutex_test.c -o mutex_test
spinlock_test.c
spinlock_test.c#define _GNU_SOURCE #include <stdio.h> #include <pthread.h> #include <stdatomic.h> #include <time.h> #define NUM_THREADS 4 #define ITERATIONS 1000000 typedef struct { atomic_int lock; long counter; } spinlock_t; static inline void spinlock_acquire(spinlock_t *s) { int expected = 0; while (!atomic_compare_exchange_weak(&s->lock, &expected, 1)) expected = 0; // 再試行 } static inline void spinlock_release(spinlock_t *s) { atomic_store_explicit(&s->lock, 0, memory_order_release); } void *worker(void *arg) { spinlock_t *s = (spinlock_t *)arg; for (long i = 0; i < ITERATIONS; ++i) { spinlock_acquire(s); s->counter++; /* ~100 ns の作業をシミュレート */ for (volatile int j = 0; j < 10; ++j); spinlock_release(s); } return NULL; } int main(void) { pthread_t threads[NUM_THREADS]; spinlock_t lock = { .lock = ATOMIC_VAR_INIT(0), .counter = 0 }; struct timespec start, end; clock_gettime(CLOCK_MONOTONIC, &start); for (int i = 0; i < NUM_THREADS; ++i) pthread_create(&threads[i], NULL, worker, &lock); for (int i = 0; i < NUM_THREADS; ++i) pthread_join(threads[i], NULL); clock_gettime(CLOCK_MONOTONIC, &end); double elapsed = (end.tv_sec - start.tv_sec) + (end.tv_nsec - start.tv_nsec) / 1e9; printf("SPINLOCK Results:\n"); printf(" Final counter: %ld\n", lock.counter); printf(" Time: %.3f seconds\n", elapsed); printf(" Ops/sec: %.0f\n", (double)(NUM_THREADS * ITERATIONS) / elapsed); printf(" CPU usage: 100%% (busy‑waiting)\n"); return 0; }
mutex_test.c
mutex_test.c#define _GNU_SOURCE #include <stdio.h> #include <pthread.h> #include <time.h> #define NUM_THREADS 4 #define ITERATIONS 1000000 typedef struct { pthread_mutex_t mutex; long counter; } mutex_lock_t; void *worker(void *arg) { mutex_lock_t *m = (mutex_lock_t *)arg; for (long i = 0; i < ITERATIONS; ++i) { pthread_mutex_lock(&m->mutex); m->counter++; /* ~100 ns の作業をシミュレート */ for (volatile int j = 0; j < 10; ++j); pthread_mutex_unlock(&m->mutex); } return NULL; } int main(void) { pthread_t threads[NUM_THREADS]; mutex_lock_t lock = { .mutex = PTHREAD_MUTEX_INITIALIZER, .counter = 0 }; struct timespec start, end; clock_gettime(CLOCK_MONOTONIC, &start); for (int i = 0; i < NUM_THREADS; ++i) pthread_create(&threads[i], NULL, worker, &lock); for (int i = 0; i < NUM_THREADS; ++i) pthread_join(threads[i], NULL); clock_gettime(CLOCK_MONOTONIC, &end); double elapsed = (end.tv_sec - start.tv_sec) + (end.tv_nsec - start.tv_nsec) / 1e9; printf("MUTEX Results:\n"); printf(" Final counter: %ld\n", lock.counter); printf(" Time: %.3f seconds\n", elapsed); printf(" Ops/sec: %.0f\n", (double)(NUM_THREADS * ITERATIONS) / elapsed); printf(" CPU efficient (threads sleep when blocked)\n"); pthread_mutex_destroy(&lock.mutex); return 0; }
テストの実行
# スピンロック – 100 % CPU が期待される ./spinlock_test & top -p $(pgrep spinlock_test) # ミューテックス – 低い CPU 使用率が期待される。スレッドはブロック時にスリープ。 ./mutex_test & top -p $(pgrep mutex_test)
strace -c でシステムコールのカウントを確認:
strace -c ./spinlock_test # futex 呼び出しほぼ無し strace -c ./mutex_test # thousands of futex(FUTEX_WAIT/WAKE)
/proc/PID/status のコンテキストスイッチ統計や perf stat -e cache-misses,cache-references でキャッシュ挙動を比較。
結論
| 項目 | 要点 |
|---|---|
| スピンロック | CPU を消費するが、数百ナノ秒程度の極めて短いクリティカルセクションに最適。低コンテストでのみ使用すべき。 |
| ミューテックス | システムコールとコンテキストスイッチを伴うオーバーヘッドがあるが、CPU を他の作業に回せる。クリティカルセクションが数クロック以上の場合はデフォルトで選ぶべき。 |
| ハイブリッド / アダプティブミューテックス | 短時間スピンしてからスリープへ移行するので、平均的な長さのセクションに有効。 |
| PI ミューテックス(PREEMPT_RT カーネル) | 境界付きレイテンシが重要なリアルタイム環境で必要。スピンロックは不適切。 |
まず
perf、strace、/proc の統計を使って測定し、保持時間・コンテストレベル・リアルタイム要件に合わせてプリミティブを選択することが最良のアプローチである。Redis では小さなスピンロックが使用され、PostgreSQL は I/O のためにミューテックスを使っているように、実際のプロダクションシステムもこの考え方に従って設計されている。