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

Ảnh chụp đoạn mã Go nền tối minh hoạ Go memory model quan hệ happens-before, không có happens-before giữa hai goroutine cùng đụng một biến ít nhất một ghi bằng data race bằng hành vi không xác định, một data race sai không chỉ là có thể sai var data int done bằng false go func data bằng 42 done bằng true không thiết lập happens-before for không done đọc done không đồng bộ data race fmt Println data có thể in 0 treo hoặc bất kỳ điều gì data race trong Go là hành vi không xác định không đảm bảo đọc giá trị cũ mà là chương trình sai theo cách nào cũng được compiler CPU được phép sắp xếp lại cache tối ưu bỏ vòng lặp, hai sửa channel thiết lập happens-before var data int done bằng make chan struct go func data bằng 42 close done đóng mọi ghi trước nó nhận thấy được sau khi nhận fmt Println data luôn in 42 an toàn, ba năm quy tắc happens-before chính 1 một goroutine theo thứ tự chương trình 2 go f câu lệnh go xảy ra trước khi f bắt đầu chạy 3 channel send xảy ra trước khi receive tương ứng hoàn tất close ch xảy ra trước khi receive trả về zero 4 mutex Unlock xảy ra trước Lock lần sau 5 sync Once f trong Do hoàn tất trước mọi Do trả về, bốn công cụ race detector go run trừ race main.go bật race detector lúc chạy go test trừ race chạy test với race detector race phát hiện truy cập không đồng bộ lúc chạy chỉ với đường code thực thi chậm hơn 5-10x nên chỉ dùng khi test CI không production

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:

  1. Trong một goroutine: theo đúng thứ tự chương trình.
  2. go f(): câu lệnh go happens-before khi f bắt đầu chạy (nên f thấy mọi ghi trước go).
  3. 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.
  4. Mutex: Unlock happens-before Lock lần sau (nên vùng critical section sau thấy ghi của vùng trước).
  5. sync.Once: f() trong once.Do(f) hoàn tất happens-before mọi once.Do trả 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:

Ảnh chụp bảng kết quả đo thật nền tối race detector bắt lỗi và chi phí happens-before go run trừ race cộng go test bench Go 1.23 arm64 10 core, bản không đồng bộ trừ race bắt 2 data race WARNING DATA RACE Write at 0x 012f by goroutine 7 ghi done Previous read at 0x 012f by main goroutine Read at 0x 0118 by main goroutine đọc data Previous write at 0x 0118 by goroutine 7 Found 2 data race race detector chỉ đúng chỗ cả cờ done lẫn biến data đều bị đọc ghi không đồng bộ giữa hai goroutine, bản channel trừ race sạch kết quả luôn đúng go run trừ race fix.go 42 không cảnh báo chạy 3 lần 42 42 42 close receive thiết lập happens-before, chi phí thiết lập happens-before không tranh chấp cách không đồng bộ baseline 0,226 ns x cộng cộng trần không an toàn khi chia sẻ mutex Lock Unlock 1,540 ns rẻ khi không tranh chấp atomic AddInt64 1,608 ns tương đương mutex ở đây channel send cộng recv 14,55 ns khoảng 9x mutex mạnh nhưng nặng nhất happens-before không miễn phí mutex atomic rẻ khoảng 1,5 ns không tranh chấp channel đắt hơn nhiều khoảng 14,5 ns vì làm nhiều việc hơn đồng bộ cộng truyền dữ liệu cộng đánh thức goroutine, cốt lõi không HB data race hành vi không xác định không chỉ giá trị cũ thiết lập channel mutex atomic go Once 5 quy tắc phát hiện go run test trừ race chậm 5-10x chỉ test chi phí mutex atomic khoảng 1,5 ns channel khoảng 14,5 ns không tranh chấp

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ộ: -race báo Found 2 data race(s) — cả done lẫn data bị đọc/ghi không đồng bộ.
  • Bản channel: -race sạ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ề

  1. 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, -race bắt 2 data race ở mẫu cờ boolean for !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).
  2. 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.
  3. 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.