鸟叔原文:从Go channel中批量读取数据
看完文章之后,感觉有一些问题没有弄明白。
-
有default 的版本 和 没有default的版本有什么区别?好像只是把case的内容重新写在了default里面
-
那为什么需要使用default,如何区分default是否会导致循环空转,大量占用CPU
-
为什么<-time.After(100 * time.Millisecond) 的实现会带来大量的Timer 不能及时被回收?
下面开始解决我的疑问,首先想一下为什么需要有default:
请看代码:
go
package main
import (
"fmt"
"time"
)
func main() {
ch := make(chan int, 10)
// 读取chan
go func() {
for {
select {
case i := <-ch:
// 只读取15次chan
fmt.Println(i)
default:
// 读取15次chan以后的操作一直在这个空语句无任何IO操作的default条件里死循环,无法出让P,以保证一个GPM关系。
// 而如果无default条件的话,则系统当读取完15次chan后,当前goroutine会发生 chan IO 阻塞, Go调度器根据GPM的调度关系,会将当前执行关系中的G切换出去,再从LRQ队列中取一个新的G,重新组成一个GPM继续执行,以实现合理利用计算机资源,提高GO的高并发性能
}
}
}()
// 写入10个值到chan
for i := 0; i < 15; i++ {
ch <- i
}
// 模拟程序效果使用
time.Sleep(time.Minute)
}
这段代码有什么问题?
程序运行时,先使用go关键字创建一个 goroutine,里面是一个 for 循环语句。for 语句里面通过 select{} 来监听是否有 chan 的 IO 操作,当 ch 中有可以读取的数据时,则将值打印出来。没有的话则执行 default 语句,而这里 default 语句为空,所以继续下一次for语句,for{} 是一个死循环语句。当读取 15 次 ch 后,由于ch 会永远处于阻塞状态,所以会一直执行 default 条件,然后再执行 for 循环。此时这段逻辑基本演变成了一个空的 for{} 语句,所以会导致CPU占用100%。如果没有外层的 for{} 语句的话,这样写则没有任何问题的。
回到鸟窝的文章中,文章内为什么需要使用到default呢?文中提到,因为在批量处理的过程中,所以如果只是匹配Case的话,当channel中没有数据,并且当前batch 的数量还未达到设置的batchSize 的时候,程序就会一直等待,直到channel中有数据,或者channel 被关闭,这样会导致消费者饥饿。所以要避免这种情况时,应当使用default来处理这种情况的发生。
scss
default:
if len(batch) > 0 {
fn(batch)
batch = make([]T, 0, batchSize) // reset
}
}
此时在第一个版本后,新增这段代码。
新增完这段代码之后,鸟叔提出,会导致CPU空转,结合我们上面的例子,就能明白,这段代码如果len(batch) ==0 的情况下,演变成一个空的for{}语句,导致无法释放P,CPU占用100%。
至此,我们明白了,为什么需要使用 default,以及 select default 会导致CPU占满的原因。
scss
case <-time.After(100 * time.Millisecond):
if len(batch) > 0 {
fn(batch)
batch = make([]T, 0, batchSize) // reset
}
}
接着文章中提出使用<-time.After(100 * time.Millisecond) 代替default 来避免CPU空转,文章提到如果生产者生产数据的速度很快,而消费者处理数据的速度很慢,那么我们就会产生大量的Timer,这些 Timer 不能及时的被回收,可能导致大量的内存占用,而且如果有大量的 Timer,也会导致 Go 运行时处理 Timer 的性能。
为什么这里会产生大量的Timer?
解答:我的理解是两种情况:
- 进入到case时,其实每个对象都已经被生成了,所以都会创建出Timer这个对象。又由于消费者处理函数很慢,无法释放这个对象
- 当生成者的数据,每一次的间隔大于100 * time.Millisecond, 就会频繁的进入到 case <-time.After(100 * time.Millisecond) , 又由于消费者处理函数很慢,无法释放这个对象
如文中所说,time.After
既带来了性能的问题,还可能导致它在休眠的时候不能及时读取 channel 中的数据,导致业务时延增加。
接下来请看最终版本。
go
default:
if len(batch) > 0 { // partial
fn(batch)
batch = make([]T, 0, batchSize) // reset
} else { // empty
// wait for more
select {
case <-ctx.Done():
if len(batch) > 0 {
fn(batch)
}
return
case v, ok := <-ch:
if !ok {
return
}
batch = append(batch, v)
}
这个版本:
-
不会产生饥饿消费
-
没有走到的case ,进入default 分支,在该分支中,如果队列为空,则当前goroutine会发生 chan IO 阻塞, Go调度器根据GPM的调度关系,会将当前执行关系中的G切换出去,再从LRQ队列中取一个新的G,重新组成一个GPM继续执行,以实现合理利用计算机资源,提高GO的高并发性能
总结
- select语句只能用于信道的读写操作
- select中的case条件(非阻塞)是并发执行的,select会选择先操作成功的那个case条件去执行,如果多个同时返回,则随机选择一个执行,此时将无法保证执行顺序。对于阻塞的case语句会直到其中有信道可以操作,如果有多个信道可操作,会随机选择其中一个 case 执行
- 对于case条件语句中,如果存在信道值为nil的读写操作,则该分支将被忽略,可以理解为从select语句中删除了这个case语句
- 如果有超时条件语句,判断逻辑为如果在这个时间段内一直没有满足条件的case,则执行这个超时case。如果此段时间内出现了可操作的case,则直接执行这个case。一般用超时语句代替了default语句
- 对于空的select{},会引起死锁
- 对于for中的select{}, 也有可能会引起cpu占用过高的问题