Golang-GMP调度器
学习刘丹冰老师的:第1讲-课程阶段目标_哔哩哔哩_bilibili 学习笔记
参考:
并发并行
并发
多个程序在一段重叠的时间段中开始、运行与结束,并没有在任何一个时刻同时在执行,同一时刻只有一个程序在执行。
并行
同一个时刻,存在两个以上任务在同时运行。
OS分类
单进程OS
面临两个问题:
- 只能一次处理一个任务
- 进程进行IO等系统调用会导致CPU空闲阻塞
多线程多进程OS
为了解决上述两个问题,操作系统通过比如实现CPU时间片切换完成宏观并发的系统调用需求,或者通过其他的任务调度方式进行任务调度。调度方式如下所示:
CPU在多个线程之间实现时间片切换,来完成多个任务的调度,实现并发执行的效果,此时宏观上看其实现了并行的效果。但是伴随着CPU调度任务的增多,进程占用资源的增长,形如下图会产生系统上下文线程切换。
线程的创建、切换、销毁过程中需要保存或者设置相关信息(切换上下文、堆栈、函数调用变量、),当进程增多时对于CPU的任务调度消耗较大。并且当多个线程程序进行运行的时候往往伴随着锁的抢占,共享资源的获取也会消耗大量的CPU时间。
进程 && 线程 && 协程
在现代操作系统中,进程、线程和协程都需要占用一定的空间大小。
- 进程是操作系统中的基本执行单元,需要分配一定的内存空间来存储程序代码、数据、堆栈等信息,进程使用的空间大小取决于程序的规模和运行时所需的资源。
- 线程是进程的子执行单元,它使用进程的内存空间,通常只需要额外的堆栈空间。
- 协程是一种更为轻量级的执行单元,它使用的空间比线程更少,通常只需要保存当前执行状态的相关信息,如CPU寄存器值、调用栈、堆栈等。协程的大小取决于其保存的状态信息的大小。
占用空间大小的大小顺序是进程 > 线程 > 协程。
目前互联网高并发场景出现频次逐年增加,因此比如一个定时的任务,每个任务创建一个线程去处理是不现实的,往往大量线程创建会占用巨量内存数据,对于系统性能要求较高。以及线程之间频繁的上下文切换也会导致运行效率降低。
对于线程而言,在Linux系统中分为用户态和内核态,Linux核心执行过程中,执行对应的PCB(进程控制块)。因此对于CPU而言并不知道执行的线程是用户线程还是内核线程,均会采用时间片调度进行线程执行。
用户态线程是由操作系统通过线程库进行调度和管理的,而协程则是由应用程序自己进行管理和调度的。在协程中,可以手动调用切换操作,实现协作式任务调度,而用户态线程则需要使用系统调用进行切换,实现抢夺式任务调度。协程通常不需要共享内核资源,而用户态线程需要共享内核资源。
协程调度关系
下面将这种协程类比为用户态线程
1:1
每一个协程调度器绑定一个内核线程执行,每一个协程调度器绑定一个协程
那么此时所有的协程调取其实与线程调度是一致的,均需要由CPU进行调度实现,回归传统,并不能节省CPU切换时间。
N:1
N个协程绑定1个线程:协程为协作式,协程让出CPU后下一个才会执行
- 优点:
- 协程在用户态线程即完成切换,不会陷入到内核态。
- 创建多个协程的内存消耗小与多个线程创建
- 缺点:
- 不能较好的使用多核心多线程加速处理的能力
- 如果某一个协程发生阻塞,会影响后续协程的执行。
M:N
线程由CPU调度是抢占式的,协程由用户态调度是协作式的,一个协程让出CPU后,才执行下一个协程。
通过这种M:N的关系可以解决上面单一协程阻塞,之后后续协程无法执行问题,其次也可以解决由于线程频繁切换导致的时间、资源消耗问题。
Golang协程
介绍
Golang中,协程(Goroutine)是一种轻量级的线程实现,它可以和其他协程并发运行,而不需要显式地管理线程和内存。协程可以看作是一种特殊的函数,它可以被异步地调用,并且可以在不同的时间和不同的线程中执行。在Golang中,协程的创建和管理非常简单,可以通过go关键字来启动一个新的协程,例如:
go func() { |
这会异步地启动一个新的协程,执行传递给 go 关键字的函数体。通过协程,我们可以很方便地并发地执行一些任务,而不需要手动管理线程和锁等同步机制,从而提高程序的性能和可读性。
特点:
- 占用内存小kb级别
- 由运行时runtime灵活调度,可伸缩数量
传统Golang协程调度器
传统情况下:当任务执行过程中会创建对应的G,每个G实际上需要物理的M来进行执行,因此每一个M都会去全局队列中获取G来进行执行,这个过程中需要反复对于全局队列进行解锁、加锁操做来保证同步互斥。
缺点:
- 创建、销毁、调度G会带来激烈锁竞争
- 在执行G的时候,这个G创建了一个新的G1,此时本身为了减小相同的堆栈信息反复创建所消耗内存空间,里应当将G1由M执行,但是按照这种调度设计,只能将G1在此放入全局队列中,等待下次被调度,会导致局部性较差。
- 因为每个M都只执行一个G之后就需要进行切换去全局G队列中获取,因此其带来的M线程切换较为频繁消耗较大。
GMP模型
含义
- G:(goroutine)
- P:(Golang逻辑处理器),其中还包含一个Gorountine本地队列,用于保存此时G的运行资源以及G队列
- M:(Machine)OS线程
结构
- G
/// runtime/runtime2.go 关键字段 |
- M
/// runtime/runtime2.go 关键字段 |
- P
/// runtime/runtime2.go 关键字段 |
GMP调度器
结构
- 全局G队列:存放(Runable)等待运行的G
- P本地队列:存放当前P待执行的等待运行的G,最大限制为256个(默认,长期实验得到的)
- P的列表:系统中启动时会按照GOMAXPROCS设置最大的P的个数,创建P,可以修改GOMAXPROCS来设置自定义P的个数:
export GOMAXPROCS=4
或者代码中设置runtime.GOMAXPROCS(n)
。 - M的列表:M列表存储对应休眠的M,当被某一个G唤醒需要抢占P队列的时候,先从休眠中进行获取M,当一个M阻塞的时候会重新创建一个新的M。与此之外Go程序启动时,会设置M的最大数量,默认10000。
runtime/debug
包中的SetMaxThreads()
函数用于设置最大 M(操作系统线程) 的数量。它可以接受一个整数 n 作为参数,代表最大 M 的数量。这个函数主要用于控制 Go 程序中的并发度,以及减小资源占用,避免对系统造成负担。
注意: - 这个函数只能限制 M 的最大数量,不能保证运行时实际存在的 M 数量会等于设定的值。
- 最大 M 的数量不会对 P 的数量产生影响,P 的数量由
runtime.GOMAXPROCS()
函数控制。 SetMaxThreads()
函数的实际效果还需要取决于程序本身的特点,如 goroutine 数量、任务类型、代码性能等等,因此需要根据具体情况进行调整。
上图中,每个P绑定一个M,M来实际运行P队列中的G进行执行,而M的执行调度,由OS进行实际调用CPU进行分配。
创建时机:
- P在运行时候之间创建GOMAXPROC个数。
- M在出现M不足以关联P的时候会自动创建,当M全部被阻塞,也会自动创建新的M。
策略
线程复用
目的:降低线程频繁切换带来的CPU消耗
Work Stealing 偷取
一般情况下,当P本地队列为空的时候,P首先会尝试从全局队列中获取待执行的G加入到自己的P本地队列中交给M执行。当全局队列为空的时候,为保证执行效率,P将会从其他的P的本地队列中偷取等待执行的G加入自己的本地队列,交给M进行执行。 —— “本地队列->全局队列->network poll->work stealing”
Hand Off 脱离
如下图,当G1发生系统syscall或者阻塞操做的时候,P会创建或者唤醒一个M3线程,之后与M1进行解绑,将P转移到新的M3中进行处理。此时M1绑定对应阻塞的G1,进行阻塞操做。此时物理CPU将会由M1、M2 -> M2、M3。当G1阻塞完成之后,G1的M会主动搜寻原先绑定的P,如果此时P未被绑定,那么将获取P进行绑定。如P已经被绑定,那么将会将G1加入全局的G队列中等待调度,并且M1加入到全局M休眠队列中。
利用并行
通过GOMAXPROC设置对应的并行P的个数,保证并行GOMAXPROC个线程在CPU上进行任务调度。
抢占
传统协程调度方式为,传统协程执行完或者主动释放CPU之后才会调度到另一个协程进行处理,因此会导致部分协程执行分配执行时间不均。Go是由抢占式调度(preemptive scheduling)来实现 goroutine 的并发和并行。,设置最大执行时间,当超时之后立马抢占CPU处理。这意味着,在运行多个 goroutine 时,调度器可以在任何时候暂停当前 goroutine 的执行,并开始执行其他的 goroutine。当其他 goroutine 执行完成或等待 I/O 等操作时,调度器再重新切换回之前暂停的 goroutine 并继续执行。
抢占式调度机制使 Go 可以充分利用多核 CPU 的并行处理能力,同时保证了 goroutine 的执行顺序。这也是 Go 语言的一个重要特点,使其在高并发场景下表现得非常出色。
Go 的运行时(runtime)会在代码执行过程中定期检查 goroutine 的状态并进行调度切换,这个切换过程是随机发生的,也就是说,一个 goroutine 不能确信自己能够一直占有 CPU 的执行权。当某个 goroutine 执行的时间过长,或发生 I/O 等等阻塞操作时,Go 调度器会主动将其暂停并切换到其他的 goroutine,这使得代码的执行过程像是在多个 goroutine 之间轮流交替执行一样。
调度场景
G1 Make G2
在G1协程中,运行创建了G2协程,此时由于G1由M1执行,并且G2由G1创建,可能此时G2中与G1部分函数堆栈存在强关联信息,因此优先将G2创建在M1的P本地队列中。
G1执行结束
当G1运行结束之后,需要调用G2进行执行的时候,首先G1通过runtime.Goexit()
退出协程,之后M1调用自己本身的G0进行任务调度,调度P中等待执行的G2到M上进行执行。此时M并不需要进行切换,因此降低了线程级别切换的CPU耗时。
G2创建多个G && 后续G的创建
假设每个P的本地队列只能存3个G。G2要创建了6个G,前3个G(G3, G4, G5)已经加入p1的本地队列,p1本地队列满了。此时,Go调度器,将P1满的队列的前一半进行打乱直接加入全局队列,并且将重新创建的G7也加入到全局队列。
G2创建G8(前面基础上)
此时由于G3,G4,G7均存储在全局队列上,P1余处空间存储,因此重新创建的G8将直接存放于P1的本地队列上。(G8为G2创建的,因此放到M1的P1的本地队列)
唤醒休眠M(自旋线程)
当系统执行M1中的P1中的G时,G将尝试唤醒额外的M(休眠队列中必须存在休眠的M)进行尝试绑定P进行绑定,如果不存在则继续休眠。绑定成功之后M2将调用自己的G0,并且此时P2中不存在任何的G,此时这个状态叫“自旋线程”不断的搜索G进行执行。
被唤醒的M2从全局队列获取G
自旋线程创建之后,首先从全局队列搜索G,全局队列中不存在的时候从其他P中窃取。
从全局队列获取的数量为:min(len(GQ) / GOMAXPROCS + 1, cap(LQ) / 2 )
当获取到G的时候,由G0进行调度运行。
- GQ:全局队列长度
- GOMAXPROCS:最大P个数
源码位置:globrunqget获取全局G方法
// Try get a batch of G's from the global runnable queue. |
- runq:全局G队列
pp *p
:本地P指针,访问P队列max
:设置的获取G数量,未设置就是0sched.lock
: 是调度器的锁。在修改全局运行队列中的 goroutine 时必须获取此锁以保证线程安全性。sched.runqsize
: 是全局运行队列中 goroutine 的数量。gomaxprocs
: 是配置的最大 CPU 核心数,即 GOMAXPROCS 环境变量的值。len(pp.runq)
: 是本地运行队列中 goroutine 的数量。sched.runq.pop()
: 是从全局运行队列尾部弹出一个 goroutine,并返回该 goroutine 的指针。runqput(pp, gp1, false)
: 是将一个 goroutine 插入到本地 P 的运行队列尾部。
全局队列为空-M2从M1中偷G
当全局队列中已经没有G,M2将执行偷取的操做,从其他P队列中进行偷取一半的G到本地队列,从队列尾部获取。
源码位置:偷取G
// stealWork attempts to steal a runnable goroutine or timer from any P. |
自旋线程的最大限制
当G2,G8分别在M1、M2上执行,全局队列中G被执行结束之后,会出现多个自旋线程如M3、M4。这些自旋线程受到GOMAXPROCS的限制,如果这里直接进行销毁M3、M4则可能会出现线程下次调度使用时候的时间、空间消耗(创建、调度、存储)过大,为了节省CPU资源这里采用自旋降低消耗。
G调用过程出现阻塞情况
当M1上的P1执G2的时候,M2上的P2执行G8的时候,G8创建了G9,创建成功之后G8出现阻塞,此时M2与P2解绑,G8与M2绑定进行阻塞,此时的P2调度会重新在休眠线程队列中唤醒一个M5进行执行目标P2中的G。如果全局的休眠线程队列中不存在休眠的M,这个时候P进入空闲P等待列表,等待M与其进行绑定执行。
当G8阻塞结束之后
系统G8进行阻塞执行之后,会尝试找回原有的P2进行绑定,当无法获取的时候,会重新获取空闲的P,如果没有空闲的P,这个时候G8会重新被标记为可执行态,加入全局G队列等待P进行抢占。此时M2没有需要执行的P与阻塞的G因此M2会被加入到休眠的线程队列中。等待P进行调度执行。
Go Func() 基本执行流程
当通过Go启动一个协程的时候,会创建一个G加入局部的P本地队列,之后若本地队列已满则执行加入全局G队列,当M1的本地队列为空时,从全局G队列中获取G,若全局也为空则进行work Stealing进行偷取,当M1中的P1中存在G的时候进行调用G在M1上执行,如果发生阻塞,则M与P进行解绑,P中若存在G则会唤醒休眠M中的M进行调度,若无则加入休眠P等待M调度。
调度器生命周期
- M0:启动程序后的编号为0的主线程,这个M对应的实例会在全局变量runtime.m0中,不需要在heap上分配,M0负责执行初始化操作和启动第一个G, 在之后M0就和其他的M一样了。
- G0:G0是每次启动一个M都会第一个创建的gourtine,G0仅用于负责调度的G,G0不指向任何可执行的函数, 每个M都会有一个自己的G0。在调度或系统调用时会使用G0的栈空间, 全局变量的G0是M0的G0。
示例代码:
package main |
- runtime 创建:
M0(启动进程的主线程runtime.m0不在heap中分配) 负责初始化和启动第一个G,启动成功之后与其他M相同
- 初始化M0、G0、栈、GC创建GOMAXPROCS个P的P列表。
- M0创建第一个Main的G进行执行
- M0创建MainG后,寻找空闲的P进行绑定加入本地队列
- M0开始执行MainG
当G出现panic/exit时候结束G的执行。
查看GMP状态
go tool trace
使用go tool trace
进行抓取堆栈信息:
package main |
- 执行代码
go run test.go
- 查看执行目录
- 通过
go tool trace test_trace.out
查看堆栈信息
访问浏览器端口:http://127.0.0.1:40255
详细trace:
可以查看:
- Gorutine: G信息
- Heap:堆信息
- Thread:M信息
调度器: - Proc0:P0
- Proc1:P1
GODEBUG=schedtrace=1000运行
GODEBUG:开启调试模式
schedtrace:设置非零数值表示跟踪N个协程调用事件
代码:
package main |
- 编译
go build test.go
- 执行
GODEBUG=schedtrace=1000 ./test
- 结果:
SCHED 0ms: gomaxprocs=20 idleprocs=19 threads=5 spinningthreads=0 idlethreads=3 runqueue=0 [0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0]
Hello World
SCHED 1002ms: gomaxprocs=20 idleprocs=20 threads=5 spinningthreads=0 idlethreads=3 runqueue=0 [0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0]
Hello World
SCHED 2012ms: gomaxprocs=20 idleprocs=20 threads=5 spinningthreads=0 idlethreads=3 runqueue=0 [0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0]
Hello World
SCHED 3019ms: gomaxprocs=20 idleprocs=20 threads=5 spinningthreads=0 idlethreads=3 runqueue=0 [0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0]
Hello World
SCHED 4028ms: gomaxprocs=20 idleprocs=20 threads=5 spinningthreads=0 idlethreads=3 runqueue=0 [0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0]
Hello World
初始最大P为20,空闲的为19,M的初始值为5,自旋的M为0,等待的为3个,运行队列长度为0
SCHED
:调试信息输出标志字符串,代表本行是goroutine
调度器的输出;0ms
:即从程序启动到输出这行日志的时间;gomaxprocs
: P的数量,本例有2个P, 因为默认的P的属性是和cpu核心数量默认一致,当然也可以通过GOMAXPROCS
来设置;idleprocs
: 处于idle状态的P的数量;通过gomaxprocs
和idleprocs
的差值,我们就可知道执行go代码的P的数量;threads: os threads/M
的数量,包含scheduler
使用的m数量,加上runtime
自用的类似sysmon
这样的thread
的数量;spinningthreads
: 处于自旋状态的os thread
数量;idlethread
: 处于idle状态的os thread
的数量;runqueue=0
:Scheduler
全局队列中G的数量;[0 0]
: 分别为2个P的local queue
中的G的数量。