2. Go 并发通信:channel 与 select
2. Go 并发通信:channel 与 select
Go 有一句经典的话:不要通过共享内存来通信,而要通过通信来共享内存。channel 就是这句话的语言级工具。它不仅能传值,还能表达同步、所有权转移、完成通知和取消信号。
channel 基础
创建 channel:
ch := make(chan int)
发送和接收:
ch <- 1 // 发送
value := <-ch // 接收
无缓冲 channel 的发送和接收会互相等待:
func main() {
ch := make(chan string)
go func() {
ch <- "hello" // 等待接收方
}()
msg := <-ch // 等待发送方
fmt.Println(msg)
}
这就是 channel 的第一个作用:传值的同时完成同步。发送方确认接收方已经接走了值,接收方确认发送方已经准备好了值。
有缓冲与无缓冲
无缓冲 channel:
ch := make(chan int)
特点:发送和接收必须同时准备好,适合强同步。
有缓冲 channel:
ch := make(chan int, 3)
特点:缓冲没满时发送不阻塞,缓冲没空时接收不阻塞,适合削峰、队列、限流。
ch := make(chan int, 2)
ch <- 1 // 不阻塞
ch <- 2 // 不阻塞
// ch <- 3 // 阻塞:buffer 已满
fmt.Println(<-ch) // 1
fmt.Println(<-ch) // 2
缓冲不是越大越好。buffer 太大可能掩盖消费慢的问题,让延迟和内存悄悄堆起来。经验上,buffer 应该表达明确的业务含义,比如 worker 数量、批处理大小、允许排队的上限。
关闭 channel
关闭 channel 表示:不会再有新值发送进来。
close(ch)
接收方可以用第二个返回值判断 channel 是否关闭:
v, ok := <-ch
if !ok {
fmt.Println("channel closed")
}
也可以用 range 持续接收,直到 channel 被关闭:
for v := range ch {
fmt.Println(v)
}
关闭规则很重要:
- 只有发送方应该关闭 channel
- 不要在接收方关闭 channel
- 不要重复关闭 channel
- 不要向已经关闭的 channel 发送数据
// ✅ 生产者负责关闭
func producer() <-chan int {
out := make(chan int)
go func() {
defer close(out)
for i := 0; i < 3; i++ {
out <- i
}
}()
return out
}
接收关闭后的 channel 会立即返回零值:
ch := make(chan int)
close(ch)
v, ok := <-ch
fmt.Println(v, ok) // 0 false
这也是为什么需要 ok。如果只写 v := <-ch,你无法区分是真的收到 0,还是 channel 已经关闭。
channel 方向
函数参数可以限制 channel 方向,让 API 更清晰:
func sendOnly(ch chan<- int) {
ch <- 1
}
func receiveOnly(ch <-chan int) {
fmt.Println(<-ch)
}
常见写法是生产者返回只读 channel:
func generate(nums ...int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for _, n := range nums {
out <- n
}
}()
return out
}
调用方只能读,不能写,也不能关闭。这能把”谁拥有写入权”表达在类型系统里。
select:等待多个事件
select 可以同时等待多个 channel 操作,谁先准备好就执行谁:
select {
case msg := <-messages:
fmt.Println("message:", msg)
case sig := <-signals:
fmt.Println("signal:", sig)
}
如果多个 case 同时可执行,Go 会随机选一个,避免固定顺序导致饥饿。
超时控制
select {
case result := <-resultCh:
return result, nil
case <-time.After(500 * time.Millisecond):
return "", errors.New("timeout")
}
注意:循环里频繁使用 time.After 会不断创建 timer。高频路径更适合复用 time.Timer,或者直接使用 context.WithTimeout。
取消控制
select {
case result := <-resultCh:
return result, nil
case <-ctx.Done():
return "", ctx.Err()
}
这是 Go 并发里最常见的模式:业务结果和取消信号同时等待。
非阻塞发送与接收
default 会让 select 变成非阻塞:
select {
case ch <- value:
fmt.Println("sent")
default:
fmt.Println("channel full")
}
select {
case v := <-ch:
fmt.Println("received:", v)
default:
fmt.Println("no value")
}
非阻塞 select 适合丢弃日志、指标采样、尝试投递任务等场景。不要用它写忙等循环:
// ❌ CPU 空转
for {
select {
case v := <-ch:
handle(v)
default:
}
}
如果没有事可做,就应该阻塞等待,或者加 ticker 控制节奏。
nil channel 的妙用
nil channel 的发送和接收会永久阻塞:
var ch chan int
// <-ch // 永久阻塞
// ch <- 1 // 永久阻塞
在 select 里,nil channel 对应的 case 永远不会被选中。这个特性可以用来动态启用或禁用 case:
var out chan<- int
var next int
for len(queue) > 0 || input != nil {
if len(queue) > 0 {
out = output
next = queue[0]
} else {
out = nil // 禁用发送 case
}
select {
case v, ok := <-input:
if !ok {
input = nil // 禁用接收 case
continue
}
queue = append(queue, v)
case out <- next:
queue = queue[1:]
}
}
这个技巧适合写事件循环、队列转发器、pipeline 中间层。它很强,但也容易让代码难读;普通业务代码不要为了炫技强行使用。
done channel 与广播
关闭 channel 可以同时唤醒所有接收者,所以常被用作广播信号:
done := make(chan struct{})
for i := 0; i < 3; i++ {
go func(id int) {
<-done
fmt.Printf("worker %d exit\n", id)
}(i)
}
close(done) // 所有 worker 同时收到信号
context.Context 的 Done() 本质上也是这个思路:取消时关闭一个只读 channel,所有监听者都会醒来。
在现代 Go 代码里,跨 API 的取消优先用 context;在局部组件内部,也可以用 done chan struct{} 表达简单广播。
channel 不是所有问题的答案
channel 适合传递所有权、排队、同步事件,但不适合所有共享状态。
// 计数器、缓存、连接池状态这类共享数据
// 用 mutex 往往比绕一层 channel 更简单
type Counter struct {
mu sync.Mutex
n int
}
选择标准可以很朴素:
- 需要传值、排队、串联阶段:优先 channel
- 需要保护共享内存的一小段临界区:优先 mutex
- 需要等待一组任务结束:WaitGroup
- 需要限制并发数量:buffered channel 或 semaphore
- 需要传播取消和超时:context
小结
channel 的核心语义是:传值、同步、关闭通知。
写 channel 代码时最重要的几个问题:
- 谁发送?
- 谁接收?
- 谁关闭?
- 如果没人接收,发送方会不会永远阻塞?
- 如果上游提前退出,下游会不会永远等待?
能回答这五个问题,channel 代码通常就不会太差。