Phần 9 cho thấy đua dữ liệu đỏ 91% trên 10 CPU, 0% trên 1 CPU, và -race biến nó thành 100%. Kết luận dễ rút ra là "bật -race là xong". Phần này đo xem kết luận đó đúng tới đâu, bằng ba lỗi đồng thời có bản chất khác nhau: một đua dữ liệu thật, một đua logic mà mọi truy cập đều có khoá đúng, và một deadlock do thứ tự khoá. Câu hỏi: công cụ nào thấy lỗi nào, lỗi không thấy được thì lộ với xác suất bao nhiêu, và làm gì để xác suất đó lên 100%.
Ba lỗi, ba bản chất

Hình 1: Đua dữ liệu, đua logic và deadlock. Biến yield chỉ tồn tại để test chèn một điểm nhường CPU vào đúng cửa sổ nguy hiểm.
Ba lỗi trong một gói:
- Đua dữ liệu:
Counter.Inctăngc.Nkhông mutex, 100 goroutine gọi cùng lúc. Hai truy cập không đồng bộ vào cùng ô nhớ — đúng định nghĩa thứ bộ dò đua tìm. - Đua logic (check-then-act):
Account.WithdrawgọiBalance()— có khoá — để kiểm đủ tiền, rồi khoá lại để trừ. Mỗi truy cập đều được bảo vệ, không có đua dữ liệu, nhưng giữa hai bước goroutine khác có thể đã trừ. Test cho 50 goroutine cùng rút 10 từ 100 và mong đúng 10 lần thành công, số dư không âm. - Deadlock:
Transfer(from, to)khoáfromrồito. Hai goroutine chuyển ngược chiều 200 lần mỗi bên: A giữ x chờ y, B giữ y chờ x.
Biến yield (bật bằng YIELD=1) chèn runtime.Gosched() vào đúng cửa sổ giữa kiểm và trừ, và giữa hai lần khoá. Đây là một kỹ thuật thật: điểm nhường chỉ có trong build test, để ép lịch chạy đi qua chỗ hẹp mà bình thường nó lướt qua.
Đo thật: ai thấy gì, lộ bao nhiêu phần trăm

Hình 2: Bộ dò đua thấy đúng một lỗi. Hai lỗi kia không lộ trong 1.000 lượt chạy thường và lộ 100% khi có điểm nhường.
| lỗi | -race |
chạy thường 10 CPU | 1 CPU | YIELD=1 |
sửa |
|---|---|---|---|---|---|
| đua dữ liệu | 20/20, 2 dòng DATA RACE | 91% (phần 9) | 0% | — | 0/100 |
| đua logic | 0 báo cáo; test đỏ 5/20 | 0/100, 0/1000 | 0/100 | 100/100 | 0/100 |
| deadlock | — | 0/20 kẹt | — | 20/20 kẹt | 20/20 xong |
-race thấy đúng một lỗi, và chỉ lỗi đó. TestCounterRace đỏ 20/20 kèm hai dòng WARNING: DATA RACE trỏ thẳng vào c.N++. TestNoOverdraft dưới -race cũng đỏ 5/20 — nhưng không một báo cáo đua nào nhắc tới Withdraw. Năm lần đỏ đó là do -race làm chương trình chậm 5–10 lần và đổi lịch chạy, khiến cửa sổ giữa kiểm và trừ rộng ra đủ để lỗi logic lộ theo cách thông thường: số dư âm, test fail. Bộ dò không "bắt" nó; nó chỉ tình cờ tạo điều kiện. Đua logic không phải đua dữ liệu — mọi truy cập đều có khoá, bộ dò không có gì để báo.
Đua logic: 0/1000 trên 10 CPU. Chạy thường, cửa sổ giữa Balance() trả về và Lock() tiếp theo là vài chục nano-giây; 50 goroutine không kịp chen vào trong 100 lượt, cũng không trong 1.000. Trên 1 CPU: 0/100 — goroutine không bị tước CPU giữa hai lệnh kề nhau. Test này xanh hàng tháng trên CI và lỗi vẫn ở đó, chờ một máy chậm hơn hoặc một lần GC đúng chỗ. Chèn một dòng runtime.Gosched() vào cửa sổ: đỏ 100/100, kể cả trên 1 CPU. Điểm nhường không tạo ra lỗi; nó chỉ bắt lịch chạy đi qua chỗ lỗi đã nằm sẵn.
Chạy nhiều hơn không thay cho đổi xác suất. Với p = 26% (đo trên 100 lượt -race), công thức 1−(1−p)^N khớp số đo: 5 lượt bắt 78–80%, 10 lượt 90–95%, 20 lượt gần 100%. Nhưng công thức chỉ có ích khi p khác 0: với p đo được ở chạy thường — 0/1.000 — không N nào đủ. Lời khuyên "chạy test đồng thời 100 lần trong CI" chữa được giòn có p vài phần trăm; với lỗi có cửa sổ hẹp, việc cần làm là đổi p (điểm nhường, -race, tải nền) chứ không phải đổi N.
Deadlock: không kẹt lần nào, rồi kẹt 20/20. 400 chuyển khoản ngược chiều ở 10 CPU: hoàn thành 20/20 lượt, vì mỗi lần giữ khoá chỉ vài chục nano-giây và hai goroutine hiếm khi cùng vào. Với điểm nhường giữa hai lần khoá: kẹt 20/20. Và một chi tiết đáng ghi: runtime Go không báo "all goroutines are asleep - deadlock!" lần nào — vì trong go test, goroutine của framework kiểm thử vẫn sống (goroutine 1 ở chan receive), nên theo runtime thì chương trình chưa kẹt hoàn toàn. Thứ cứu bạn là -timeout: hết 2 giây, test bị giết và goroutine dump in ra goroutine 7 [sync.Mutex.Lock]: t10.Transfer(...) — đúng hàm, đúng trạng thái. Không đặt -timeout thì CI treo tới khi job bị huỷ, không có dump.
Bản sửa: 0/100 với -race -shuffle=on, deadlock test xong 20/20. Withdraw kiểm và trừ trong cùng một lần giữ khoá; Transfer sắp thứ tự khoá theo địa chỉ trước khi Lock. Cả hai đều là sửa ở mã sản phẩm — và cả hai đều không thể kiểm chứng bằng test thường ở 10 CPU, vì bản lỗi cũng xanh ở đó.
Đánh đổi cần cân nhắc
Bộ dò đua trả lời một câu, chắc chắn, và chỉ một câu. "Có hai truy cập không đồng bộ vào cùng ô nhớ không?" — có thì báo, kể cả khi lỗi chưa gây hậu quả. Nó không biết gì về bất biến nghiệp vụ (số dư không âm), về thứ tự khoá, về kênh bị bỏ quên. Bật -race mặc định trong CI là đúng; coi -race xanh là "đồng thời đã an toàn" là sai.
Điểm nhường là mã test lẫn vào mã sản phẩm — có giá. if yield { runtime.Gosched() } là một nhánh chết trong production và một chỗ người đọc phải hiểu. Cách sạch hơn: tách phần "check" và "act" thành hàm riêng rồi test gọi chúng xen kẽ có chủ ý; hoặc dùng bất biến thay vì lịch chạy — như Withdraw sửa xong thì không còn cửa sổ nào để chèn. Dù cách nào, điểm quan trọng là test phải điều khiển được lịch chạy, không phải cầu may vào scheduler.
-timeout là bắt buộc cho mọi gói có goroutine. Mặc định 10 phút của go test là quá dài cho CI; đặt theo gói, vài giây cho unit. Deadlock không tự báo trong test; timeout cộng dump là cách duy nhất nó lộ ra có địa chỉ.
Phạm vi đo. Ba lỗi cấy vào, cửa sổ cực hẹp, 10 CPU; tỉ lệ 0/1.000 là của máy này và cửa sổ này. Mã thật có cửa sổ rộng hơn (I/O giữa kiểm và trừ) sẽ lộ sớm hơn — và đó chính là lý do lỗi kiểu này hay xuất hiện ở production có độ trễ mạng chứ không ở test.
Ba ý mang về
-racechắc chắn với đua dữ liệu (20/20) và mù với đua logic (0 báo cáo): khoá đúng từng truy cập không làm hai bước thành một. Bật-racemặc định, nhưng đừng đọc xanh là an toàn.- Lỗi cửa sổ hẹp không lộ bằng chạy nhiều (0/1.000) mà lộ bằng đổi xác suất: một
runtime.Gosched()đặt đúng chỗ cho 100/100; công thức 1−(1−p)^N chỉ có nghĩa khi p > 0. - Runtime Go không báo deadlock trong
go test: luôn đặt-timeoutngắn, và đọc goroutine dump — nó chỉ đúng hàm đang giữ khoá.
Nguồn
- Go — Data Race Detector: https://go.dev/doc/articles/race_detector
- Go — The Go Memory Model: https://go.dev/ref/mem
- Dmitry Vyukov — ThreadSanitizer: algorithm và giới hạn (tài liệu TSan): https://github.com/google/sanitizers/wiki/ThreadSanitizerAlgorithm
- Go —
runtime.Gosched,-cpu,-timeouttrongtesting: https://pkg.go.dev/testing - Tu, Liu, Song, Zhang — Understanding Real-World Concurrency Bugs in Go (ASPLOS 2019): https://songlh.github.io/paper/go-study.pdf
Phần sau đưa lỗi từ bên ngoài vào: cắt mạng, làm chậm, đóng kết nối giữa chừng bằng Toxiproxy và tc netem, để kiểm xem retry, timeout và circuit breaker của bạn có làm đúng điều chúng hứa.