文章总结: 文档分析了Go语言for-select循环中通道关闭后的潜在问题:关闭的通道会持续返回零值导致CPU空转,正确做法是通过ok判断关闭状态并将通道置为nil使其不再就绪。关键建议包括使用range替代select处理单通道、避免多个goroutine关闭同一通道以防止panic,并提供了多通道合并处理的实际代码示例。 综合评分: 85 文章分类: 安全开发,WEB安全,实战经验,代码审计,其他
Go 语言 for select 时,如果通道已经关闭会怎么样?
原创
go go
Go语言教程
2026年6月20日 13:22 陕西
在小说阅读器读本章
去阅读
goroutine 没退出,CPU 却被打满了。 日志里没有报错,也没有 panic,只有一行消费结束之后,进程还在那儿空转。
代码翻出来一看,基本就是这种味道:
for {
select {
case job := <-jobCh:
handle(job)
case <-quitCh:
return
}
}
这段代码第一眼看着没毛病。
但如果 jobCh 被关闭了,这个 case job := <-jobCh 不会阻塞,它会立刻返回通道元素类型的零值。
也就是说,如果 jobCh 是 chan int,拿到的是 0;如果是 chan *Order,拿到的是 nil;如果是 chan string,拿到的是空字符串。
这地方最坑的不是返回零值,而是它会一直命中。
通道关闭之后,读它永远都是就绪状态。放在 for select 里面,就等于你塞了一个永远可执行的分支。
我一般看到这种 CPU 空转,第一反应不是怀疑业务逻辑,而是先搜这种代码:
case v := <-ch:
没有 ok 判断的,都不太信。
正确写法至少得这样:
for {
select {
case job, ok := <-jobCh:
if !ok {
jobCh = nil
continue
}
handle(job)
case <-quitCh:
return
}
}
这里有个细节,jobCh = nil 不是多余的。
关闭的 channel 在 select 里会一直就绪,而 nil channel 在 select 里永远不会就绪。把关闭后的通道置成 nil,相当于把这个分支从 select 里摘掉。
这招在线上代码里很常用,尤其是多个输入通道合并处理的时候。
比如一个订单服务,同时吃普通订单和补偿订单:
func dispatch(normal <-chan Order, retry <-chan Order, stop <-chan struct{}) {
for normal != nil || retry != nil {
select {
case o, ok := <-normal:
if !ok {
normal = nil
continue
}
saveOrder("normal", o)
case o, ok := <-retry:
if !ok {
retry = nil
continue
}
saveOrder("retry", o)
case <-stop:
return
}
}
}
这个循环条件也别省。
如果两个业务 channel 都关了,再继续 for { select {} },最后就容易写出奇怪的阻塞逻辑。直接让循环结束,干净一点。
还有一个更容易被忽略的问题:从关闭的 channel 读没事,往关闭的 channel 写会 panic。
func pushResult(ch chan<- Result, r Result) {
ch <- r
}
如果外面有人把 ch 关了,这里不会返回错误,不会给你一个 false,它直接 panic:
panic: send on closed channel
所以我一直不喜欢让多个 goroutine 乱关同一个 channel。
谁创建,谁关闭。 谁负责生产,谁关闭。 消费者不要手欠去 close。
这比背什么原则都管用。
再看一个现场更像的写法。比如有个批量导入任务,一个 goroutine 读文件,一个 goroutine 校验,一个 goroutine 入库。读文件结束后,可以关闭任务通道,通知下游没活了:
type ImportRow struct {
Line int
Raw string
}
func readRows(path string, out chan<- ImportRow) {
defer close(out)
// 这里省掉文件打开,只保留关键逻辑
for lineNo, text := range loadLines(path) {
if text == "" {
continue
}
out <- ImportRow{
Line: lineNo + 1,
Raw: text,
}
}
}
消费端别这么写:
for {
select {
case row := <-rowCh:
check(row)
}
}
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 checkRows(rowCh <-chan ImportRow, stop <-chan struct{}) {
for {
select {
case row, ok := <-rowCh:
if !ok {
return
}
if row.Raw == "" {
log.Printf("skip empty row, line=%d", row.Line)
continue
}
check(row)
case <-stop:
log.Printf("import checker stopped")
return
}
}
}
如果只有一个 channel,其实不用 select,直接 range 更省心:
for row := range rowCh {
check(row)
}
range 会在 channel 关闭并且数据读完后自动退出。这个写法不花哨,但不容易出事。
不过注意,是“数据读完后退出”。
channel 关闭不代表里面的数据立刻没了。关闭前已经写进去的数据,还能继续读出来。读完之后,再读才会拿到零值和 ok=false。
可以用一小段代码看清楚:
func main() {
ch := make(chan int, 2)
ch <- 7
ch <- 9
close(ch)
for i := 0; i < 3; i++ {
v, ok := <-ch
fmt.Printf("v=%d ok=%v\n", v, ok)
}
}
输出是:
v=7 ok=true
v=9 ok=true
v=0 ok=false
这就是 Go channel 关闭后的读行为。
放到 select 里,还有一个点也别误判:如果多个 case 同时就绪,Go 会随机选一个执行。关闭的 channel 是就绪,已经有数据的 channel 也是就绪,stop 信号来了也是就绪。
所以这种代码:
select {
case v, ok := <-dataCh:
if !ok {
return
}
handle(v)
case <-stopCh:
return
}
如果 dataCh 已经关闭,stopCh 也已经关闭,两个 case 都能走。不要在这里假设一定先走 stop,或者一定先处理 data。
写并发代码,最怕脑子里替调度器安排顺序。
我自己的习惯是,遇到 for select 先看三件事:
第一,读 channel 有没有 ok。 第二,关闭后要不要置 nil。 第三,有没有多个地方 close 同一个 channel。
这三个地方没处理好,代码短的时候还看不出来,一上压测、一停任务、一重启消费者,问题就会露出来。
channel 关闭不是“没有消息了”这么简单。
在 for select 里,它更像一个永远亮着的信号灯。你不把它摘掉,它就一直抢执行机会。CPU 空转、零值脏数据、偶发 panic,很多都是从这个小口子漏出来的。
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:Go语言教程 go go《Go 语言 for select 时,如果通道已经关闭会怎么样?》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。










评论