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".
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.