文章总结: 本文详细分析了Go语言中for-select循环在通道关闭时的行为隐患及解决方案。当通道关闭后,接收操作会立即返回零值而非阻塞,导致循环空转、日志刷屏或CPU飙升。关键修复方案包括使用ok模式判断通道状态、将已关闭通道置为nil以禁用对应case、优先使用for-range处理单通道场景,并强调了发送到已关闭通道会panic的注意事项。 综合评分: 85 文章分类: 安全开发,实战经验,技术标准
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 runOrderWorker(jobs <-chan OrderJob) {
for {
select {
case job := <-jobs:
handleOrder(job)
}
}
}
func handleOrder(job OrderJob) {
fmt.Printf("handle order, id=%d, user=%d\n", job.ID, job.UserID)
}
这代码平时看不出问题。
jobs 里有数据,就消费。没数据,就卡在 select 那里等。
但只要外面执行了:
close(jobs)
问题就变味了。
case job := <-jobs 会一直就绪,每次都能读出来一个 OrderJob{}。也就是:
OrderJob{
ID: 0,
UserID: 0,
}
所以日志里才会一直出现 order_id=0。
这不是 Go 调度器抽风,也不是 select 随机选错了,是代码没判断 channel 是否关闭。
正确写法至少要带上 ok:
func runOrderWorker(jobs <-chan OrderJob) {
for {
select {
case job, ok := <-jobs:
if !ok {
fmt.Println("jobs channel closed, worker exit")
return
}
if job.ID <= 0 {
fmt.Printf("skip dirty job: %+v\n", job)
continue
}
handleOrder(job)
}
}
}
ok=false 才是关键信号。
这个地方别偷懒。尤其 channel 里传的是 int、string、结构体这种类型,零值看起来很像正常数据。
比如 chan int64 关闭后读出来是 0。
比如 chan string 关闭后读出来是 ""。
比如 chan *OrderJob 关闭后读出来是 nil。
如果后面代码没兜住,就会变成各种奇怪问题。轻一点是日志刷屏,重一点是拿着空数据去查库、调接口、写脏记录。
我见过有人这么写:
func watchUserEvent(events <-chan int64) {
for {
select {
case userID := <-events:
refreshUserCache(userID)
default:
time.Sleep(20 * time.Millisecond)
}
}
}
他以为加了 default 就不会卡死。
但 channel 关闭后,case userID := <-events 永远是可执行状态,default 根本没机会走。于是 refreshUserCache(0) 被疯狂调用。
修的时候不要靠 sleep 压住,这种压法只能把事故从“马上炸”改成“慢慢烂”。
改成这样:
func watchUserEvent(events <-chan int64) {
for {
select {
case userID, ok := <-events:
if !ok {
fmt.Println("events channel closed, stop watcher")
return
}
if userID == 0 {
fmt.Println("ignore empty user event")
continue
}
refreshUserCache(userID)
default:
time.Sleep(20 * time.Millisecond)
}
}
}
再说一个更容易被忽略的点:多个 case 时,关闭的 channel 会一直参与 select。
比如:
func mergePayEvent(payCh <-chan string, refundCh <-chan string) {
for {
select {
case pay := <-payCh:
fmt.Println("pay:", pay)
case refund := <-refundCh:
fmt.Println("refund:", refund)
}
}
}
如果 payCh 被关闭了,refundCh 还没关闭,这个循环不会老老实实只等退款事件。
payCh 那个 case 会一直就绪,读出来空字符串。
最后你可能看到这种日志:
pay:
pay:
pay:
refund: R20240518001
pay:
pay:
这里我一般会把关闭的 channel 置成 nil。
因为 nil channel 在 select 里永远不会就绪,相当于把这个 case 临时摘掉。
func mergePayEvent(payCh <-chan string, refundCh <-chan string) {
for payCh != nil || refundCh != nil {
select {
case pay, ok := <-payCh:
if !ok {
fmt.Println("pay channel closed")
payCh = nil
continue
}
fmt.Println("pay:", pay)
case refund, ok := <-refundCh:
if !ok {
fmt.Println("refund channel closed")
refundCh = nil
continue
}
fmt.Println("refund:", refund)
}
}
fmt.Println("all event channels closed")
}
这段比到处加标志位舒服。
payCh = nil 之后,select 就不会再选它。等两个 channel 都变成 nil,外层循环退出。
这里要注意,不能写成死循环:
for {
select {
case v := <-ch:
fmt.Println(v)
}
}
如果只有这一个 case,情况更简单,也更坑。
channel 没关闭时,它会阻塞等数据。
channel 关闭后,它不会阻塞,会一直读零值,然后循环马上进入下一轮。
这段代码关闭后等价于一个空转机器:
for {
v := <-closedCh
fmt.Println(v)
}
打印慢一点还好,要是里面没 IO,只做计数、判断、调用本地函数,CPU 很快就能顶上去。
还有人问:只有一个 case,那还要不要 select?
多数时候不需要。
只有一个接收分支,直接 range 反而更干净:
func consumeAuditLog(logCh <-chan string) {
for line := range logCh {
if line == "" {
continue
}
writeAuditFile(line)
}
fmt.Println("audit log channel closed")
}
for range channel 会在 channel 关闭并且数据读完后自动退出。
这个写法适合单通道消费。少一个 select,少一个坑。
但如果你还要监听退出信号,就可以保留 select:
func consumeWithStop(logCh <-chan string, stopCh <-chan struct{}) {
for {
select {
case line, ok := <-logCh:
if !ok {
fmt.Println("log channel closed")
return
}
writeAuditFile(line)
case <-stopCh:
fmt.Println("receive stop signal")
return
}
}
}
这里还有个反向坑:往已经关闭的 channel 发送数据会 panic。
func pushMetric(metricCh chan<- string, name string) {
metricCh <- name
}
如果外面把 metricCh 关了,这里不是返回失败,也不是阻塞,是直接 panic:
panic: send on closed channel
所以 channel 的关闭权要收紧。
谁创建,谁负责关闭。多个发送方抢着 close,迟早出事。
我一般会把发送方收口到一个 goroutine,外面只发数据,不碰 close:
type MetricBus struct {
ch chanstring
done chanstruct{}
}
func NewMetricBus() *MetricBus {
return &MetricBus{
ch: make(chanstring, 128),
done: make(chanstruct{}),
}
}
func (b *MetricBus) Push(name string) bool {
select {
case b.ch <- name:
returntrue
case <-b.done:
returnfalse
default:
fmt.Printf("metric dropped: %s\n", name)
returnfalse
}
}
func (b *MetricBus) Close() {
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 呢?》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。










评论