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. Hình dung một biến dùng chung như tấm bảng đếm treo tường mà cả nhóm cùng ghi — và bài này về khi nào cần một cây phấn.
Đ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. Muốn cộng một lên bảng, bạn đọc số đang có, cộng một trong đầu, rồi ghi số mới lại. Hai người 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++
}
Đây là cây phấn duy nhất: ai cầm nó mới được ghi, người khác đứng chờ.
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()
Đây là cái đếm cơ khí một nút bấm: cú "+1" không chia nhỏ được nên chẳng cần phấn.
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. RWMutex như cái luật "nhiều người được cùng nhìn bảng, nhưng muốn ghi thì mọi người phải lùi ra" — cái cổng đếm người vào-ra tự nó tốn công, nên 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; liếc một cái thì cứ chộp lấy phấn còn nhanh hơn.
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
Photocopy cả tấm bảng kèm luật-cây-phấn ra thành bản thứ hai thì bản sao có cây phấn riêng, và luật không còn điều phối được hai bản nữa. Đâ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.
Nếu chỉ thử một thứ sau bài này, chạy đoạn đua dữ liệu ở đầu bài năm lần liên tiếp trong ba mươi giây. 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 race detector tồn tại (ta sẽ dùng nó ở một bài sau).
Mẫu số chung
Lỗi mất-cập-nhật — hai bên cùng đọc một giá trị, cùng cộng một, cùng ghi lại, và một lần tăng biến mất — là tính chất của bộ nhớ chung có thể ghi, không phải của Go: Java mất y hệt (bài 68), C/C++ gọi nó là hành vi không xác định, và ngay cả Python với GIL cũng mất += trên một biến dùng chung vì nó là mấy bytecode. Cách chữa luôn là một trong ba: loại trừ lẫn nhau (một cái khoá), một lệnh phần cứng không chia nhỏ được (atomic fetch-add/CAS), hoặc không chia sẻ gì cả (nhốt dữ liệu vào một nơi rồi trao đổi bằng thông điệp). Điều nằm dưới cả ba: "một câu lệnh" trong mã nguồn không phải "một bước" với CPU — tính đúng của đồng thời sống bên dưới cú pháp.
Điều thứ hai: khoá đọc-ghi không phải một nâng cấp miễn phí. Cho nhiều người đọc vào cùng lúc kéo theo sổ sách, và nó chỉ đáng khi mỗi lần đọc đủ dài để cái song song bù lại phần chi phí — với một lần đọc một nano giây thì Mutex thường (hay một atomic) thắng, đúng như đo được. Đây lại là điệp khúc đo-đừng-đoán giống "pool to hơn không nhanh hơn": một công cụ chuyên biệt, khôn hơn, mang theo chi phí chỉ khấu hao ở đúng quy mô — nên mặc định chọn cái đơn giản và chỉ đổi khi có bằng chứng. Cùng bản năng đó phân xử khoá-với-channel: một cái khoá để giữ trạng thái, một channel để chuyển giao quyền sở hữu hay sắp thứ tự các bước — khớp cơ chế với hình dạng bài toán, đừng khớp với một khẩu hiệu.
Ngày mai: sync.WaitGroup, Once và Pool.
Bài tập làm thử
Bài 1 (đọc hiểu). 100 goroutine cùng chạy x++ 1000 lần trên biến chung x, không có bảo vệ đồng bộ nào. Giải thích tại sao kết quả cuối cùng thường nhỏ hơn 100000 và khác nhau giữa các lần chạy, dựa trên bản chất của phép toán x++.
Đáp án
x++ không phải một thao tác nguyên tử — nó thực chất là ba bước: đọc giá trị hiện tại, cộng thêm 1 trong một biến tạm, rồi ghi giá trị mới lại. Nếu hai goroutine xen kẽ giữa ba bước này (ví dụ cả hai cùng đọc ra giá trị 5, cả hai cùng tính ra 6, cả hai cùng ghi 6), thì hai lần tăng chỉ tạo ra hiệu quả của một lần tăng — mất-cập-nhật (lost update). Vì thứ tự xen kẽ giữa các goroutine không xác định (do scheduler quyết định), mỗi lần chạy cho một kết quả khác nhau, luôn nhỏ hơn hoặc bằng 100000.
Bài 2 (sửa lỗi). Đoạn mã sau bị go vet báo lỗi "passes lock by value: Kho contains sync.Mutex". Tìm và sửa:
type Kho struct {
mu sync.Mutex
m map[string]int
}
func (k Kho) Tang(key string) {
k.mu.Lock()
defer k.mu.Unlock()
k.m[key]++
}
Đáp án
Receiver dùng giá trị (k Kho) khiến mỗi lần gọi method sao chép toàn bộ struct Kho, bao gồm cả sync.Mutex — "photocopy cả tấm bảng kèm luật-cây-phấn" thành một bản mới có cây phấn riêng, nên khoá không còn điều phối được giữa các lần gọi khác nhau (mỗi lần gọi khoá một bản sao độc lập, không khoá lẫn nhau). Sửa bằng receiver con trỏ:
func (k *Kho) Tang(key string) {
k.mu.Lock()
defer k.mu.Unlock()
k.m[key]++
}
Quy tắc: kiểu chứa sync.Mutex bắt buộc dùng receiver con trỏ cho mọi method.
Bài 3 (vận dụng — code Go). Viết một struct BoDem an toàn với goroutine, có method Tang() để tăng bộ đếm lên 1 và GiaTri() để đọc giá trị hiện tại, dùng sync/atomic kiểu mới (từ Go 1.19) thay vì Mutex.
Đáp án
type BoDem struct {
n atomic.Int64
}
func (b *BoDem) Tang() {
b.n.Add(1)
}
func (b *BoDem) GiaTri() int64 {
return b.n.Load()
}
Dùng atomic.Int64 phù hợp vì đây là thao tác đơn giản trên một biến số nguyên — không cần "cây phấn" (Mutex) cho một thao tác phần cứng đã không chia nhỏ được (atomic fetch-add).
Bài 4 (bẫy/đánh đổi). Một lập trình viên thay sync.Mutex bằng sync.RWMutex cho một bộ đếm chỉ đọc một biến int64 đơn giản (RLock() rồi đọc), với kỳ vọng "đọc nhiều thì RWMutex phải nhanh hơn". Theo số liệu trong bài (Mutex 2 triệu thao tác: 4ms, RWMutex 2 triệu RLock: 7ms), giải thích tại sao kỳ vọng này sai, và khi nào RWMutex mới thực sự đáng dùng.
Đáp án
RWMutex có chi phí quản lý (sổ sách đếm số người đọc/ghi đang hoạt động) cao hơn Mutex thường, và chi phí đó chỉ được bù lại khi phần việc thực hiện trong khoá đủ dài để nhiều goroutine đọc song song thực sự tiết kiệm được thời gian. Với một thao tác cực ngắn như đọc một biến (cỡ nano giây), chi phí quản lý của cổng đếm người vào-ra lớn hơn thứ nó tiết kiệm được, nên RWMutex chậm hơn Mutex (7ms so với 4ms trong phép đo). RWMutex chỉ đáng dùng khi có nhiều goroutine đọc và mỗi lần đọc tốn đáng kể — ví dụ duyệt một map lớn hoặc tính toán trên ảnh chụp dữ liệu.
Bài 5 (đọc hiểu/khái niệm). Bài viết đưa ra quy tắc phân biệt khi nào dùng Mutex và khi nào dùng channel. Áp dụng quy tắc đó cho hai tình huống sau: (a) một bộ nhớ đệm (cache) trong bộ nhớ được nhiều goroutine đọc/ghi; (b) một pipeline ba bước nơi bước 1 phải xong trước khi bước 2 bắt đầu xử lý dữ liệu đó.
Đáp án
(a) Dùng Mutex (hoặc RWMutex nếu đọc nhiều và tốn thời gian) — đây là bảo vệ trạng thái dùng chung (cache là một biến/cấu trúc dữ liệu tồn tại lâu dài, nhiều goroutine cùng truy cập).
(b) Dùng channel — đây là chuyển giao quyền sở hữu dữ liệu và phối hợp thứ tự các bước (dữ liệu "đi qua" từng bước theo trình tự), không phải bảo vệ một biến chung. Theo bài viết: "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."