channel 不是“并发安全的队列”这么简单。它更准确的角色是:让 goroutine 在传递一个值的同时建立同步关系。发送方把值交出去,接收方把值拿走;如果此刻无法完成交接,其中一方就会等待,直到另一个 goroutine 让这次通信继续。

理解 channel 时,建议始终回答四个问题:

  1. 值存在哪里:直接交给等待的接收者,还是进入缓冲区?
  2. 发送或接收不能立即完成时,哪个 goroutine 会等待?
  3. 谁拥有关闭 channel 的权力?
  4. 这次等待如何因取消、超时或定时任务而结束?

本文从运行时的实现模型出发,依次讲清楚 goroutine 如何收发数据、select 怎样选择 case,以及定时器为什么能和 channel 自然配合。

先建立正确模型:通信、队列与同步

1
2
unbuffered := make(chan int)    // 容量为 0
buffered := make(chan int, 3) // 容量为 3
  • 无缓冲 channel:发送与接收必须会合。ch <- v 成功,意味着已有接收者接走了 vv := <-ch 成功,意味着已有发送者交出了值。
  • 有缓冲 channel:缓冲区还有空位时,发送者可先离开;缓冲区还有值时,接收者可先取走。只有“缓冲满时发送”或“缓冲空时接收”才需要等待。
  • FIFO 只描述缓冲中的值:多个发送者或接收者并发竞争时,谁先真正完成通信由调度和等待队列决定;不要把它当作跨 goroutine 的全局业务排序器。

下图把两种 channel 的等待点放在一起:区别在于是否有可暂存值的环形缓冲区,而不是并发安全性。

无缓冲 Channel 需要发送与接收会合;有缓冲 Channel 在容量未满或队列非空时可继续,满或空时分别阻塞

channel 在运行时里长什么样

make(chan T, n) 会在运行时创建一个内部结构(当前运行时名称为 hchan)。它不是 Go 语言规范承诺的 API,字段和优化会随版本调整。下面按运行时源码的字段顺序,使用更容易阅读的 C 风格表示(类型名称做了等价转写):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
struct waitq {
struct sudog *first;
struct sudog *last;
};

struct hchan {
uintptr_t qcount; // 缓冲区中已有元素数量
uintptr_t dataqsiz; // 环形缓冲区容量
void *buf; // 指向 dataqsiz 个元素的缓冲区
uint16_t elemsize; // 单个元素大小
uint32_t closed; // 1 表示已关闭
struct timer *timer; // 向该 channel 投递事件的定时器
struct _type *elemtype; // 元素类型信息
uintptr_t sendx; // 下一个写入位置
uintptr_t recvx; // 下一个读取位置
struct waitq recvq; // 等待接收的 goroutine 队列
struct waitq sendq; // 等待发送的 goroutine 队列
struct synctestBubble *bubble;
struct mutex lock; // 保护状态和等待队列
};

最关键的字段是 buf + sendx + recvx + qcount 组成的环形缓冲区,以及 recvqsendq 两条 goroutine 等待队列。lock 保护这些字段及相关等待记录;timerTimerTicker 的时间事件能走进同一套 channel 收发逻辑。

阻塞的 goroutine 不会傻等。运行时会为它创建一个等待记录(内部称为 sudog),其中保存 goroutine、本次待发送值或接收目标的位置,以及它正在等待的 channel;随后把 goroutine park,让线程去运行别的 goroutine。之后有匹配的收发或 close 发生时,运行时再把它 ready,等待调度器重新运行它。

谁唤醒阻塞的 goroutine

一句话结论:不是 channel 自己创建后台线程轮询,也不是被阻塞的 goroutine 自己醒来;是后来完成配对收发或执行 close 的那个 goroutine,在运行时中调用 goready 将对方标记为可运行。

以接收方先到、发送方后到为例:

接收 goroutine 阻塞后进入 recvq;后到发送 goroutine 复制值并调用 goready,使接收者从 Waiting 进入 Runnable;调度器随后选择其继续执行;close 会批量唤醒等待者

源码层面,阻塞发送会把当前 goroutine 的 sudog 放入 sendq,再调用 gopark(chanparkcommit, ...);阻塞接收同理放入 recvqchanparkcommit 会在 goroutine 已经进入可安全等待的状态后释放 hchan.lock,这样后到的 goroutine 才能拿锁、完成通信并唤醒它。

当前谁在等待后来是谁操作同一条 channel运行时完成什么谁调用唤醒
发送者在 sendq接收者执行 <-ch复制值,取出等待发送者接收者所在 goroutine 通过 recv 调用 goready(发送者)
接收者在 recvq发送者执行 ch <- value复制值,取出等待接收者发送者所在 goroutine 通过 send 调用 goready(接收者)
发送者或接收者在队列中任意拥有关闭权的 goroutine 执行 close(ch)将两条等待队列中的 goroutine 收集起来closechan 释放 channel 锁后逐个调用 goready

goready 的含义是变为可运行,不是“马上在当前调用栈继续执行”。运行时会把目标 goroutine 从 Waiting 标为 Runnable,放入某个 P 的运行队列并通知调度器;它究竟立刻运行,还是在稍后由某个 M 执行,仍由 GMP 调度决定。这样,持有 hchan.lock 的 goroutine 不会在锁内直接切换到对方,避免锁与调度交错造成死锁。

这里可以和 Golang GMP 调度模型 对照理解:channel 决定 G 因通信而 WaitingRunnable,GMP 再从 P 的运行队列中挑选可运行的 G 交给 M 执行。也就是说,goready 不是立即恢复执行,而是把“这次通信已经满足”的 G 交回调度器。

close 是一个特殊的批量唤醒:等待接收者恢复后会观察到 ok == false;等待发送者恢复后会触发“向已关闭 channel 发送”的 panic。因此,close 只表示“发送端永久结束”,不能作为强制停止其他发送 goroutine 的手段。

对于 select,一个 goroutine 可能同时挂在多条 channel 的等待队列里。第一个成功配对的操作会用 selectDone 原子标记赢得唤醒权,再由 goready 唤醒该 goroutine;它恢复后会自行从其余 channel 的等待队列摘除自己的 sudog。这就是多个 case 不会把同一个 goroutine 唤醒多次的原因。

这也是 channel 并发安全的来源:运行时在同一把锁下检查状态、移动数据、维护等待队列,最后再唤醒对方。它不意味着你的业务对象天然安全——例如把同一个 map 指针发给两个 goroutine 后,仍需设计好其后续访问方式。

发送:优先直接交接,其次写缓冲,最后等待

执行 ch <- value 时,可以把运行时逻辑理解成下面的优先级:

  1. channel 为 nil:普通发送永久阻塞;在 select 中,这个 case 被禁用。
  2. channel 已关闭:立即 panic。
  3. 已有 goroutine 在 recvq 等待:把 value 直接复制到那个接收者的目标位置,唤醒它。即使是有缓冲 channel,这一步也会绕过缓冲区。
  4. 没有等待接收者,但缓冲未满:把值复制到 buf[sendx],移动 sendxqcount + 1
  5. 否则:当前 goroutine 带着待发送值进入 sendq 并 park,直到接收者取走它,或 channel 被关闭。

无缓冲 channel 的容量为零,因此必然落在第 3 或第 5 步:它就是一次严格的发送—接收会合。

发送端必须为取消留出口

下面的生产者只拥有 out 的发送权,因此也由它负责关闭。关键不在 go 关键字,而在第二个 select:当消费者已经返回时,ctx.Done() 能让生产者不再永久卡在发送处。

1
2
3
4
5
6
7
8
9
10
11
12
func produce(ctx context.Context, out chan<- int) {
defer close(out) // 只有唯一发送方,关闭权才清晰

for i := 1; i <= 5; i++ {
select {
case out <- i:
// 已交给接收方或写入缓冲区。
case <-ctx.Done():
return
}
}
}

chan<- int 是只发送 channel。它不是新建了一条 channel,而是把同一条 channel 的“关闭与发送能力”明确留在生产者侧,避免接收方误发送或误关闭。

接收:优先取缓冲,其次直接取等待发送者

执行 value, ok := <-ch 时,逻辑与发送相对:

  1. channel 为 nil:普通接收永久阻塞;在 select 中,这个 case 被禁用。
  2. 缓冲区非空:从 buf[recvx] 取值,移动 recvxqcount - 1
  3. 缓冲为空但有 goroutine 在 sendq 等待:直接从等待发送者取得值,唤醒发送者。
  4. channel 已关闭且缓冲已空:立刻得到元素零值与 ok == false
  5. 否则:当前 goroutine 进入 recvq 并 park,等待发送、关闭或取消路径。

一个容易遗漏的细节:缓冲已满且有发送者在等待时,接收并不是简单地“取走一个值”。运行时会把队头元素交给当前接收者,同时把等待发送者的值补到刚空出来的槽位,再唤醒发送者。这样既保持缓冲 FIFO,也让等待发送者立即继续。

接收端也要响应取消

接收时使用双返回值,才能区分“发送者真的发送了零值”和“channel 关闭且已耗尽”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func sum(ctx context.Context, in <-chan int) (int, error) {
total := 0
for {
select {
case value, ok := <-in:
if !ok {
return total, nil
}
total += value
case <-ctx.Done():
return 0, ctx.Err()
}
}
}

in 是只接收 channel。调用方可以把同一条双向 channel 传进来,但函数内部既不能发送,也不能关闭它。方向约束让“谁负责什么”直接体现在签名上。

把两个函数组合起来,生命周期就清晰了:

1
2
3
4
5
6
7
8
ctx, cancel := context.WithCancel(context.Background())
defer cancel()

numbers := make(chan int, 2)
go produce(ctx, numbers)

total, err := sum(ctx, numbers)
fmt.Println(total, err) // 15 <nil>

close 是“不会再发送”,不是“删除 channel”

关闭只是一条广播式的状态变化:它表示以后不会再有新值进入。已经在缓冲区中的值仍按 FIFO 被接收;等缓冲耗尽后,接收立刻返回零值与 ok == false。因此 for value := range ch 会在“已关闭且已取空”时结束。

channel 状态发送接收close
nil永久阻塞永久阻塞panic
打开、缓冲可用或有配对方可继续可继续成功
打开、无法立刻配对阻塞阻塞成功并唤醒等待者
已关闭、缓冲未空panic继续取缓冲值panic
已关闭、缓冲为空panic立即得到零值,ok == falsepanic

通常遵守一个简单的所有权规则就够了:由唯一发送者关闭;多个发送者时,由能确认所有发送都结束的协调者关闭;接收者不关闭。 close 会唤醒所有等待接收者和发送者,但被唤醒的发送者会因“向已关闭 channel 发送”而 panic,所以它不能用来粗暴终止仍在发送的 goroutine。

Channel 从 nil、打开、已关闭但有缓冲,到关闭且耗尽的状态变化;包含发送、接收、close、ok 和 range 的精确行为

select:在多个通信机会中挑一个

select 是 channel 操作的多路复用器。它只关心 case 中的发送或接收是否现在就能完成

1
2
3
4
5
6
7
8
9
10
11
12
select {
case value := <-fast:
use(value)
case value := <-slow:
use(value)
case out <- result:
sent()
case <-ctx.Done():
return ctx.Err()
default:
// 没有任何通信可立即完成时才执行。
}

语义可以记为:

  • 所有 case 的 channel 操作数(以及发送值表达式)会先求值一次;真正选中的 case 才进行收发、左侧赋值与语句体。
  • 没有就绪 case 时:有 default 就立刻执行它;没有 default,当前 goroutine 阻塞。
  • 有多个就绪 case 时,Go 规范要求在它们中作均匀伪随机选择。因此 select 不能保证优先级、公平轮转或消息顺序。
  • 对已关闭 channel 的接收永远就绪;如果在循环里不处理 ok == false,会变成高速空转。
  • nil channel 的 case 永远不会就绪,可用来动态禁用一个分支。

运行时如何避免“多头等待”

select { case <-a: case <-b: } 为例,运行时会大致执行以下流程:

  1. 收集非 nil case,并打乱轮询顺序,避免固定从第一个 case 开始造成偏向。
  2. 按 channel 地址排序后依次加锁,避免多个 goroutine 同时 select 多条 channel 时发生锁顺序死锁。
  3. 按已打乱的顺序检查:是否有等待发送者、缓冲值、空位或关闭状态;若有,立即完成一个 case。
  4. 如果没有就绪 case 且存在 default,直接返回 default
  5. 否则把同一个 goroutine 的等待记录分别挂到每条相关 channel 的发送或接收队列,然后 park。
  6. 任一通信把它唤醒后,运行时只保留胜出的那一个等待记录,并从其余 channel 的队列删除记录,再继续执行对应 case。

所以 select 不是轮询加 time.Sleep,也不是起多个 goroutine 去抢结果;它由运行时把“等待任一个事件”的状态原子地管理起来。

select 从就绪 case 中选择一个,default 的非阻塞语义,以及 ctx Done 同时解除发送和接收阻塞的关系

两个实用模式

非阻塞尝试只适合确实允许丢弃或稍后重试的场景:

1
2
3
4
5
6
select {
case jobs <- job:
// 已入队
default:
// 队列满:记录指标、降级或交给调用方处理
}

不要把 default 当作“更高性能的 channel”。它改变的是语义:原来要等待的背压,现在变成了丢弃、重试或忙等的责任。

动态合并多个输入可将已经耗尽的输入置为 nil,让它退出后续选择:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
for left != nil || right != nil {
select {
case value, ok := <-left:
if !ok {
left = nil
continue
}
fmt.Println("left:", value)
case value, ok := <-right:
if !ok {
right = nil
continue
}
fmt.Println("right:", value)
}
}

定时器:把“时间到了”变成一个 channel 事件

time 包把时间事件暴露为只读 channel,因此可以直接放入 select。这让“结果先到、取消先到、超时先到”可以写在同一个等待点。

工具含义适用场景
time.After(d)d 后向返回的 channel 发送一次时间一次性的简单超时
time.NewTimer(d)StopReset 的一次性计时器需要取消或重复复用计时器
time.NewTicker(d)每隔 d 产生一次 tick周期性检查、刷新、批处理触发
time.AfterFunc(d, f)d 后在自己的 goroutine 调用 f明确需要回调时;需自行协调回调并发

一次超时:让等待有终点

对于一个局部、一次性的等待,time.After 最直接:

1
2
3
4
5
6
7
8
9
10
11
12
13
func receiveWithin(ctx context.Context, in <-chan string, d time.Duration) (string, error) {
select {
case value, ok := <-in:
if !ok {
return "", io.EOF
}
return value, nil
case <-ctx.Done():
return "", ctx.Err()
case <-time.After(d):
return "", context.DeadlineExceeded
}
}

如果调用方要取消、提前结束或在循环中重设同一个截止时间,用 Timer 更合适:

1
2
3
4
5
6
7
8
9
10
11
timer := time.NewTimer(5 * time.Second)
defer timer.Stop() // 结束后不再需要这次计时

select {
case result := <-resultCh:
use(result)
case <-timer.C:
handleTimeout()
case <-ctx.Done():
return ctx.Err()
}

从 Go 1.23 起,未引用的 TimerTicker 可以被垃圾回收,Timer.C 的语义也消除了旧版本 Stop / Reset 后可能读到陈旧 tick 的问题。不过 Stop 仍有明确业务意义:你知道计时器已不该继续触发时,应停止它;Ticker.Stop 尤其重要,因为它不会关闭 C,等待它的 goroutine 仍须通过 contextdone 离开。

周期任务:tick 不是“必须执行一次”的任务队列

Ticker 的接收者处理得比周期慢时,运行时会调整间隔或丢弃 tick,避免无限堆积。因此它适合“定期看一眼状态”,不适合“每一秒的任务都必须执行一次”。后者应保存任务时间点并通过持久化队列补偿。

下面是一个可停止、可等待退出的周期循环。stop 由外部关闭来广播停止,done 表示 goroutine 已完成收尾;两者不要混用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
func startFlusher(interval time.Duration, flush func()) (stop func(), done <-chan struct{}) {
stopCh := make(chan struct{})
doneCh := make(chan struct{})

go func() {
defer close(doneCh)

ticker := time.NewTicker(interval)
defer ticker.Stop()

for {
select {
case <-ticker.C:
flush()
case <-stopCh:
return
}
}
}()

return func() { close(stopCh) }, doneCh
}

// 使用方:关闭后等待任务真正退出。
stop, done := startFlusher(time.Second, flush)
stop()
<-done

实际服务通常以 ctx.Done() 代替 stopCh,把周期任务纳入请求、组件或进程的统一生命周期管理。

一个完整的 worker pool:收发、关闭与收尾

下面的结构把所有权放在正确的位置:任务提交方关闭 jobs;协调者等待所有 worker 结束后关闭 results;消费者只读取 results。每一个可能阻塞的收发都同时监听 ctx.Done()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
func worker(ctx context.Context, jobs <-chan int, results chan<- int, wg *sync.WaitGroup) {
defer wg.Done()

for {
select {
case job, ok := <-jobs:
if !ok {
return
}

result := job * job
select {
case results <- result:
case <-ctx.Done():
return
}
case <-ctx.Done():
return
}
}
}

func squares(ctx context.Context, inputs []int, workers int) ([]int, error) {
jobs := make(chan int)
results := make(chan int)

var wg sync.WaitGroup
wg.Add(workers)
for range workers {
go worker(ctx, jobs, results, &wg)
}

go func() {
defer close(jobs)
for _, input := range inputs {
select {
case jobs <- input:
case <-ctx.Done():
return
}
}
}()

go func() {
wg.Wait()
close(results)
}()

out := make([]int, 0, len(inputs))
for {
select {
case result, ok := <-results:
if !ok {
return out, nil
}
out = append(out, result)
case <-ctx.Done():
return nil, ctx.Err()
}
}
}

注意结果顺序不保证与 inputs 一致:多个 worker 的完成时间本来就不同。若业务需要按输入顺序返回,应连同索引一起发送,再在接收端归位。

生产者关闭 jobs,多个 worker 扇出处理并扇入 results,WaitGroup 协调者在全部 worker 结束后关闭 results,消费者 range 退出

写 channel 代码前的检查清单

  • 这个 goroutine 在什么条件下退出?调用方能否等待它退出?
  • 谁发送、谁接收、谁拥有关闭权?多发送者时谁来统一关闭?
  • 每个可能阻塞的发送和接收,取消后是否都能返回?
  • 缓冲区大小对应什么实际容量或背压策略?不要用“大一点试试”掩盖消费速度问题。
  • select 中有 default 时,满/空 channel 的数据该如何处理?是否会忙等?
  • 这是一次超时、可重置的 deadline,还是可丢 tick 的周期检查?分别选 AfterTimerTicker

总结

channel 的核心不是语法 <-,而是运行时对数据复制、缓冲状态、等待队列和 goroutine 唤醒的协作。发送会优先交给等待接收者,接收会优先取出缓冲值;不能继续时,goroutine 被 park 而不是占用线程空转。select 把“等待任一个通信事件”变成一个原子选择,定时器则把时间也接入同一套事件模型。

当你能明确回答“值去哪、谁会等、谁关闭、如何取消”时,channel 代码通常就已经具备了正确的骨架。