golang协程goroutine

协程goroutine

概念

协程(英语:coroutine)是计算机程序的一类组件,推广了协作式多任务子例程,允许执行被挂起与被恢复。相对子例程而言,协程更为一般和灵活,但在实践中使用没有子例程那样广泛。协程更适合于用来实现彼此熟悉的程序组件,如协作式多任务异常处理事件循环迭代器无限列表管道。根据维基百科的记录,协程术语coroutine最早出现在1963年发表的论文中,论文作者是美国计算机科学家马尔文·爱德华·康威。

协程并不是Go发明的概念,支持协程的编程语言有很多,比如python、perl等,但没有哪个语言能像Go一样把协程支持的这么优雅,Go在语言层面直接提供了对协程的支持,称为goroutine。

基本概念

进程

进程是应用程序的启动实例,每个进程都有独立的内存空间,不同进程通过进程之间的通信方式来通信。

线程

线程从属于进程,每个进程至少包含一个线程,**线程是CPU调度的基本单位,**多个线程之间可以共享进程的资源并通过共享内存等线程间的通信方式来通信。

协程

协程可以理解为一种轻量级线程,与线程相比,协程不受操作系统的调度,协程调度器由用户应用程序提供,协程调度器按照调度策略把协程调度到线程中运行。Go程序的协程调度器由runtime包提供,用户使用go关键字即可创建协程,这也就是在语言层面直接支持协程的含义。

协程的优势

我们知道在高并发应用中频繁创建线程会造成不必要的开销,所以有了线程池技术。在线程池中预先创建一定数量的线程,新任务将不在以创建线程的方式去执行,而是将任务发布到任务队列中,线程池中的线程不断从任务队列中获取任务并执行,这样子可以有效的减少线程的创建和销毁所带来的性能开销。

下图展示了一个典型的线程池:

image-20210830104434272

我们把任务队列中的每一个任务称为G,而G往往是一个函数。线程池中的worker线程不断的从任务队列中获取任务并执行,而worker线程则交给操作系统来调度。

如果worker线程执行的G任务发生了系统调用,则操作系统会将该线程设置为阻塞状态,也就意味着该线程在怠工,由于消费任务队列中的worker线程变少了,所以线程池消费任务队列的能力也就变弱了。

如果任务队列中的大部分任务都进行系统调用,则会让这种状态恶化,大部分worker线程进入阻塞状态,从而任务队列中的任务产生堆积。

解决这个问题的一个思路就是重新审视线程池中的线程数量,增加线程池中的线程数量可以在一定程度上提高消费能力,但随着线程数量增多,过多的线程争夺CPU资源,消费能力会有上线,甚至出现消费能力下降的情况。

image-20210830105914704

过多的线程会导致上下文切换的开销变大,而工作在用户态的协程则能大大减少上下文切换的开销。协程调度器把可运行的协程逐个调度到线程中执行,同时及时把阻塞的协程调度出线程,从而有效的避免了线程频繁的切换,达到了使用少量线程实现高并发的效果。

多个协程分享操作系统分配给线程的时间片,从而达到充分利用CPU算力的目的,协程调度器则决定了协程运行的顺序。如下图所示,线程运行调度器指派的协程,但每一确定的时刻只能运行一个协程。

image-20210830110613328

调度模型

线程模型

线程可分为用户线程和内核线程,用户线程由用户创建、同步和销毁,内核线程则由内核来管理。根据用户线程管理方式的不同,分为三种线程模型。

  • 一种是N:1模型,即N个用户线程运行在1个内核线程中,优点是用户线程上下文切换快,缺点是无法充分利用CPU多核的算力。
  • 一种是1:1模型,即每个用户线程对应一个内核线程,优点是充分利用CPU的算力,缺点是线程上下文切换比较慢。
  • Go实现的是M:N模型,即前两种模型的组合,M个用户线程(协程)运行在N个线程中,优点是充分利用CPU的算力且协程上下文切换快,缺点则是该模型的调度算法比较复杂

调度器模型

Go协程调度器模型其中包含了三个关键实体,machine(简称M),processor(简称P)和goroutine(简称G)

  • M:工作线程,它由操作系统调度
  • P:处理器(Go定义的一个概念,不是指CPU),包含运行Go代码的必要资源,也有调度goroutine的能力
  • G:即Go协程,每个Go关键字都会创建一个协程

M必须持有P才能执行代码,跟系统中的其他线程一样,M也会被系统调用阻塞。P的个数在程序启动时决定,默认情况下等同于CPU的核数,可以使用环境变量GOMAXPROCS或者在程序中使用runtime.GOMAXPROCS()函数指定P的个数。

例如,使用环境变量设置GOMAXPROCS为100:

export GOMAXPROCS=100

使用runtime.GOMAXPROCS()方法设置GOMAXPROCS为100:

runtime.GOMAXPROCS(100)

M的个数通常稍大于P的个数,因为除了运行Go代码,runtime包还有其他的内置任务需要处理。

一个简单的调度器模型如下图所示:

image-20210830140530300

上图中包括两个工作线程M,每个M持有一个处理器P,并且每个M中有一个协程G在运行。绿色背景的协程正在等待被调度,它们位于被称为runqueues的队列中。每个处理器P中拥有一个runqueues队列,此外还有一个全局的runqueues队列,由多个P共享。

早期的调度器实现中(Go1.1之前)只包含全局的runqueues,多个处理器P通过互斥锁来调度队列中的协程,在多个CPU或者多核环境中,多个处理器需要经常抢夺锁资源来调度全局队列中的协程,严重影响了并发效率。后来便引入了局部的runqueues,每个处理器P访问自己的runqueues时不需要加锁,大大提高了效率。

一般来说,处理器P中的协程G额外再创建的协程会加入本地的runqueues中,但如果本地的队列已满,或者阻塞的协程被唤醒,则协程会被放入全局的runqueues中,处理器P除了调度本地的runqu队列以外,还会周期的从全局runqueues队列中获取协程来调度。

调度策略

队列轮转

每个处理器P维护者一个协程G的队列,处理器P依次将协程G调度到M中执行。

协程G执行结束后,处理器P会再次调度一个协程G到M中执行(协程进入系统调用和协程长时间运行的场景略微复杂,后面再介绍)。

同时,每个P会周期性的检查全局队列中是否有G待运行并将其调度到M中执行,全局队列中的G主要来自从系统调用中回复的G。之所以P会周期性的查看全局队列,也是为了防止全局队列中的G长时间得不到调度机会而被"饿死"。

系统调用

我们知道,当线程在执行系统调用时,可能会被阻塞,对应到调度器模型,如果一个协程发起系统调用,那么对应的工作线程会被阻塞,这样一来,处理器P的runqueues队列中的协程将得不到调度,相当于队列中所有的协程都被阻塞。

前面提到P的个数默认等于CPU的核数,每个M必须持有一个P才可以执行G。一般情况下M的个数会略大于P的个数,多出来的M将会在G产生系统调用时发挥作用,与线程池类似,Go也提供一个M的池子,需要时从池子中获取,用完后放回池子,不够用时就再创建一个。

当M运行的某个G产生系统调用时,过程如下图所示:

image-20210830144534215

如上图所示,当G0即将进入系统调用时,M0将释放P,进而某个冗余的M1获取P,继续执行P队列中剩余的G。M0由于陷入系统调用而被阻塞,M1接替M0的工作,只要P不空闲,就可以保证充分利用CPU。

冗余的M的来源有可能是缓存池,也有可能是新创建的。当G0结束系统调用后,根据M0是否能获取到P,对G0进行不同的处理。

  • 如果有空闲的P,则获取一个P,继续执行G0
  • 如果没有空闲的P,则将G0放入到全局runqueues队列中,等待被其他的P调度,然后M0将进入缓存池中睡眠

工作量窃取

通过go关键字创建的协程通常会优先放到当前协程对应的处理器队列中,可能有些协程自身不断地派生出新的协程,而有些协程不派生协程。如此一来,多个处理器P中维护的G队列有可能是不均衡的,如果不加以控制,则有可能会出现部分处理器P非常忙,而部分处理器P怠工的情况。(类似数据倾斜的场景)

为此,Go调度器提供了工作量窃取策略,即当某个处理器P没有需要调度的协程时,将从其他处理器中偷取协程,如下图所示:

image-20210830145531120

发生窃取前(左半部分),右侧的处理器P在没有协程需要调度时,会先查询全局队列,如果全局队列中也没有协程需要调度,则会从另一个正在运行的处理器P中偷取协程,每次偷取一半,偷取完的效果就如图中右半部分。

抢占式调度

所谓的抢占式调度,是指避免某个协程长时间执行,而阻碍其他协程被调度的机制。

调度器会监控每个协程的执行时间,一但执行时间过长且有其他的协程在等待时,会把协程暂停,转而调度等待的协程,以达到类似于时间片轮转的效果。

在Go1.14之前,Go协程调度器抢占式调度机制有一定的局限性,在该设计中,函数调用间隙检查协程是否可被抢占,如果协程没有函数调用,则会无限期的占用执行权,如以下代码所示:

func main(){
  runtime.GOMAXPROCS(1)
  go func(){
    for{
      //无函数调用的无限循环
    }
  }()
  time.Sleep(1*time.Second)	//系统调用,出让执行权给上面的协程
  fmt.Println("Done")	
}

上面的代码在Go1.14之前会陷入协程的无限循环中,协程永远无法被抢占,导致主协程无法继续执行。直到在Go1.14中,调度器引入了基于信号的抢占机制,这个问题才得以解决。

GOMAXPROCS对性能的影响

一般来讲,程序运行时就将GOMAXPROCS的大小设置为CPU的核数,可让Go程序充分利用CPU。在某些I/O密集型的应用中,这个值可能并不意味着性能最好。理论上当某个goroutine进入系统调用时,会有一个新的M被启用或者创建,继续占满CPU。但由于Go调度器检测到M被阻塞是有一定的延迟,即旧的M被阻塞和新的M得到运行之间是有一定间隔的,所以在I/O密集型应用中不妨把GOMAXPROCS的值设置的大一些,或许有好的效果。

  • 0
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

董洪臣

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值