type
status
date
slug
summary
tags
category
icon
password

overview

 
Go语言的核心特点在于面向接口与天然支持高并发,其中后者的核心在于goroutine以及对于goroutine的调度,对于任何一位gopher来说,了解goroutine的调度原理对于深入理解并发都有着巨大的帮助。本文包含对goroutine scheduler的基本原理与优势的介绍,GPM模型解释,阻塞问题等内容。
 

Introduction to Goroutine Scheduler

 
对于注重性能的现代程序来说,可靠的的并发能力十分重要;
传统的编程语言,比如c,c++,通过“应用程序创建多线程,操作系统调度多线程”的方式,实现并发能力,但这种方式有着诸多缺陷;
首先,应用程序可以创建的线程数量受到线程大小的限制。 每个线程都需要一定量的内存用于其堆栈,并且所有线程可用的内存总量是有限的。 如果线程太大,应用程序可能无法创建最佳性能所需的线程数;
其次,操作系统切换线程时,cpu会进行上下文切换操作,为了保存未执行完成的线程的执行状态,操作系统会将用户态转移到内核态,而当恢复该线程时,又要从内核态转移到用户态,这是一项十分复杂的操作。
 
基于这些传统的并发问题,go语言采用了goroutine代替操作系统的线程成为并发中的基本单位,调度者也有操作系统变成了go语言中的go运行时(关于go运行时:在go编译后附加在程序里,负责内存分配,垃圾回收以及goroutine的调度);
goroutine是一种用户态的线程,或者说,一种协程,也因此,其所占资源相比操作系统的线程小很多,每一个goroutine栈默认大小为2KB,与此同时,goroutine的切换是不需要进入内核态的,其上下文切换的开销大约是线程的十分之一,因此一个go程序可以产生成千上万个goroutine而不用担心性能问题。
 
负责将goroutine按照一定的规则来调度执行的,我们称之为goroutine scheduler(实际为前文提到的go runtime)。之所以需要调度,说明资源不够,需要一个良好的竞争规则,那么,对于这些goroutine来说,竞争的对象是cpu资源吗?并不是,与cpu打交道的是操作系统而非go这样的应用程序,goroutine对于操作系统来说是不可见的,最终操作系统还是将线程作为基本单位去竞争cpu资源;goroutine之间竞争的,正是这些操作系统的线程,而goroutine scheduler,会用一定的算法将goroutine调度到不同的操作系统线程上。
 

GM and GPM

 
Go团队最早实现的调度算法,叫做GM模型,G为Goroutine,M为Machine,代表了操作系统的线程;
流程为:go runtime将G调度到M上执行,另外使用GOMAXPROCS来控制M的数目;
这个模型虽然可以运行,但是缺陷也很多:
  1. 创建、销毁、调度G都需要每个M获取锁,这就形成了激烈的锁竞争。
  1. M转移G会造成延迟和额外的系统负载。比如当G中包含创建新协程的时候,M创建了G‘,为了继续执行G,需要把G’交给M‘执行,也造成了很差的局部性,因为G’和G是相关的,最好放在M上执行,而不是其他M。
  1. 系统调用(CPU在M之间的切换)导致频繁的线程阻塞和取消阻塞操作增加了系统开销
 
曾有人说,“任何一个计算机科学的问题都可以通过增加一个间接的中间层来解决“,于是,在GM模型的基础上,谷歌工程师Dmitry Vyukov开发出了GPM模型;
增加的P代表了逻辑上的processor,负责将G与M绑定在一起;每一个G,都会进入到一个P的本地队列中,而每一个P,又和一个M绑定,该M从该P的本地队列中,源源不断的提取G来运行;默认情况下,P的数目等于cpu逻辑核的数目,以便更好地利用现代cpu的多核达到并行,可以使用runtime.GOMAXPROCS改变;
 
GPM模型:
notion image
 
当go程序被运行时,入口并非是main函数,而是go runtime中的一个函数,完成初始化工作,并创建m0和g0,为main生成一个goroutine,并被m0执行;m0是Go程序启动时的第一个线程(主线程),也是唯一一个放在全局变量而非运行时的局部变量的线程,g0是调度用的goroutine,每个m都会有g0,该对象的栈有系统分配,大小一般固定是8MB;每当一个goroutine运行完成或被新创建时,调度器都会先切换到该队列中的g0上,让g0负责调度;此外,与m0绑定的g0也是放在全局变量中的。
 

Blocking

 
假如我们采取上述的GPM模型,那当有一个G运行了一个死循环时,该G所在的P和M将永远阻塞住,而该本地队列中的其他的G将永远得不到调度;于是,我们需要一种机制,使得当前队列中即使有G被阻塞住,其余G也能够被调度到。
 
在Go1.2中,Dmitry Vyukov实现了基于协作的goroutine抢占式调度,原理是:在每一个函数或方法的调用入口处加上一段额外的代码,使得go runtime能够检查这段代码中是否需要执行抢占式调度;
在Go1.4中,又增加了非协作式的抢占式调度,原理为:通过向线程发送系统信号,信号处理函数会进行调度。
具体来说,当g被阻塞在channel或是网络IO操作时,G被放入等待队列中,M尝试运行该P中的下一个G,若没有下一个G则M与P解绑并挂起,G完成阻塞操作后,从等待队列中出来重新变为可运行,寻找一个可以进入的p去执行;当g被阻塞在系统调用中,M与P解绑,M与G一起被挂起,P寻找空闲的M,若没有则创建一个M,当系统调用返回之后,该G会尝试寻找一个可以进入的P,若没有则该G被标记为可运行,该M继续挂起,
 
即使并未发生阻塞,goroutine也不会长时间运行,而是会被抢占式调度;
从go部分源码中,我们可以看到sysmon这个特殊的g0,并不需要P,独立绑定一个线程M,定时运行,检查那些网络IO相关或是运行超过10ms的goroutine,将其加入队列中,防止资源被过度占用:
 

Conclusion

 
在这个多核CPU的时代,Go语言的GPM模型和非协作式抢占策略使得Goroutine调度器能够更有效地利用多核处理器,提高并发性能,同时保持了高度的可靠性和强大的功能。这些改进使得Go语言在处理高并发任务时表现出色,满足了现代软件开发的需求。
 
cpu性能提高术 个人信息与规划系统构建
Alex
Alex
某不知名青年|web2.5人士|喜欢猫与美少女
公告
type
status
date
slug
summary
tags
category
icon
password
有事请邮箱联系:alexwu7@outlook.com
🚀🚀🚀