Go语言中forselect时,如果通道已经关闭会怎么样?如果只有一个case呢?

admin 2026-06-26 07:25:52 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 本文详细分析了Go语言中for-select循环在通道关闭时的行为隐患及解决方案。当通道关闭后,接收操作会立即返回零值而非阻塞,导致循环空转、日志刷屏或CPU飙升。关键修复方案包括使用ok模式判断通道状态、将已关闭通道置为nil以禁用对应case、优先使用for-range处理单通道场景,并强调了发送到已关闭通道会panic的注意事项。 综合评分: 85 文章分类: 安全开发,实战经验,技术标准


cover_image

Go 语言中 for select 时,如果通道已经关闭会怎么样?如果只有一个 case 呢?

原创

go go

Go语言教程

2026年6月24日 13:21 陕西

在小说阅读器读本章

去阅读

生产上看到这种日志,我第一眼就不太信业务代码:

worker=sync-order cost=0ms order_id=0
worker=sync-order cost=0ms order_id=0
worker=sync-order cost=0ms order_id=0
worker=sync-order cost=0ms order_id=0

CPU 还跟着涨,接口没多少流量,日志倒是刷得挺勤。

这种情况我一般先翻 for select,尤其是里面读 channel 的地方。Go 里 channel 一旦被关闭,接收方不是阻塞,也不是报错,而是立刻返回零值。

看这段代码:

type OrderJob struct {
 ID     int64
 UserID int64
}

func&nbsp;runOrderWorker(jobs <-chan&nbsp;OrderJob)&nbsp;{
for&nbsp;{
select&nbsp;{
case&nbsp;job := <-jobs:
&nbsp; &nbsp;handleOrder(job)
&nbsp; }
&nbsp;}
}

func&nbsp;handleOrder(job OrderJob)&nbsp;{
&nbsp;fmt.Printf("handle order, id=%d, user=%d\n", job.ID, job.UserID)
}

这代码平时看不出问题。

jobs 里有数据,就消费。没数据,就卡在 select 那里等。

但只要外面执行了:

close(jobs)

问题就变味了。

case job := <-jobs 会一直就绪,每次都能读出来一个 OrderJob{}。也就是:

OrderJob{
&nbsp;ID: &nbsp; &nbsp;&nbsp;0,
&nbsp;UserID:&nbsp;0,
}

所以日志里才会一直出现 order_id=0

这不是 Go 调度器抽风,也不是 select 随机选错了,是代码没判断 channel 是否关闭。

正确写法至少要带上 ok

func&nbsp;runOrderWorker(jobs <-chan&nbsp;OrderJob)&nbsp;{
for&nbsp;{
select&nbsp;{
case&nbsp;job, ok := <-jobs:
&nbsp; &nbsp;if&nbsp;!ok {
&nbsp; &nbsp; fmt.Println("jobs channel closed, worker exit")
&nbsp; &nbsp;&nbsp;return
&nbsp; &nbsp;}

&nbsp; &nbsp;if&nbsp;job.ID <=&nbsp;0&nbsp;{
&nbsp; &nbsp; fmt.Printf("skip dirty job: %+v\n", job)
&nbsp; &nbsp;&nbsp;continue
&nbsp; &nbsp;}

&nbsp; &nbsp;handleOrder(job)
&nbsp; }
&nbsp;}
}

ok=false 才是关键信号。

这个地方别偷懒。尤其 channel 里传的是 intstring、结构体这种类型,零值看起来很像正常数据。

比如 chan int64 关闭后读出来是 0

比如 chan string 关闭后读出来是 ""

比如 chan *OrderJob 关闭后读出来是 nil

如果后面代码没兜住,就会变成各种奇怪问题。轻一点是日志刷屏,重一点是拿着空数据去查库、调接口、写脏记录。

我见过有人这么写:

func&nbsp;watchUserEvent(events <-chan&nbsp;int64)&nbsp;{
&nbsp;for&nbsp;{
&nbsp;&nbsp;select&nbsp;{
&nbsp;&nbsp;case&nbsp;userID := <-events:
&nbsp; &nbsp;refreshUserCache(userID)
&nbsp;&nbsp;default:
&nbsp; &nbsp;time.Sleep(20&nbsp;* time.Millisecond)
&nbsp; }
&nbsp;}
}

他以为加了 default 就不会卡死。

但 channel 关闭后,case userID := <-events 永远是可执行状态,default 根本没机会走。于是 refreshUserCache(0) 被疯狂调用。

修的时候不要靠 sleep 压住,这种压法只能把事故从“马上炸”改成“慢慢烂”。

改成这样:

func&nbsp;watchUserEvent(events <-chan&nbsp;int64)&nbsp;{
for&nbsp;{
select&nbsp;{
case&nbsp;userID, ok := <-events:
&nbsp; &nbsp;if&nbsp;!ok {
&nbsp; &nbsp; fmt.Println("events channel closed, stop watcher")
&nbsp; &nbsp;&nbsp;return
&nbsp; &nbsp;}

&nbsp; &nbsp;if&nbsp;userID ==&nbsp;0&nbsp;{
&nbsp; &nbsp; fmt.Println("ignore empty user event")
&nbsp; &nbsp;&nbsp;continue
&nbsp; &nbsp;}

&nbsp; &nbsp;refreshUserCache(userID)

default:
&nbsp; &nbsp;time.Sleep(20&nbsp;* time.Millisecond)
&nbsp; }
&nbsp;}
}

再说一个更容易被忽略的点:多个 case 时,关闭的 channel 会一直参与 select。

比如:

func&nbsp;mergePayEvent(payCh <-chan&nbsp;string, refundCh <-chan&nbsp;string)&nbsp;{
&nbsp;for&nbsp;{
&nbsp;&nbsp;select&nbsp;{
&nbsp;&nbsp;case&nbsp;pay := <-payCh:
&nbsp; &nbsp;fmt.Println("pay:", pay)

&nbsp;&nbsp;case&nbsp;refund := <-refundCh:
&nbsp; &nbsp;fmt.Println("refund:", refund)
&nbsp; }
&nbsp;}
}

如果 payCh 被关闭了,refundCh 还没关闭,这个循环不会老老实实只等退款事件。

payCh 那个 case 会一直就绪,读出来空字符串。

最后你可能看到这种日志:

pay:
pay:
pay:
refund: R20240518001
pay:
pay:

这里我一般会把关闭的 channel 置成 nil

因为 nil channel 在 select 里永远不会就绪,相当于把这个 case 临时摘掉。

func&nbsp;mergePayEvent(payCh <-chan&nbsp;string, refundCh <-chan&nbsp;string)&nbsp;{
for&nbsp;payCh !=&nbsp;nil&nbsp;|| refundCh !=&nbsp;nil&nbsp;{
select&nbsp;{
case&nbsp;pay, ok := <-payCh:
&nbsp; &nbsp;if&nbsp;!ok {
&nbsp; &nbsp; fmt.Println("pay channel closed")
&nbsp; &nbsp; payCh =&nbsp;nil
&nbsp; &nbsp;&nbsp;continue
&nbsp; &nbsp;}
&nbsp; &nbsp;fmt.Println("pay:", pay)

case&nbsp;refund, ok := <-refundCh:
&nbsp; &nbsp;if&nbsp;!ok {
&nbsp; &nbsp; fmt.Println("refund channel closed")
&nbsp; &nbsp; refundCh =&nbsp;nil
&nbsp; &nbsp;&nbsp;continue
&nbsp; &nbsp;}
&nbsp; &nbsp;fmt.Println("refund:", refund)
&nbsp; }
&nbsp;}

&nbsp;fmt.Println("all event channels closed")
}

这段比到处加标志位舒服。

payCh = nil 之后,select 就不会再选它。等两个 channel 都变成 nil,外层循环退出。

这里要注意,不能写成死循环:

for&nbsp;{
&nbsp;select&nbsp;{
&nbsp;case&nbsp;v := <-ch:
&nbsp; fmt.Println(v)
&nbsp;}
}

如果只有这一个 case,情况更简单,也更坑。

channel 没关闭时,它会阻塞等数据。

channel 关闭后,它不会阻塞,会一直读零值,然后循环马上进入下一轮。

这段代码关闭后等价于一个空转机器:

for&nbsp;{
&nbsp;v := <-closedCh
&nbsp;fmt.Println(v)
}

打印慢一点还好,要是里面没 IO,只做计数、判断、调用本地函数,CPU 很快就能顶上去。

还有人问:只有一个 case,那还要不要 select?

多数时候不需要。

只有一个接收分支,直接 range 反而更干净:

func&nbsp;consumeAuditLog(logCh <-chan&nbsp;string)&nbsp;{
&nbsp;for&nbsp;line :=&nbsp;range&nbsp;logCh {
&nbsp;&nbsp;if&nbsp;line ==&nbsp;""&nbsp;{
&nbsp; &nbsp;continue
&nbsp; }
&nbsp; writeAuditFile(line)
&nbsp;}

&nbsp;fmt.Println("audit log channel closed")
}

for range channel 会在 channel 关闭并且数据读完后自动退出。

这个写法适合单通道消费。少一个 select,少一个坑。

但如果你还要监听退出信号,就可以保留 select:

func&nbsp;consumeWithStop(logCh <-chan&nbsp;string, stopCh <-chan&nbsp;struct{})&nbsp;{
for&nbsp;{
select&nbsp;{
case&nbsp;line, ok := <-logCh:
&nbsp; &nbsp;if&nbsp;!ok {
&nbsp; &nbsp; fmt.Println("log channel closed")
&nbsp; &nbsp;&nbsp;return
&nbsp; &nbsp;}
&nbsp; &nbsp;writeAuditFile(line)

case&nbsp;<-stopCh:
&nbsp; &nbsp;fmt.Println("receive stop signal")
&nbsp; &nbsp;return
&nbsp; }
&nbsp;}
}

这里还有个反向坑:往已经关闭的 channel 发送数据会 panic。

func&nbsp;pushMetric(metricCh&nbsp;chan<-&nbsp;string, name&nbsp;string)&nbsp;{
&nbsp;metricCh <- name
}

如果外面把 metricCh 关了,这里不是返回失败,也不是阻塞,是直接 panic:

panic: send on closed channel

所以 channel 的关闭权要收紧。

谁创建,谁负责关闭。多个发送方抢着 close,迟早出事。

我一般会把发送方收口到一个 goroutine,外面只发数据,不碰 close:

type&nbsp;MetricBus&nbsp;struct&nbsp;{
&nbsp;ch &nbsp;&nbsp;chanstring
&nbsp;done&nbsp;chanstruct{}
}

func&nbsp;NewMetricBus()&nbsp;*MetricBus&nbsp;{
return&nbsp;&MetricBus{
&nbsp; ch: &nbsp;&nbsp;make(chanstring,&nbsp;128),
&nbsp; done:&nbsp;make(chanstruct{}),
&nbsp;}
}

func&nbsp;(b *MetricBus)&nbsp;Push(name&nbsp;string)&nbsp;bool&nbsp;{
select&nbsp;{
case&nbsp;b.ch <- name:
returntrue
case&nbsp;<-b.done:
returnfalse
default:
&nbsp; fmt.Printf("metric dropped: %s\n", name)
returnfalse
&nbsp;}
}

func&nbsp;(b *MetricBus)&nbsp;Close()&nbsp;{
close(b.done)
close(b.ch)
}

这段不是让你照搬,重点是别让一堆业务代码到处 close(ch)

for select 里遇到关闭 channel,记住几个判断就够了:

读关闭的 channel,不阻塞,返回零值,ok=false

关闭的 channel 在 select 里永远就绪。

只有一个 receive case 时,channel 一关,循环就可能空转。

多个 channel 时,某个关了,要么 return,要么置 nil 摘掉。

发送到关闭 channel,会 panic。

Go 的 channel 设计没问题,问题通常出在我们把“没数据”和“关闭了”混在一起看。线上排这种问题,我不会先怀疑 goroutine 泄漏,也不会先调调度参数,先看接收方有没有写 ok。这个地方漏一次,日志和 CPU 会帮你记住。


免责声明:

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

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

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

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

评论:0   参与:  0