Go语言forselect时,如果通道已经关闭会怎么样?

admin 2026-06-22 04:13:40 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 文档分析了Go语言for-select循环中通道关闭后的潜在问题:关闭的通道会持续返回零值导致CPU空转,正确做法是通过ok判断关闭状态并将通道置为nil使其不再就绪。关键建议包括使用range替代select处理单通道、避免多个goroutine关闭同一通道以防止panic,并提供了多通道合并处理的实际代码示例。 综合评分: 85 文章分类: 安全开发,WEB安全,实战经验,代码审计,其他


cover_image

Go 语言 for select 时,如果通道已经关闭会怎么样?

原创

go go

Go语言教程

2026年6月20日 13:22 陕西

在小说阅读器读本章

去阅读

goroutine 没退出,CPU 却被打满了。 日志里没有报错,也没有 panic,只有一行消费结束之后,进程还在那儿空转。

代码翻出来一看,基本就是这种味道:

for {
 select {
&nbsp;case&nbsp;job := <-jobCh:
&nbsp; handle(job)
&nbsp;case&nbsp;<-quitCh:
&nbsp;&nbsp;return
&nbsp;}
}

这段代码第一眼看着没毛病。

但如果 jobCh 被关闭了,这个 case job := <-jobCh 不会阻塞,它会立刻返回通道元素类型的零值。

也就是说,如果 jobCh 是 chan int,拿到的是 0;如果是 chan *Order,拿到的是 nil;如果是 chan string,拿到的是空字符串。

这地方最坑的不是返回零值,而是它会一直命中。

通道关闭之后,读它永远都是就绪状态。放在 for select 里面,就等于你塞了一个永远可执行的分支。

我一般看到这种 CPU 空转,第一反应不是怀疑业务逻辑,而是先搜这种代码:

case&nbsp;v := <-ch:

没有 ok 判断的,都不太信。

正确写法至少得这样:

for&nbsp;{
&nbsp;select&nbsp;{
&nbsp;case&nbsp;job, ok := <-jobCh:
&nbsp;&nbsp;if&nbsp;!ok {
&nbsp; &nbsp;jobCh =&nbsp;nil
&nbsp; &nbsp;continue
&nbsp; }
&nbsp; handle(job)

&nbsp;case&nbsp;<-quitCh:
&nbsp;&nbsp;return
&nbsp;}
}

这里有个细节,jobCh = nil 不是多余的。

关闭的 channel 在 select 里会一直就绪,而 nil channel 在 select 里永远不会就绪。把关闭后的通道置成 nil,相当于把这个分支从 select 里摘掉。

这招在线上代码里很常用,尤其是多个输入通道合并处理的时候。

比如一个订单服务,同时吃普通订单和补偿订单:

func&nbsp;dispatch(normal <-chan&nbsp;Order, retry <-chan&nbsp;Order, stop <-chan&nbsp;struct{})&nbsp;{
&nbsp;for&nbsp;normal !=&nbsp;nil&nbsp;|| retry !=&nbsp;nil&nbsp;{
&nbsp;&nbsp;select&nbsp;{
&nbsp;&nbsp;case&nbsp;o, ok := <-normal:
&nbsp; &nbsp;if&nbsp;!ok {
&nbsp; &nbsp; normal =&nbsp;nil
&nbsp; &nbsp;&nbsp;continue
&nbsp; &nbsp;}
&nbsp; &nbsp;saveOrder("normal", o)

&nbsp;&nbsp;case&nbsp;o, ok := <-retry:
&nbsp; &nbsp;if&nbsp;!ok {
&nbsp; &nbsp; retry =&nbsp;nil
&nbsp; &nbsp;&nbsp;continue
&nbsp; &nbsp;}
&nbsp; &nbsp;saveOrder("retry", o)

&nbsp;&nbsp;case&nbsp;<-stop:
&nbsp; &nbsp;return
&nbsp; }
&nbsp;}
}

这个循环条件也别省。

如果两个业务 channel 都关了,再继续 for { select {} },最后就容易写出奇怪的阻塞逻辑。直接让循环结束,干净一点。

还有一个更容易被忽略的问题:从关闭的 channel 读没事,往关闭的 channel 写会 panic。

func&nbsp;pushResult(ch&nbsp;chan<- Result, r Result)&nbsp;{
&nbsp;ch <- r
}

如果外面有人把 ch 关了,这里不会返回错误,不会给你一个 false,它直接 panic:

panic: send on closed channel

所以我一直不喜欢让多个 goroutine 乱关同一个 channel。

谁创建,谁关闭。 谁负责生产,谁关闭。 消费者不要手欠去 close。

这比背什么原则都管用。

再看一个现场更像的写法。比如有个批量导入任务,一个 goroutine 读文件,一个 goroutine 校验,一个 goroutine 入库。读文件结束后,可以关闭任务通道,通知下游没活了:

type&nbsp;ImportRow&nbsp;struct&nbsp;{
&nbsp;Line&nbsp;int
&nbsp;Raw &nbsp;string
}

func&nbsp;readRows(path&nbsp;string, out&nbsp;chan<- ImportRow)&nbsp;{
&nbsp;defer&nbsp;close(out)

&nbsp;// 这里省掉文件打开,只保留关键逻辑
&nbsp;for&nbsp;lineNo, text :=&nbsp;range&nbsp;loadLines(path) {
&nbsp;&nbsp;if&nbsp;text ==&nbsp;""&nbsp;{
&nbsp; &nbsp;continue
&nbsp; }
&nbsp; out <- ImportRow{
&nbsp; &nbsp;Line: lineNo +&nbsp;1,
&nbsp; &nbsp;Raw: &nbsp;text,
&nbsp; }
&nbsp;}
}

消费端别这么写:

for&nbsp;{
&nbsp;select&nbsp;{
&nbsp;case&nbsp;row := <-rowCh:
&nbsp; check(row)
&nbsp;}
}

rowCh 一关,row 就会变成 ImportRow{}Line 是 0,Raw 是空。然后你的校验逻辑可能开始打印这种日志:

import check failed, line=0, raw is empty
import check failed, line=0, raw is empty
import check failed, line=0, raw is empty

这日志我见过类似的,看着像脏数据,实际上是 channel 已经关了,消费者还在假装有数据。

该怎么写:

func&nbsp;checkRows(rowCh <-chan&nbsp;ImportRow, stop <-chan&nbsp;struct{})&nbsp;{
&nbsp;for&nbsp;{
&nbsp;&nbsp;select&nbsp;{
&nbsp;&nbsp;case&nbsp;row, ok := <-rowCh:
&nbsp; &nbsp;if&nbsp;!ok {
&nbsp; &nbsp;&nbsp;return
&nbsp; &nbsp;}
&nbsp; &nbsp;if&nbsp;row.Raw ==&nbsp;""&nbsp;{
&nbsp; &nbsp; log.Printf("skip empty row, line=%d", row.Line)
&nbsp; &nbsp;&nbsp;continue
&nbsp; &nbsp;}
&nbsp; &nbsp;check(row)

&nbsp;&nbsp;case&nbsp;<-stop:
&nbsp; &nbsp;log.Printf("import checker stopped")
&nbsp; &nbsp;return
&nbsp; }
&nbsp;}
}

如果只有一个 channel,其实不用 select,直接 range 更省心:

for&nbsp;row :=&nbsp;range&nbsp;rowCh {
&nbsp;check(row)
}

range 会在 channel 关闭并且数据读完后自动退出。这个写法不花哨,但不容易出事。

不过注意,是“数据读完后退出”。

channel 关闭不代表里面的数据立刻没了。关闭前已经写进去的数据,还能继续读出来。读完之后,再读才会拿到零值和 ok=false

可以用一小段代码看清楚:

func&nbsp;main()&nbsp;{
&nbsp;ch :=&nbsp;make(chan&nbsp;int,&nbsp;2)
&nbsp;ch <-&nbsp;7
&nbsp;ch <-&nbsp;9
&nbsp;close(ch)

&nbsp;for&nbsp;i :=&nbsp;0; i <&nbsp;3; i++ {
&nbsp; v, ok := <-ch
&nbsp; fmt.Printf("v=%d ok=%v\n", v, ok)
&nbsp;}
}

输出是:

v=7 ok=true
v=9 ok=true
v=0 ok=false

这就是 Go channel 关闭后的读行为。

放到 select 里,还有一个点也别误判:如果多个 case 同时就绪,Go 会随机选一个执行。关闭的 channel 是就绪,已经有数据的 channel 也是就绪,stop 信号来了也是就绪。

所以这种代码:

select&nbsp;{
case&nbsp;v, ok := <-dataCh:
&nbsp;if&nbsp;!ok {
&nbsp;&nbsp;return
&nbsp;}
&nbsp;handle(v)

case&nbsp;<-stopCh:
&nbsp;return
}

如果 dataCh 已经关闭,stopCh 也已经关闭,两个 case 都能走。不要在这里假设一定先走 stop,或者一定先处理 data。

写并发代码,最怕脑子里替调度器安排顺序。

我自己的习惯是,遇到 for select 先看三件事:

第一,读 channel 有没有 ok。 第二,关闭后要不要置 nil。 第三,有没有多个地方 close 同一个 channel。

这三个地方没处理好,代码短的时候还看不出来,一上压测、一停任务、一重启消费者,问题就会露出来。

channel 关闭不是“没有消息了”这么简单。

在 for select 里,它更像一个永远亮着的信号灯。你不把它摘掉,它就一直抢执行机会。CPU 空转、零值脏数据、偶发 panic,很多都是从这个小口子漏出来的。


免责声明:

本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。

任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。

本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我

本文转载自:Go语言教程 go go《Go 语言 for select 时,如果通道已经关闭会怎么样?》

评论:0   参与:  0