Hãy hình dung một thao tác atomic như cú bật công tắc đèn. Công tắc hoặc bật hoặc tắt — không có khoảnh khắc nào nó ở lưng chừng để một người khác đi ngang nhìn thấy "đang chuyển". Đó là toàn bộ ý nghĩa của chữ atomic: không chia cắt được, không ai bắt gặp được trạng thái nửa vời. Đối lại, Mutex giống chìa khoá một căn phòng — ai cầm chìa mới vào, nhưng vào rồi thì làm bao nhiêu việc tuỳ thích. Và CompareAndSwap là "tôi chỉ lật công tắc nếu nó vẫn đang ở đúng vị trí tôi nhớ lúc nãy". Giữ ba hình ảnh đó, phần còn lại của bài chỉ là đo và đặt chúng đúng chỗ. sync/atomic là tầng thấp nhất của đồng thời trong Go.

Kiểu atomic từ Go 1.19

var c atomic.Int64
c.Add(5)
c.Load()
c.Store(9)
c.Swap(100)
c.CompareAndSwap(5, 9)
  Add(5)                 -> Load()=5
  CompareAndSwap(5,9)=true  -> 9
  CompareAndSwap(5,1)=false -> 9 (không đổi)
  Swap(100)=9            -> 100

Dùng kiểu (atomic.Int64, atomic.Bool, atomic.Pointer[T]) thay vì hàm cũ (atomic.AddInt64(&x, 1)). Kiểu mới an toàn hơn: không thể vô tình đọc biến đó bằng cách thường, và không cần nhớ truyền con trỏ.

Tốc độ

Tám goroutine, 500.000 thao tác:

  Mutex   : 21 ms
  atomic  :  6 ms
  channel : 12 ms

atomic nhanh hơn Mutex khoảng ba lần rưỡi ở phép đo này.

Nhưng chú ý dòng thứ ba: channel nhanh hơn Mutex, 12 ms so với 21 ms. Điều này đi ngược niềm tin phổ biến rằng channel luôn chậm hơn khoá.

Lý do là channel trong phép đo có đệm 1000, nên các goroutine gửi ít khi phải chặn, và một goroutine duy nhất đếm — không có tranh chấp. Trong khi Mutex bị tám goroutine tranh nhau ở mọi thao tác.

Nên điều cần nhớ là: so sánh phải theo cấu trúc bài toán, không theo tên công cụ. "channel chậm hơn khoá" là một lời đồn chỉ đúng trong vài cấu hình; đổi cách bố trí bài toán là thứ tự đảo ngay. Bài 42 sẽ nói kỹ.

CompareAndSwap: nền của mọi cấu trúc không khoá

for {
	cu := c.Load()
	moi := tinh(cu)
	if c.CompareAndSwap(cu, moi) { break }
}

"Nếu giá trị vẫn là cu thì đổi thành moi, không thì báo thất bại." Vòng lặp thử lại tới khi thành công.

Đây chính là cơ chế bài 70 sê-ri Java đã đo, và cùng cảnh báo áp dụng: khi tranh chấp cao, số lần thử lại tăng vọt và CPU bị đốt vô ích. Với tranh chấp nặng, Mutex thường thắng vì goroutine chờ được cho ngủ.

atomic.Value và atomic.Pointer

Để thay cả một đối tượng một cách nguyên tử:

var cauHinh atomic.Pointer[CauHinh]

cauHinh.Store(&CauHinh{...})     // goroutine nạp lại cấu hình
c := cauHinh.Load()              // mọi goroutine khác đọc, không khoá

Mẫu này rất hợp cho cấu hình đọc-nhiều-ghi-hiếm: đọc gần như miễn phí, và người ghi chỉ thay con trỏ.

Điều kiện bắt buộc: đối tượng phải bất biến sau khi Store. Sửa nó tại chỗ là quay lại đúng lỗi đua ban đầu.

atomic.Value (kiểu cũ) có ràng buộc khó chịu: mọi lần Store phải cùng kiểu cụ thể, không thì panic. atomic.Pointer[T] từ Go 1.19 không có vấn đề đó — dùng nó.

Mô hình bộ nhớ Go

Go có đặc tả mô hình bộ nhớ chính thức, và nó ngắn hơn Java nhiều. Ba điều đáng nhớ:

Không có volatile. Go không có từ khoá tương đương. Mọi bảo đảm về thứ tự đến từ channel, sync, hoặc sync/atomic.

Các thao tác atomic tạo quan hệ xảy-ra-trước. Từ Go 1.19, đặc tả nói rõ chúng hành xử như thao tác tuần tự nhất quán. Nên nếu bạn Store dữ liệu rồi Store một cờ atomic, goroutine đọc cờ đó sẽ thấy dữ liệu.

Đọc ghi biến thường mà không đồng bộ là hành vi không xác định. Không phải "giá trị cũ" — mà là không xác định. Bài 34 đo được kết quả sai; nhưng về nguyên tắc, trình biên dịch được phép làm bất cứ điều gì.

Đây là lý do "chỉ đọc thôi nên chắc không sao" là suy nghĩ sai. Một goroutine ghi và một goroutine đọc, không đồng bộ, đã là đua.

Khi nào dùng atomic

Bộ đếm — số request, số lỗi, số byte. Đây là trường hợp rõ ràng nhất.

Cờ — atomic.Bool cho trạng thái đang tắt.

Thay con trỏ nguyên khối — cấu hình, snapshot.

Ngoài ba cái đó: dùng Mutex. atomic chỉ bảo vệ một biến — đúng như công tắc chỉ bật được một bóng đèn; cần hai biến nhất quán với nhau là phải khoá cả căn phòng.

Và đừng tự viết cấu trúc dữ liệu không khoá. Bài 96 sê-ri Java đã kết luận điều tương tự: bạn không test được tới mức chắc chắn, nên hãy dùng thứ đã có người chứng minh.

Nếu muốn tận mắt thấy một cuộc đua, thử đúng ba mươi giây:

var n int64
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
	wg.Add(1)
	go func() { defer wg.Done(); for j := 0; j < 1000; j++ { n++ } }()
}
wg.Wait()
fmt.Println(n)

Chạy với -race — nó báo lỗi ngay. Đổi n thành atomic.Int64 và n.Add(1), chạy lại — sạch, và kết quả đúng 100000. Cờ -race là công cụ tốt nhất Go cho bạn ở mảng này; tập thói quen chạy nó trong CI.

Mẫu số chung

atomic và "mô hình bộ nhớ" nghe như chuyện nội bộ của Go, nhưng cả hai đều là phiên bản của những thứ mọi ngôn ngữ chạy song song trên phần cứng hiện đại đều phải đối mặt — vì CPU và trình biên dịch đều được phép sắp xếp lại thứ tự đọc ghi, và chỉ primitive đồng bộ mới áp được trật tự.

  • Java có java.util.concurrent.atomic (AtomicLong…), có volatile, và mô hình bộ nhớ JMM với quan hệ happens-before — chính cái Go 1.19 diễn đạt gọn hơn. Cùng khái niệm, Java chỉ nhiều nút hơn.
  • C++11 phơi bày thứ Go giấu đi: std::atomic đi kèm thứ tự bộ nhớ tường minh (relaxed, acquire, release, seq_cst). Bạn được quyền chọn nới lỏng để nhanh hơn — và cũng được quyền tự bắn vào chân mình. Go chốt sẵn ở "tuần tự nhất quán" để bạn khỏi phải chọn.
  • Rust dùng đúng bộ Ordering như C++, nhưng thêm một lớp mà không ngôn ngữ nào ở trên có: hệ thống kiểu (Send/Sync cộng quyền sở hữu) bắt đua dữ liệu ngay lúc biên dịch, chứ không đợi -race lúc chạy. Cuộc đua bạn vừa gây ra ở trên, Rust thậm chí không cho biên dịch.

Sợi chỉ chung đáng mang theo có hai vế. Một: đua dữ liệu là hành vi không xác định ở khắp nơi, không phải "đọc ra giá trị cũ" — C++ gọi thẳng là undefined behavior, Java cho ra những giá trị "từ trên trời rơi xuống", Go nói "không xác định"; cái trực giác "một bên chỉ đọc thì chắc an toàn" sai ở mọi ngôn ngữ. Hai: khác biệt thật giữa các ngôn ngữ là chúng bắt bạn nói rõ bao nhiêu, và bắt lỗi sớm tới đâu — Go và Java giấu thứ tự bộ nhớ cho bạn nhẹ đầu, C++ phơi ra để bạn tối ưu, Rust thì chặn ngay ở trình biên dịch. Còn lời khuyên cuối cùng thì bất biến qua mọi ngôn ngữ: chỉ với tới atomic cho bộ đếm, cờ, và thay con trỏ nguyên khối; quá ba thứ đó thì khoá; và đừng bao giờ tự viết cấu trúc không khoá.

Ngày mai: errgroup — điều phối nhiều goroutine có lỗi.

Bài tập làm thử

Bài 1 (đọc hiểu). Cho đoạn mã sau, giá trị cuối cùng của c là bao nhiêu và CompareAndSwap thứ hai trả về gì?

var c atomic.Int64
c.Add(5)
ok1 := c.CompareAndSwap(5, 9)
ok2 := c.CompareAndSwap(5, 1)
fmt.Println(c.Load(), ok1, ok2)
Đáp án

In ra 9 true false. CompareAndSwap(5, 9) thành công vì giá trị hiện tại đúng là 5, nên đổi thành 9 và trả true. CompareAndSwap(5, 1) thất bại vì giá trị hiện tại đã là 9 chứ không còn là 5, nên trả false và giữ nguyên 9.

Bài 2 (sửa lỗi). Đoạn mã sau muốn thay cấu hình một cách an toàn giữa nhiều goroutine nhưng có một lỗi thiết kế nghiêm trọng. Chỉ ra lỗi và sửa lại.

var cauHinh atomic.Pointer[CauHinh]

func CapNhat(muc string) {
	c := cauHinh.Load()
	c.MucLog = muc // sửa tại chỗ
}
Đáp án

Lỗi: hàm CapNhat sửa trực tiếp vào đối tượng *CauHinh đã Load() được — tức là sửa tại chỗ (mutate in place) một đối tượng mà các goroutine khác đang đọc đồng thời qua Load(). Điều kiện bắt buộc của atomic.Pointer[T] là đối tượng trỏ tới phải bất biến sau khi Store; sửa tại chỗ quay lại đúng lỗi đua dữ liệu ban đầu mà atomic vốn để tránh. Sửa đúng là tạo bản sao mới rồi Store con trỏ mới:

func CapNhat(muc string) {
	cu := cauHinh.Load()
	moi := *cu
	moi.MucLog = muc
	cauHinh.Store(&moi)
}

Bài 3 (đọc hiểu). Bài viết đo được: Mutex 21ms, atomic 6ms, channel 12ms cho cùng khối lượng công việc — channel nhanh hơn Mutex. Giải thích tại sao kết luận "channel luôn nhanh hơn mutex" là sai, dựa theo đúng lý do bài viết đưa ra.

Đáp án

Channel trong phép đo có đệm 1000 nên tám goroutine gửi hiếm khi phải chặn, và chỉ một goroutine duy nhất đọc và đếm — không có tranh chấp. Trong khi đó, Mutex bị cả tám goroutine tranh nhau ở mọi thao tác. Kết quả phụ thuộc vào cấu trúc bài toán (có tranh chấp hay không), không phải vào bản chất công cụ — đổi cách bố trí (ví dụ nhiều goroutine cùng ghi vào channel không đệm) có thể đảo ngược kết quả ngay.

Bài 4 (vận dụng thực tế). Bạn cần một bộ đếm số request đang xử lý, được tăng/giảm bởi hàng trăm goroutine đồng thời, và một cờ boolean báo "đang tắt máy". Chọn công cụ đồng bộ phù hợp nhất và viết khai báo kiểu dữ liệu cho hai biến này.

Đáp án

Cả hai đều thuộc nhóm "một biến duy nhất, thao tác đơn giản" nên dùng sync/atomic:

var soRequestDangXuLy atomic.Int64
var dangTat atomic.Bool

Không cần Mutex vì mỗi biến độc lập, không cần giữ nhất quán giữa nhiều trường với nhau.

Bài 5 (bẫy/đánh đổi). Vì sao bài viết khẳng định "chỉ đọc thôi chắc không sao" là một suy nghĩ sai lầm nguy hiểm, xét theo đúng mô hình bộ nhớ Go được nêu?

Đáp án

Theo mô hình bộ nhớ Go, đọc và ghi một biến thường (không dùng atomic, channel, hay sync) mà không đồng bộ là hành vi không xác định — không phải "đọc ra giá trị cũ" như trực giác thường nghĩ. Một goroutine ghi và một goroutine chỉ đọc, không đồng bộ, đã đủ để tạo thành một cuộc đua dữ liệu (data race): về nguyên tắc trình biên dịch được phép làm bất cứ điều gì với các thao tác đó, kể cả sắp xếp lại thứ tự đọc/ghi theo cách khiến bên đọc thấy giá trị vô nghĩa. "Chỉ đọc thì an toàn" chỉ đúng nếu không có bên nào khác đang ghi cùng lúc mà không đồng bộ.