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)
}

关闭规则很重要:

  1. 只有发送方应该关闭 channel
  2. 不要在接收方关闭 channel
  3. 不要重复关闭 channel
  4. 不要向已经关闭的 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.ContextDone() 本质上也是这个思路:取消时关闭一个只读 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 代码通常就不会太差。