学习刘丹冰老师的第1讲-课程阶段目标_哔哩哔哩_bilibili 学习笔记
参考

  1. 2、Golang的协程调度器原理及GMP设计思想 (yuque.com)
  2. Go协程模型——GMP模型 - 掘金 (juejin.cn)
  3. go/proc.go· golang/go · GitHub

并发并行

并发

多个程序在一段重叠的时间段中开始、运行与结束,并没有在任何一个时刻同时在执行,同一时刻只有一个程序在执行

并行

同一个时刻,存在两个以上任务在同时运行。

OS分类

单进程OS

面临两个问题:

  • 只能一次处理一个任务
  • 进程进行IO等系统调用会导致CPU空闲阻塞

多线程多进程OS

为了解决上述两个问题,操作系统通过比如实现CPU时间片切换完成宏观并发的系统调用需求,或者通过其他的任务调度方式进行任务调度。调度方式如下所示:

image.png

CPU在多个线程之间实现时间片切换,来完成多个任务的调度,实现并发执行的效果,此时宏观上看其实现了并行的效果。但是伴随着CPU调度任务的增多,进程占用资源的增长,形如下图会产生系统上下文线程切换。

image.png

线程的创建、切换、销毁过程中需要保存或者设置相关信息(切换上下文、堆栈、函数调用变量、),当进程增多时对于CPU的任务调度消耗较大。并且当多个线程程序进行运行的时候往往伴随着锁的抢占,共享资源的获取也会消耗大量的CPU时间。

进程 && 线程 && 协程

在现代操作系统中,进程、线程和协程都需要占用一定的空间大小。

  • 进程是操作系统中的基本执行单元,需要分配一定的内存空间来存储程序代码、数据、堆栈等信息,进程使用的空间大小取决于程序的规模和运行时所需的资源。
  • 线程是进程的子执行单元,它使用进程的内存空间,通常只需要额外的堆栈空间。
  • 协程是一种更为轻量级的执行单元,它使用的空间比线程更少,通常只需要保存当前执行状态的相关信息,如CPU寄存器值、调用栈、堆栈等。协程的大小取决于其保存的状态信息的大小。
    占用空间大小的大小顺序是进程 > 线程 > 协程

目前互联网高并发场景出现频次逐年增加,因此比如一个定时的任务,每个任务创建一个线程去处理是不现实的,往往大量线程创建会占用巨量内存数据,对于系统性能要求较高。以及线程之间频繁的上下文切换也会导致运行效率降低。

对于线程而言,在Linux系统中分为用户态和内核态,Linux核心执行过程中,执行对应的PCB(进程控制块)。因此对于CPU而言并不知道执行的线程是用户线程还是内核线程,均会采用时间片调度进行线程执行。

用户态线程是由操作系统通过线程库进行调度和管理的,而协程则是由应用程序自己进行管理和调度的。在协程中,可以手动调用切换操作,实现协作式任务调度,而用户态线程则需要使用系统调用进行切换,实现抢夺式任务调度协程通常不需要共享内核资源,而用户态线程需要共享内核资源。

协程调度关系

下面将这种协程类比为用户态线程

1:1

每一个协程调度器绑定一个内核线程执行,每一个协程调度器绑定一个协程
那么此时所有的协程调取其实与线程调度是一致的,均需要由CPU进行调度实现,回归传统,并不能节省CPU切换时间。

image.png

N:1

N个协程绑定1个线程:协程为协作式,协程让出CPU后下一个才会执行

  • 优点:
    • 协程在用户态线程即完成切换,不会陷入到内核态
    • 创建多个协程的内存消耗小与多个线程创建
  • 缺点:
    • 不能较好的使用多核心多线程加速处理的能力
    • 如果某一个协程发生阻塞,会影响后续协程的执行。

image.png

M:N

线程由CPU调度是抢占式的,协程由用户态调度是协作式的一个协程让出CPU后,才执行下一个协程

通过这种M:N的关系可以解决上面单一协程阻塞,之后后续协程无法执行问题,其次也可以解决由于线程频繁切换导致的时间、资源消耗问题。

image.png

Golang协程

介绍

Golang中,协程(Goroutine)是一种轻量级的线程实现,它可以和其他协程并发运行,而不需要显式地管理线程和内存。协程可以看作是一种特殊的函数,它可以被异步地调用,并且可以在不同的时间和不同的线程中执行。在Golang中,协程的创建和管理非常简单,可以通过go关键字来启动一个新的协程,例如:

go func() {
// This code will run as a new Goroutine
}()

这会异步地启动一个新的协程,执行传递给 go 关键字的函数体。通过协程,我们可以很方便地并发地执行一些任务,而不需要手动管理线程和锁等同步机制,从而提高程序的性能和可读性。

特点:

  • 占用内存小kb级别
  • 由运行时runtime灵活调度,可伸缩数量

传统Golang协程调度器

传统情况下:当任务执行过程中会创建对应的G,每个G实际上需要物理的M来进行执行,因此每一个M都会去全局队列中获取G来进行执行,这个过程中需要反复对于全局队列进行解锁、加锁操做来保证同步互斥。
缺点:

  • 创建、销毁、调度G会带来激烈锁竞争
  • 在执行G的时候,这个G创建了一个新的G1,此时本身为了减小相同的堆栈信息反复创建所消耗内存空间,里应当将G1由M执行,但是按照这种调度设计,只能将G1在此放入全局队列中,等待下次被调度,会导致局部性较差。
  • 因为每个M都只执行一个G之后就需要进行切换去全局G队列中获取,因此其带来的M线程切换较为频繁消耗较大。

image.png

GMP模型

含义

  • G:(goroutine)
  • P:(Golang逻辑处理器),其中还包含一个Gorountine本地队列,用于保存此时G的运行资源以及G队列
  • M:(Machine)OS线程

结构

  1. G
/// runtime/runtime2.go 关键字段
type g struct {
stack stack // g自己的栈

m *m // 执行当前g的m
sched gobuf // 保存了g的现场,goroutine切换时通过它来恢复
atomicstatus uint32 // g的状态Gidle,Grunnable,Grunning,Gsyscall,Gwaiting,Gdead
goid int64
schedlink guintptr // 下一个g, g链表

preempt bool //抢占标记

lockedm muintptr // 锁定的M,g中断恢复指定M执行
gopc uintptr // 创建该goroutine的指令地址
startpc uintptr // goroutine 函数的指令地址
}
  1. M
/// runtime/runtime2.go 关键字段
type m struct {
g0 *g // g0, 每个M都有自己独有的g0

curg *g // 当前正在运行的g
p puintptr // 当前用于的p
nextp puintptr // 当m被唤醒时,首先拥有这个p
id int64
spinning bool // 是否处于自旋

park note
alllink *m // on allm
schedlink muintptr // 下一个m, m链表
mcache *mcache // 内存分配
lockedg guintptr // 和 G 的lockedm对应
freelink *m // on sched.freem

}
  1. P
/// runtime/runtime2.go 关键字段
type p struct {
id int32
status uint32 // 状态
link puintptr // 下一个P, P链表
m muintptr // 拥有这个P的M
mcache *mcache

// P本地runnable状态的G队列
runqhead uint32
runqtail uint32
runq [256]guintptr

runnext guintptr // 一个比runq优先级更高的runnable G

// 状态为dead的G链表,在获取G时会从这里面获取
gFree struct {
gList
n int32
}

gcBgMarkWorker guintptr // (atomic)
gcw gcWork

}

GMP调度器

结构

  1. 全局G队列:存放(Runable)等待运行的G
  2. P本地队列:存放当前P待执行的等待运行的G,最大限制为256个(默认,长期实验得到的)
  3. P的列表:系统中启动时会按照GOMAXPROCS设置最大的P的个数,创建P,可以修改GOMAXPROCS来设置自定义P的个数:export GOMAXPROCS=4 或者代码中设置 runtime.GOMAXPROCS(n)
  4. M的列表:M列表存储对应休眠的M,当被某一个G唤醒需要抢占P队列的时候,先从休眠中进行获取M,当一个M阻塞的时候会重新创建一个新的M。与此之外Go程序启动时,会设置M的最大数量,默认10000。runtime/debug 包中的 SetMaxThreads() 函数用于设置最大 M(操作系统线程) 的数量。它可以接受一个整数 n 作为参数,代表最大 M 的数量。这个函数主要用于控制 Go 程序中的并发度,以及减小资源占用,避免对系统造成负担。
    注意:
  5. 这个函数只能限制 M 的最大数量,不能保证运行时实际存在的 M 数量会等于设定的值。
  6. 最大 M 的数量不会对 P 的数量产生影响,P 的数量由 runtime.GOMAXPROCS() 函数控制。
  7. SetMaxThreads() 函数的实际效果还需要取决于程序本身的特点,如 goroutine 数量、任务类型、代码性能等等,因此需要根据具体情况进行调整。

image.png

上图中,每个P绑定一个M,M来实际运行P队列中的G进行执行,而M的执行调度,由OS进行实际调用CPU进行分配。

创建时机:

  1. P在运行时候之间创建GOMAXPROC个数。
  2. M在出现M不足以关联P的时候会自动创建,当M全部被阻塞,也会自动创建新的M。

策略

线程复用

目的:降低线程频繁切换带来的CPU消耗

Work Stealing 偷取

一般情况下,当P本地队列为空的时候,P首先会尝试从全局队列中获取待执行的G加入到自己的P本地队列中交给M执行。当全局队列为空的时候,为保证执行效率,P将会从其他的P的本地队列中偷取等待执行的G加入自己的本地队列,交给M进行执行。 —— “本地队列->全局队列->network poll->work stealing”

image.png

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休眠队列中。

image.png

利用并行

通过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 之间轮流交替执行一样。

image.png

调度场景

G1 Make G2

在G1协程中,运行创建了G2协程,此时由于G1由M1执行,并且G2由G1创建,可能此时G2中与G1部分函数堆栈存在强关联信息,因此优先将G2创建在M1的P本地队列中。

image.png

G1执行结束

当G1运行结束之后,需要调用G2进行执行的时候,首先G1通过runtime.Goexit() 退出协程,之后M1调用自己本身的G0进行任务调度,调度P中等待执行的G2到M上进行执行。此时M并不需要进行切换,因此降低了线程级别切换的CPU耗时。

image.png

G2创建多个G && 后续G的创建

假设每个P的本地队列只能存3个G。G2要创建了6个G,前3个G(G3, G4, G5)已经加入p1的本地队列,p1本地队列满了。此时,Go调度器,将P1满的队列的前一半进行打乱直接加入全局队列,并且将重新创建的G7也加入到全局队列。

image.png

G2创建G8(前面基础上)

此时由于G3,G4,G7均存储在全局队列上,P1余处空间存储,因此重新创建的G8将直接存放于P1的本地队列上。(G8为G2创建的,因此放到M1的P1的本地队列)

image.png

唤醒休眠M(自旋线程)

当系统执行M1中的P1中的G时,G将尝试唤醒额外的M(休眠队列中必须存在休眠的M)进行尝试绑定P进行绑定,如果不存在则继续休眠。绑定成功之后M2将调用自己的G0,并且此时P2中不存在任何的G,此时这个状态叫“自旋线程”不断的搜索G进行执行。

image.png

被唤醒的M2从全局队列获取G

自旋线程创建之后,首先从全局队列搜索G,全局队列中不存在的时候从其他P中窃取。
从全局队列获取的数量为:min(len(GQ) / GOMAXPROCS + 1, cap(LQ) / 2 )
当获取到G的时候,由G0进行调度运行。

image.png

  • GQ:全局队列长度
  • GOMAXPROCS:最大P个数

image.png

源码位置:globrunqget获取全局G方法

// Try get a batch of G's from the global runnable queue.
// sched.lock must be held.
func globrunqget(pp *p, max int32) *g {
// 断言是否存在调度器锁
assertLockHeld(&sched.lock)
// 全局队列没有就返回
if sched.runqsize == 0 {
return nil
}
// 计算n min(len(GQ) / GOMAXPROCS + 1, cap(LQ) / 2 )
n := sched.runqsize/gomaxprocs + 1
if n > sched.runqsize {
n = sched.runqsize
}
// 如果设置了 max,并且 n 超过了 max,则将 n 设置为 max
if max > 0 && n > max {
n = max
}
// 确保将获取的 G 数量不超过本地运行队列的一半
if n > int32(len(pp.runq))/2 {
n = int32(len(pp.runq)) / 2
}
// 更新大小为弹出n个之后的任务大小长度
sched.runqsize -= n
// 将全局运行队列中的第一个 G 弹出来,存储到 gp 变量中
gp := sched.runq.pop()
// 已弹出一个,n-1
n--
// 依次弹出,并插入本地运行队列
for ; n > 0; n-- {
gp1 := sched.runq.pop()
// 加入本地的P队列
runqput(pp, gp1, false)
}
// 返回弹出的队列
return gp
}
  • runq:全局G队列
  • pp *p :本地P指针,访问P队列
  • max:设置的获取G数量,未设置就是0
  • sched.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到本地队列,从队列尾部获取。

image.png

源码位置:偷取G

// stealWork attempts to steal a runnable goroutine or timer from any P.
//
// If newWork is true, new work may have been readied.
//
// If now is not 0 it is the current time. stealWork returns the passed time or
// the current time if now was passed as 0.
func stealWork(now int64) (gp *g, inheritTime bool, rnow, pollUntil int64, newWork bool) {
pp := getg().m.p.ptr()

ranTimer := false

const stealTries = 4
for i := 0; i < stealTries; i++ {
stealTimersOrRunNextG := i == stealTries-1

for enum := stealOrder.start(fastrand()); !enum.done(); enum.next() {
if sched.gcwaiting.Load() {
// GC work may be available.
return nil, false, now, pollUntil, true
}
p2 := allp[enum.position()]
if pp == p2 {
continue
}

// Steal timers from p2. This call to checkTimers is the only place
// where we might hold a lock on a different P's timers. We do this
// once on the last pass before checking runnext because stealing
// from the other P's runnext should be the last resort, so if there
// are timers to steal do that first.
//
// We only check timers on one of the stealing iterations because
// the time stored in now doesn't change in this loop and checking
// the timers for each P more than once with the same value of now
// is probably a waste of time.
//
// timerpMask tells us whether the P may have timers at all. If it
// can't, no need to check at all.
if stealTimersOrRunNextG && timerpMask.read(enum.position()) {
tnow, w, ran := checkTimers(p2, now)
now = tnow
if w != 0 && (pollUntil == 0 || w < pollUntil) {
pollUntil = w
}
if ran {
// Running the timers may have
// made an arbitrary number of G's
// ready and added them to this P's
// local run queue. That invalidates
// the assumption of runqsteal
// that it always has room to add
// stolen G's. So check now if there
// is a local G to run.
if gp, inheritTime := runqget(pp); gp != nil {
return gp, inheritTime, now, pollUntil, ranTimer
}
ranTimer = true
}
}

// Don't bother to attempt to steal if p2 is idle.
if !idlepMask.read(enum.position()) {
if gp := runqsteal(pp, p2, stealTimersOrRunNextG); gp != nil {
return gp, false, now, pollUntil, ranTimer
}
}
}
}

// No goroutines found to steal. Regardless, running a timer may have
// made some goroutine ready that we missed. Indicate the next timer to
// wait for.
return nil, false, now, pollUntil, ranTimer
}

自旋线程的最大限制

当G2,G8分别在M1、M2上执行,全局队列中G被执行结束之后,会出现多个自旋线程如M3、M4。这些自旋线程受到GOMAXPROCS的限制,如果这里直接进行销毁M3、M4则可能会出现线程下次调度使用时候的时间、空间消耗(创建、调度、存储)过大,为了节省CPU资源这里采用自旋降低消耗。

image.png

G调用过程出现阻塞情况

当M1上的P1执G2的时候,M2上的P2执行G8的时候,G8创建了G9,创建成功之后G8出现阻塞,此时M2与P2解绑,G8与M2绑定进行阻塞,此时的P2调度会重新在休眠线程队列中唤醒一个M5进行执行目标P2中的G。如果全局的休眠线程队列中不存在休眠的M,这个时候P进入空闲P等待列表,等待M与其进行绑定执行。

image.png

当G8阻塞结束之后

系统G8进行阻塞执行之后,会尝试找回原有的P2进行绑定,当无法获取的时候,会重新获取空闲的P,如果没有空闲的P,这个时候G8会重新被标记为可执行态,加入全局G队列等待P进行抢占。此时M2没有需要执行的P与阻塞的G因此M2会被加入到休眠的线程队列中。等待P进行调度执行。

image.png

Go Func() 基本执行流程

当通过Go启动一个协程的时候,会创建一个G加入局部的P本地队列,之后若本地队列已满则执行加入全局G队列,当M1的本地队列为空时,从全局G队列中获取G,若全局也为空则进行work Stealing进行偷取,当M1中的P1中存在G的时候进行调用G在M1上执行,如果发生阻塞,则M与P进行解绑,P中若存在G则会唤醒休眠M中的M进行调度,若无则加入休眠P等待M调度。

image.png

调度器生命周期

  • M0:启动程序后的编号为0的主线程,这个M对应的实例会在全局变量runtime.m0中,不需要在heap上分配,M0负责执行初始化操作和启动第一个G, 在之后M0就和其他的M一样了。
  • G0:G0是每次启动一个M都会第一个创建的gourtine,G0仅用于负责调度的G,G0不指向任何可执行的函数, 每个M都会有一个自己的G0。在调度或系统调用时会使用G0的栈空间, 全局变量的G0是M0的G0。

image.png

示例代码:

package main

import "fmt"

func main() {
fmt.Println("Hello world")
}
  1. runtime 创建:
    M0(启动进程的主线程runtime.m0不在heap中分配) 负责初始化和启动第一个G,启动成功之后与其他M相同

image.png

  1. 初始化M0、G0、栈、GC创建GOMAXPROCS个P的P列表。

image.png

  1. M0创建第一个Main的G进行执行
  • M0创建MainG后,寻找空闲的P进行绑定加入本地队列

image.png

  • M0开始执行MainG
    当G出现panic/exit时候结束G的执行。

image.png

查看GMP状态

go tool trace

使用go tool trace进行抓取堆栈信息:

package main

import (
"os"
"fmt"
"runtime/trace"
)

func main() {

//创建trace文件
f, err := os.Create("test_trace.out")
if err != nil {
panic(err)
}

defer f.Close()

//启动trace goroutine
err = trace.Start(f)
if err != nil {
panic(err)
}
defer trace.Stop()

//main
fmt.Println("Hello World")
}
  1. 执行代码 go run test.go
  2. 查看执行目录

image.png

  1. 通过go tool trace test_trace.out查看堆栈信息

image.png

访问浏览器端口:http://127.0.0.1:40255

image.png

详细trace:
image.png

可以查看:

  • Gorutine: G信息
  • Heap:堆信息
  • Thread:M信息
    调度器:
  • Proc0:P0
  • Proc1:P1

image.png

image.png

GODEBUG=schedtrace=1000运行

GODEBUG:开启调试模式
schedtrace:设置非零数值表示跟踪N个协程调用事件

代码:

package main

import (
"fmt"
"time"
)

func main() {
for i := 0; i < 5; i++ {
time.Sleep(time.Second)
fmt.Println("Hello World")
}
}
  1. 编译go build test.go
  2. 执行GODEBUG=schedtrace=1000 ./test
  3. 结果:
    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的数量;通过gomaxprocsidleprocs的差值,我们就可知道执行go代码的P的数量;
  • threads: os threads/M的数量,包含scheduler使用的m数量,加上runtime自用的类似sysmon这样的thread的数量;
  • spinningthreads: 处于自旋状态的os thread数量;
  • idlethread: 处于idle状态的os thread的数量;
  • runqueue=0Scheduler全局队列中G的数量;
  • [0 0]: 分别为2个P的local queue中的G的数量。