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

Ảnh chụp đoạn mã nền tối kiểm thử mã đồng thời ba lỗi ba bản chất khác nhau với Go 1.23 race count cpu 1 timeout YIELD 1 chèn runtime.Gosched vào cửa sổ đua chỉ build test FIX 1 bật bản sửa, 1 đua dữ liệu hai goroutine ghi cùng ô nhớ không đồng bộ bộ dò nhìn thấy gồm func Inc c.N cộng cộng 100 goroutine race WARNING DATA RACE, 2 đua logic check-then-act mọi truy cập đều có mutex không có đua dữ liệu vẫn sai gồm func Withdraw amt nếu a.Balance lớn hơn bằng amt là kiểm với Balance tự khoá mở, nếu yield thì runtime.Gosched là điểm nhường phóng to cửa sổ giữa kiểm và trừ, a.mu.Lock a.bal trừ amt a.mu.Unlock là trừ nhưng người khác đã trừ trước số dư âm, return true, ghi chú race im lặng vì từng truy cập đều được khoá đúng lỗi nằm ở việc hai bước không là một, 3 deadlock khoá theo thứ tự tham số nên Transfer x y và Transfer y x kẹt nhau gồm from.mu.Lock nếu yield thì Gosched rồi to.mu.Lock là A giữ x chờ y B giữ y chờ x, sửa sắp thứ tự khoá toàn cục theo địa chỉ trước khi Lock, cách đo gồm go test race count 20 bộ dò thấy test nào, go test count 100 hoặc cpu 1 hoặc YIELD 1 hoặc race cho xác suất TestNoOverdraft đỏ theo cách chạy, chia 100 lượt thành lô N để đo P ít nhất 1 đỏ so với 1 trừ 1 trừ p mũ N, 20 tiến trình timeout 2s cho TestTransferBothWays xem runtime báo deadlock hết timeout hay chạy xong

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:

  1. Đua dữ liệu: Counter.Inc tăng c.N khô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.
  2. Đua logic (check-then-act): Account.Withdraw gọi Balance() — 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.
  3. Deadlock: Transfer(from, to) khoá from rồi to. 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

Ảnh chụp output thật nền tối race thấy 1 trên 3 lỗi hai lỗi kia 0 trên 1000 lần lộ trừ khi ép với go-lab 10 CPU TestCounterRace TestNoOverdraft TestTransferBothWays bản sửa FIX 1, A bộ dò đua count 20 gồm TestCounterRace đỏ 20 trên 20 WARNING DATA RACE 2 dòng chỉ vào c.N cộng cộng, TestNoOverdraft đỏ 5 trên 20 race report nhắc tới Withdraw 0 vì đua logic không phải đua dữ liệu và 5 lần đỏ là do race làm chậm đổi lịch chạy không phải do bộ dò, B đua logic TestNoOverdraft xác suất lộ theo cách chạy gồm mặc định 10 CPU count 100 đỏ 0 trên 100 count 1000 đỏ 0 trên 1000 vì cửa sổ giữa kiểm và trừ quá hẹp, cpu 1 đỏ 0 trên 100, race 20 đỏ 3 tới 5 trên 20 là 15 tới 25 phần trăm, YIELD 1 Gosched trong cửa sổ đỏ 100 trên 100 một dòng nhường CPU lộ chắc chắn, YIELD 1 cpu 1 đỏ 100 trên 100, C bắt được ít nhất một lần trong N lượt với p 26 phần trăm đo từ 100 lượt race gồm N 1 26,0 đo 26,0, N 5 77,8 đo 80,0, N 10 95,1 đo 90,0, N 20 99,8 đo 100, với p 0,26 cần khoảng 10 lượt để 95 phần trăm với p đo ở B 0 trên 1000 không N nào đủ phải đổi p không đổi N, D deadlock TestTransferBothWays 20 tiến trình riêng timeout 2s gồm mặc định runtime báo all goroutines are asleep 0 trên 20 hết timeout 0 trên 20 chạy xong 20 trên 20 vì 400 chuyển khoản không kẹt lần nào, YIELD 1 runtime báo deadlock 0 trên 20 hết timeout 20 trên 20 chạy xong 0 trên 20, stack lúc kẹt goroutine 7 sync.Mutex.Lock t10.Transfer goroutine 1 chan receive, runtime không báo deadlock trong go test vì goroutine của testing còn sống chỉ timeout và goroutine dump chỉ ra chỗ kẹt, E bản sửa FIX 1 race shuffle on count 100 gồm TestCounterRace 0 trên 100 TestNoOverdraft 0 trên 100 kiểm và trừ trong cùng một lần giữ khoá TestTransferBothWays 20 trên 20 xong khoá theo thứ tự địa chỉ, nói thẳng race là công cụ duy nhất cho câu trả lời chắc chắn và nó chỉ trả lời một câu có truy cập không đồng bộ không đua logic và deadlock ở 10 CPU lộ 0 trên 1000 lần thứ làm chúng lộ không phải chạy nhiều hơn mà là một điểm nhường CPU đặt đúng chỗ

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ề

  1. -race chắ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 -race mặc định, nhưng đừng đọc xanh là an toàn.
  2. 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.
  3. Runtime Go không báo deadlock trong go test: luôn đặt -timeout ngắn, và đọc goroutine dump — nó chỉ đúng hàm đang giữ khoá.

Nguồn

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.