race-conditions について
タイミングが正しさに影響する競合状態を見つけ、対処する開発者向けスキルです。検出パターンに加え、アトミック操作、トランザクション、ロック、冪等性キーを扱います。
主な機能
- 01確認後に実行する競合パターンの特定
- 02アトミック更新とDBトランザクションの説明
- 03楽観ロックと分散ロックの紹介
- 04冪等性キーによる重複処理の防止
- 05並行テストの考え方
活用シーン
- 01断続的で再現しにくい不具合の調査
- 02DBの読み取り後の書き込み処理の検討
- 03共有状態や並行APIリクエストの点検
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ yanko-belov/ code-craft --skill race-conditionsインストール後、Claude Code で/race-conditionsを入力して読み込まれたか確認します。
詳細
競合状態
概要
結果がタイミングによって変わるなら、それは競合状態です。競合状態は、いつ発生してもおかしくないバグです。
競合状態は、正しさがイベントの相対的なタイミングに依存する場合に発生します。ほとんどの場合は問題なく動作する一方で、ランダムに失敗し、再現がほぼ不可能なため、厄介です。
使用する状況
- 複数の非同期処理が共有状態にアクセスする
- データベースの読み取り後に書き込むパターン
- 同時に実行される API リクエストが同じリソースを変更する
- 「開発環境では動くが、本番環境では失敗する」
- 再現できない断続的なバグ
絶対原則
NEVER read-then-write without atomicity guarantees.
例外はありません。
- 「処理が速いから、タイミングは関係ない」場合でも
- 「一度にユーザーは 1 人だけだから」場合でも
- 「壊れたら修正すればいい」場合でも
- 「テストでは動く」場合でも
タイミングが結果に影響する可能性があるなら、競合状態が発生しています。
検出:TOCTOU パターン
Time-Of-Check to Time-Of-Use:何かを確認してから、それに基づいて処理を行うことです。
// ❌ VIOLATION: Classic race condition
async function withdrawMoney(accountId: string, amount: number): Promise<void> {
// Time-of-check
const account = await db.accounts.findById(accountId);
if (account.balance >= amount) {
// Time-of-use (gap where another transaction can happen!)
await db.accounts.update(accountId, {
balance: account.balance - amount // Uses stale value!
});
}
}
// Two simultaneous $80 withdrawals from $100 account:
// T1: Reads balance = $100 ✓
// T2: Reads balance = $100 ✓ (race!)
// T1: balance >= 80? Yes. Update to $20
// T2: balance >= 80? Yes. Update to $20 (should have been denied!)
// Result: Two $80 withdrawals, final balance $20 (should be overdraft error)
正しいパターン
1. アトミック操作
// ✅ CORRECT: Atomic update with condition
async function withdrawMoney(accountId: string, amount: number): Promise<boolean> {
const result = await db.accounts.updateOne(
{
_id: accountId,
balance: { $gte: amount } // Check AND update atomically
},
{
$inc: { balance: -amount }
}
);
if (result.modifiedCount === 0) {
throw new InsufficientFundsError(accountId, amount);
}
return true;
}
2. データベーストランザクション
// ✅ CORRECT: Transaction with proper isolation
async function transferMoney(
fromId: string,
toId: string,
amount: number
): Promise<void> {
await db.transaction(async (tx) => {
// Lock rows with FOR UPDATE
const from = await tx.accounts
.findById(fromId)
.forUpdate(); // Locks row until commit
const to = await tx.accounts
.findById(toId)
.forUpdate();
if (from.balance < amount) {
throw new InsufficientFundsError(fromId, amount);
}
await tx.accounts.update(fromId, { balance: from.balance - amount });
await tx.accounts.update(toId, { balance: to.balance + amount });
// Commit releases locks
});
}
3. 楽観的ロック
// ✅ CORRECT: Optimistic locking with version
async function updateDocument(
id: string,
updates: Partial<Document>
): Promise<Document> {
const maxRetries = 3;
for (let attempt = 0; attempt < maxRetries; attempt++) {
const doc = await db.documents.findById(id);
const result = await db.documents.updateOne(
{
_id: id,
version: doc.version // Only update if version matches
},
{
$set: updates,
$inc: { version: 1 } // Increment version
}
);
if (result.modifiedCount > 0) {
return { ...doc, ...updates, version: doc.version + 1 };
}
// Version mismatch - someone else updated, retry
await sleep(Math.random() * 100); // Jitter
}
throw new ConcurrentModificationError(id);
}
4. 分散ロック
// ✅ CORRECT: Distributed lock for complex operations
async function processOrder(orderId: string): Promise<void> {
const lockKey = `order:${orderId}:lock`;
const lockTTL = 30000; // 30 seconds
const lock = await redis.acquireLock(lockKey, lockTTL);
if (!lock) {
throw new OrderAlreadyProcessingError(orderId);
}
try {
// Safe - only one process can be here for this order
await doExpensiveOrderProcessing(orderId);
} finally {
await redis.releaseLock(lockKey, lock);
}
}
5. 冪等性キー
// ✅ CORRECT: Idempotency prevents duplicate processing
async function createPayment(
idempotencyKey: string,
data: PaymentData
): Promise<Payment> {
// Check if already processed
const existing = await db.payments.findByIdempotencyKey(idempotencyKey);
if (existing) {
return existing; // Return previous result
}
// Try to claim the idempotency key atomically
try {
await db.idempotencyKeys.insert({
key: idempotencyKey,
status: 'processing',
createdAt: new Date(),
});
} catch (error) {
if (isDuplicateKeyError(error)) {
// Another request claimed it - fetch and return
const existing = await db.payments.findByIdempotencyKey(idempotencyKey);
if (existing) return existing;
throw new PaymentProcessingError('Payment in progress');
}
throw error;
}
// Safe to process - we own the idempotency key
const payment = await processPayment(data);
await db.idempotencyKeys.update(idempotencyKey, {
status: 'completed',
result: payment.id,
});
return payment;
}
よくある競合状態のパターン
| パターン | 問題 | 解決策 |
|---|---|---|
| 確認してから実行 | 確認と実行の間に状態が変化する | 確認と実行をアトミックに行う |
| 読み取り・変更・書き込み | 読み取り後に値が変化する | アトミックな更新またはロック |
| 遅延初期化 | 複数のスレッドが初期化する | ダブルチェックロックまたはアトミックな初期化 |
| カウンターの増分 | 更新が失われる | アトミックな増分 |
| 最初の 1 件を取得 | 複数の処理が「最初」を取得する | 一意制約を使ったアトミックな取得 |
言語固有のパターン
JavaScript/Node.js
// ❌ VIOLATION: Shared state in closure
let requestCount = 0;
async function handleRequest() {
requestCount++; // Race! Read-modify-write is NOT atomic
// ...
}
// ✅ CORRECT: Atomic counter
import { createClient } from 'redis';
const redis = createClient();
async function handleRequest() {
const count = await redis.incr('request_count'); // Atomic
// ...
}
Python
# ❌ VIOLATION: Check-then-act
def get_or_create(key: str, factory: Callable) -> Any:
if key not in cache: # Check
cache[key] = factory() # Act - race!
return cache[key]
# ✅ CORRECT: Atomic with lock
from threading import Lock
lock = Lock()
def get_or_create(key: str, factory: Callable) -> Any:
with lock:
if key not in cache:
cache[key] = factory()
return cache[key]
プレッシャーへの対処プロトコル
1. 「処理が速いから、タイミングは関係ない」
プレッシャー:「処理にかかる時間はマイクロ秒です」
**回答:**本番環境の負荷によって処理が重なります。負荷が高いと、「速い」処理も頻繁に重なります。競合状態はトラフィックに応じて増加します。
**対応:**アトミック操作を使います。速度は競合状態を防ぎません。
2. 「一度にユーザーは 1 人だけ」
プレッシャー:「トラフィックが少ないので、リクエストは同時に発生しません」
**回答:**ユーザーはダブルクリックします。タブは更新されます。ボットは大量のリクエストを送ります。モバイル端末はタイムアウト時にリトライします。「トラフィックが少ない」場合でも、一時的に集中することがあります。
**対応:**同時実行を想定していない場合でも、同時実行に対応できるよう設計します。
3. 「壊れたら修正すればいい」
プレッシャー:「まずリリースして、修正は後で」
**回答:**競合状態は再現がほぼ不可能です。「ランダムな」障害のデバッグに何週間もかかることになります。
**対応:**最初から正しく構築します。後からデバッグするよりも低コストです。
4. 「テストでは動く」
プレッシャー:「テストはすべて合格しています」
**回答:**テストは順次実行されます。本番環境では同時に実行されます。逐次実行では競合状態が表面化しません。
**対応:**並行テストを作成します。負荷テストを実施します。競合状態があるものと考えます。
危険信号 — 作業を止めて再検討
次のパターンが 1 つでもあれば、競合状態が発生している可能性があります。
if (condition) { update based on condition }read(); compute(); write(computed);check availability; book;get count; increment; save count;- グローバル状態または共有される可変状態
- 「ほとんどの場合は動く」
- 「開発環境では再現できない」
- 断続的なバグを「解消する」タイムアウト
これらはすべて、アトミック性を保証する必要があることを示しています。
競合状態のテスト
// Concurrent test to expose races
describe('withdraw', () => {
it('handles concurrent withdrawals correctly', async () => {
await db.accounts.create({ id: 'test', balance: 100 });
// Simulate 10 concurrent $20 withdrawals
const withdrawals = Array(10).fill(null).map(() =>
withdrawMoney('test', 20).catch(() => 'failed')
);
const results = await Promise.all(withdrawals);
const successful = results.filter(r => r !== 'failed').length;
// Only 5 should succeed (5 × $20 = $100)
expect(successful).toBe(5);
const account = await db.accounts.findById('test');
expect(account.balance).toBe(0);
});
});
よくある自己正当化(すべて誤り)
| 言い訳 | 実態 |
|---|---|
| 「十分に速い」 | 速い処理でも、負荷が高いと重なります。 |
| 「トラフィックが少ない」 | リトライ、ダブルクリック、ボットによって同時実行が発生します。 |
| 「開発環境では動く」 | 開発環境では逐次実行されます。本番環境では並行実行されます。 |
| 「壊れたら修正する」 | 競合状態のバグは見つけるのが困難です。今すぐ修正します。 |
| 「スリープを追加すればいい」 | スリープは競合状態を修正せず、隠すだけです。 |
| 「ユーザーはそんなことをしない」 | ユーザーは、あなたが想定しないことを何でもします。 |
クイックリファレンス
| シナリオ | 解決策 |
|---|---|
| 更新前の残高確認 | アトミックな条件付き更新 |
| カウンターのインクリメント | アトミックなインクリメント(Redis INCR、SQL += 1) |
| 複雑な複数ステップの操作 | ロックを使用したデータベーストランザクション |
| サービス間の操作 | 分散ロック |
| リクエストの重複防止 | 冪等性キー |
| バージョンの競合 | 楽観的ロック |
要点
正確性がタイミングに左右されるなら、それはバグです。
読み取り後の書き込みには競合が発生します。確認後の実行にも競合が発生します。状態を確認してから操作するまでの間には、どこであれ競合が発生します。アトミック操作、トランザクション、ロック、または冪等性キーを使用してください。「十分に速い」や「トラフィックが少ない」と決めつけてはいけません。