Bạn viết data = 42 ở một goroutine, rồi đọc data ở goroutine khác. Câu hỏi tưởng đơn giản: goroutine kia có thấy 42 không? Câu trả lời của Go memory model gây bất ngờ: nếu không có quan hệ happens-before giữa hai truy cập, đó là data race — và data race là hành vi không xác định, không phải "đọc được giá trị cũ". Bài này mổ xẻ happens-before, đo thật bằng race detector, và đo chi phí các cách thiết lập nó.
Data race: sai, không phải "có thể sai"
Xét đoạn code kinh điển "báo hiệu bằng cờ boolean":
var data int
done := false
go func() {
data = 42
done = true // KHÔNG thiết lập happens-before
}()
for !done {} // đọc done không đồng bộ → DATA RACE
fmt.Println(data) // có thể in 0, treo mãi, hoặc bất kỳ điều gì
Nhiều người nghĩ "tệ nhất là đọc giá trị cũ của data". Sai. Trong Go, data race là hành vi không xác định — chương trình được phép sai theo bất kỳ cách nào. Compiler có thể tối ưu for !done {} thành vòng lặp vô hạn (vì trong một goroutine, done không đổi), CPU có thể sắp xếp lại data=42 và done=true, cache có thể giữ giá trị cũ vô thời hạn. Không có bảo đảm nào cả.

Hình 1: Data race và cách sửa. Không happens-before → hành vi không xác định. Channel close→receive thiết lập happens-before; năm quy tắc chính và race detector.
Sửa: channel thiết lập happens-before
Thay cờ boolean bằng channel, quan hệ happens-before được thiết lập:
var data int
done := make(chan struct{})
go func() {
data = 42
close(done) // đóng: mọi ghi TRƯỚC nó...
}()
<-done // ...nhìn thấy được SAU khi nhận
fmt.Println(data) // LUÔN in 42 — an toàn
Quy tắc channel: một close(done) (hoặc send) happens-before việc <-done (receive) hoàn tất. Vì data = 42 happens-before close(done) (cùng goroutine, thứ tự chương trình), và close happens-before <-done, nên bắc cầu: data = 42 happens-before fmt.Println(data). Kết quả luôn là 42.
Năm quy tắc happens-before chính
Go memory model cho các bảo đảm này:
- Trong một goroutine: theo đúng thứ tự chương trình.
go f(): câu lệnhgohappens-before khifbắt đầu chạy (nênfthấy mọi ghi trướcgo).- Channel: một send happens-before receive tương ứng hoàn tất;
close(ch)happens-before một receive trả về giá trị zero. - Mutex:
Unlockhappens-beforeLocklần sau (nên vùng critical section sau thấy ghi của vùng trước). sync.Once:f()trongonce.Do(f)hoàn tất happens-before mọionce.Dotrả về.
Bất kỳ hai truy cập tới cùng biến ở hai goroutine mà không có một trong các quan hệ này (và ít nhất một là ghi) đều là data race.
Đo thật: race detector và chi phí
Chạy bản không đồng bộ với -race:

Hình 2: -race bắt 2 data race ở bản không đồng bộ (cờ done + biến data); bản channel sạch và luôn in 42. Chi phí: mutex 1,54 ns, atomic 1,61 ns, channel 14,55 ns, không đồng bộ 0,23 ns.
- Bản không đồng bộ:
-racebáoFound 2 data race(s)— cảdonelẫndatabị đọc/ghi không đồng bộ. - Bản channel:
-racesạch, chạy 3 lần đều in 42.
Chi phí thiết lập happens-before (không tranh chấp):
- Không đồng bộ (baseline): 0,226 ns — nhưng không an toàn khi chia sẻ.
- Mutex (Lock/Unlock): 1,540 ns.
- Atomic (AddInt64): 1,608 ns — tương đương mutex ở đây.
- Channel (send+recv): 14,55 ns — ~9x mutex.
Happens-before không miễn phí. Mutex/atomic rẻ (~1,5 ns khi không tranh chấp); channel đắt hơn nhiều (~14,5 ns) vì làm nhiều việc hơn: đồng bộ, truyền dữ liệu, và có thể đánh thức goroutine đang chờ.
Ứng dụng thực tế
Luôn chạy test với -race. Race detector bắt data race lúc chạy trên đường code thực thi. Nó chậm ~5-10x nên không dùng production, nhưng bật trong test và CI là bắt buộc — data race là loại bug nguy hiểm nhất trong code đồng thời, khó tái hiện, chỉ hiện ra ở tải cao trên production.
Chọn nguyên thủy theo nhu cầu, không theo thói quen. Nếu chỉ cần bảo vệ một biến đếm, atomic hoặc mutex (~1,5 ns) rẻ hơn nhiều channel (~14,5 ns). Channel đáng khi bạn cần truyền dữ liệu hoặc điều phối giữa goroutine, không phải chỉ để bảo vệ một biến. "Đừng giao tiếp bằng chia sẻ bộ nhớ" đúng, nhưng chia sẻ có kiểm soát bằng mutex cũng hoàn toàn hợp lệ và thường rẻ hơn.
Cờ boolean chia sẻ luôn cần đồng bộ. Mẫu done := false + for !done {} là bug kinh điển. Nếu cần báo hiệu, dùng channel (close), sync.WaitGroup, hoặc atomic.Bool. Đừng bao giờ đọc/ghi một biến thường chia sẻ giữa goroutine mà không có happens-before.
Đánh đổi cần cân nhắc
Race detector chỉ bắt cái nó thấy chạy. -race không phân tích tĩnh — nó chỉ phát hiện race trên đường code thực thi trong lần chạy đó. Một race ở nhánh hiếm có thể lọt nếu test không chạm tới. Vì vậy -race là công cụ mạnh nhưng không phải bảo đảm tuyệt đối; viết code đúng theo memory model vẫn là gốc.
Happens-before đúng không có nghĩa nhanh. Code không race vẫn có thể chậm nếu lạm dụng đồng bộ (tranh chấp mutex, channel quá nhỏ). Bài này đo chi phí không tranh chấp; dưới tranh chấp cao, chi phí tăng vọt. Đúng đắn trước, rồi mới tối ưu — nhưng cả hai đều quan trọng.
Đừng "tối ưu" bằng cách bỏ đồng bộ. Thấy mutex tốn 1,5 ns rồi bỏ nó đi để "nhanh hơn" là công thức tạo data race. 1,5 ns là cái giá của tính đúng đắn — luôn đáng. Chỉ bỏ đồng bộ khi bạn chứng minh được không có chia sẻ (ví dụ mỗi goroutine sở hữu dữ liệu riêng).
Ba ý mang về
- Không có happens-before giữa hai goroutine cùng đụng một biến (ít nhất một ghi) là data race — hành vi KHÔNG xác định: đo thật,
-racebắt 2 data race ở mẫu cờ booleanfor !done {}; đây không phải "đọc giá trị cũ" mà là chương trình sai theo bất kỳ cách nào (compiler/CPU được sắp xếp lại, cache, bỏ vòng lặp). - Năm quy tắc happens-before thiết lập bảo đảm: thứ tự chương trình trong một goroutine,
go f(), channel send/close→receive, mutex Unlock→Lock, vàsync.Once— bản channel (close→<-) sửa được race, đo thật luôn in đúng 42. - Happens-before không miễn phí, chọn nguyên thủy theo nhu cầu: đo thật mutex/atomic ~1,5 ns (rẻ, không tranh chấp) so với channel ~14,5 ns (~9x, nhưng làm nhiều việc hơn) — luôn chạy test với
-race, và đừng bao giờ bỏ đồng bộ để "tối ưu".
Bài tập làm thử
Bài 1 (đọc hiểu — sửa quan niệm sai). Đoạn code sau chạy có thể in ra 0, treo mãi, hoặc bất kỳ hành vi nào khác:
var data int
done := false
go func() {
data = 42
done = true
}()
for !done {}
fmt.Println(data)
Một người giải thích: "tệ nhất là chương trình in ra giá trị cũ của data (tức là 0)". Cách hiểu này có đúng theo Go memory model không? Giải thích.
Đáp án
Không đúng. Vì không có quan hệ happens-before nào giữa việc ghi (done = true trong goroutine) và việc đọc (for !done {} ở goroutine chính), đây là data race, và trong Go, data race là hành vi không xác định — không chỉ giới hạn ở "đọc giá trị cũ". Compiler có thể tối ưu for !done {} thành vòng lặp vô hạn (vì trong phạm vi một goroutine, nó không thấy done bao giờ đổi), CPU có thể sắp xếp lại thứ tự data = 42 và done = true, cache có thể giữ giá trị cũ vô thời hạn ở lõi khác. Chương trình được phép sai theo bất kỳ cách nào, không có bảo đảm cụ thể nào về "giá trị cũ".
Bài 2 (sửa lỗi). Sửa lại đoạn code ở Bài 1 để loại bỏ data race, đảm bảo fmt.Println(data) luôn in ra đúng 42.
Đáp án
var data int
done := make(chan struct{})
go func() {
data = 42
close(done)
}()
<-done
fmt.Println(data)
Theo quy tắc happens-before của channel: một close(done) happens-before việc <-done hoàn tất. Vì data = 42 happens-before close(done) (cùng một goroutine, theo thứ tự chương trình), và close(done) happens-before <-done, nên bắc cầu ta có data = 42 happens-before fmt.Println(data) — đảm bảo luôn thấy giá trị 42.
Bài 3 (đọc hiểu — năm quy tắc). Liệt kê đúng năm quy tắc happens-before chính mà Go memory model đảm bảo, mỗi quy tắc một câu ngắn.
Đáp án
- Trong một goroutine, các thao tác xảy ra theo đúng thứ tự chương trình (program order). 2. Câu lệnh
go f()happens-before khifbắt đầu chạy. 3. Một channel send happens-before receive tương ứng hoàn tất;close(ch)happens-before một receive nhận giá trị zero. 4. Với mutex,Unlockhappens-before lầnLockkế tiếp (thành công). 5. Vớisync.Once, việc thực thif()bên trongonce.Do(f)hoàn tất happens-before mọi lệnh gọionce.Dokhác trả về.
Bài 4 (công cụ + vận dụng). Bạn nghi ngờ một đoạn code đồng thời có data race nhưng test vẫn "xanh" bình thường. Nêu đúng lệnh để bật race detector khi chạy test, và giải thích một giới hạn quan trọng của công cụ này (nó có phải bảo đảm tuyệt đối không?).
Đáp án
Lệnh: go test -race ./... (hoặc go run -race main.go để chạy chương trình). Giới hạn quan trọng: race detector không phân tích tĩnh — nó chỉ phát hiện race trên chính đường code đã thực thi trong lần chạy đó. Nếu một race chỉ xảy ra ở một nhánh hiếm mà test không chạm tới, -race sẽ không bắt được nó. Vì vậy đây là công cụ mạnh nhưng không phải bảo đảm tuyệt đối; viết code đúng theo memory model từ đầu vẫn là gốc rễ, và chỉ nên bật -race trong test/CI (chậm ~5-10x) chứ không dùng production.
Bài 5 (đọc hiểu số liệu — đánh đổi). Bài đo chi phí thiết lập happens-before (không tranh chấp): mutex ~1,54 ns, atomic ~1,61 ns, channel (send+recv) ~14,55 ns. Một lập trình viên chỉ cần bảo vệ một biến đếm dùng chung giữa vài goroutine và định dùng channel để "an toàn hơn". Dựa trên số liệu này và khuyến nghị của bài, góp ý gì cho lựa chọn đó?
Đáp án
Với nhu cầu chỉ bảo vệ một biến (không cần truyền dữ liệu phức tạp hay điều phối giữa các goroutine), mutex hoặc atomic là lựa chọn hợp lý hơn — chúng chỉ tốn ~1,5-1,6 ns khi không tranh chấp, trong khi channel tốn ~14,5 ns (~9 lần đắt hơn) vì phải làm nhiều việc hơn: đồng bộ, truyền dữ liệu, và có thể đánh thức goroutine đang chờ. Channel không "an toàn hơn" về mặt đúng đắn trong trường hợp này — cả ba cách đều thiết lập đúng happens-before nếu dùng đúng — channel chỉ đáng dùng khi thực sự cần truyền dữ liệu hoặc điều phối, không phải chỉ để bảo vệ một biến đếm đơn giản.
Phần sau ta đi sâu vào hai nguyên thủy atomic cấp cao cho chia sẻ con trỏ/giá trị an toàn: Phần sau mổ xẻ atomic.Pointer và atomic.Value — cách hoán đổi con trỏ/cấu hình không khóa, khi nào chúng thay thế được mutex, và cạm bẫy kiểu.