Bài 34 cho thấy lỗi đua khó phát hiện tới mức nào: ba lần chạy, ba kết quả, và có lần ra đúng. Hình dung race detector như một người giám sát đứng xem ca làm thật ở một bàn làm việc chung, với camera tua lại được — chứ không phải ngồi đọc sơ đồ xưởng. Nó bắt đúng khoảnh khắc hai người cùng với tay vào một chi tiết mà không ai ra hiệu trước. Go có công cụ giải quyết đúng vấn đề đó.

Bật

go run -race main.go
go test -race ./...
go build -race

Và nó chỉ thẳng ra chỗ hỏng:

  WARNING: DATA RACE
  Read at 0x00c000180028 by goroutine 16:
    main.main.func1()
        /g/b33/main.go:67 +0x94

  Previous write at 0x00c000180028 by goroutine 9:
    main.main.func1()
        /g/b33/main.go:67 +0xa4

Địa chỉ bộ nhớ, hai goroutine liên quan, và số dòng của cả lần đọc lẫn lần ghi — như đoạn camera ghi lại đúng giây, đúng chỗ, đúng hai người.

Đây là thứ mà bài 96 sê-ri Java phải dựng cả một bộ khung mới có được — trong Go nó là một cờ dòng lệnh.

Chi phí

  không -race : 163 ms   | binary 2,1 MB
  có -race    : 972 ms   | binary 2,7 MB

Chậm khoảng sáu lần, binary lớn hơn 30%. Tài liệu chính thức nói chậm 2–20 lần và tốn 5–10 lần bộ nhớ, tuỳ tải.

Nên: đừng bật trong sản xuất, nhưng luôn bật trong test và CI.

Nó hoạt động ra sao

Race detector không phân tích tĩnh — nó theo dõi lúc chạy. Với mỗi truy cập bộ nhớ, nó ghi lại goroutine nào đọc, goroutine nào ghi, và quan hệ xảy-ra-trước giữa chúng.

Khi phát hiện hai truy cập tới cùng địa chỉ mà ít nhất một là ghi, và không có quan hệ đồng bộ giữa chúng, nó báo.

Hệ quả quan trọng nhất từ cách hoạt động này:

Nó chỉ bắt được lỗi đua trên đường mã thật sự CHẠY. Không chạy qua đoạn đó thì không phát hiện được gì. Nó không chứng minh chương trình của bạn không có đua — nó chỉ chứng minh những gì vừa chạy là sạch.

Camera chỉ quay được lối đi nó phủ tới và ca làm thật sự diễn ra; một cú va chạm ở lối không có camera, hay trong ca không ai làm, là vô hình. Đây là lý do phải chạy -race với bộ test có độ phủ tốt, và tốt nhất là dưới tải giống thật.

Những gì nó không bắt

Lỗi logic đồng thời không phải đua bộ nhớ. Deadlock, goroutine rò rỉ, thứ tự sai — nó không quan tâm.

Đua qua hệ thống bên ngoài — hai tiến trình cùng ghi một tệp, hai bản sao dịch vụ cùng cập nhật một dòng CSDL.

Đường mã hiếm. Nhánh lỗi, xử lý hết giờ, mã chỉ chạy khi hệ thống quá tải.

Nó cũng không có dương tính giả: mọi cảnh báo nó đưa ra đều là lỗi đua thật. Camera này không bao giờ la làng nhầm — nên đừng bao giờ bỏ qua một cảnh báo vì nghĩ "chắc nó nhầm".

Đưa vào CI

- run: go test -race ./...

Một dòng, và nó bắt được cả một lớp lỗi mà test thường bỏ sót.

Nếu bộ test chạy quá lâu với -race, hãy tách: test nhanh chạy mọi lần push, test có -race chạy trên nhánh chính hoặc theo lịch. Nhưng đừng bỏ hẳn.

Một mẹo: -race kết hợp với -count=N chạy lại test nhiều lần, tăng cơ hội trúng đường mã hiếm:

go test -race -count=10 ./...

Sửa lỗi đua

Khi nhận được cảnh báo, có bốn cách theo thứ tự nên thử:

Không chia sẻ. Mỗi goroutine có bản sao riêng, gộp kết quả ở cuối. Đây là cách tốt nhất khi làm được.

Chuyển giao quyền sở hữu qua channel. Chỉ một goroutine chạm vào dữ liệu tại một thời điểm.

Khoá — Mutex như bài 34.

atomic cho thao tác đơn giản trên một biến.

Điều không nên làm: thêm time.Sleep để "tránh" đua. Nó chỉ làm lỗi hiếm hơn, và biến một lỗi tái hiện được thành một lỗi không tài nào tái hiện.

Cái bẫy phổ biến nhất: biến bị bắt trong closure

for _, v := range ds {
	go func() { xuLy(v) }()      // trước Go 1.22: đua trên v
}

Bài 6 đã đo: Go 1.22 sửa ngữ nghĩa biến vòng lặp, nên đoạn này giờ đúng. Nhưng nếu go.mod của bạn khai dưới go 1.22, nó vẫn là lỗi đua — và race detector sẽ bắt được.

Đây là ví dụ tốt cho thấy vì sao nên kiểm dòng go trong go.mod.

Nếu chỉ chạy một thứ sau bài này, hỏi thẳng bộ test xem nó đang chạy xanh bên cạnh lỗi nào, trong ba mươi giây:

go test -race ./... 2>&1 | grep -c "DATA RACE"

Con số lớn hơn 0 nghĩa là bạn vừa tìm ra những lỗi mà bộ test hiện tại đang chạy xanh bên cạnh. Và như bài 34 đã đo, chúng có thể đã sai kết quả trong sản xuất suốt thời gian qua mà không ai biết.

Mẫu số chung

Race detector là một phân tích động: nó xem một lần chạy thật, nên nó đúng nhưng không đủ — mọi cảnh báo đều là đua thật (không dương tính giả), nhưng một lần chạy sạch chỉ chứng nhận những đường mã bạn đã đi qua, không bao giờ chứng nhận cả chương trình. Đây là cái đánh đổi định nghĩa của mọi công cụ kiểm lúc chạy so với phân tích tĩnh, và detector của Go chính là ThreadSanitizer — cùng cỗ máy đứng sau -fsanitize=thread của C/C++/Rust; Valgrind/Helgrind và jcstress của Java cùng một họ. Bản năng phải có: "không có cảnh báo" không phải là "không có lỗi" — nên hãy chạy dưới độ phủ tốt và ép lặp (-count), và đừng bao giờ đọc một lần chạy động màu xanh như bằng chứng đúng đắn.

Điều thứ hai đáng khắc sâu: thế nào là một lỗi đua thì ở đâu cũng định nghĩa giống nhau, bằng quan hệ xảy-ra-trước — hai truy cập tới cùng một ô nhớ, ít nhất một là ghi, và không có đồng bộ nào sắp thứ tự chúng. Đó đúng là định nghĩa trong mô hình bộ nhớ của C++, Java và Rust, không phải nét riêng của Go. Nên thuốc chữa luôn là tạo một cạnh xảy-ra-trước — đừng chia sẻ, chuyển giao quyền sở hữu, khoá, hoặc atomic — và không bao giờ bằng time.Sleep, thứ chẳng tạo ra thứ tự nào mà chỉ biến một lỗi tái hiện được thành một con bọ Heisenberg không tài nào bắt lại.

Ngày mai: ba mẫu đồng thời dùng nhiều nhất — fan-out, fan-in và pipeline.

Bài tập làm thử

Bài 1 (đọc hiểu). Race detector báo cảnh báo sau. Giải thích ý nghĩa của từng phần thông tin trong đó:

WARNING: DATA RACE
Read at 0x00c000180028 by goroutine 16:
  main.main.func1()
      /g/b33/main.go:67 +0x94

Previous write at 0x00c000180028 by goroutine 9:
  main.main.func1()
      /g/b33/main.go:67 +0xa4
Đáp án

Cảnh báo cho biết: có hai goroutine (16 và 9) cùng truy cập một địa chỉ bộ nhớ (0x00c000180028) — goroutine 9 ghi vào đó, goroutine 16 đọc từ đó — mà không có quan hệ đồng bộ (xảy-ra-trước) nào đảm bảo thứ tự giữa hai thao tác này. Cả hai truy cập đều xảy ra ở cùng dòng 67 của main.go (thường là một closure dùng chung biến trong vòng lặp go func(){...}()). Vì race detector không có dương tính giả, đây chắc chắn là một lỗi đua thật.

Bài 2 (sửa lỗi). Đoạn mã sau bị race detector báo lỗi đua trên biến vòng lặp (dự án khai go 1.21 trong go.mod). Sửa lỗi mà không đổi phiên bản Go khai trong go.mod:

for _, v := range ds {
	go func() { xuLy(v) }()
}
Đáp án

Với go.mod khai dưới go 1.22, biến vòng lặp v được chia sẻ giữa các lần lặp, nên nhiều goroutine có thể cùng đọc/ghi nó trong lúc vòng lặp vẫn đang chạy tiếp — gây đua. Sửa bằng cách tạo bản sao cục bộ trong mỗi lần lặp:

for _, v := range ds {
	v := v // bản sao riêng cho mỗi goroutine
	go func() { xuLy(v) }()
}

Hoặc truyền v làm tham số của closure:

for _, v := range ds {
	go func(v int) { xuLy(v) }(v)
}

(Nếu nâng go.mod lên go 1.22 trở lên, Go tự sửa ngữ nghĩa biến vòng lặp và đoạn gốc trở nên đúng — nhưng đề bài yêu cầu không đổi phiên bản khai báo.)

Bài 3 (vận dụng). Viết dòng lệnh CI (GitHub Actions) chạy toàn bộ test với race detector bật, và giải thích vì sao bài viết khuyên "luôn bật trong test và CI" nhưng "đừng bật trong sản xuất".

Đáp án
- run: go test -race ./...

Race detector làm chương trình chậm khoảng 6 lần (theo số liệu bài viết: 163ms → 972ms) và tăng kích thước binary khoảng 30%, cùng với tăng đáng kể mức dùng bộ nhớ (tài liệu chính thức: 5–10 lần). Mức chi phí đó chấp nhận được trong test/CI (chạy một lần, không phục vụ traffic thật) nhưng không chấp nhận được trong sản xuất, nơi hiệu năng và tài nguyên ảnh hưởng trực tiếp tới người dùng và chi phí vận hành.

Bài 4 (bẫy/đánh đổi). Một lập trình viên chạy go test -race ./... một lần, thấy xanh, và kết luận "chương trình của tôi không có lỗi đua". Bài viết nói đây là kết luận sai. Giải thích vì sao, và nêu hai cách cải thiện độ tin cậy của kết luận.

Đáp án

Race detector không phân tích tĩnh — nó chỉ theo dõi lúc chạy. Nó chỉ bắt được lỗi đua trên đường mã thật sự chạy trong lần test đó. Một lần chạy xanh chỉ chứng minh những gì vừa chạy là sạch, không chứng minh cả chương trình không có đua — nhánh lỗi hiếm gặp, đường mã chỉ chạy khi hệ thống quá tải, hay ca hiếm khác đều có thể không được chạm tới. Hai cách cải thiện: (1) chạy với bộ test có độ phủ tốt, tốt nhất dưới tải giống thật; (2) dùng go test -race -count=10 ./... để lặp lại nhiều lần, tăng cơ hội trúng đường mã hiếm.

Bài 5 (đọc hiểu số liệu/khái niệm). Race detector được mô tả là "không bao giờ báo nhầm" (không dương tính giả). Giải thích ý nghĩa thực tế của tính chất này đối với hành động của lập trình viên khi nhận được cảnh báo DATA RACE.

Đáp án

Vì mọi cảnh báo đều là lỗi đua thật (theo đúng định nghĩa "hai truy cập cùng ô nhớ, ít nhất một là ghi, không có quan hệ xảy-ra-trước"), lập trình viên không bao giờ nên bỏ qua một cảnh báo với suy nghĩ "chắc nó báo nhầm" — điều đó không xảy ra với race detector của Go. Mọi cảnh báo đều cần được sửa bằng một trong bốn cách: không chia sẻ dữ liệu, chuyển giao quyền sở hữu qua channel, dùng Mutex, hoặc dùng atomic. Đặc biệt không được "sửa" bằng cách thêm time.Sleep — nó chỉ làm lỗi hiếm gặp hơn chứ không loại bỏ, biến một lỗi tái hiện được thành lỗi Heisenberg khó bắt lại.