3. Go 多路等待:select

上一章的 channel 解决了两端如何交接数据。但一个 goroutine 往往不只等一件事:它可能既要等结果,也要等超时,还要在调用方取消时退出。

如果依次写 <-results 和 <-done,程序就会先卡在结果上,无法及时观察取消。select 用来把这些等待放在一起,让当前能够执行的通信操作推动程序继续运行。

一次选择一个通信操作

select 看起来像 switch,但 case 写的是 channel 的发送或接收:

select {
case value := <-results:
    fmt.Println("结果:", value)
case <-done:
    fmt.Println("收到结束信号")
}

这里只执行一个分支,然后离开 select。如果需要持续监听,要在外层加 for。

按 Go 语言规范,有多个通信操作可以执行时,会通过均匀的伪随机选择决定一个分支;没有操作可执行时,有 default 就执行它,否则阻塞等待。空的 select {} 会永久阻塞。

因此,分支写在前面不代表优先级更高。结果与取消同时就绪时,仍可能选择结果;随机选择也不等于严格轮流执行,更不能保证某个分支在有限次数内一定被选中。

发送也能参与选择

下面的函数需要导入标准库 context。它要么把值交出去,要么在取消后停止等待:

func send(ctx context.Context, out chan<- int, value int) error {
    select {
    case out <- value:
        return nil
    case <-ctx.Done():
        return ctx.Err()
    }
}

如果只在发送之前检查一次取消,然后执行普通的 out <- value,发送仍可能无限等待。把发送本身放入 select,才能在等待期间响应取消。

这里仍然要求调用方保证 out 不会被并发关闭。select 不会把向已关闭 channel 发送变成安全操作,被选中时一样会 panic。

default:这一次不等待

某些数据允许丢弃,可以尝试投递,暂时发不出去就走其他逻辑:

select {
case logs <- message:
    // 已投递。
default:
    // 当前无法投递,按业务约定丢弃。
}

default 表示现在没有通信分支可执行,并不只意味着“缓冲满了”。无缓冲 channel 没有接收者、channel 为 nil,也会走到这里。

接收也可以非阻塞:

select {
case value, ok := <-jobs:
    if !ok {
        fmt.Println("任务流已结束")
    } else {
        fmt.Println(value)
    }
default:
    fmt.Println("暂时没有任务")
}

关闭的 channel 可以立即接收,所以不会因为它已经关闭就进入 default。不检查 ok,就可能把关闭后的零值当成任务。

不要把空的 default 放进持续监听的循环。没有事件时,循环会不断执行,造成 CPU 空转。需要等事件就让 select 阻塞;需要周期性工作,可以增加 time.Ticker 的接收分支。

超时:给等待加一个期限

time.After 返回一个 channel,在指定时间过去后提供时间值,可以直接作为另一个等待条件:

// 需要导入 errors 和 time。
func awaitResult(results <-chan string) (string, error) {
    select {
    case result, ok := <-results:
        if !ok {
            return "", errors.New("result channel closed")
        }
        return result, nil
    case <-time.After(500 * time.Millisecond):
        return "", errors.New("waiting for result timed out")
    }
}

这限制的是调用方等待结果的时间,不会自动终止产生结果的 goroutine。如果生产者随后向一个无人接收的无缓冲 channel 发送,它仍会阻塞。需要停止整个任务时,应当把取消信号传给生产者。

在循环里还要明确期限针对什么。如果每轮调用 time.After,每轮都会创建新的 timer,表达的是一次新的等待期限。持续有其他事件时,这种写法可能始终到不了超时分支。

如果要限制整段消费过程的总时间,应在循环外创建 timer:

// 需要导入 errors、fmt 和 time。
func consumeUntilTimeout(input <-chan int) error {
    timer := time.NewTimer(time.Second)
    defer timer.Stop()

    for {
        select {
        case value, ok := <-input:
            if !ok {
                return nil
            }
            fmt.Println(value)
        case <-timer.C:
            return errors.New("consumption timed out")
        }
    }
}

超时信号要等执行回到 select 才能被处理。如果分支里的工作本身耗时很长,timer 不会中断它;同时就绪的数据也可能被选中,所以这不是精确到点的强制停止。

time 文档说明,Go 1.23 起垃圾回收器可以回收不再被引用的未到期 timer,不应再笼统地把 time.After 描述成必然泄漏。高频循环反复创建 timer 仍有开销,是否复用应根据实际路径决定。

取消:让生产者和消费者都有退出路径

跨函数传递取消与超时,通常使用 context.Context。它的 Done() 返回一个只接收的 channel,取消时会关闭;不支持取消的 context 则可能返回 nil。

下面是一个完整例子。调用方只取前三个平方数,随后取消生产者,并等待它退出:

package main

import (
    "context"
    "fmt"
)

func squares(ctx context.Context) (<-chan int, <-chan struct{}) {
    out := make(chan int)
    stopped := make(chan struct{})

    go func() {
        defer close(stopped)
        defer close(out)

        for n := 1; ; n++ {
            select {
            case <-ctx.Done():
                return
            case out <- n * n:
            }
        }
    }()

    return out, stopped
}

func main() {
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()

    values, stopped := squares(ctx)
    for i := 0; i < 3; i++ {
        fmt.Println(<-values)
    }

    cancel()
    <-stopped
    fmt.Println("生产者已退出")
}

输出依次是 1、4、9,然后是退出提示。调用方停止接收后,生产者的发送无法继续,但取消分支可以让它返回。数据 channel 仍由生产者关闭,消费者只发取消信号。

cancel() 发出请求,<-stopped 确认 goroutine 已退出,这是两件事。真实业务里的网络请求、数据库调用和较长计算也要配合取消;单纯在外层加一个 select,无法中断分支内部正在运行的工作。

nil channel:让某个分支暂时退出选择

向 nil channel 发送或从中接收都无法继续,所以它在 select 中对应的通信分支不会被选中。这个特性特别适合处理多路输入的结束。

下面合并两路输入,直到两路都关闭并读完。emit 是调用方传入的处理函数,示例假设它会返回:

func consumeBoth(a, b <-chan int, emit func(int)) {
    for a != nil || b != nil {
        select {
        case value, ok := <-a:
            if !ok {
                a = nil
                continue
            }
            emit(value)
        case value, ok := <-b:
            if !ok {
                b = nil
                continue
            }
            emit(value)
        }
    }
}

已关闭且读空的 channel 会一直返回零值。如果不移除这个分支,循环就可能反复处理它,形成空转。将当前函数里的变量设为 nil 后,下一轮就不会再选择这一端;另一端仍然可以正常接收。

两端都设为 nil 时,循环条件变为 false。如果没有这个退出条件,最后就会卡在所有分支都不可执行的 select 中。

类似地,队列为空时可以把发送分支的 channel 变量设为 nil,有待发送数据时再恢复。不过队列仍要有容量上限,否则内部 slice 的无限增长会绕过原本希望保留的背压。

两个容易忽略的执行细节

case 中的表达式会先求值

进入 select 时,各分支的 channel 表达式和发送值表达式会按源码顺序求值一次,再决定执行哪个通信操作:

select {
case out <- buildPayload():
    fmt.Println("已发送")
default:
    fmt.Println("未发送")
}

即使最后进入 default,buildPayload() 也已经执行。如果它很慢,带 default 的写法也不会立即返回;如果它有副作用,也不能把“分支没选中”当成“函数没调用”。

nil 只禁用通信,不跳过这些求值。不要以为把 out 设为 nil 就能避免这里的构造开销。

break 默认只离开 select

在 for { select { ... } } 中,分支内不带标签的 break 结束的是最内层 select,随后外层循环仍会继续。

如果接收结束就是函数结束,用 return 最直观。函数后面还有收尾逻辑时,可以使用循环标签:

Loop:
    for {
        select {
        case _, ok := <-input:
            if !ok {
                break Loop
            }
        case <-done:
            break Loop
        }
    }
// 在这里执行循环结束后的收尾逻辑。

写 select 时,除了列出要等待的事件,还要沿每个分支继续往下看:关闭后会不会重复进入,取消后能不能退出循环,分支内部还有没有无法取消的阻塞。等待条件写全了,退出路径也要跟上。