golang 大杀器——GMP模型

思维导图:

1. 发展过程

思维导图:

在单机时代是没有多线程、多进程、协程这些概念的。早期的操作系统都是顺序执行

单进程的缺点有

  • 单一执行流程、计算机只能一个任务一个任务进行处理
  • 进程阻塞所带来的CPU时间的浪费
时间片轮训算法
并发执行

这样的好处是充分利用了CPU,但是也带来了一些问题,例如时间片切换需要花费额外的开销

成本就越大浪费
锁竞争冲突
创建、切换、销毁进程调度

所以提高cpu的利用率成为我们需要解决的问题

线程上下文切换
io流的读写操作用户态和内核态

这个时候我们的线程模型是这样的

内核态用户态
做好绑定堵塞
内核线程
用户线程协程(co-runtine)
一比一
N 比 1
event-loop
M 比 N
M 比 N协程调度器
协程调度器协程内存
早期的协程调度器队列MG全局G队列M加锁互斥/同步全局G队列互斥锁

老调度器有几个缺点:

激烈的锁竞争延迟和额外的系统负载G'GG'M'很差的局部性G'GM'

2. GMP模型设计思想

思维导图:

2.1 GMP模型

goroutine协程machineprocessor处理器

在Go中,线程是运行goroutine的实体,调度器的功能是把可运行的goroutine分配到工作线程上

GG256G'G'GPGOMAXPROCSPGPM全局队列GPPP
$GOMAXPROCSruntime.GOMAXPROCS()一万runtime/debugSetMaxThreadsM阻塞新的MM空闲回收或者休眠

2.2 调度器的设计策略

golang调度器的设计策略思想主要有以下几点:

  • 复用线程
  • 利用并行
  • 抢占
  • 全局G队列

2.2.1 复用线程

work stealing机制hand off机制
work stealing充分利用线程进行并行计算,并减少了线程间的竞争
双端队列被窃取任务头部窃取任务尾部
M1睡眠或销毁

2.2.2 利用并行

GOMAXPROCSGOMAXPROCSGOMAXPROCSGOMAXPROCS = 核数/2

2.2.3 抢占策略

co-routinegoroutine

2.2.4 全局G队列

  • 全局G队列其实是复用线程的补充,当工作窃取时,优先从全局队列去取,取不到才从别的p本地队列取(1.17版本)

  • 在新的调度器中依然有全局G队列,但功能已经被弱化了,当M执行work stealing从其他P偷不到G时,它可以从全局G队列获取G

go func()
go func()G局部调度器P全局G队列本地队列全局的队列MP1:1可执行状态的Gsyscall其他阻塞摘除(detach)空闲的P本地队列休眠状态空闲线程全局队列

2.4 调度器的生命周期

M0G0编号为0runtime.m0heap启动第一个GM0就和其他的M一样了启动一个M第一个创建的gourtineG0负责调度G可执行的函数

具体流程为:

我们来分析一段代码:

package main

import "fmt"

func main() {
    fmt.Println("Hello world")
}
PP列表main.mainruntimeruntime.mainruntime.mainmain.mainruntime.mainmain.mainruntime.mainruntime.exit
runtime.mainruntime.mainruntime.main

2.5 可视化的CMP编程

2.5.1 trace方式

go tool trace trace.out
package main

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

// trace的编码过程
// 1. 创建文件
// 2. 启动
// 3. 停止
func main() {
	// 1.创建一个trace文件
	f, err := os.Create("trace.out")
	if err != nil {
		panic(err)
	}
	defer func(f *os.File) {
		err := f.Close()
		if err != nil {
			panic(err)
		}
	}(f)
	// 2. 启动trace
	err = trace.Start(f)
	if err != nil {
		panic(err)
	}
	// 正常要调试的业务
	fmt.Println("hello GMP")
	// 3. 停止trace
	trace.Stop()
}

view trace

G的信息

M的信息

P的信息

2.5.2 debug方式

使用debug方式可以不需要trace文件

先搞一段代码

package main

import (
	"fmt"
	"time"
)

func main() {
	for i := 0; i < 5; i++ {
		time.Sleep(time.Second)
		fmt.Println("hello GMP")
	}
}
 

debug执行一下

$ GODEBUG=schedtrace=1000 ./debug.exe
SCHED 0ms: gomaxprocs=8 idleprocs=7 threads=6 spinningthreads=0 idlethreads=3 runqueue=0 [0 0 0 0 0 0 0 0]
SCHED 1008ms: gomaxprocs=8 idleprocs=8 threads=6 spinningthreads=0 idlethreads=3 runqueue=0 [0 0 0 0 0 0 0 0]
hello GMP
SCHED 2009ms: gomaxprocs=8 idleprocs=8 threads=6 spinningthreads=0 idlethreads=3 runqueue=0 [0 0 0 0 0 0 0 0]
hello GMP
SCHED 3010ms: gomaxprocs=8 idleprocs=8 threads=6 spinningthreads=0 idlethreads=3 runqueue=0 [0 0 0 0 0 0 0 0]
hello GMP
hello GMP
SCHED 4017ms: gomaxprocs=8 idleprocs=8 threads=6 spinningthreads=0 idlethreads=3 runqueue=0 [0 0 0 0 0 0 0 0]
hello GMP
SCHED0msgomaxprocsidleprocsthreads: os threads/Mspinningthreadsidlethreadrunqueue=0[0 0]

3. GMP场景分析

3.1 G1创建G3

go func()

3.2 G1执行完毕

goexitscheduleexecute

3.3 G溢出

假设每个P的本地队列只能存3个G。G2要创建了6个G,前3个G(G3, G4, G5)已经加入p1的本地队列,p1本地队列满了

本地队列G饥饿

这些G被转移到全局队列时,会被打乱顺序。所以G3,G4,G7被转移到全局队列。

G2创建G8时,P1的本地队列未满,所以G8会被加入到P1的本地队列

3.4 唤醒正在休眠的M

规定:在创建G时,运行的G会尝试唤醒其他空闲的P和M组合去执行

自旋线程

3.5 自旋线程获取G

n =  min(len(GQ) / GOMAXPROCS +  1,  cap(LQ) / 2 )
自旋线程

相关源码参考:

// 从全局队列中偷取,调用时必须锁住调度器
func globrunqget(_p_ *p, max int32) *g {
	// 如果全局队列中没有 g 直接返回
	if sched.runqsize == 0 {
		return nil
	}

	// per-P 的部分,如果只有一个 P 的全部取
	n := sched.runqsize/gomaxprocs + 1
	if n > sched.runqsize {
		n = sched.runqsize
	}

	// 不能超过取的最大个数
	if max > 0 && n > max {
		n = max
	}

	// 计算能不能在本地队列中放下 n 个
	if n > int32(len(_p_.runq))/2 {
		n = int32(len(_p_.runq)) / 2
	}

	// 修改本地队列的剩余空间
	sched.runqsize -= n
	// 拿到全局队列队头 g
	gp := sched.runq.pop()
	// 计数
	n--

	// 继续取剩下的 n-1 个全局队列放入本地队列
	for ; n > 0; n-- {
		gp1 := sched.runq.pop()
		runqput(_p_, gp1, false)
	}
	return gp
}

3.6 M2从M1中偷取G

全局队列已经没有G,那m就要执行work stealing(偷取):从其他有G的P哪里偷取一半G过来,放到自己的P本地队列。P2从P1的本地队列尾部取一半的G,本例中一半则只有1个G8,放到P2的本地队列并执行。

  • 偷取队列元素的一半

3.7 自旋线程的最大限制

G1本地队列G5、G6已经被其他M偷走并运行完成,当前M1和M2分别在运行G2和G8,M3和M4没有goroutine可以运行,M3和M4处于自旋状态,它们不断寻找goroutine

  • 正在运行的M + 自旋线程 <= GOMAXPROCS
  • 如果M大于P,则进入休眠线程队列
GOMAXPROCSGOMAXPROCS

3.8 G发送系统调用/阻塞

假定当前除了M3和M4为自旋线程,还有M5和M6为空闲的线程(没有得到P的绑定,注意我们这里最多就只能够存在4个P,所以P的数量应该永远是M>=P,大部分都是M在抢占需要运行的P),G8创建了G9,G8进行了阻塞的系统调用,M2和P2立即解绑,P2会执行以下判断:如果P2本地队列有G、全局队列有G或有空闲的M,P2都会立马唤醒1个M和它绑定,否则P2则会加入到空闲P列表,等待M来获取可用的p。本场景中,P2本地队列有G9,可以和其他空闲的线程M5绑定

  • 自旋线程抢占G,不抢占P

3.9 G发送系统调用/非阻塞

G8空闲p队列M阻塞队列