Skip to content

Channel:把数据交出去,也把结束方式说清楚 ​

channel 同时做两件事:传值,让不同 goroutine 在明确的通信事件上同步。用好它,关键是能判断一次发送或接收会不会等待,以及谁有权宣布“以后不再有值”。这篇文章从这两个问题展开,再处理 nil、select、超时和默认分支。

最基本的入口是 make(chan T)、make(chan T, n)、ch <- v、v := <-ch、v, ok := <-ch 和 close(ch)。chan T 可发送也可接收;chan<- T 只允许发送,<-chan T 只允许接收。函数返回只读 channel 能收窄调用者的能力,不过方向类型不会自动决定生命周期,也不会深拷贝传入的值。[1]

先判断它会不会等 ​

channel 的状态发送 ch <- v接收 <-chclose(ch)
nil一直阻塞一直阻塞panic
无缓冲、未关闭等待接收者配对等待发送者配对关闭;此后不能发送
有缓冲、未关闭有空位即可完成,满了等待有值即可取出,空了等待关闭;已缓冲的值仍可读
已关闭、缓冲仍有值panic依次取出已有值,ok=truepanic
已关闭、缓冲已空panic立即得到零值,ok=falsepanic

表中的阻塞指普通发送或接收;放进 select 后,未就绪的分支不会被选中,可以由其他分支或 default 继续。向已关闭 channel 发送则会 panic,select 不会把这种错误变成安全的“未就绪”。[2][3][4][5]

无缓冲:双方见面以后,发送才能继续 ​

go
unbuffered := make(chan int)
select {
case unbuffered <- 1:
default:
    fmt.Println("unbuffered: not ready")
}

实际输出:

text
unbuffered: not ready

没有接收者,发送不能完成,default 才被选中。无缓冲 channel 适合希望交接双方形成同步点的场合,但发送返回只说明接收发生,不说明接收者后续的业务处理已经结束。需要“处理完”的确认,就另发回执或等待完成信号。[6]

有缓冲:先放进去,代价是积压 ​

go
buffered := make(chan int, 1)
buffered <- 7
close(buffered)
a, okA := <-buffered
b, okB := <-buffered
fmt.Println(a, okA, b, okB)

实际输出:

text
7 true 0 false

第一条发送不需要同时存在接收者,因为还有一个空位。关闭之后,7 没有消失;先把缓冲读完,后续接收才返回 0, false。因此仅凭收到零值不能判断结束,0 也可能是合法数据。[3:1][4:1]

正在绘制图示…

可切换适合窗口或原始尺寸
查看图源代码
flowchart LR
    S[发送者] -->|有空位:写入| Q[容量为 2 的队列]
    Q -->|取出一个值:腾出空位| R[接收者]
    S -. 队列满:等待接收释放空位 .-> Q
    C[唯一关闭者] -->|保证不再发送后 close| Q
    Q -->|已关闭且读空:返回零值和 false| E[接收循环结束]
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
flowchart LR
    S[发送者] -->|有空位:写入| Q[容量为 2 的队列]
    Q -->|取出一个值:腾出空位| R[接收者]
    S -. 队列满:等待接收释放空位 .-> Q
    C[唯一关闭者] -->|保证不再发送后 close| Q
    Q -->|已关闭且读空:返回零值和 false| E[接收循环结束]

缓冲容量决定可以暂存多少个元素。它能吸收短时间的生产速度波动;长期生产快于消费,最终仍然会满。满了让上游等待就是背压。盲目扩大缓冲只会把等待挪进内存,还可能延长数据处理延迟。这里的容量建议是根据上表行为推导出的设计取舍,不是运行时给出的最佳参数。

len(ch) 返回当时排队的元素数,不能当作后续操作一定安全的保证。检查完之后,其他 goroutine 可能已发送、接收或关闭 channel。要尝试立即发送,直接用带 default 的 select 表达这个动作;要保证关闭安全,则要设计发送者之间的协调。[7][5:1]

交互实验:channel 发送、接收与关闭 ​

GO CHANNEL · 一次交错,一次观察

这次操作会完成、等待,还是 panic?

让 G1~G4 共用一个 chan int。你决定哪个 G 先行动:观察值去了哪里、谁在等待,再试着关闭 channel。

01

新建 channel · 先决定容量

清空本轮操作,重新开始

修改容量会创建一个新 channel,并清空本轮记录;Go 中已有 channel 的容量不能修改。

02

本步动作 · 由谁来做什么?

已创建容量为 1 的 chan int。选择一个 G,尝试发送、接收或关闭。

打开 · 缓冲 空 / 容量 1

G1:可继续

G2:可继续

G3:可继续

G4:可继续

这三个动作分别做什么?

发送把值交给接收者或缓冲,无法交付时等待。接收取出下一个值,同时返回 ok;关闭会保留缓冲数据并唤醒等待者,重复关闭会 panic。

03

观察 · 值在哪里,谁在等待?

第 0 步:已创建容量为 1 的 chan int。选择一个 G,尝试发送、接收或关闭。

channel 生命周期未关闭关闭不等于缓冲已清空
缓冲已有值 · len0只计缓冲,不计等待发送者
缓冲容量 · cap10 表示无缓冲,必须配对

缓冲:左侧为队首,先入先出;右侧空位可以接纳新值。

  1. 空位空槽 · 队首位置

等待发送

没有发送者等待。

等待接收

没有接收者等待。

  • G1 · 本步执行者可继续
  • G2可继续
  • G3可继续
  • G4可继续
操作结果(0 次)

还没有操作。阻塞操作会保留在这里;配对或关闭后,同一条记录会更新结果。

按点击顺序回看事件(0 步)
  1. 第 0 步:已创建容量为 1 的 chan int。选择一个 G,尝试发送、接收或关闭。
04

再试三组 · 先预测,再手动执行

以下是操作指导,不会替你切换容量或执行动作。请先重建 channel,再逐步尝试。

练习 01 · 配对
没有缓冲,值去哪里?

容量 0:G1 发送 7;再选 G2 接收。

先观察 G1 阻塞,再观察两者配对,len 始终为 0。
练习 02 · 队列
缓冲满了,谁先进去?

容量 1:G1 发送 7;G2 发送 8;G3 接收。

G3 得到 7,G2 的 8 才进入缓冲,缓冲仍满。
练习 03 · 关闭
两个 0,为何意义不同?

容量 1:G1 发送 0 后关闭;用 G2 连续接收两次。

第一次 ok=true,第二次 ok=false。也可先让几个 G 等待,再由另一个 G 关闭。
模型边界:确定的是本次交错,不是调度承诺

这是 JavaScript 教学状态机,没有运行 Go。每次点击选择一个普通 channel 操作;本例按进入等待队列的顺序处理配对,把通信结果与相关唤醒合并成一步。“可继续”只表示本例已记下结果,不表示真实 G 立即获得 CPU,也不保证它先返回。

缓冲按本例已经发生的发送顺序出队。不同 G 谁先到达、谁先恢复,不由 G 的编号决定;不要据此推断所有 Go 程序的输出顺序或调度公平性。这里只演示 make 得到的非 nil channel,不包含 select、取消、真实并行或完整的 panic/recover 控制流。为便于查看,每轮最多记录 200 步;到达上限后需重建,不会悄悄删掉旧记录。

close 唤醒等待接收者,让它们得到 0, false;它也唤醒等待发送者,但结果是 panic,并不会替它们完成发送。真实运行时在等待者恢复执行后体现这些结果。关闭者必须协调好发送生命周期;关闭成功本身不能证明这样做安全。

槽位显示的是逻辑队列,读取后会左移以便观察;并非 Go 运行时环形缓冲区的物理内存布局。len 也是本步快照,不能用来保证后续并发操作安全。

不操作也能读懂:无缓冲要配对;缓冲满了发送会等,空且未关闭时接收会等;close 保留已有值;读空后返回 0, false;关闭后发送和重复关闭会 panic。

依据:Go 规范:channel、接收、发送、关闭、panic/recover;runtime/chan.go 的等待队列与唤醒路径(核对日期:2026-10-14,源码链接随版本更新)。

关闭的是数据流,不是 goroutine ​

发送完 close(ch) 表示后续不再发送。接收者可以用 for v := range ch 读到结束。channel 不必为了垃圾回收而关闭;通常在接收者需要知道“没有更多值”时才需要 close。[8]

“发送方关闭”是很好的所有权惯例,但语言并没有检查“谁是发送方”。更准确的要求是:关闭者必须能够证明所有发送已经结束,而且只关闭一次。

发送结构关闭责任为什么这样安排
一个生产者生产者发送完后关闭它知道自己不会再发送
多个生产者共享输出协调者等所有生产者退出后关闭任一生产者先关闭都可能撞上其他发送者
消费者决定不再读消费者发取消信号,不直接关闭上游数据 channel上游可能还在发送;需要让发送者先退出
多个接收者等结束通知由一个所有者关闭专门的 done channel每个接收者都能观察到关闭状态

多发送者常用 WaitGroup 记录发送者的结束,再由一个协调 goroutine 执行 Wait(); close(out)。登记计数必须发生在等待可能返回之前。不能让一个生产者在 defer close(out) 里抢先关闭共享输出。[9][10]

关闭输出和取消计算也有区别。关闭一个没有数据的 done channel 可以广播停止请求,但每个工作者仍得写出接收它并返回的逻辑。关闭业务数据 channel 不会自动撤回缓冲中的任务,更不会强制中断正在处理的任务。

nil channel 很危险,也很有用 ​

未初始化的 channel 变量是 nil:

go
var disabled chan int
select {
case disabled <- 1:
    panic("unreachable")
default:
    fmt.Println("nil channel: default")
}

实际输出:

text
nil channel: default

nil channel 在 select 里相当于禁用该通信分支。这在合并多个输入时很实用:某个输入关闭以后,把本地变量设为 nil,就不再反复选到它。[1:1][5:2]

go
case v, ok := <-a:
    if !ok {
        a = nil
        continue
    }
    out = append(out, v)

文末的 Merge 对 a、b 都这样做,外层条件为 a != nil || b != nil。两个输入都关闭后循环结束。本次把 a 中的 1、3 和 b 中的 2、4 合并,再排序打印:

text
merged, sorted: [1 2 3 4]

这里专门写了“再排序”:不同 channel 之间没有全局发送顺序,合并的自然结果不保证有序。Merge 还假设所有非 nil 输入最终都会关闭;如果输入可能永远等不到下一条数据,这个版本仍需增加 context 取消分支。关闭输入后不置 nil,则已关闭分支一直可接收,可能形成空转并不断读到零值。[3:2][5:3]

select 选的是当前能做的动作 ​

select 会先求值各分支的 channel 表达式和发送值表达式,再从能够进行的通信中选择一个;若有多个就绪分支,规范规定按均匀伪随机方式选择。没有就绪分支时,有 default 就执行它,没有则阻塞。[5:4]

由此有三个实际后果。

第一,select 没有“第一条分支优先”的承诺。把 ctx.Done() 写在最前面,不能保证取消一到就总先执行它;也不能据此承诺每个工作者或请求在有限时间内得到公平服务。

第二,发送右侧的函数会先求值。写 case out <- expensive():,即使最后选择了取消分支,expensive() 也已被调用。因此昂贵计算最好有自己的取消检查,别把 select 当成延迟求值语法。[5:5]

第三,default 是“现在做不了就继续”,不是“稍后再试”。循环中不断进入 default 而不做阻塞等待,可能让 CPU 持续空转。丢弃日志、拒绝超额请求可以有明确的 default 策略;希望等待数据时,一般应让 select 阻塞。

交互实验:select 的就绪分支集合 ​

GO SELECT · 先看集合,再选分支

数据和取消都到了,select 会选谁?

先看哪些接收现在能进行,再亲手选择一个合法分支。试试读完缓冲、关闭 channel 和赋 nil,观察就绪集合怎样改变。

01

配置 · 每次修改都重建本轮

非 nil 时是容量 1 的 chan int。
nil 与“打开但为空”是不同状态。

初始值范围 −999~999。修改配置会创建新 channel 并清空记录;已关闭的 channel 不能重新打开。

保留初始配置,清空真实步骤记录
02

选择 · 集合里有什么,才可以做什么

就绪通信集合A、Done只含 A、B、Done;∅ 表示空集
本次可选分支A、Donedefault 不是通信分支
select 状态可选分支查看集合不会执行或消耗数据

A、Done 同时就绪,任选其中一条都合法。书写顺序不决定优先级,Done 也不会自动抢先。

Tab 选择按钮,Enter 或空格执行;一次只完成一个分支。
为什么不自动随机选择?

Go 规范要求从能进行的通信中均匀伪随机地选择一个。本例让你指定一个合法结果,便于对照;点击次数不代表随机频率、有限时间内的公平性或 CPU 耗时。

03

改变条件 · 让等待继续,或停用一个输入

A · 外部事件
B · 外部事件
Done · 已关闭

关闭会让取消分支就绪;不强制替你选择它,也不自动结束本例。

送入值、关闭和触发 Done 都代表其他 G 的外部事件。赋 nil 是接收方在两次 select 之间执行的赋值,不是关闭原 channel。

等待中为什么不能赋 nil?

进入 select 时,各接收分支的 channel 操作数已经求值。本例不让正在等待的同一个 G 跳到后面的赋值;先让本次接收完成,再把变量赋 nil,影响下一次 select。这里没有其他接收者抢走数据。

04

结果 · 哪个分支执行,收到了什么?

第 0 步:尚未执行。先看上方就绪集合,再选择一个合法分支。

下方保留最近一次已完成的 select 结果;外部事件或赋 nil 不会冒充一次接收。

最近执行分支尚未执行已完成 0 次 select
接收值 · value—default 没有接收值
是否来自一次发送 · ok—关闭且空才返回 false
实际动作步数0含外部事件、进入等待和赋 nil
真实步骤日志 · 0 步,按实际操作顺序

还没有操作;就绪预览和预设配置不计步骤。

模型边界与不操作时的结论

这是 JavaScript 接收分支教学模型,浏览器没有运行 Go。A、B 各自最多缓冲一个 int,Done 是只关闭、不发送值的 chan struct{}。这里只展示通信是否能进行,不模拟发送表达式求值、超时或完整运行时调度。

nil 不会就绪;打开且空需要外部事件;关闭后先读完缓冲,再重复得到 0、false。无通信就绪时,有 default 就继续,没有就等待。多个通信同时就绪时,只执行其中一个,取消分支没有书写优先权。

依据:Go 规范:Select statements;Receive operator(核对日期:2026-10-14)。

超时要先决定:等一条,还是等整段工作 ​

一个局部等待可以创建 timer,再与输入竞争:

go
timer := time.NewTimer(d)
defer timer.Stop()
select {
case v, ok := <-in:
    return v, ok
case <-timer.C:
    return 0, false
}

本次对 nil 输入调用 ReceiveWithin(nil, time.Millisecond):

text
nil receive timed out: true

这个教学函数用同一个 false 表示输入关闭或超时,调用者若需要区分两种原因,接口应改成返回显式状态或 error。一毫秒只是让测试有一个很短的到期时间,不是对调度精度的保证。[11]

等待范围比较清楚的表达容易改变的语义
当前接收最多等一段时间本次调用创建一个 timer重试时重新计时,相当于每次都重新给预算
整个请求共用一个截止时间传入同一个有 deadline 的 context每一层重新建独立根 context,会丢掉总预算
周期工作time.NewTicker 加取消分支ticker 不是任务完成确认;慢消费者可能丢 tick

Go 1.23 起,垃圾回收器可以回收不再被引用的 timer,timer channel 也改为无缓冲语义,避免 Stop/Reset 返回后收到旧值。Go 1.23 的过渡规则曾受模块版本和 GODEBUG=asynctimerchan 影响;本文使用的 Go 1.27.2 已移除该旧行为开关,不能再靠它回到旧模式。这里仍调用 Stop,是为了明确停止不再需要的 timer,不是声称当前版本必须靠 Stop 才能回收。[11:1][12]

channel 传的是值。发送切片、map 或指针只复制这些值本身,不会顺手复制它们指向的数据。发送后双方继续修改同一底层对象,仍需约定独占权或使用锁;通信的同步关系不等于对象从此永远线程安全。[13][6:1]

依据与本次验证 ​

全部示例在 Go 1.27.2、Linux/amd64 运行,go test -race -count=1 ./...、go vet ./... 通过。测试覆盖两个关闭输入的合并和 nil 输入超时。输出中的合并序列在打印前显式排序,没有用一次运行结果宣称跨 channel 有序或 select 公平。关闭后发送的失败实验放在《Go 并发常见坑》,本文的完整程序均正常退出。

完整代码与复现方法 ​

以同一目录为根,按下面标出的相对路径保存文件。使用 Go 1.27.2 运行:

sh
go run .
go test -race -count=1 ./...
go vet ./...

go.mod ​

go.mod
module example.com/go-articles/02_channel

go 1.25.0

main.go ​

代码块 · 74 行 · 点击收起 / 展开
go
package main

import (
	"fmt"
	"slices"
	"time"
)

// Merge consumes finite, eventually closed inputs. Nil disables an input.
func Merge(a, b <-chan int) []int {
	var out []int
	for a != nil || b != nil {
		select {
		case v, ok := <-a:
			if !ok {
				a = nil
				continue
			}
			out = append(out, v)
		case v, ok := <-b:
			if !ok {
				b = nil
				continue
			}
			out = append(out, v)
		}
	}
	return out
}

func ReceiveWithin(in <-chan int, d time.Duration) (int, bool) {
	timer := time.NewTimer(d)
	defer timer.Stop()
	select {
	case v, ok := <-in:
		return v, ok
	case <-timer.C:
		return 0, false
	}
}

func main() {
	var disabled chan int
	select {
	case disabled <- 1:
		panic("unreachable")
	default:
		fmt.Println("nil channel: default")
	}
	unbuffered := make(chan int)
	select {
	case unbuffered <- 1:
	default:
		fmt.Println("unbuffered: not ready")
	}
	buffered := make(chan int, 1)
	buffered <- 7
	close(buffered)
	a, okA := <-buffered
	b, okB := <-buffered
	fmt.Println(a, okA, b, okB)
	_, ok := ReceiveWithin(nil, time.Millisecond)
	fmt.Println("nil receive timed out:", !ok)
	aIn, bIn := make(chan int, 2), make(chan int, 2)
	aIn <- 1
	aIn <- 3
	bIn <- 2
	bIn <- 4
	close(aIn)
	close(bIn)
	merged := Merge(aIn, bIn)
	slices.Sort(merged)
	fmt.Println("merged, sorted:", merged)
}

main_test.go ​

go
package main

import (
	"slices"
	"testing"
	"time"
)

func TestMergeClosedInputs(t *testing.T) {
	a, b := make(chan int, 2), make(chan int, 2)
	a <- 1
	a <- 3
	b <- 2
	b <- 4
	close(a)
	close(b)
	got := Merge(a, b)
	slices.Sort(got)
	if !slices.Equal(got, []int{1, 2, 3, 4}) {
		t.Fatalf("got %v", got)
	}
}
func TestNilTimeout(t *testing.T) {
	if _, ok := ReceiveWithin(nil, time.Millisecond); ok {
		t.Fatal("unexpected result")
	}
}

  1. 规范:Channel types,方向、nil、缓冲与 FIFO 条件。 ↩︎ ↩︎

  2. 规范:Send statements。 ↩︎

  3. 规范:Receive operator。 ↩︎ ↩︎ ↩︎

  4. 规范:Close。 ↩︎ ↩︎

  5. 规范:Select statements,包括求值顺序与就绪分支选择。 ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎

  6. Go 内存模型:Channel communication。 ↩︎ ↩︎

  7. 规范:Length and capacity。 ↩︎

  8. Go Tour:Range and Close。发送者关闭、关闭并非总是必要,是这里的教学惯例。 ↩︎

  9. 官方 pipeline 文章,Fan-out, fan-in 与 Stopping short。 ↩︎

  10. sync.WaitGroup。 ↩︎

  11. time,Go 1.27.2,NewTimer、Timer.Stop、NewTicker。 ↩︎ ↩︎

  12. 官方 Go 1.23 Timer Channel Changes说明历史过渡规则;当前版本对照 Go 1.27.2 已移除设置表,asynctimerchan 的 Removed 为 27,旧值 1、2 已不再支持。 ↩︎

  13. 规范:Assignments,赋值复制值及共享引用的数据。 ↩︎

保留各书来源与许可 · 示例运行条件以正文为准