sync/atomic là tầng thấp nhất của đồng thời trong Go. Bài này về khi nào nó đáng dùng.
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.
Bài học: so sánh phải theo cấu trúc bài toán, không theo tên công cụ. 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; cần hai biến nhất quán với nhau là phải khoá.
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.
Thử 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.
Ngày mai: errgroup — điều phối nhiều goroutine có lỗi.