Hình dung select như ngồi trông nhiều cần câu cùng lúc, mỗi cần thả xuống một channel: bạn giật cái nào rung trước. Nói cách khác, nó là switch cho channel — chờ nhiều channel cùng lúc và xử lý cái nào sẵn sàng trước.

Cơ bản

select {
case v := <-a:
	fmt.Println("nhận", v)
case v := <-b:
	fmt.Println("nhận", v)
}

select chặn tới khi có ít nhất một nhánh sẵn sàng. Nhánh cũng có thể là lệnh gửi:

select {
case ch <- v:
	// gửi được
case <-ctx.Done():
	return ctx.Err()
}

Nhiều nhánh sẵn sàng: ngẫu nhiên

  1000 lượt: x=477  y=523

Khi nhiều nhánh cùng sẵn sàng, Go chọn ngẫu nhiên đều — hai cần cùng rung thì bốc thăm. Đây là quyết định thiết kế, giống việc xáo trộn thứ tự duyệt map ở bài 10.

Lý do: nếu Go chọn theo thứ tự viết, nhánh đầu tiên sẽ luôn thắng và nhánh sau chết đói. Ngẫu nhiên bảo đảm mọi nhánh đều có cơ hội.

Hệ quả: đừng viết mã dựa vào thứ tự nhánh. Cần ưu tiên thì phải làm tường minh:

select {
case v := <-uuTien:
	xuLy(v)
default:
	select {
	case v := <-uuTien: xuLy(v)
	case v := <-thuong: xuLy(v)
	}
}

default: không chặn

  không có gì, đi tiếp ngay

Có default, select không bao giờ chặn — không nhánh nào sẵn sàng thì chạy default ngay, thay vì ngồi lì chờ cá cắn câu.

Hai công dụng chính:

// thử nhận, không chờ
select {
case v := <-ch: xuLy(v)
default:
}

// thử gửi, bỏ qua nếu đầy — mẫu "vứt khi quá tải"
select {
case ch <- v:
default:
	metrics.Bo++
}

Mẫu thứ hai hữu ích cho log hoặc số liệu giám sát: thà mất một điểm dữ liệu còn hơn chặn đường xử lý chính.

Hết giờ

select {
case v := <-ch:
	xuLy(v)
case <-time.After(40 * time.Millisecond):
	return errors.New("hết giờ")
}
  hết giờ sau 40ms

Đây là cái đồng hồ báo thức đặt cạnh cần câu: không con nào cắn trong 40ms thì dọn đồ đi. Một cảnh báo về time.After: nó tạo một timer không được thu hồi cho tới khi hết hạn. Trong vòng lặp chạy nhiều lần, đó là rò rỉ:

for {
	select {
	case v := <-ch: xuLy(v)
	case <-time.After(time.Second):   // tạo timer MỚI mỗi vòng
	}
}

Với vòng lặp nóng, dùng time.NewTimer và Reset, hoặc tốt hơn là dùng context.WithTimeout bên ngoài vòng lặp.

Từ Go 1.23, bộ thu gom rác dọn được timer chưa hết hạn nên vấn đề này nhẹ đi nhiều — nhưng thói quen vẫn nên giữ.

Mẹo: gán channel về nil để tắt nhánh

Bài 32 đã nói channel nil chặn vĩnh viễn. Trong select, điều đó nghĩa là nhánh ấy không bao giờ được chọn — và đó là cách tắt nó, như cuốn một cần câu lên cất đi để khỏi trông nó nữa:

for con > 0 {
	select {
	case v, ok := <-d1:
		if !ok { d1 = nil; con--; continue }   // TẮT nhánh này
		xuLy(v)
	case v, ok := <-d2:
		if !ok { d2 = nil; con--; continue }
		xuLy(v)
	}
}
  d1: 1
  d1 cạn -> tắt nhánh
  d2: 2
  d2 cạn -> tắt nhánh

Không có mẹo này, channel đã đóng sẽ luôn sẵn sàng (trả zero value ngay), và select sẽ quay tít trong vòng lặp bận.

Đây là mẫu chuẩn để hợp nhất nhiều nguồn có số lượng khác nhau, và nó là lý do chính khiến channel nil tồn tại.

select rỗng và vòng lặp vô hạn

select {}       // chặn vĩnh viễn

Dùng ở cuối main khi mọi việc chạy trong goroutine. Nhưng nó cũng gây fatal error: all goroutines are asleep nếu không còn goroutine nào chạy — nên trong dịch vụ thật, hãy chờ tín hiệu hệ điều hành thay vì select{}. Bài 58 sẽ nói.

Mẫu kết hợp với context

Đây là mẫu bạn sẽ viết nhiều nhất trong dịch vụ:

for {
	select {
	case <-ctx.Done():
		return ctx.Err()
	case viec, ok := <-hangDoi:
		if !ok { return nil }
		xuLy(viec)
	}
}

Nhánh ctx.Done() luôn ở đầu theo quy ước — dù thứ tự không ảnh hưởng chức năng, nó giúp người đọc thấy ngay vòng lặp này dừng được.

Nếu chỉ thử một thứ sau bài này, thử đúng cái bẫy channel-đóng trong ba mươi giây:

ch := make(chan int)
close(ch)
for i := 0; i < 3; i++ {
	select {
	case v := <-ch: fmt.Println("nhận", v)
	}
}

In ra nhận 0 ba lần, tức thì. Channel đã đóng luôn sẵn sàng — một cái cần mắc hook dưới đáy, rung liên tục dù chẳng có cá. Nếu đây là vòng lặp vô hạn, CPU của bạn lên 100%. Ba mươi giây đó giải thích vì sao mẹo gán nil tồn tại.

Mẫu số chung

select là ghép kênh theo mức sẵn sàng: chờ nhiều nguồn sự kiện cùng lúc và hành động theo cái nào sẵn sàng trước — đúng nguyên thuỷ mà lời gọi select/poll/epoll của hệ điều hành (cái nó mượn tên) cung cấp, và cùng hình dạng với Selector của Java NIO, Promise.race của JavaScript, asyncio.wait(FIRST_COMPLETED) của Python, tokio::select! của Rust. Ở đâu cũng kèm hai thứ: một phép thăm dò không chặn (default, hay hạn chờ bằng 0) và một nhánh hết giờ. Và lựa chọn công bằng — bốc thăm giữa các nhánh cùng sẵn sàng để không nhánh nào chết đói — lặp lại ở mọi nơi một bộ lập lịch phải chọn giữa các việc đã sẵn sàng (Go cũng xáo trộn thứ tự duyệt map); hợp đồng cố ý không có thứ tự, nên dựa vào thứ tự nhánh là một lỗi.

Điều thứ hai, đắt hơn vẻ ngoài: trong bất cứ vòng ghép kênh nào, một nguồn đã "sẵn sàng vĩnh viễn" sẽ quay tít cả vòng vì luôn thắng — channel đóng trả zero value tức thì là cùng một cái bẫy với một socket ở EOF mà select cứ báo đọc được, hay một cú đánh thức giả (spurious wakeup) của biến điều kiện. Thuốc chữa là thôi trông cái nguồn đã chết — gán channel về nil, gỡ fd khỏi tập — đúng là lý do Go cho một channel nil chặn mãi. Và hãy đặt huỷ ngay trong select như một nhánh nữa (ctx.Done()), đừng chắp bên ngoài: bạn chờ tín hiệu "dừng" y như chờ một nguồn việc.

Ngày mai: sync.Mutex — khi nào khoá thắng channel.

Bài tập làm thử

Bài 1 (đọc hiểu). Đoạn mã sau chạy 1000 lần, mỗi lần cả channel a và b đều sẵn sàng cùng lúc. Kết quả in ra gần đúng là gì, và tại sao không phải luôn luôn in "nhận từ a"?

select {
case v := <-a:
	fmt.Println("nhận từ a", v)
case v := <-b:
	fmt.Println("nhận từ b", v)
}
Đáp án

Kết quả gần chia đôi ngẫu nhiên, ví dụ khoảng 477 lần nhận từ a và 523 lần nhận từ b (giống số liệu ví dụ trong bài: x=477, y=523). Khi nhiều nhánh cùng sẵn sàng, Go chọn ngẫu nhiên đều, không theo thứ tự viết trong mã nguồn — đây là quyết định thiết kế cố ý để đảm bảo công bằng: nếu Go luôn ưu tiên nhánh đầu tiên, nhánh viết sau sẽ "chết đói" (không bao giờ được chọn khi nhánh đầu luôn sẵn sàng).

Bài 2 (sửa lỗi). Đoạn mã sau chạy trong vòng lặp nóng (gọi nhiều lần liên tục) và bị rò rỉ tài nguyên timer. Sửa theo gợi ý của bài viết:

for {
	select {
	case v := <-ch:
		xuLy(v)
	case <-time.After(time.Second):
		lamGiHet:
	}
}
Đáp án

time.After tạo một timer mới ở mỗi vòng lặp, và timer đó không được thu hồi cho tới khi hết hạn — trong vòng lặp chạy liên tục, đây là rò rỉ (dù từ Go 1.23 GC dọn được timer chưa hết hạn nên vấn đề nhẹ hơn, thói quen vẫn nên giữ). Sửa bằng time.NewTimer và Reset, hoặc tốt hơn là đưa context.WithTimeout ra ngoài vòng lặp:

timer := time.NewTimer(time.Second)
defer timer.Stop()
for {
	if !timer.Stop() {
		<-timer.C
	}
	timer.Reset(time.Second)
	select {
	case v := <-ch:
		xuLy(v)
	case <-timer.C:
		lamGiHet()
	}
}

Bài 3 (vận dụng). Viết một vòng lặp Go hợp nhất hai channel đầu vào d1 và d2 (mỗi channel đóng lại khi hết dữ liệu), xử lý mọi giá trị nhận được từ cả hai, và dừng vòng lặp khi cả hai đã đóng — dùng đúng mẹo "gán channel về nil" trong bài để tránh vòng lặp bận (busy loop).

Đáp án
con := 2
for con > 0 {
	select {
	case v, ok := <-d1:
		if !ok {
			d1 = nil
			con--
			continue
		}
		xuLy(v)
	case v, ok := <-d2:
		if !ok {
			d2 = nil
			con--
			continue
		}
		xuLy(v)
	}
}

Khi một channel đóng, gán nó về nil khiến nhánh đó "chặn vĩnh viễn" và không bao giờ được select chọn nữa — thay vì cứ liên tục nhận zero value từ channel đã đóng (gây vòng lặp bận, CPU 100%).

Bài 4 (bẫy/đánh đổi). Đoạn mã sau chạy 3 lần lặp trên một channel đã đóng. Cho biết kết quả in ra và giải thích tại sao đây là một cái bẫy nguy hiểm nếu đặt trong vòng lặp for {} vô hạn:

ch := make(chan int)
close(ch)
for i := 0; i < 3; i++ {
	select {
	case v := <-ch:
		fmt.Println("nhận", v)
	}
}
Đáp án

In ra nhận 0 ba lần, gần như tức thì. Channel đã đóng luôn sẵn sàng — mỗi lần nhận trả về ngay zero value của kiểu channel (ở đây là 0 cho int) mà không chặn. Nếu đặt trong vòng lặp vô hạn for {} thay vì lặp 3 lần cố định, nhánh này sẽ luôn thắng ngay lập tức ở mọi lượt select, khiến vòng lặp quay liên tục không chặn bao giờ — đẩy CPU lên 100%. Đây chính là lý do mẹo gán channel về nil (Bài 3) tồn tại: phải "cất cần câu" của channel đã đóng đi, không được tiếp tục trông nó.

Bài 5 (đọc hiểu/vận dụng). Viết đoạn mã dùng mẫu "vứt khi quá tải" (drop when overloaded) để gửi số liệu giám sát vào channel metricsCh mà không bao giờ chặn luồng xử lý chính nếu channel đầy.

Đáp án
select {
case metricsCh <- diem:
default:
	metrics.Bo++
}

Có default, select không bao giờ chặn: nếu metricsCh đầy (không gửi được ngay), nhánh default chạy ngay lập tức, tăng bộ đếm số điểm bị bỏ thay vì chờ. Nguyên tắc: thà mất một điểm dữ liệu giám sát còn hơn làm nghẽn đường xử lý chính.