博客原文:

并发(并行),一直以来都是一个编程语言里的核心主题之一,也是被开发者关注最多的话题;Go语言作为一个出道以来就自带 『高并发』光环的富二代编程语言,它的并发(并行)编程肯定是值得开发者去探究的,而Go语言中的并发(并行)编程是经由goroutine实现的,goroutine是golang最重要的特性之一,具有使用成本低、消耗资源低、能效高等特点,官方宣称原生goroutine并发成千上万不成问题,于是它也成为Gopher们经常使用的特性。
Goroutine是优秀的,但不是完美的,在极大规模的高并发场景下,也可能会暴露出问题,什么问题呢?又有什么可选的解决方案?本文将通过runtime对goroutine的调度分析,帮助大家理解它的机理和发现一些内存和调度的原理和问题,并且基于此提出一种个人的解决方案 — 一个高性能的Goroutine Pool(协程池)。
并发(并行),一直以来都是一个编程语言里的核心主题之一,也是被开发者关注最多的话题;Go语言作为一个出道以来就自带 『高并发』光环的富二代编程语言,它的并发(并行)编程肯定是值得开发者去探究的,而Go语言中的并发(并行)编程是经由goroutine实现的,goroutine是golang最重要的特性之一,具有使用成本低、消耗资源低、能效高等特点,官方宣称原生goroutine并发成千上万不成问题,于是它也成为Gopher们经常使用的特性。
Goroutine是优秀的,但不是完美的,在极大规模的高并发场景下,也可能会暴露出问题,什么问题呢?又有什么可选的解决方案?本文将通过runtime对goroutine的调度分析,帮助大家理解它的机理和发现一些内存和调度的原理和问题,并且基于此提出一种个人的解决方案 — 一个高性能的Goroutine Pool(协程池)。

Goroutine & Scheduler

Go Scheduler

线程那些事儿

互联网时代以降,由于在线用户数量的爆炸,单台服务器处理的连接也水涨船高,迫使编程模式由从前的串行模式升级到并发模型,而几十年来,并发模型也是一代代地升级,有IO多路复用、多进程以及多线程,这几种模型都各有长短,现代复杂的高并发架构大多是几种模型协同使用,不同场景应用不同模型,扬长避短,发挥服务器的最大性能,而多线程,因为其轻量和易用,成为并发编程中使用频率最高的并发模型,而后衍生的协程等其他子产品,也都基于它,而我们今天要分析的 goroutine 也是基于线程,因此,我们先来聊聊线程的三大模型:

线程的实现模型主要有3种:内核级线程模型、用户级线程模型和两级线程模型(也称混合型线程模型),它们之间最大的差异就在于用户线程与内核调度实体(KSE,Kernel Scheduling Entity)之间的对应关系上。而所谓的内核调度实体 KSE 就是指可以被操作系统内核调度器调度的对象实体(这说的啥玩意儿,敢不敢通俗易懂一点?)。简单来说 KSE 就是内核级线程,是操作系统内核的最小调度单元,也就是我们写代码的时候通俗理解上的线程了(这么说不就懂了嘛!装什么13)。

用户级线程模型

用户线程与内核线程KSE是多对一(N : 1)的映射模型,多个用户线程的一般从属于单个进程并且多线程的调度是由用户自己的线程库来完成,线程的创建、销毁以及多线程之间的协调等操作都是由用户自己的线程库来负责而无须借助系统调用来实现。一个进程中所有创建的线程都只和同一个KSE在运行时动态绑定,也就是说,操作系统只知道用户进程而对其中的线程是无感知的,内核的所有调度都是基于用户进程。许多语言实现的 协程库 基本上都属于这种方式(比如python的gevent)。由于线程调度是在用户层面完成的,也就是相较于内核调度不需要让CPU在用户态和内核态之间切换,这种实现方式相比内核级线程可以做的很轻量级,对系统资源的消耗会小很多,因此可以创建的线程数量与上下文切换所花费的代价也会小得多。但该模型有个原罪:并不能做到真正意义上的并发,假设在某个用户进程上的某个用户线程因为一个阻塞调用(比如I/O阻塞)而被CPU给中断(抢占式调度)了,那么该进程内的所有线程都被阻塞(因为单个用户进程内的线程自调度是没有CPU时钟中断的,从而没有轮转调度),整个进程被挂起。即便是多CPU的机器,也无济于事,因为在用户级线程模型下,一个CPU关联运行的是整个用户进程,进程内的子线程绑定到CPU执行是由用户进程调度的,内部线程对CPU是不可见的,此时可以理解为CPU的调度单位是用户进程。所以很多的协程库会把自己一些阻塞的操作重新封装为完全的非阻塞形式,然后在以前要阻塞的点上,主动让出自己,并通过某种方式通知或唤醒其他待执行的用户线程在该KSE上运行,从而避免了内核调度器由于KSE阻塞而做上下文切换,这样整个进程也不会被阻塞了。

内核级线程模型

用户线程与内核线程KSE是一对一(1 : 1)的映射模型,也就是每一个用户线程绑定一个实际的内核线程,而线程的调度则完全交付给操作系统内核去做,应用程序对线程的创建、终止以及同步都基于内核提供的系统调用来完成,大部分编程语言的线程库(比如Java的java.lang.Thread、C++11的std::thread等等)都是对操作系统的线程(内核级线程)的一层封装,创建出来的每个线程与一个独立的KSE静态绑定,因此其调度完全由操作系统内核调度器去做,也就是说,一个进程里创建出来的多个线程每一个都绑定一个KSE。这种模型的优势和劣势同样明显:优势是实现简单,直接借助操作系统内核的线程以及调度器,所以CPU可以快速切换调度线程,于是多个线程可以同时运行,因此相较于用户级线程模型它真正做到了并行处理;但它的劣势是,由于直接借助了操作系统内核来创建、销毁和以及多个线程之间的上下文切换和调度,因此资源成本大幅上涨,且对性能影响很大。

两级线程模型

两级线程模型是博采众长之后的产物,充分吸收前两种线程模型的优点且尽量规避它们的缺点。在此模型下,用户线程与内核KSE是多对多(N : M)的映射模型:首先,区别于用户级线程模型,两级线程模型中的一个进程可以与多个内核线程KSE关联,也就是说一个进程内的多个线程可以分别绑定一个自己的KSE,这点和内核级线程模型相似;其次,又区别于内核级线程模型,它的进程里的线程并不与KSE唯一绑定,而是可以多个用户线程映射到同一个KSE,当某个KSE因为其绑定的线程的阻塞操作被内核调度出CPU时,其关联的进程中其余用户线程可以重新与其他KSE绑定运行。所以,两级线程模型既不是用户级线程模型那种完全靠自己调度的也不是内核级线程模型完全靠操作系统调度的,而是中间态(自身调度与系统调度协同工作),也就是 — 『薛定谔的模型』(误),因为这种模型的高度复杂性,操作系统内核开发者一般不会使用,所以更多时候是作为第三方库的形式出现,而Go语言中的runtime调度器就是采用的这种实现方案,实现了Goroutine与KSE之间的动态关联,不过Go语言的实现更加高级和优雅;该模型为何被称为两级?即用户调度器实现用户线程到KSE的『调度』,内核调度器实现KSE到CPU上的『调度』

G-P-M 模型概述

每一个OS线程都有一个固定大小的内存块(一般会是2MB)来做栈,这个栈会用来存储当前正在被调用或挂起(指在调用其它函数时)的函数的内部变量。这个固定大小的栈同时很大又很小。因为2MB的栈对于一个小小的goroutine来说是很大的内存浪费,而对于一些复杂的任务(如深度嵌套的递归)来说又显得太小。因此,Go语言做了它自己的『线程』。

在Go语言中,每一个goroutine是一个独立的执行单元,相较于每个OS线程固定分配2M内存的模式,goroutine的栈采取了动态扩容方式, 初始时仅为2KB,随着任务执行按需增长,最大可达1GB(64位机器最大是1G,32位机器最大是256M),且完全由golang自己的调度器 Go Scheduler 来调度。此外,GC还会周期性地将不再使用的内存回收,收缩栈空间。 因此,Go程序可以同时并发成千上万个goroutine是得益于它强劲的调度器和高效的内存模型。Go的创造者大概对goroutine的定位就是屠龙刀,因为他们不仅让goroutine作为golang并发编程的最核心组件(开发者的程序都是基于goroutine运行的)而且golang中的许多标准库的实现也到处能见到goroutine的身影,比如net/http这个包,甚至语言本身的组件runtime运行时和GC垃圾回收器都是运行在goroutine上的,作者对goroutine的厚望可见一斑。

任何用户线程最终肯定都是要交由OS线程来执行的,goroutine(称为G)也不例外,但是G并不直接绑定OS线程运行,而是由Goroutine Scheduler中的 P - Logical Processor (逻辑处理器)来作为两者的『中介』,P可以看作是一个抽象的资源或者一个上下文,一个P绑定一个OS线程,在golang的实现里把OS线程抽象成一个数据结构:M,G实际上是由M通过P来进行调度运行的,但是在G的层面来看,P提供了G运行所需的一切资源和环境,因此在G看来P就是运行它的 “CPU”,由 G、P、M 这三种由Go抽象出来的实现,最终形成了Go调度器的基本结构:

  • G: 表示Goroutine,每个Goroutine对应一个G结构体,G存储Goroutine的运行堆栈、状态以及任务函数,可重用。G并非执行体,每个G需要绑定到P才能被调度执行。
  • P: Processor,表示逻辑处理器, 对G来说,P相当于CPU核,G只有绑定到P(在P的local runq中)才能被调度。对M来说,P提供了相关的执行环境(Context),如内存分配状态(mcache),任务队列(G)等,P的数量决定了系统内最大可并行的G的数量(前提:物理CPU核数 >= P的数量),P的数量由用户设置的GOMAXPROCS决定,但是不论GOMAXPROCS设置为多大,P的数量最大为256。
  • M: Machine,OS线程抽象,代表着真正执行计算的资源,在绑定有效的P后,进入schedule循环;而schedule循环的机制大致是从Global队列、P的Local队列以及wait队列中获取G,切换到G的执行栈上并执行G的函数,调用goexit做清理工作并回到M,如此反复。M并不保留G状态,这是G可以跨M调度的基础,M的数量是不定的,由Go Runtime调整,为了防止创建过多OS线程导致系统调度不过来,目前默认最大限制为10000个。

关于P,我们需要再絮叨几句,在Go 1.0发布的时候,它的调度器其实G-M模型,也就是没有P的,调度过程全由G和M完成,这个模型暴露出一些问题:

  • 单一全局互斥锁(Sched.Lock)和集中状态存储的存在导致所有goroutine相关操作,比如:创建、重新调度等都要上锁;
  • goroutine传递问题:M经常在M之间传递『可运行』的goroutine,这导致调度延迟增大以及额外的性能损耗;
  • 每个M做内存缓存,导致内存占用过高,数据局部性较差;
  • 由于syscall调用而形成的剧烈的worker thread阻塞和解除阻塞,导致额外的性能损耗。

这些问题实在太扎眼了,导致Go1.0虽然号称原生支持并发,却在并发性能上一直饱受诟病,然后,Go语言委员会中一个核心开发大佬看不下了,亲自下场重新设计和实现了Go调度器(在原有的G-M模型中引入了P)并且实现了一个叫做 的调度算法:

  • 每个P维护一个G的本地队列;
  • 当一个G被创建出来,或者变为可执行状态时,就把他放到P的可执行队列中;
  • 当一个G在M里执行结束后,P会从队列中把该G取出;如果此时P的队列为空,即没有其他G可以执行, M就随机选择另外一个P,从其可执行的G队列中取走一半。

该算法避免了在goroutine调度时使用全局锁。

至此,Go调度器的基本模型确立:



G-P-M 模型调度

Go调度器工作时会维护两种用来保存G的任务队列:一种是一个Global任务队列,一种是每个P维护的Local任务队列。

gowork-stealing

如果一切正常,调度器会以上述的那种方式顺畅地运行,但这个世界没这么美好,总有意外发生,以下分析goroutine在两种例外情况下的行为。

Go runtime会在下面的goroutine被阻塞的情况下运行另外一个goroutine:

  • blocking syscall (for example opening a file)
  • network input
  • channel operations
  • primitives in the sync package

这四种场景又可归类为两种类型:

用户态阻塞/唤醒

_Gruning_Gwaitting

系统调用阻塞

_Gsyscall
src/runtime/runtime2.gosrc/runtime/proc.go

大规模Goroutine的瓶颈

既然Go调度器已经这么~~牛逼~~优秀了,我们为什么还要自己去实现一个golang的 Goroutine Pool 呢?事实上,优秀不代表完美,任何不考虑具体应用场景的编程模式都是耍流氓!有基于G-P-M的Go调度器背书,go程序的并发编程中,可以任性地起大规模的goroutine来执行任务,官方也宣称用golang写并发程序的时候随便起个成千上万的goroutine毫无压力。

然而,你起1000个goroutine没有问题,10000也没有问题,10w个可能也没问题;那,100w个呢?1000w个呢?(这里只是举个极端的例子,实际编程起这么大规模的goroutine的例子极少)这里就会出问题,什么问题呢?

  1. 首先,即便每个goroutine只分配2KB的内存,但如果是恐怖如斯的数量,聚少成多,内存暴涨,就会对GC造成极大的负担,写过java的同学应该知道jvm GC那万恶的STW(Stop The World)机制,也就是GC的时候会挂起用户程序直到垃圾回收完,虽然Go1.8之后的GC已经去掉了STW以及优化成了并行GC,性能上有了不小的提升,但是,如果太过于频繁地进行GC,依然会有性能瓶颈;
  2. 其次,还记得前面我们说的runtime和GC也都是goroutine吗?是的,如果goroutine规模太大,内存吃紧,runtime调度和垃圾回收同样会出问题,虽然G-P-M模型足够优秀,韩信点兵,多多益善,但你不能不给士兵发口粮(内存)吧?巧妇难为无米之炊,没有内存,Go调度器就会阻塞goroutine,结果就是P的Local队列积压,又导致内存溢出,这就是个死循环...,甚至极有可能程序直接Crash掉,本来是想享受golang并发带来的~~快感~~效益,结果却得不偿失。

一个http标准库引发的血案

我想,作为golang拥趸的Gopher们一定都使用过它的net/http标准库,很多人都说用golang写web server完全可以不用借助第三方的web framework,仅用net/http标准库就能写一个高性能的web server,的确,我也用过它写过web server,简洁高效,性能表现也相当不错,除非有比较特殊的需求否则一般的确不用借助第三方web framework,但是天下没有白吃的午餐,net/http为啥这么快?要搞清这个问题,从源码入手是最好的途径。孔子曾经曰过:源码面前,如同裸奔。所以,~~高清~~无码是阻碍程序猿发展大大滴绊脚石啊,源码才是我们进步阶梯,切记切记!

接下来我们就来先看看net/http内部是怎么实现的。

src/net/http/server.goListenAndServe
Serve
(l net.Listener)rw, e := l.Accept()go c.serve(ctx)

釜底抽薪

有问题,就一定有解决的办法,那么,有什么方案可以减缓大规模goroutine对系统的调度和内存压力?要想解决问题,最重要的是找到造成问题的根源,这个问题根源是什么?goroutine的数量过多导致资源侵占,那要解决这个问题就要限制运行的goroutine数量,合理复用,节省资源,具体就是 — goroutine池化。

超大规模并发的场景下,不加限制的大规模的goroutine可能造成内存暴涨,给机器带来极大的压力,吞吐量下降和处理速度变慢还是其次,更危险的是可能使得程序crash。所以,goroutine池化是有其现实意义的。

首先,100w个任务,是不是真的需要100w个goroutine来处理?未必!用1w个goroutine也一样可以处理,让一个goroutine多处理几个任务就是了嘛,池化的核心优势就在于对goroutine的复用。此举首先极大减轻了runtime调度goroutine的压力,其次,便是降低了对内存的消耗。



有一个商场,来了1000个顾客买东西,那么该如何安排导购员服务这1000人呢?有两种方案:

第一,我雇1000个导购员实行一对一服务,这种当然是最高效的,但是太浪费资源了,雇1000个人的成本极高且管理困难,这些可以先按下不表,但是每个顾客到商场买东西也不是一进来就马上买,一般都得逛一逛,选一选,也就是得花时间挑,1000个导购员一对一盯着,效率极低;这就引出第二种方案:我只雇10个导购员,就在商场里待命,有顾客需要咨询的时候招呼导购员过去进行处理,导购员处理完之后就回来,等下一个顾客需要咨询的时候再去,如此往返反复...

第二种方案有没有觉得很眼熟?没错,其基本思路就是模拟一个I/O多路复用,通过一种机制,可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。关于多路复用,不在本文的讨论范围之内,便不再赘述,详细原理可以参考 I/O多路复用。

第一种方案就是net/http标准库采用的:来一个请求开一个goroutine处理;第二种方案就是Goroutine Pool(I/O多路复用)。

实现一个 Goroutine Pool

因为上述陈列的一些由于goroutine规模过大而可能引发的问题,需要有方案来解决这些问题,上文已经分析过,把goroutine池化是一种行之有效的方案,基于此,可以实现一个Goroutine Pool,复用goroutine,减轻runtime的调度压力以及缓解内存压力,依托这些优化,在大规模goroutine并发的场景下可以极大地提高并发性能。

哎玛!前面絮絮叨叨了这么多,终于进入正题了,接下来就开始讲解如何实现一个高性能的Goroutine Pool,秒杀原生并发的goroutine,在执行速度和占用内存上提高并发程序的性能。好了,话不多说,开始~~装逼~~分析。

设计思路

Goroutine Pool 的实现思路大致如下:

启动服务之时先初始化一个 Goroutine Pool 池,这个Pool维护了一个类似栈的LIFO队列 ,里面存放负责处理任务的Worker,然后在client端提交task到Pool中之后,在Pool内部,接收task之后的核心操作是:
  1. 检查当前Worker队列中是否有空闲的Worker,如果有,取出执行当前的task;
  2. 没有空闲Worker,判断当前在运行的Worker是否已超过该Pool的容量,是 — 阻塞等待直至有Worker被放回Pool;否 — 新开一个Worker(goroutine)处理;
  3. 每个Worker执行完任务之后,放回Pool的队列中等待。

核心调度流程如下:

按照这个设计思路,我实现了一个高性能的Goroutine Pool,较好地解决了上述的大规模调度和资源占用的问题,在执行速度和内存占用方面相较于原生goroutine并发占有明显的优势,尤其是内存占用,因为复用,所以规避了无脑启动大规模goroutine的弊端,可以节省大量的内存。

此外,该调度系统还有一个清理过期Worker的定时任务,该任务在初始化一个Pool之时启动,每隔一定的时间间隔去检查空闲Worker队列中是否有已经过期的Worker,有则清理掉,通过定时清理过期worker,进一步节省系统资源。

完整的项目代码可以在我的github上获取:传送门,也欢迎提意见和交流。

实现细节

Goroutine Pool的设计原理前面已经讲过了,整个调度过程相信大家应该可以理解了,但是有一句老话说得好,空谈误国,实干兴邦,设计思路有了,具体实现的时候肯定会有很多细节、难点,接下来我们通过分析这个Goroutine Pool的几个核心实现以及它们的联动来引导大家过一遍Goroutine Pool的原理。

Pool struct
PoolPoolWithFuncPoolWithFuncpfPoolWithFuncPoolPool
capacityrunningexpiryDurationworkersworkersreleaselockonce

初始化Pool并启动定期清理过期worker任务

提交任务到Pool

p.Submit(task f)
task

获取可用worker(核心)

p.getWorker()
goants

其他部分可以依照注释理解,这里不再赘述。

任务执行

p.Submit(task f)p.getWorker()w.run()tasksendTask(task f)w.pool.putWorker(w *Worker)

Worker回收(goroutine复用)

动态扩容或者缩小池容量

定期清理过期Worker

定期检查空闲worker队列中是否有已过期的worker并清理:因为采用了LIFO后进先出队列存放空闲worker,所以该队列默认已经是按照worker的最后运行时间由远及近排序,可以方便地按顺序取出空闲队列中的每个worker并判断它们的最后运行时间与当前时间之差是否超过设置的过期时长,若是,则清理掉该goroutine,释放该worker,并且将剩下的未过期worker重新分配到当前Pool的空闲worker队列中,进一步节省系统资源。

ants

彩蛋

Pool structPoolWithFunc structPoolWithFuncPoolPool

上码!

PoolWithFunc structPool structpoolFunc pftask fpoolFunc pfWorkerWithFunc
WorkerWithFuncWorkerPoolWithFuncpoolFunc pfWorker
taskWorkerPool struct

Benchmarks

吹了这么久的Goroutine Pool,那都是虚的,理论上池化可以复用goroutine,提升性能节省内存,没有benchmark数据之前,好像也不能服众哈!所以,本章就来进行一次实测,验证一下再大规模goroutine并发的场景下,Goroutine Pool的表现是不是真的比原生Goroutine并发更好!

测试机器参数:

Pool测试

测试结果:

antsantsants

PoolWithFunc测试

测试结果:

基准测试函数名-GOMAXPROCS
PoolWithFuncPoolantsantsantsants

吞吐量测试

上面的benchmarks出来以后,我当时的内心是这样的:



ants

彼时,我内心变成了这样:

antsantsants

测试结果:

10w 吞吐量



100w 吞吐量



1000w 吞吐量



ants
ants

总结

ant
antsants
  1. 限制并发的goroutine数量;
  2. 复用goroutine,减轻runtime调度压力,提升程序性能;
  3. 规避过多的goroutine侵占系统资源(CPU&内存)。

后记

Go语言的三位最初的缔造者 — Rob Pike、Robert Griesemer 和 Ken Thompson 中,Robert Griesemer 参与设计了Java的HotSpot虚拟机和Chrome浏览器的JavaScript V8引擎,Rob Pike 在大名鼎鼎的bell lab侵淫多年,参与了Plan9操作系统、C编译器以及多种语言编译器的设计和实现,Ken Thompson 更是图灵奖得主、Unix之父、C语言之父。这三人在计算机史上可是元老级别的人物,特别是 Ken Thompson ,是一手缔造了Unix和C语言计算机领域的上古大神,所以Go语言的设计哲学有着深深的Unix烙印:简单、模块化、正交、组合、pipe、功能短小且聚焦等;而令许多开发者青睐于Go的简洁、高效编程模式的原因,也正在于此。



本文从三大线程模型到Go并发调度器再到自定制的 Goroutine Pool,算是较为完整的窥探了整个Go语言并发模型的前世今生,我们也可以看到,Go的设计当然不完美,比如一直被诟病的error处理模式、不支持泛型、差强人意的包管理以及面向对象模式的过度抽象化等等,实际上没有任何一门编程语言敢说自己是完美的,还是那句话,任何不考虑应用场景和语言定位的争执都毫无意义,而Go的定位从出道开始就是系统编程语言&云计算编程语言(这个有点模糊),而Go的作者们也一直坚持的是用最简单抽象的工程化设计完成最复杂的功能,所以如果从这个层面去看Go的并发模型,就可以看出其实除了G-P-M模型中引入的 P ,并没有太多革新的原创理论,两级线程模型是早已成熟的理论,抢占式调度更不是什么新鲜的调度模式,Go的伟大之处是在于它诞生之初就是依照Go在谷歌:以软件工程为目的的语言设计而设计的,Go其实就是将这些经典的理论和技术以一种优雅高效的工程化方式组合了起来,并用简单抽象的API或语法糖开放给使用者,Go一直致力于找寻一个高性能&开发效率的双赢点,目前为止,它做得远不够完美,但足够优秀。另外Go通过引入channel与goroutine协同工作,将一种区别于锁&原子操作的并发编程模式 — CSP 带入了Go语言,对开发人员在并发编程模式上的思考有很大的启发。

antsants

感谢阅读。

参考

我是潘少,人生这么长,做个有趣的人,写点有趣的文字。此文章首发于我的个人公众号 - 远赴星辰,欢迎关注,代码、故事、灵魂,那里都有。