C
OS/同期/Lesson 04

並行性 — ロック・デッドロック・同期/非同期・ブロッキング/ノンブロッキング

60分·theory

並行性 — ロック・デッドロック・同期/非同期・ブロッキング/ノンブロッキング

🎯 このレッスンを読み終えたら

このレッスンを読み終えると、以下の3つのトピックについて自信を持って説明できるようになります。

  • ✅ Mutex vs Semaphore vs Monitor
  • ✅ Race Condition + デッドロックの4条件
  • ✅ 同期 vs 非同期 + ブロッキング vs ノンブロッキング の4つの組み合わせ

学習目標をチェックリストとして手元に置き、すべてに答えられたらレッスンを閉じてください。

Race Condition — 同時変更の悲劇

一言で言えば: 2つのスレッドが同じデータを同時に変更 → 結果が予測不能。

:

code
初期値: counter = 0
Thread A: counter = counter + 1    (1)
Thread B: counter = counter + 1    (2)
期待値: counter = 2
実際: counter = 1 (あり得る!)

理由: counter + 13つの命令から成る:
1. read counter → register
2. register + 1
3. register → counter

Aがステップ1・2を実行した後にBがステップ1を実行すると → 両方が0を読んで+1し、結果として1しか保存されない。

解決策:

方法用途
Mutex (Lock)一度に1つのスレッドのみ進入。最も一般的
SemaphoreN件の同時アクセスを許可 (例: DBコネクションプール)
Atomic単一変数 (CAS / AtomicInteger) — ロック不要、高速
Channel/Actorメッセージパッシング (Go・Erlang) — データ共有を回避
Immutable不変データ (Rust・関数型) — そもそもraceが発生しない

よくあるrace conditionの落とし穴:

  • ❌ デバッガを起動すると消えるバグ (Heisenbug) — デバッガがタイミングを変えてしまう
  • ❌ 「自分のマシンでは動く」 — CPUコア数や負荷が異なる
  • ✅ 必ず並行性テストを実施する (-race フラグ・ThreadSanitizer)

Mutex — 最も一般的なロック

Mutex (MUTual EXclusion): 一度に1つのスレッドのみクリティカルセクションに進入できる。

使用フロー:

python
import threading
lock = threading.Lock()
counter = 0

def increment():
    global counter
    with lock:           # acquire
        counter += 1     # critical section
    # release (自動)

言語別Mutex:

言語構文
Javasynchronized(obj) { ... } または ReentrantLock
Pythonwith threading.Lock():
Gosync.MutexLock()/Unlock()
RustMutex<T>.lock() (所有権でreleaseが自動化)
C++std::lock_guard<std::mutex>

Mutexのよくある間違い:

間違い結果
❌ unlockし忘れ他のスレッドが永遠に待ち続ける
❌ ロックを保持したままI/O (DBコール)処理時間が長くなりスループット低下
❌ ロック保持中に別のロックを取得デッドロックの危険
❌ 再帰呼び出しで同じロックnon-recursive mutexは自分自身とデッドロック

ReadWriteLock: 読み取りは複数同時 + 書き込みは排他 → 読み取りが多いワークロードに有利。
Optimistic Lock: DBでよく使用。競合時にリトライ (CASと類似)。

デッドロック — 永遠の待機

4つの必要条件 (すべてが揃うとデッドロック):
1. 相互排除 (Mutual Exclusion) — リソースは一度に1スレッドのみが保持できる
2. 占有と待機 (Hold and Wait) — リソースを保持しながら別のリソースを待つ
3. 非横取り (No Preemption) — リソースを強制的に取り上げられない
4. 循環待機 (Circular Wait) — A→B→C→Aの循環

古典的な例: 2つのスレッドが同じ2つのロックを異なる順序で取得:

code
Thread 1: lock A → lock B
Thread 2: lock B → lock A

→ スレッド1はAを保持してBを待つ / スレッド2はBを保持してAを待つ → 永遠に進まない

6つの解決策:

方法原理
ロック順序の統一すべてのスレッドが同じ順序でロックを取得する
タイムアウトtryLock(5秒) — 失敗したら諦めてリトライ
デッドロック検出DBのように待機グラフを分析してvictimを選定
一括取得必要なロックをすべて一度に取得 (分割しない)
ロック回避ロック自体を使わない (Actor・Channel)
タイムドロックlock.tryLock(time) で保護

デッドロックのデバッグ:

  • Java: jstack <PID> → 「Found one Java-level deadlock」を自動検出
  • Python: faulthandler + thread dump
  • Go: go run -race (race detector)
  • DB: PostgreSQLが自動検出してvictimトランザクションをabort

同期 vs 非同期 · ブロッキング vs ノンブロッキング

4つの組み合わせ (よく混同されるため個別に理解する):

組み合わせ意味
同期 + ブロッキング結果が出るまで呼び出し元が待つresult = db.query(...)
同期 + ノンブロッキング即座に返す (現在の状態)。呼び出し元がポーリングsocket.recv() (NONBLOCK)
非同期 + ブロッキングタスク開始後に別の処理。結果受け取り時にブロックFuture.get()
非同期 + ノンブロッキングタスク開始後、コールバック・イベントで結果を通知async/await、callback

同期/非同期 = 完了通知の方式
ブロッキング/ノンブロッキング = 呼び出し元が処理を続けられるかどうか

Linux I/Oモデル 4種類:

モデルシステムコール備考
ブロッキングI/Oread() デフォルト最も単純。1スレッド = 1コネクション
ノンブロッキングI/OO_NONBLOCK + read()ポーリングが必要 (CPU無駄)
I/Oマルチプレキシングselectpollepollkqueue1スレッドでNソケット (Nginx・Redis)
非同期I/Oio_uring (Linux 5.1+) · POSIX AIO真の非同期。コールバック・イベントで通知

epollの動作 (Linuxの効率的なI/Oマルチプレキシング):
1. epoll_create() — インスタンス生成
2. epoll_ctl(ADD) — 監視するfdを登録
3. epoll_wait() — イベント発生までブロック
4. fdを処理 → 再びwait

Node.jsとNginxが速い理由 = シングルスレッド + epollイベントループ → コンテキストスイッチなし。

💻 📌 シナリオで学ぶ並行性デバッグ
# ============================================================
# シナリオ 1: "Javaアプリが停止した。デッドロックか?"
# ============================================================
# jstack — 全スレッドのスタックトレース + *デッドロック自動検出*
jstack 28391 > /tmp/dump.txt
grep -A 50 'Found one Java-level deadlock' /tmp/dump.txt
# 出力例:
# "Thread-1" waiting to lock <0xabc> (a Account)
#   which is held by "Thread-2"
# "Thread-2" waiting to lock <0xdef> (a Order)
#   which is held by "Thread-1"
# → 2つのスレッドが互いに*逆順*でロック → デッドロック

# スレッド状態分析 (BLOCKED·WAITING·RUNNABLE)
grep -E 'java.lang.Thread.State' /tmp/dump.txt | sort | uniq -c
# 例:  150 RUNNABLE  / 5 BLOCKED  / 845 WAITING
# BLOCKEDが多い場合 = ロック競合の疑い

# より親切なツール
jcmd 28391 Thread.print                 # jstackのモダンバージョン
jconsole &                              # GUI (リアルタイムスレッド·MBean)

# ============================================================
# シナリオ 2: "スループットが急に低下 — どこで時間を使っている?"
# ============================================================
# システムコールカウントでホットスポットを探す
strace -c -p 28391                # 30秒ほど後にCtrl+C
# 出力例:
#   % time  calls  syscall
#   45.2    1820   futex      ← ロック待機が多い! (mutex)
#   22.1    8500   read       ← ディスクI/Oが多い
#   15.3    8500   write
#   ...

# *futex* が1位なら → ロック競合の疑い
# *read·write* が1位なら → ディスクI/Oのボトルネック

# 特定のシステムコールのみリアルタイム追跡
strace -e trace=futex,epoll_wait -p 28391

# CPUホットスポット (どの関数がCPUを消費しているか)
perf top -p 28391                 # 対話型
perf record -p 28391 -g sleep 30  # 30秒録画
perf report                        # 分析

# ============================================================
# シナリオ 3: "Goアプリにレースコンディションの疑い"
# ============================================================
# Goのrace detector — コンパイル時オプション
go run -race ./cmd/server          # 開発環境
go test -race ./...                 # CIで常に推奨

# race発見時の出力:
# WARNING: DATA RACE
# Read at 0x00c000010080 by goroutine 7:
#   main.counter
# Previous write at 0x00c000010080 by goroutine 6:
#   main.increment
# ⚠️ 2つのgoroutineが同時アクセス = race

# ============================================================
# シナリオ 4: "Pythonアプリがハングした"
# ============================================================
# faulthandlerで現在すべてのスレッドスタックをダンプ
# コード開始時:
#   import faulthandler
#   faulthandler.enable()
# その後外部から:
kill -SIGABRT 28391                 # stderrにスタック出力

# py-spy — コード変更なしでリアルタイムプロファイリング (強力)
# pip install py-spy
py-spy dump --pid 28391            # 現在のスタック
py-spy top --pid 28391             # リアルタイムtop (どの関数がCPU)
py-spy record -o profile.svg --pid 28391  # flamegraph

# ============================================================
# シナリオ 5: "I/Oがボトルネックか確認"
# ============================================================
# ディスクI/Oはどのプロセスが多いか?
iotop -o                            # アクティブなプロセスのみ (-o)

# ディスク別IOPS·待機時間
iostat -x 1
# %util > 80%·await > 50ms = ディスクボトルネック

# ソケット統計
ss -s                              # 要約 (TCP·UDP·established数)
ss -tnp                             # TCP接続 + PID
ss -tn state established sport :443 # 443に入ってきたアクティブな接続

# 開いているfd数 (よく遭遇する 'too many open files')
ls /proc/28391/fd/ | wc -l
cat /proc/28391/limits | grep 'open files'

# ============================================================
# シナリオ 6: "epollベースサーバー — どのようなイベントを待機中?"
# ============================================================
# Node.js·nginx·Redisのようなイベントループサーバー
cat /proc/28391/fdinfo/<epoll_fd>   # epollインスタンス情報
# またはstraceで直接:
strace -e trace=epoll_wait,epoll_ctl -p 28391

# すべての開いているepollを探す
ls -la /proc/28391/fd/ | grep eventpoll

🤖 AIへのリクエスト例

このレッスンの概念を知っていれば、AIに具体的な指示を出せます。

  • 「このメソッドの並行性の問題を分析して、synchronizedとReentrantLockのどちらが適切か推薦してください。」
  • 「このコードにデッドロックの可能性があります。リソース取得順序を基に診断してください。」
  • 「このタスクをCompletableFuture + thenCombineで並列処理してください。」

なぜこれでトークンが減るのか

「ロック / デッドロック / volatile / atomic」という語彙を知っていれば、AIの返答が即コードで返ってきます。知らなければ「並行性とは...」という説明から始まります。

並行性 — ロック・デッドロック・同期/非同期 - OS