Hình dung hai cách chuyển một gói hàng. Cách thứ nhất: trao tận tay — người giao đứng chờ tới khi người nhận có mặt, đặt gói vào tay họ, và chỉ rời đi khi chắc chắn gói đã sang tay. Cách thứ hai: bỏ vào một tủ khoá có N ngăn — người giao cứ bỏ vào rồi đi, miễn là còn ngăn trống; tủ đầy thì phải đứng chờ, tủ rỗng thì người nhận phải chờ. Channel không đệm là cuộc trao tận tay. Channel có đệm là cái tủ khoá. Giữ hai hình ảnh đó thì gần như mọi hành vi của channel trở nên hiển nhiên. Channel là kiểu dữ liệu có cú pháp riêng trong Go — không phải thư viện; bài này về ngữ nghĩa của nó.
Không đệm: một cuộc bắt tay
ch := make(chan int) // cap = 0
go func() { time.Sleep(50*time.Millisecond); ch <- 1 }()
<-ch
người nhận chờ 50ms -> gửi và nhận GẶP NHAU
Channel không đệm không chứa gì cả. Lệnh gửi chặn tới khi có người nhận, và ngược lại. Hai goroutine gặp nhau tại đúng một thời điểm — đúng nghĩa trao tận tay.
Đây là điểm khác BlockingQueue của Java: nó không phải hàng đợi, nó là điểm hẹn. Và vì thế nó cho bạn một bảo đảm mạnh — khi lệnh gửi trả về, bạn biết chắc người nhận đã cầm được giá trị.
Có đệm: hàng đợi
cb := make(chan int, 2)
cb <- 1
cb <- 2 // vẫn không chặn
gửi 2 vào đệm 2: 0s (không chặn), len=2 cap=2
Gửi chỉ chặn khi đệm đầy; nhận chỉ chặn khi đệm rỗng — tủ khoá đầy thì đứng chờ, rỗng thì người nhận chờ. len() là số phần tử đang có, cap() là sức chứa.
Chọn kích thước đệm thế nào? Ba nguyên tắc:
Đệm 0 khi bạn cần bảo đảm bên nhận đã xử lý — bàn giao đồng bộ.
Đệm nhỏ cố định để hấp thụ dao động ngắn, ví dụ make(chan Viec, 100).
Đừng dùng đệm rất lớn để "cho chắc". Nó chỉ giấu vấn đề: bên sản xuất nhanh hơn bên tiêu thụ thì đệm nào cũng đầy, chỉ là muộn hơn. Và đệm lớn nghĩa là nhiều dữ liệu bị mất khi chương trình dừng đột ngột. Đây đúng bài học LinkedBlockingQueue không giới hạn ở bài 73 sê-ri Java.
Đóng channel
close(cc)
v, ok := <-cc
nhận sau close : v=7 ok=true (còn dữ liệu trong đệm)
nhận tiếp : v=0 ok=false (đã cạn)
Đóng không vứt dữ liệu — người nhận vẫn lấy hết những gì còn trong đệm, rồi mới nhận zero value với ok=false.
range trên channel dừng tự động khi đóng:
1 2 3
Đây là mẫu chuẩn cho bên tiêu thụ:
for v := range ch {
xuLy(v)
}
// tới đây nghĩa là channel đã đóng và cạn
Không đóng channel thì vòng range này chặn vĩnh viễn — và đó là nguồn rò rỉ goroutine phổ biến nhất, bài 38 sẽ nói.
Bốn thao tác gây panic
gửi vào channel đã đóng -> panic: send on closed channel
đóng channel đã đóng -> panic: close of closed channel
Cộng hai cái nữa: đóng channel nil, và gửi vào channel nil (cái sau chặn vĩnh viễn chứ không panic).
Vì close là thao tác nguy hiểm, Go có một quy ước rất chặt, và đáng thuộc lòng: chỉ bên GỬI được đóng channel, và chỉ khi có đúng một bên gửi. Bên nhận không bao giờ đóng. Nhiều bên gửi thì không ai trong số đó đóng — dùng sync.WaitGroup để biết khi nào tất cả xong rồi mới đóng ở một goroutine điều phối riêng. Lý do đơn giản: bên nhận không biết bên gửi còn gửi nữa không, nên đóng từ phía nhận sẽ gây panic cho bên gửi.
Mẫu chuẩn cho nhiều bên gửi:
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1)
go func() { defer wg.Done(); ch <- lam() }()
}
go func() { wg.Wait(); close(ch) }() // goroutine riêng chỉ để đóng
for v := range ch { ... }
Channel một chiều
func sanXuat(out chan<- int) // chỉ gửi
func tieuThu(in <-chan int) // chỉ nhận
Mũi tên trong kiểu giới hạn hướng. Trình biên dịch chặn nếu bạn nhận từ chan<-.
Đây là công cụ tài liệu tốt: nhìn chữ ký là biết hàm này sản xuất hay tiêu thụ. Và nó chặn luôn việc đóng channel từ phía nhận — close trên <-chan là lỗi biên dịch.
Channel nil chặn vĩnh viễn
var c chan int
<-c // chặn mãi mãi
Nghe vô dụng, nhưng nó là mẹo quan trọng trong select — bài mai sẽ dùng: gán một channel về nil để "tắt" nhánh đó.
Deadlock được phát hiện
Nếu mọi goroutine đều chặn, Go phát hiện và báo:
fatal error: all goroutines are asleep - deadlock!
Đây là fatal error, không recover được. Nó chỉ bắt được trường hợp toàn bộ chương trình đứng — nếu còn một goroutine chạy (kể cả vòng lặp vô ích), Go không báo gì.
Muốn thấy tận mắt khác biệt giữa điểm hẹn và hàng đợi, thử đúng ba mươi giây:
func main() {
ch := make(chan int)
ch <- 1
}
fatal error: all goroutines are asleep - deadlock!
Channel không đệm cần người nhận đang chờ — không có ai nhận, cuộc trao tận tay không bao giờ xảy ra, và chương trình chết. Thêm make(chan int, 1) — giờ có một ngăn tủ để bỏ vào — và nó chạy.
Mẫu số chung
Hai khái niệm lõi của bài này — "điểm hẹn so với hàng đợi" và "đệm có giới hạn so với vô hạn" — không phải của riêng Go, mà là hai ý cũ của khoa học máy tính, xuất hiện ở mọi hệ thống có luồng dữ liệu giữa các bên.
- Điểm hẹn không đệm chính là synchronous channel trong lý thuyết CSP của Hoare, có từ ngôn ngữ Occam. Rust phơi bày rõ ràng:
mpsc::channel()là hàng đợi vô hạn, cònmpsc::sync_channel(0)là điểm hẹn không đệm,sync_channel(n)là đệm n — đúng bộ ba của Go. Java thậm chí có một lớp tên thẳng làSynchronousQueue— một hàng đợi không chứa gì, đúng channel không đệm, vàExecutorsdùng nó để trao việc tận tay. - Đệm có giới hạn là một tính năng, không phải hạn chế — vì nó cho bạn backpressure (phản áp): khi bên tiêu thụ chậm, bên sản xuất buộc phải chờ, và cái chờ đó là tín hiệu "hãy chậm lại". Đệm vô hạn (cap khổng lồ của Go,
LinkedBlockingQueuecủa Java, stream Node không đặthighWaterMark) không sửa được bên tiêu thụ chậm — nó chỉ đổi "chờ ngay" thành "hết bộ nhớ muộn hơn và mất nhiều dữ liệu hơn". Kafka, Reactive Streams, stream của Node đều dựng hẳn cơ chế backpressure vì đúng bài học này.
Sợi chỉ chung đáng mang theo: một hàng đợi có giới hạn ép bạn đối diện với sự mất cân bằng tốc độ ngay lúc nó xảy ra, thay vì giấu nó cho tới khi sập — nên chọn đệm nhỏ và cố định gần như luôn đúng hơn chọn đệm lớn "cho chắc". Và quy tắc "chỉ bên gửi đóng channel" là một cách viết của một luật phổ quát: kết thúc luồng là tín hiệu của bên sản xuất, không phải bên tiêu thụ — Kafka có producer báo hết, stream có .end(), iterator có cạn phần tử; ai đang đổ dữ liệu vào mới là người biết khi nào hết, và mới là người được quyền nói "xong".
Ngày mai: select — chờ nhiều channel cùng lúc.
Bài tập làm thử
Bài 1 (đọc hiểu). Chương trình sau sẽ in ra gì, hay xảy ra chuyện gì khác?
func main() {
ch := make(chan int)
ch <- 1
fmt.Println(<-ch)
}
Đáp án
Chương trình không in được gì mà dừng với fatal error: all goroutines are asleep - deadlock!. ch là channel không đệm (cap = 0), nên lệnh gửi ch <- 1 chặn cho tới khi có người nhận đang chờ sẵn — nhưng ở đây chưa có goroutine nào khác đang chờ nhận (dòng nhận nằm ngay sau, trong cùng goroutine, chưa chạy tới). Channel không đệm là một cuộc "bắt tay", không phải kho chứa; không có ai nhận thì cuộc bắt tay không xảy ra và toàn bộ chương trình (chỉ có một goroutine) bị chặn vĩnh viễn — Go phát hiện và báo deadlock.
Bài 2 (sửa lỗi). Đoạn mã sau có nhiều goroutine cùng gửi vào một channel rồi một goroutine điều phối gọi close, nhưng có lỗi thiết kế khiến chương trình panic ngẫu nhiên. Tìm và sửa.
ch := make(chan int)
for i := 0; i < 5; i++ {
go func() {
ch <- lam()
close(ch) // mỗi goroutine tự đóng
}()
}
for v := range ch {
fmt.Println(v)
}
Đáp án
Lỗi: có năm goroutine gửi vào cùng một channel, nhưng mỗi goroutine lại tự gọi close(ch) sau khi gửi. Quy ước bắt buộc của Go là "chỉ bên gửi được đóng channel, và chỉ khi có đúng một bên gửi" — với nhiều bên gửi, không ai trong số đó được đóng riêng lẻ, vì gọi close trên channel đã đóng sẽ panic (close of closed channel), và gửi vào channel đã đóng cũng panic (send on closed channel). Sửa bằng cách dùng sync.WaitGroup để biết khi nào tất cả đã xong, rồi đóng ở một goroutine điều phối riêng:
ch := make(chan int)
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func() {
defer wg.Done()
ch <- lam()
}()
}
go func() { wg.Wait(); close(ch) }()
for v := range ch {
fmt.Println(v)
}
Bài 3 (đọc hiểu). Sau khi close(cc) được gọi trên một channel có đệm còn dữ liệu, đoạn mã sau in ra gì?
cc := make(chan int, 2)
cc <- 7
close(cc)
v1, ok1 := <-cc
v2, ok2 := <-cc
fmt.Println(v1, ok1, v2, ok2)
Đáp án
In ra 7 true 0 false. close không vứt dữ liệu đang có trong đệm — người nhận vẫn lấy hết những gì còn lại (v1=7, ok1=true), và chỉ khi channel đã cạn thì lần nhận tiếp theo mới trả về zero value kèm ok=false (v2=0, ok2=false).
Bài 4 (vận dụng thực tế). Bạn cần xây một hàng đợi công việc nội bộ nhận việc từ nhiều nơi, có khả năng hấp thụ những đợt tăng đột biến ngắn hạn mà không cần một bộ đệm khổng lồ "cho chắc". Viết khai báo channel phù hợp và giải thích tại sao không nên chọn đệm rất lớn.
Đáp án
viec := make(chan Viec, 100) // đệm nhỏ, cố định
Theo bài viết, đệm nhỏ cố định là lựa chọn để hấp thụ dao động ngắn. Đừng dùng đệm rất lớn "cho chắc" vì nó chỉ giấu vấn đề: nếu bên sản xuất nhanh hơn bên tiêu thụ một cách bền vững (không phải chỉ dao động ngắn), đệm nào cũng sẽ đầy — chỉ là muộn hơn và khó phát hiện hơn. Thêm nữa, đệm lớn nghĩa là nhiều dữ liệu hơn sẽ bị mất nếu chương trình dừng đột ngột, vì dữ liệu đang nằm trong bộ nhớ của channel chưa được xử lý.
Bài 5 (bẫy/đánh đổi). Giải thích khái niệm "backpressure" (phản áp) mà bài viết nói tới, và tại sao "đệm có giới hạn là một tính năng, không phải hạn chế".
Đáp án
Backpressure là hiện tượng: khi đệm của channel có giới hạn và bị đầy, bên gửi buộc phải chờ (bị chặn) cho tới khi có chỗ trống — cái chờ đó là tín hiệu phản hồi ngược "hãy chậm lại" gửi tới bên sản xuất. Đây là một tính năng vì nó buộc hệ thống đối diện với sự mất cân bằng tốc độ giữa sản xuất và tiêu thụ ngay lúc nó xảy ra, cho phép xử lý chủ động (ví dụ giảm tốc độ nhận request mới). Ngược lại, một đệm vô hạn (hoặc rất lớn) không sửa được vấn đề bên tiêu thụ chậm — nó chỉ trì hoãn triệu chứng, đổi "chờ ngay bây giờ" thành "hết bộ nhớ muộn hơn và mất nhiều dữ liệu hơn" khi hệ thống sụp đổ. Đây là lý do các hệ thống thực tế như Kafka, Reactive Streams, và stream của Node đều dựng hẳn cơ chế backpressure thay vì dùng bộ đệm không giới hạn.