Bài trước ta xây stack lock-free và thấy nó chậm hơn mutex. Bài này ta thử một cấu trúc khó hơn nhiều: hàng đợi lock-free. Stack chỉ có một điểm truy cập (đỉnh); hàng đợi có hai — lấy ở đầu (head), thêm ở cuối (tail) — nên phải phối hợp hai con trỏ nhất quán. Thuật toán chuẩn là Michael-Scott queue, một trong những cấu trúc lock-free nổi tiếng nhất. Ta sẽ xây nó, kiểm đúng, và đo — rồi thấy vì sao channel của Go vẫn là câu trả lời cho gần như mọi trường hợp.
Vì sao hàng đợi khó hơn stack
Stack lock-free chỉ cần CAS một con trỏ head. Hàng đợi phức tạp hơn hẳn:
- Hai điểm truy cập:
Enqueueđụngtail,Dequeueđụnghead— phải giữ hai con trỏ nhất quán với nhau. - Node giả (dummy): queue bắt đầu với một node rỗng để
headvàtailluôn trỏ tới một node hợp lệ, tránh trường hợp đặc biệt khi rỗng. - Cập nhật hai bước: thêm một phần tử cần hai thao tác (gắn node vào cuối, rồi đẩy
tail) — mà giữa hai bước có thể bị goroutine khác chen vào.
type MSQueue struct {
head atomic.Pointer[node] // lấy ở đây
tail atomic.Pointer[node] // thêm ở đây
}

Hình 1: Michael-Scott queue. head/tail riêng, node giả, Enqueue hai CAS, và cơ chế "giúp đỡ" — goroutine khác giúp hoàn tất thao tác dở của goroutine bị treo.
Cơ chế "giúp đỡ": điểm tinh vi nhất
Enqueue có hai bước: gắn node mới vào tail.next, rồi đẩy tail trỏ tới node mới. Nếu goroutine A làm xong bước 1 nhưng bị treo trước bước 2, tail sẽ tụt hậu (chưa trỏ node cuối cùng). Michael-Scott giải quyết bằng helping:
} else {
q.tail.CompareAndSwap(tail, next) // tail tụt hậu → GIÚP đẩy hộ
}
Bất kỳ goroutine nào thấy tail.next != nil (tức tail tụt hậu) sẽ giúp đẩy tail hộ goroutine đang treo, rồi mới làm việc của mình. Nhờ vậy không goroutine nào bị chặn bởi một goroutine khác dừng giữa chừng — đây chính là điều làm nó thật sự lock-free (đảm bảo tiến triển), và là lý do MS queue được coi là "chuẩn vàng".
Đo thật: đúng đắn, nhưng chậm hơn channel
Kiểm đúng: 8 goroutine cùng enqueue 1000 phần tử, rồi dequeue hết:
enqueue 8 goroutine x 1000 = 8000; dequeue được: 8000
go run -race: SẠCH
Đủ 8000, FIFO bảo toàn, -race sạch — kể cả khi goroutine dừng giữa hai bước enqueue (nhờ helping). Nhưng hiệu năng dưới tranh chấp:

Hình 2: Đúng đắn (8000/8000, FIFO, -race sạch). Hiệu năng: MS queue ~330 ns so với channel ~110 ns — channel nhanh hơn ~3 lần. Bảng chọn channel vs MS queue.
- MS queue lock-free: 330,7 / 330,0 / 332,8 ns/op.
- Channel có đệm: 114,8 / 103,8 / 114,9 ns/op.
Channel nhanh hơn MS queue ~3 lần dưới tranh chấp. Lý do tương tự stack nhưng nặng hơn: queue lock-free đụng hai hotspot (head và tail), mỗi thao tác cần 2 CAS cộng bão retry. Trong khi channel của Go được tối ưu cực kỹ ở tầng runtime — hàng đợi vòng (ring buffer), đánh thức goroutine thông minh, tích hợp scheduler.
Ứng dụng thực tế
Channel là câu trả lời cho gần như mọi producer-consumer FIFO. Nhanh, đơn giản, và tích hợp select, close, range. Trước khi nghĩ tới bất kỳ hàng đợi tự viết nào, hãy dùng channel — nó gần như luôn đúng và luôn đủ nhanh. "Đừng giao tiếp bằng chia sẻ bộ nhớ, hãy chia sẻ bộ nhớ bằng giao tiếp" — channel hiện thực triết lý này.
Chỉ tự viết queue lock-free khi channel không làm được. Vài trường hợp channel không phủ: hàng đợi unbounded không bao giờ block (channel có đệm cố định, đầy thì block/drop), hoặc cần peek (xem phần tử đầu không lấy ra), hoặc thứ tự phi-FIFO (ưu tiên). Đây là những lý do hiếm; nếu gặp, cân nhắc thư viện đã kiểm chứng trước khi tự viết.
MS queue là kiến thức nền quý, dù ít dùng trực tiếp. Hiểu cơ chế helping và cách phối hợp head/tail giúp bạn đọc được code lock-free của người khác (runtime Go, thư viện hiệu năng cao) và suy luận đúng về đồng thời. Học nó để hiểu, không nhất thiết để viết.
Đánh đổi cần cân nhắc
Độ phức tạp của queue lock-free rất cao so với lợi ích. MS queue có nhiều trường hợp tinh vi (node giả, helping, kiểm tra tail chưa đổi hai lần) mà một lỗi nhỏ gây bug đồng thời cực khó tìm. So với make(chan T, n) một dòng, chi phí bảo trì không đáng trừ khi có lý do rất cụ thể. Đơn giản là một tính năng.
Node-per-element gây áp lực cấp phát và GC. Mỗi Enqueue cấp một node mới; queue rỗng vẫn giữ node giả. Với throughput cao, đây là nguồn rác đáng kể (channel dùng ring buffer cấp phát một lần). Nếu tự viết, cân nhắc pool node — nhưng điều đó lại mở ra vấn đề ABA đã bàn ở bài trước.
Đo trên mẫu tải thật. Như cả stack lẫn queue cho thấy, "lock-free nhanh hơn" là niềm tin sai cho tranh chấp cao một hotspot. Nếu bạn đang cân nhắc lock-free vì hiệu năng, hãy đo channel/mutex trước — chúng thường thắng và đơn giản hơn nhiều. Chỉ khi đo chứng minh chúng là nút thắt và không có cách giảm tranh chấp (sharding), mới tính tới lock-free.
Ba ý mang về
- Hàng đợi lock-free khó hơn stack vì có hai điểm truy cập: Michael-Scott queue dùng head/tail riêng, node giả, và cập nhật hai bước (gắn node rồi đẩy tail) với cơ chế helping — goroutine khác giúp đẩy tail hộ goroutine treo giữa chừng, đảm bảo tiến triển thật sự; đo thật đúng đắn 8000/8000 phần tử, FIFO,
-racesạch. - Channel vẫn nhanh hơn MS queue lock-free ~3 lần dưới tranh chấp: đo thật 330 ns so với 110 ns — queue lock-free đụng hai hotspot (head + tail), mỗi thao tác 2 CAS + bão retry, trong khi channel được tối ưu cực kỹ ở tầng runtime (ring buffer, đánh thức thông minh).
- Dùng channel cho gần như mọi producer-consumer FIFO: nhanh, đơn giản, tích hợp select/close/range — chỉ tự viết queue lock-free khi cần thứ channel không làm được (unbounded không block, peek, ưu tiên phi-FIFO), và luôn đo trước; MS queue là kiến thức nền quý để hiểu code lock-free, không phải để viết hằng ngày.
Phần sau ta xem một cấu trúc đồng thời của thư viện chuẩn được tối ưu sẵn: Phần sau mổ xẻ sync.Map — cấu trúc read/dirty hai tầng bên trong, khi nào nó thắng map+mutex, và vì sao nó không phải "map nhanh hơn" cho mọi trường hợp.