Có câu châm ngôn nổi tiếng trong Go: "Don't communicate by sharing memory; share memory by communicating." Nhưng đôi khi khoá vẫn là câu trả lời đúng. Bài này về khi nào.
Đua dữ liệu, đo bằng số
100 goroutine, mỗi cái tăng cùng một biến 1000 lần:
lần 1: mong đợi 100000 -> thực tế 79279
lần 2: mong đợi 100000 -> thực tế 58071
lần 3: mong đợi 100000 -> thực tế 47948
Mất hơn một nửa, và ba lần cho ba kết quả khác nhau.
x++ không phải một thao tác: nó là đọc, cộng, ghi. Hai goroutine xen kẽ giữa ba bước thì cả hai cùng đọc ra 5, cùng tính 6, cùng ghi 6 — hai lần tăng chỉ được một.
Đây là kết quả giống hệt bài 68 sê-ri Java. Vấn đề là của mô hình bộ nhớ, không phải của ngôn ngữ.
Ba cách chữa, cùng đúng
Mutex : 100000
atomic: 100000
sync.Mutex:
var mu sync.Mutex
mu.Lock()
x++
mu.Unlock()
Mẫu chuẩn là dùng defer để không quên mở khoá:
func (c *Dem) Tang() {
c.mu.Lock()
defer c.mu.Unlock()
c.n++
}
sync/atomic cho thao tác đơn giản trên một biến:
var z int64
atomic.AddInt64(&z, 1)
Từ Go 1.19 có kiểu tiện hơn, và nên dùng cái này:
var z atomic.Int64
z.Add(1)
z.Load()
Channel — bài 42 sẽ so sánh chi tiết.
Tốc độ
Mutex 2.000.000 thao tác : 4 ms
atomic 2.000.000 thao tác : 4 ms
RWMutex 2.000.000 RLock : 7 ms
Ba kết quả đáng chú ý.
Mutex và atomic ngang nhau khi không có tranh chấp. sync.Mutex của Go dùng đường nhanh bằng atomic khi khoá đang rảnh, nên chi phí gần như bằng nhau. Khác biệt chỉ hiện ra khi nhiều goroutine tranh nhau.
RWMutex chậm hơn Mutex ở phép đo này — 7ms so với 4ms, dù chỉ đọc.
Điều này lặp lại đúng kết luận bài 71 sê-ri Java: khoá đọc-ghi có chi phí quản lý cao hơn, và nó chỉ thắng khi phần việc trong khoá đủ dài. Với thao tác một nano giây như đọc một biến, chi phí đó lớn hơn thứ nó tiết kiệm.
Quy tắc: dùng Mutex mặc định. Chỉ đổi sang RWMutex khi có nhiều goroutine đọc và mỗi lần đọc tốn đáng kể — duyệt một map lớn, tính toán trên ảnh chụp dữ liệu.
Mutex là zero value dùng được
type Kho struct {
mu sync.Mutex
m map[string]int
}
var k Kho // mu dùng được ngay
Đúng nguyên tắc bài 18. Nhưng có một ràng buộc quan trọng: Mutex không được sao chép sau khi dùng.
func (k Kho) Sai() { k.mu.Lock() } // receiver GIÁ TRỊ -> sao chép mutex
func (k *Kho) Dung() { k.mu.Lock() } // đúng
Đây là lý do bài 12 nói kiểu chứa Mutex bắt buộc dùng receiver con trỏ. go vet bắt được:
passes lock by value: Kho contains sync.Mutex
Quy ước đặt Mutex cạnh dữ liệu
type Kho struct {
mu sync.Mutex // bảo vệ các trường DƯỚI đây
m map[string]int
n int
}
Comment ngắn đó rất đáng viết: nó nói rõ khoá bảo vệ cái gì. Trong struct lớn có nhiều khoá, không có comment thì không ai đoán được.
Và đừng để Mutex lộ ra ngoài — trường phải viết thường. Người dùng khoá được struct của bạn là bạn mất kiểm soát bất biến.
Khi nào khoá thắng channel
Câu châm ngôn khuyên dùng channel, nhưng thực tế:
Dùng Mutex khi bảo vệ trạng thái — bộ nhớ đệm, bộ đếm, cấu hình đọc nhiều ghi ít. Ngắn gọn hơn channel rất nhiều, và thư viện chuẩn dùng nó khắp nơi.
Dùng channel khi chuyển giao quyền sở hữu dữ liệu, khi cần phối hợp thời điểm, khi có luồng dữ liệu đi qua nhiều bước.
Cách phân biệt tôi thấy đúng: nếu bạn thấy mình dùng channel để bảo vệ một biến, hãy dùng Mutex. Nếu bạn thấy mình dùng Mutex để điều phối thứ tự các bước, hãy dùng channel.
sync.Map: đọc kỹ trước khi dùng
sync.Map không phải "map có khoá sẵn". Nó được tối ưu cho đúng hai mẫu: khoá ghi một lần rồi đọc nhiều lần, và các goroutine thao tác trên tập khoá rời nhau.
Với mẫu đọc-ghi thông thường, map bọc RWMutex thường nhanh hơn và dễ đọc hơn. Đừng chọn sync.Map chỉ vì tên nó nghe an toàn.
Thử ba mươi giây
Chạy đoạn đua dữ liệu ở đầu bài năm lần liên tiếp. Bạn sẽ thấy năm con số khác nhau, và có thể một lần ra đúng 100000.
Đó chính là điều làm lỗi đua nguy hiểm — và là lý do bài mai về race detector tồn tại.
Ngày mai: sync.WaitGroup, Once và Pool.