Hình dung việc điều phối goroutine như điều phối một nhóm người trong văn phòng. Hỏi "channel hay mutex cái nào nhanh hơn" cũng vô nghĩa như hỏi "email nhanh hơn hay họp phòng nhanh hơn" — chẳng trả lời được nếu chưa biết đang phối hợp việc gì. Channel là chuyền tay một tập tài liệu, trao hẳn quyền sở hữu cho người sau. Mutex là một căn phòng chung có đúng một cây bút: ai cầm bút mới được ghi lên tấm bảng trạng thái. Atomic là cái đếm tay bấm tách một phát. Và đôi khi phương án tốt nhất là phát cho mỗi người một cuốn sổ riêng, cuối buổi gộp lại — không phối hợp gì cả. Bài cuối của chặng đồng thời là về chuyện chọn đúng phương tiện cho đúng việc, câu hỏi bị trả lời sai nhiều nhất trong cộng đồng Go.
Châm ngôn
Don't communicate by sharing memory; share memory by communicating.
Câu này in trên áo, dán trên tường, và bị hiểu thành "luôn dùng channel, đừng dùng khoá".
Chính Rob Pike đã nói rõ đó không phải ý ông: câu châm ngôn nói về cách nghĩ, không phải một lệnh cấm. Và thư viện chuẩn của Go dùng sync.Mutex ở khắp nơi.
Số đo
Tám goroutine, 500.000 thao tác tăng bộ đếm:
Mutex : 21 ms
atomic : 6 ms
channel : 12 ms
Channel nhanh hơn Mutex ở phép đo này — đi ngược niềm tin phổ biến.
Nhưng đừng vội kết luận. Lý do channel thắng nằm ở cấu trúc, không ở công cụ: channel 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 đếm. Không có tranh chấp — đúng kiểu mọi người chuyền tài liệu về cho một thư ký duy nhất ghi sổ.
Mutex thì bị cả tám goroutine tranh nhau ở mỗi thao tác: tám người chen nhau giành một cây bút.
Nếu tôi đổi thiết kế — mỗi goroutine giữ bộ đếm riêng rồi cộng lại ở cuối — cả hai đều bị atomic bỏ xa, và thậm chí một biến thường cũng thắng.
Nên bài học ở đây không phải "channel nhanh hơn". Nó là: con số phụ thuộc vào cách bạn cấu trúc bài toán nhiều hơn phụ thuộc vào công cụ. So sánh "channel với mutex" tách khỏi ngữ cảnh là một câu hỏi không có câu trả lời, y như so email với phòng họp mà không nói để làm gì.
Bộ tiêu chí chọn
Thay vì hỏi cái nào nhanh hơn, hãy hỏi bài toán của bạn thuộc dạng nào.
Dùng atomic khi: một biến duy nhất, thao tác đơn giản (đếm, cờ, thay con trỏ). Nhanh nhất, và không có gì để dùng sai ngoài việc quên rằng nó chỉ bảo vệ một biến.
Dùng Mutex khi: bảo vệ trạng thái — một struct với vài trường phải nhất quán, một map, một cache. Đây là trường hợp phổ biến nhất trong mã ứng dụng, và mã ngắn hơn channel rất nhiều.
Dùng channel khi: chuyển giao quyền sở hữu dữ liệu giữa các giai đoạn, phối hợp thời điểm, hoặc khi có luồng dữ liệu đi qua nhiều bước. Pipeline ở bài 37 là ví dụ mẫu.
Dùng errgroup khi: chạy nhiều việc độc lập và cần biết cái nào hỏng.
Không dùng gì cả khi: chia dữ liệu để mỗi goroutine có phần riêng, gộp kết quả ở cuối. Đây là phương án nhanh nhất và cũng đúng nhất — và thường bị bỏ qua vì nó không dùng công cụ đồng thời nào.
Dấu hiệu bạn đang chọn sai
Dùng channel để bảo vệ một biến:
type Dem struct{ ch chan int } // phức tạp không cần thiết
Nếu goroutine duy nhất của bạn chỉ ngồi đọc channel rồi tăng một biến, atomic làm điều đó trong một dòng.
Dùng Mutex để điều phối thứ tự:
mu.Lock()
// chờ bước trước xong...
Khoá không diễn đạt được "chờ tới khi". Đó là việc của channel hoặc sync.Cond.
Khoá giữ quá lâu:
mu.Lock()
resp, _ := http.Get(url) // GỌI MẠNG trong khi giữ khoá
mu.Unlock()
Đây là lỗi nghiêm trọng nhất, và bài 71 sê-ri Java đã đo: đưa việc chậm ra ngoài khoá giảm thời gian chạy năm lần. Nguyên tắc giống hệt trong Go — đừng giữ cây bút chung trong lúc đi pha cà phê.
Ba nguyên tắc rút từ cả chặng
Thiết kế để không chia sẻ. Mọi công cụ trong bài này đều là cách xử lý hậu quả của việc chia sẻ trạng thái. Không chia sẻ thì không cần công cụ nào.
Đo, đừng tin châm ngôn. Bảng ở đầu bài lật ngược một niềm tin rất phổ biến — và nó vẫn chỉ đúng cho đúng cấu trúc đó.
Bật -race trong CI. Bài 36 đã đo: chậm sáu lần khi test, nhưng nó là công cụ duy nhất bắt được cả một lớp lỗi mà bộ test chạy xanh bên cạnh.
Nhìn lại chặng đồng thời
Mười hai bài, và tôi thấy chúng dồn về ba điều:
Goroutine rẻ đổi cách thiết kế. 2,47 µs và 576 byte nghĩa là bạn không cần pool, không cần callback, không cần lập trình bất đồng bộ. Viết mã tuần tự, chạy nhiều bản song song.
Nhưng rẻ không có nghĩa vô hạn. Bài 38 đo được 100 goroutine kẹt vĩnh viễn từ một hàm ba dòng, và bộ thu gom rác không cứu được.
Công cụ chẩn đoán của Go đi trước. -race chỉ thẳng dòng gây lỗi đua; pprof gom nhóm goroutine theo ngăn xếp. Cả hai đều là một cờ dòng lệnh, trong khi phần tương đương ở Java cần dựng khung đo riêng.
Nếu muốn tự kiểm thiết kế của mình ngay, tìm một sync.Mutex trong dự án và hỏi: khoá này bảo vệ những trường nào? Trả lời được ngay và ghi thành comment thì thiết kế ổn. Phải đọc lại cả tệp mới biết thì đó là dấu hiệu trạng thái đang chia sẻ rộng hơn cần thiết — và cách chữa tốt nhất không phải đổi công cụ, mà là thu hẹp phạm vi chia sẻ.
Mẫu số chung
"Nhắn tin cho nhau hay cùng ghi lên một tấm bảng chung" — message-passing so với shared-state-cộng-khoá — là trục lớn mà mọi ngôn ngữ có đồng thời đều phải chọn chỗ đứng, và cách mỗi ngôn ngữ nghiêng về đâu nói lên triết lý của nó.
- Erlang/Elixir đứng hẳn ở cực "không chia sẻ gì cả": mô hình actor, mỗi tiến trình có bộ nhớ riêng tuyệt đối, nói chuyện chỉ bằng cách gửi thông điệp. Không có khoá vì không có gì chung để khoá — đúng cái "mỗi người một cuốn sổ" đẩy tới tận cùng.
- Java và C++ mặc định ngược lại: bộ nhớ chung cộng khoá (
synchronized,std::mutex) là đường chính, còn hàng đợi thông điệp là thư viện thêm vào. - Go và Rust đứng giữa và cho cả hai, nhưng đều đẩy bạn về phía trao quyền sở hữu: Go bằng châm ngôn CSP, Rust bằng hệ thống kiểu —
Send/Syncvà ownership khiến "chia sẻ trạng thái đổi được mà không khoá" đơn giản là không biên dịch nổi.
Sợi chỉ chung đáng mang theo có hai vế. Một: "cái nào nhanh hơn" là câu hỏi sai ở mọi ngôn ngữ — cấu trúc bài toán quyết định, không phải tên công cụ; một channel có đệm với một người tiêu thụ, hay một khoá bị tám luồng tranh, cho ra con số khác hẳn nhau dù cùng là "đồng thời". Hai, sâu hơn: chiến thắng lớn nhất, ở Go, Rust, Java hay Erlang, là thiết kế để không chia sẻ trạng thái đổi được — bất biến, mỗi luồng một phần dữ liệu riêng, hoặc trao hẳn quyền sở hữu qua thông điệp. Mọi khoá, mọi channel, mọi atomic chỉ là cách xử lý hậu quả của việc chia sẻ; bỏ được việc chia sẻ thì bỏ luôn cả lớp vấn đề. Và cái bẫy "giữ khoá trong lúc gọi việc chậm" thì phổ quát y như nhau — đừng cầm cây bút chung khi đi pha cà phê, ở bất kỳ ngôn ngữ nào.
Ngày mai bắt đầu chặng thư viện chuẩn: fmt, io và bufio.
Bài tập làm thử
Bài 1 (đọc hiểu). Bài viết đo được channel (12ms) nhanh hơn Mutex (21ms) cho cùng khối lượng công việc, đi ngược niềm tin phổ biến. Giải thích chính xác lý do cấu trúc khiến channel thắng trong phép đo này — không phải "channel bản chất nhanh hơn".
Đá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à cộng dồn — không có tranh chấp giữa nhiều bên ghi. Ngược lại, Mutex bị cả tám goroutine tranh nhau giành quyền ghi ở mỗi thao tác. Bài học đúng là: 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 thiết kế (ví dụ mỗi goroutine giữ bộ đếm riêng rồi cộng ở cuối) sẽ khiến cả hai đều bị bỏ xa bởi cách không cần đồng bộ gì cả.
Bài 2 (sửa lỗi). Đoạn mã sau mắc đúng lỗi nghiêm trọng nhất mà bài viết cảnh báo về việc giữ khoá. Tìm và sửa.
var mu sync.Mutex
var cache = map[string]string{}
func LayHoacTai(key, url string) string {
mu.Lock()
defer mu.Unlock()
if v, ok := cache[key]; ok {
return v
}
resp, _ := http.Get(url) // gọi mạng trong khi giữ khoá
body, _ := io.ReadAll(resp.Body)
cache[key] = string(body)
return string(body)
}
Đáp án
Lỗi: gọi http.Get (thao tác mạng, có thể mất hàng trăm mili giây hoặc treo) trong khi vẫn giữ khoá mu — mọi goroutine khác muốn đọc/ghi cache đều phải chờ hết thời gian gọi mạng đó. Bài viết ví đây là "cầm cây bút chung trong lúc đi pha cà phê". Sửa bằng cách đưa việc chậm ra ngoài phạm vi khoá:
func LayHoacTai(key, url string) string {
mu.Lock()
if v, ok := cache[key]; ok {
mu.Unlock()
return v
}
mu.Unlock()
resp, _ := http.Get(url)
body, _ := io.ReadAll(resp.Body)
mu.Lock()
cache[key] = string(body)
mu.Unlock()
return string(body)
}
Bài 3 (đọc hiểu). Câu châm ngôn nổi tiếng của Go "Don't communicate by sharing memory; share memory by communicating" thường bị hiểu sai thành gì, và theo bài viết, cách hiểu đúng là gì?
Đáp án
Nó thường bị hiểu sai thành "luôn dùng channel, đừng dùng khoá" — một mệnh lệnh cấm dùng Mutex. Nhưng theo bài viết, chính Rob Pike đã nói rõ đó không phải ý ông: câu châm ngôn nói về cách nghĩ (ưu tiên tư duy chuyển giao quyền sở hữu dữ liệu hơn là chia sẻ trạng thái), không phải một lệnh cấm cứng nhắc. Bằng chứng là chính thư viện chuẩn của Go dùng sync.Mutex ở khắp nơi.
Bài 4 (vận dụng thực tế). Bạn cần thiết kế một cache trong bộ nhớ với vài trường phải luôn nhất quán với nhau (ví dụ số lượng tồn kho và thời điểm cập nhật cuối), được đọc/ghi bởi nhiều goroutine. Theo bộ tiêu chí của bài viết, nên dùng atomic, Mutex, hay channel? Viết khai báo struct tương ứng.
Đáp án
Dùng Mutex, vì đây là trường hợp "bảo vệ trạng thái" — một struct với vài trường phải nhất quán với nhau, chứ không phải một biến đơn lẻ (loại atomic) hay việc chuyển giao quyền sở hữu dữ liệu qua nhiều giai đoạn (loại channel):
type TonKho struct {
mu sync.Mutex
soLuong int
capNhatCuoi time.Time
}
func (t *TonKho) CapNhat(sl int) {
t.mu.Lock()
defer t.mu.Unlock()
t.soLuong = sl
t.capNhatCuoi = time.Now()
}
atomic chỉ bảo vệ được một biến; ở đây hai trường soLuong và capNhatCuoi phải đổi cùng lúc và nhất quán với nhau, nên cần khoá cả "căn phòng".
Bài 5 (bẫy/đánh đổi). Bài viết đưa ra một bài tự kiểm thiết kế: "tìm một sync.Mutex trong dự án và hỏi: khoá này bảo vệ những trường nào?" Giải thích ý nghĩa của câu hỏi này và tại sao "phải đọc lại cả tệp mới trả lời được" là một dấu hiệu xấu.
Đáp án
Nếu bạn trả lời được ngay danh sách trường mà một Mutex bảo vệ (và ghi được thành comment), nghĩa là phạm vi trạng thái dùng chung đã được thiết kế rõ ràng, gọn và có chủ đích. Ngược lại, nếu phải đọc lại cả tệp (hoặc cả package) mới biết được khoá đó đang bảo vệ những gì, đó là dấu hiệu trạng thái chia sẻ đang lan rộng hơn cần thiết — nhiều đoạn mã rải rác cùng phụ thuộc vào cùng một khoá mà không ai nắm được toàn cảnh. Cách chữa đúng theo bài viết không phải đổi công cụ (không phải chuyển từ Mutex sang channel hay atomic), mà là thu hẹp phạm vi chia sẻ — thiết kế lại để ít trạng thái cần dùng chung hơn.