本人是JAVA转GO,最近在GO开发的时候为了提高性能,想到了使用多线程(以前Java的说法),在GO里面是协程,然而发现GO原生是没有协程池之类的东西的,因此就比较感兴趣:

  • 单机的goroutine数量需要控制吗?如果我不控制,会怎么样?
  • 如果需要,什么时候需要需要,什么时候不需要
  • 对于goroutine数量怎么控制了?

是否必要

我们写一个最简单的代码,看看一直创建协程会出什么问题,代码如下:

当程序运行起来后,可以看到CPU和内存开销,持续上涨,如下图所示:

当协程数开到1048575的时候,程序就会被杀掉,杀掉时日志如下:

从上面的DEMO测试,我们可以得出结论:首先,协程并不是可以无限创建;到达了1048575这个数字之后,就会panic:

为什么是1048575了?这其实是对单个file/socket的并发操作个数超过了系统上限,这个是标准输出造成的,具体一点,就是文件句柄数量达到限制。我们换一个例子,去除掉fmt,看看情况:

运行上面的程序会报:errno 1455,即Out of Memory错误。为什么了?因为默认每个 goroutine 占用 8KB 内存,那么一台8GB内存的机器大约能创建8GB/8KB = 1000000 个 goroutine,系统还需要保留一部分保证其他日常任务运行,因此当协程数创建到内存使用上限后,就会panic。

通过上面我们知道了:就协程数来说,只会受机器内存以及文件句柄等影响,一般可达百万、千万量级的协程。但是创建了过多的协程后,对于GC和调度是否会有影响了?

Goroutine 是一个由 Go 运行时管理的轻量级线程,一般称其为 “协程”。操作系统本身是无法明确感知到 Goroutine 的存在的,Goroutine 的操作和切换归属于 “用户态” 中。Goroutine 由特定的调度模式来控制,以 “多路复用” 的形式运行在操作系统为 Go 程序分配的几个系统线程上。

既然有了用户态的代表 Goroutine,操作系统又看不到他。必然需要有某个东西去管理他,才能更好的运作起来。这指的就是Go语言中的调度。

Go scheduler的主要功能是针对在处理器上运行的OS线程分发可运行的 Goroutine,而我们一提到调度器,就离不开三个经常被提到的缩写,分别是:

  • G:Goroutine,实际上我们每次调用 go func 就是生成了一个G。
  • P:Processor,处理器,一般P的数量就是处理器的核数,可以通过GOMAXPROCS进行修改。
  • M:Machine,系统线程。

这三者交互实际来源于Go 的 M: N 调度模型。也就是M必须与P进行绑定,然后不断地在M 上循环寻找可运行的G来执行相应的任务。

据测试,一次协程的切换,耗时大概在100ns,相对于线程的微秒级耗时切换,性能表现非常优秀,但是仍有一些开销。同时,协程运行结束,占用的内存资源是需要由 GC来回收,如果无休止地创建大量 Go 程后,势必会造成也会对GC造成一定的的压力。总的来说,就是有消耗有压力,但是还不至于崩。

那么,实际业务开发中,我们是不是就可以肆无忌惮的用协程了了?一般真实的业务场景,我们开协程一般都是提高性能,而一般影响性能的,都是一个网络或者IO或者其他其他计算等:

在真实的业务场景中,业务逻辑常常访问DB,其他服务,或者文件系统等,goroutine本身限制影响不大,但是其他第三方组件可能受不了,比如我们访问MYSQL,一般MYSQL的读写并发,只有几千的样子,如果我们开一个一万的协程去访问MYSQL,很可能就把MYSQ打挂了,因此,在真实场景中,我们往往需要考虑到业务逻辑对其他组件的使用情况。

当然除了考虑对第三方组件的保护外,我们也需要看看业务逻辑是不是内存消耗性,或者CPU消耗型,如果消耗内存比较严重,那么也需要根据实际情况控制协程的数量,已保护系统资源。

总结:在实际场景中,我们需要根据实际情况做到:1)保护第三方组件,2)保护自身系统资源。

如何控制

我们可以通过以下方式达到控制goroutine数量的目的,不过本身Go的goroutine就已经很轻量了,所以控制goroutine的数量还是要根据具体场景分析,并不是所有场景都需要控制goroutine的数量的,一般在并发场景我们会考虑控制goroutine的数量,接下来我们来看一看如下几种方式达到控制goroutine数量的目的:

  • 信号量Semaphore
  • 协程池

信号量Semaphore

Go语言的官方扩展包为我们提供了一个基于权重的信号量Semaphore,我可以根据信号量来控制一定数量的 goroutine 并发工作,一起看看官方也给提供了的例子:

从上面例子可以看到Semaphore的使用方式为:

  • 首先定义信号量的值;
  • 开启协程前,先获取信号量,如果获取成功,那么就可以开一个新的协程;
  • goroutine执行完了释放协程。

通过信号量,确实可以达到控制协程数的效果,然而却没有阻塞等功能,下面看看开源的协程池。

协程池

使用java,大家都会用线程池,一方面服用线程,另一方面避免线程无限制创建,Go里面,也可以实现一个协程池,现在已经有不少的开源的协程池库,如下所示:

项目名star数开源链接
ants9.1k
jeffail/tunny3.2k
go-playground/pool695

这里我们重点看下ants这个开源库。

其执行流程如下:

再看一下官方案例对于使用的说明:

可以使用默认的协程池,也可以通过ants.NewPoolWithFunc的方式使用协程池。详细说明和原理,可以查看官方文档:

总结

本文主要讨论了我们在使用的过程中,是否应该对协程的数量进行控制,以及怎么控制。结论为,go本身作为高性能语言,其协程数理论上是没有限制的,但是在实际的业务中,我们需要考虑对于第三方组件的访问,以及自身业务内存开销等情况。因此,对于需要大量协程的场景,一般建议对协程数量进行控制,可以使用开源的协程池。

参考文档