Go Channel 通道 — 无缓冲、缓冲、select 与超时

上一节我们用 sync.Mutex 保护共享变量,这其实是"通过共享内存来通信"的思路。Go 还提供另一条更优雅的路——"通过通信来共享内存",这就是 Channel(通道)。Channel 是 goroutine 之间传递数据的类型安全管道:一头发送、一头接收,自带同步语义,大幅减少显式加锁。Go 团队认为这是写出清晰并发代码的首选姿势。

一、创建与基本操作

make(chan 元素类型) 创建,三种核心操作:

无缓冲通道 make(chan int)同步的:发送方必须等到有接收方接住,这个发送才能完成——所以又叫会合(rendezvous)。下面这个例子如果去掉接收方的 goroutine,主 goroutine 会一直阻塞在 ch <- 42 上,最终死锁。

package main

import "fmt"

func main() {
    // 无缓冲通道:make(chan T),没有容量
    ch := make(chan int)

    // 启动一个 goroutine 负责接收
    go func() {
        n := <-ch            // 接收
        fmt.Println("收到:", n)
    }()

    ch <- 42                 // 发送(无缓冲:必须有人接,否则阻塞)
    fmt.Println("发送完成")
}

二、有缓冲通道

加第二个参数就成了有缓冲通道:make(chan int, 5) 能暂存 5 个元素。缓冲没满之前发送不阻塞、缓冲没空之前接收不阻塞,起到"削峰填谷"的作用。len(ch) 返回当前元素数,cap(ch) 返回容量。缓冲通道适合生产者和消费者速度不匹配的场景,但容量不是越大越好——它本质上是个队列,会延迟反压信号。

package main

import "fmt"

func main() {
    // 有缓冲通道:容量为 3
    ch := make(chan string, 3)

    ch <- "a"                // 不阻塞
    ch <- "b"                // 不阻塞
    ch <- "c"                // 缓冲满,再写才阻塞

    fmt.Println(len(ch), cap(ch))   // 3 3

    // 关闭通道:告诉接收方"不会再发数据了"
    close(ch)

    // for range 遍历,直到通道关闭才结束
    for v := range ch {
        fmt.Println(v)       // a b c
    }
}

三、遍历通道

for v := range ch 会反复接收,直到通道被 close 才停止。所以发送方一定要记得关闭,否则接收方会永远阻塞、最终死锁报错 fatal error: all goroutines are asleep - deadlock!。约定俗成:由发送方关闭,因为接收方无法知道还有没有后续数据。从已关闭的通道接收,会拿到元素类型的零值。

四、单向通道

通道还能声明成单向的:chan<- int 只能发送、<-chan int 只能接收。它常用于函数参数——明确告诉调用方"我这个函数只往里写"或"只往外读",编译器替你把关,误操作直接编译失败。把双向通道赋给单向通道是自动转换的,反之不行。这是 Go 用类型系统表达意图的又一个例子。

五、select 多路监听

select 语句长得像 switch,但专门用来同时监听多个 channel 的收发。它会随机挑一个就绪的分支执行(就绪的都不阻塞);如果都不就绪,有 default 就走 default(实现非阻塞收发),没有就阻塞等待。多个分支同时就绪时随机选,这点很重要——可以避免某个 channel 一直被冷落。

package main

import (
    "fmt"
    "time"
)

func main() {
    ch1 := make(chan string)
    ch2 := make(chan string)

    go func() {
        time.Sleep(1 * time.Second)
        ch1 <- "来自 ch1"
    }()
    go func() {
        time.Sleep(2 * time.Second)
        ch2 <- "来自 ch2"
    }()

    // select 同时监听多个 channel,哪个先就绪就走哪个分支
    for i := 0; i < 2; i++ {
        select {
        case msg := <-ch1:
            fmt.Println(msg)   // 1 秒后打印
        case msg := <-ch2:
            fmt.Println(msg)   // 2 秒后打印
        }
    }
}

六、超时模式

现实中我们绝不能让程序永远等一个 channel。经典做法是把接收放进 select,再配一个 time.After(duration) 分支——一旦超时,就走超时分支,避免 goroutine 被永久挂起(协程泄漏)。这是 Go 并发的看家本领,几乎所有"调外部服务"的代码都会用。注意 time.After 每次都会创建一个定时器,频繁调用要考虑用 time.NewTimer 复用。

package main

import (
    "fmt"
    "time"
)

func slowWork() chan string {
    ch := make(chan string, 1)   // 带缓冲,发送不阻塞,协程不会泄漏
    go func() {
        time.Sleep(3 * time.Second)   // 故意很慢
        ch <- "完成"
    }()
    return ch
}

func main() {
    ch := slowWork()
    // 经典超时模式:select 配 time.After
    select {
    case res := <-ch:
        fmt.Println("收到:", res)
    case <-time.After(1 * time.Second):
        fmt.Println("超时了,不等了")   // 1 秒后走这里
    }

    // 单向通道:常用于函数参数,约束只能发或只能收
    sendOnly := func(ch chan<- int) { ch <- 1 }
    recvOnly := func(ch <-chan int) int { return <-ch }

    bidir := make(chan int, 1)   // 双向通道
    sendOnly(bidir)              // 自动转成"只发送"
    fmt.Println("收到:", recvOnly(bidir))   // 收到: 1
}

七、关闭通道的注意事项

关闭通道有几条铁律,违反其中任何一条都会触发 panic:

所以"到底要不要关闭"也要看场景:如果接收方靠 for range 等结束信号,就得关;如果是一个长期运行的事件流(比如全局的日志 channel),通常不关,程序退出时自然回收。

小结

上一篇 Go 并发 Goroutine | 下一篇 Go 包管理与 go mod

✈️💬