Class bug cổ điển của hệ thống file / OS: TOCTOU — time-of-check to time-of-use. Bạn kiểm tra điều kiện, rồi mới dùng kết quả kiểm tra đó; giữa hai bước thế giới đã đổi.
Trong JS single-thread, người ta hay nghĩ “không race”. Sai chỗ:
mỗi await là điểm nhường — task khác
(request khác, job khác, click khác) có thể chạy logic tương tự trên
cùng resource. Không cần hai CPU; chỉ cần hai async flow xen kẽ.
Demo: TOCTOU — hai request “check balance rồi trừ”.
Pattern hỏng
async function withdraw(userId, amount) {
const bal = await db.getBalance(userId); // CHECK
if (bal < amount) throw new Error("insufficient");
// ← await khác có thể xen vào đây
await db.setBalance(userId, bal - amount); // USE (dựa trên bal cũ)
}
Hai request song song, balance = 100, mỗi cái rút 80:
A: get → 100, ok
B: get → 100, ok
A: set 100-80 = 20
B: set 100-80 = 20 // cả hai “thành công”, đã rút 160 từ 100
Cùng họ với:
if (!await exists(id)) await create(id)→ duplicateif (!inflight.has(k)) { ... set }không atomic với async work (single-flight đúng phải set promise trước await work — xem single-flight)- Idempotency “get key miss rồi charge” không lock — double charge
- Coupon “còn lượt?” rồi redeem — oversell
Vì sao single-thread vẫn dính
Event loop không chạy hai hàm đồng thời trên một call stack — nhưng
sau await db.getBalance, continuation của A bị xếp
hàng; B có thể chạy hết check của mình trước khi A resume
setBalance. Đó là
interleaving trên shared state, không phải data
race memory kiểu C++.
// “An toàn” giả: không await giữa check và act…
// …nhưng I/O bắt buộc phải await. Vấn đề nằm ở shared mutable state + yield.
Cách chữa (từ rẻ đến chắc)
1. Atomic operation ở store
// SQL
UPDATE accounts SET balance = balance - $1
WHERE id = $2 AND balance >= $1;
-- rowCount === 0 → insufficient hoặc conflict
// Redis
// Lua / MULTI: GET + điều kiện + DECRBY một atomic unit
Check và use gộp một vòng phía engine — không mang
bal về app rồi tính.
2. Optimistic concurrency (version / ETag)
async function withdraw(userId, amount) {
for (let i = 0; i < 5; i++) {
const row = await db.get(userId); // { balance, version }
if (row.balance < amount) throw insufficient;
const ok = await db.updateWhere(
userId,
{ balance: row.balance - amount, version: row.version + 1 },
{ version: row.version } // CAS
);
if (ok) return;
// conflict → đọc lại
}
throw new Error("conflict");
}
Ai ghi sau với version cũ fail → retry. Đúng mental model compare-and-swap.
3. Pessimistic lock
BEGIN;
SELECT balance FROM accounts WHERE id = $1 FOR UPDATE;
-- check + update trong transaction
COMMIT;
Đơn giản về correctness; dễ contention; timeout lock cần nghĩ.
4. Unique constraint / “insert claim”
// thay vì if (!exists) create
INSERT INTO redemptions (coupon_id, user_id) VALUES ($1, $2);
// unique(coupon_id, user_id) → lần 2 bị 23505 → coi như đã redeem
Rất hợp idempotency record: “claim key” bằng insert unique, không phải check-then-insert.
In-process: mutex async
Khi state chỉ trong một process (rate limit memory, in-memory wallet demo):
const locks = new Map(); // key → Promise chain
function withLock(key, fn) {
const prev = locks.get(key) || Promise.resolve();
let release;
const gate = new Promise((r) => { release = r; });
const next = prev.then(() => gate);
locks.set(key, next);
return prev.then(fn).finally(() => {
release();
if (locks.get(key) === next) locks.delete(key);
});
}
await withLock(userId, () => withdrawUnsafe(userId, amount));
Chỉ gộp trong một process — multi-instance vẫn cần DB/Redis lock. Giống bài học single-flight local vs distributed.
Checklist review PR
- Có
awaitgiữa “đọc quyết định” và “ghi side-effect”? - Hai request cùng resource có thể chồng không?
- Fail path có để state nửa vời không?
- Test có case song song (hai Promise cùng lúc) không — test tuần tự không bắt được TOCTOU.
Một câu để nhớ
if (await check) await act không atomic.
Mỗi await là khe hở. Gộp check+act vào một primitive atomic, CAS +
retry, lock, hoặc unique claim — đừng tin “mình vừa đọc xong thì
vẫn đúng”.