概述
当代计算机的多进程、多线程模型
现在计算机的 cpu 核心数是有限的,比如 8 核心 cpu,同一时刻只能并行运行 8 个进程。而同一时间操作系统上要运行的程序要远远多于 cpu 的核心数。面对这个问题,操作系统的解决办法是不停的切换进程来执行,每个进程占用一小段时间片。
由于进程拥有太多的资源(虚拟内存占用 4Gb),进程的创建、切换、销毁会占用很长的时间。线程虽然占用的资源少了一点(也要占用 4MB),但是多线程开发会比较复杂,开发者要考虑很多同步竞争问题,比如锁,资源竞争。
由于进程和线程的切换成本高,当进程和线程的数量比较多的时候,会导致 cpu 很大一部分时间都浪费在切换进程和线程上,没有真正的用在执行逻辑程序上。
引入协程模型
协程类似于线程,属于用户态的线程,系统的是线程是内核态的线程;用户态的比内核态的轻量(goroutine 只占用几 Kb),切换的执行的时候,比较快。
一个用户态线程,必须绑定一个内核态线程,内核态线程可以由协程调度创建。
线程和协程的区别是:
- 线程由 cpu 调度,是抢占式的
- 协程是用户动态调度的,是协作式的,一个协程让出 cpu 后才会执行下一个协程
GPM 调度器模型
GPM 的模型中:
- G 指的是 go 协程 goroutine
- P 是协程调度器 processer,G 是放在 P 里,然后 M 执行 P 里的 G
- M 是 machine,也就是线程,用于执行 G
在 go 中,线程是运行 goroutine 的实体,调度器是把 goroutine 分配到线程上执行。在 GPM 模型中,有几个重要的概念,如下图所示:
- 全局队列:存放等待运行的 G,可以被任意的 P 获取执行,相当于整个 GPM 模型里的共用资源,所以需要加入互斥锁
- P 的本地队列:存放的也是等待执行的 G,但是存放的数量有限,最多 256 个。该队列里的 G 新建的 G,会优先放到本队列,如果本队列的 G 满了,再放到全局队列中,具体见下文
- P 列表:在程序启动的时候创建,最多可以有 GOMAXPORCS 个
- M 线程:线程是由 P 创建,要执行 G 的话,M 首先要绑定 P,从 P 的本地队列获取 G,如果 P 的本地队列为空,则尝试从全局队列获取一批 G,放到本地队列,全局队列再为空的话再从其他 P 的本地队列偷一半放到本地队列。
有关 p 和 m 的数量的问题
- P 的个数是启动的时候,环境变量
$GOMAXPROCS
或 runtime 的GOMAXPROCS()
方法决定的。也就是说,程序在运行的时候,只有$GOMAXPROCS
个 goroutine 同时在运行。这个数量一般设置成内核的一半 - M 的数量,由 Go 语言本身的限制决定的,Go 在启动的时候,会设置 M 的最大数量为 1 万个,但是操作系统很难支撑这么多的线程,所以这个限制忽略不计。runtime/deBug 中的
SetMaxThreads()
方法,可以设置 M 的最大数量。当一个 M 阻塞的时候,会创建新的 M。
总得来说,P 和 M 的数量没有绝对关系,一个 M 阻塞,P 会创建新的 M 或切换到其它空闲的 M 上。
GPM 的设计策略
复用线程
GMP 会对线程复用,避免频繁的创建和销毁线程,也就避免让线程闲着。
偷取机制
线程 M 绑定到 P,当一个 P 上没有可执行的 G 的时候,本 P 就会从其他 P 的本地队列上偷去一半的 G 到本队列执行。这样就不会导致空闲的 P 无事可做了。
移交机制
当 M 正在执行的 G 发生了阻塞,那么 P 就会和绑定的 M 发生分离,和一个空闲的 M 再次绑定,如果没有空闲的 M, P 新创建一个 M,这样就不会因为一个 G 阻塞,影响到队列上的其他 G 没法执行。
利用并行
通过 GOMAXPROCS 来设置 P 的数量,一般设置为核心数的一半,表示最多利用核心数的一半在并行的运行
抢占
go 中的协程,一个 goroutine 最多执行 10ms ,就会获取下一个可执行状态的 G 来执行。
全局 G 队列
当 P 去其他 P 的本地队列上偷不到 G 的时候,它可以从全局队列获取 G。
创建 goroutine 的流程
当我们在代码中执行
go func()
的时候,GPM 会进行哪些操作呢?
当创建协程后,G 会优先保存到 P 的本地队列中,当 P 的本地队列满了,就会保存到全局队列中。执行流程如下图的序号所示:
调度器的生命周期
在 GPM 里,还有两个比较重要的角色,分别是 M0 和 G0。
M0 线程
- 启动程序后编号为 0 的主线程
- 在全局命令
runtime.m()
中,不需要在 heap 堆上分配 - 负责执行初始化操作,和启动第一个 G
- 启动 1 个 G 后,M0 就和其他 M 一样了
G0 协程
- 每启动一个 M,创建第一个 goroutine 就是 G0
- G0 负责调度 G
- G0 不指向任何一个可执行的函数
- 每个 M 都会有一个自己的 G0
- 在调度或者系统调度的时候,M 会切换到 G0,再通过 G0 取获取其他 G
- M0 和 G0 会放在全局空间
gpm 可视化 trace
go tool trace 的方式
例如:
import (
"fmt"
"os"
"runtime/trace"
)
func main() {
f, err := os.Create("trace.out")
if err != nil {
panic(err)
}
defer f.Close()
err = trace.Start(f)
if err != nil {
panic(err)
}
defer trace.Stop()
fmt.Println("hello world")
}
运行完上面代码,会得到 trace. out
文件,可以用下列命令打开
go tool trace trace.out
然后浏览器打开 http://127.0.0.1:34646/trace
, 可以得到下图:
debug trace
如下例代码
package main
import (
"fmt"
"time"
)
func main() {
for i := 0; i < 5; i++ {
time.Sleep(time.Second)
fmt.Println("hello world")
}
}
执行下面命令,得到 main.exe
go build main.go
然后执行
GODEBUG=schedtrace=1000 ./main.exe
输出
SCHED 0ms: gomaxprocs=16 idleprocs=14 threads=5 spinningthreads=1 idlethreads=0 runqueue=0 [1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0]
SCHED 1007ms: gomaxprocs=16 idleprocs=16 threads=6 spinningthreads=0 idlethreads=3 runqueue=0 [0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0]
hello world
SCHED 2016ms: gomaxprocs=16 idleprocs=16 threads=6 spinningthreads=0 idlethreads=3 runqueue=0 [0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0]
hello world
SCHED 3018ms: gomaxprocs=16 idleprocs=16 threads=6 spinningthreads=0 idlethreads=3 runqueue=0 [0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0]
hello world
SCHED 4027ms: gomaxprocs=16 idleprocs=16 threads=6 spinningthreads=0 idlethreads=3 runqueue=0 [0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0]
hello world
SCHED 5030ms: gomaxprocs=16 idleprocs=16 threads=6 spinningthreads=0 idlethreads=3 runqueue=0 [0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0]
hello world
上面输出的含义:
- SCHED: 代表本行是调度器的输出日志
- 0ms: 从程序启动,到输出这行日志的时间
- gomaxprocs: 设置的 processor 的数量
- idlprocs: 处于 idl 状态的 P 的数量,
gomaxprocs-idlprocs=正在运行的协程的数量
- threads:总的线程的数量,包括 M 和 go 里自用的一些内部线程
- spinningthreads:处于自旋状态的 os 线程数量
- idlethreads:出于 idle 转头的 os 线程数量
- runqueue=0:全局队列中 G 的数量
[1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0]
:为 16 个 P 的本地队列的 G 的数量
场景
场景 1:G1 创建 G2,G2 会存在那个队列
G1 使用 go func ()
创建 G2,为了局部性,因为 G1 和 G2 所保存的内存和堆栈信息最为相同,切换成本比较低,所以 G2 优先加入到 G1 的本地队列
场景 2 :G1 执行完成后,如何获取新的 G2
当 G1 执行完成后,M1 上的 goroutine 切换到 G0,G0 就可以取获取 G2 (先从本地队列,然后从全局队列,最后从其他 P 的本地队列获取),然后切换到 G2 来执行。
场景 3:如果 P 的本地队列只能存放 4 个 G,G1 创建了 6 个 G,此时多出来的 1 个 G 放在哪里?
因为 P 上可以放下 4 个 G,所以 G2,G3,G4,G5 会放在 G1 的本地队列里,G6 会放在全局队列里,然后还会讲 G1 本地队列的前面一半放到 G2,G3 放到全局队列里,然后把 G4,G5 移到队列的头部。此时 G1 的本地队列只有 G4,G5。
需要注意的是:G2, G3, G5 会以乱序的方式放到全局队列里。
场景 4:线程空闲的时候,会和 P 分离,并且休眠,如何重新唤醒休眠的线程呢?
空闲的线程,操作系统不会立刻回收,而是休眠,当尝试创建一个 G 的时候,就会尝试从休眠线程中获取 M,然后 M 尝试去绑定可以被利用的 P,绑定完成后,M ,P,G0 组成自旋线程,G0 会去寻找 G 放到自己的本地队列里
场景 5:自旋线程从全局队列获取多少个 G
n = min(全局队列的G数量/GOMAXPROCS+1,cap(P的本地队列)/2)
场景 6:全局队列已经没有 G 了,自旋线程需要从其他 P 的本地队列偷取多少个 G
从其他 G 的 P 那里偷去一半 G,放到自己的本地队列里。
场景 7:G1 如果发生了阻塞的系统调用,这时候会发生什么呢
如果 G1 发生了阻塞,那么相应的 M1 和 P1 就会立即解绑。
- 此时如果 P1 本地队列或者全局对了有 G 待执行,如果有休眠的进程,P1 会唤醒一个线程和 P1 绑定,没有空闲的线程的话,就会创建一个新的线程和 P1 绑定。
- 否则,P1 会加入空闲的 P 列表,等待 M 获取可用的 P
当 G1 的阻塞状态解除后,M1 会尝试获取之前记住的 P1,如果 P1 是可获取状态 (也就是说此时 P1 没有和其他 M 绑定,在空闲 P 列表中),M1 和 P1 会重新绑定,否则就会从空闲的 P 列表里寻找可以用的 P。如果找不到空闲的 P,则 G1 会被标记为可以运行状态,并且加入到全局队列中。然后 M1 因为没有 P 的绑定变成休眠状态,长时间的休眠,会被 GC 回收
Goroutine 编程
Goroutine 是 go 程序并发的执行体。
在程序启动的时候,只有一个 goroutine 调用 main 函数,叫做主 goroutine。新的 goroutine 需要用 go 语句创建,go 语句本身执行是立即完成:
f()
go f()
需要注意的是,当 main 函数返回的时候,所有的 goroutine 都会被暴力的直接终止退出。
func hello() {
fmt.Println("2")
}
func main() {
go hello() // 这个有时候不会打印,因为有时候main很快就结束了
fmt.Println("1")
}
我们可以用 sync.WaitGroup
来解决父 goroutine 过早的退出的问题
var wg sync.WaitGroup
func hello() {
fmt.Println("2")
wg.Done()
}
func main() {
wg.Add(1)
go hello()
fmt.Println("1")
wg.Wait()
}
还可以通过同步通道来解决
func hello(ch chan int) {
ch <- 0
fmt.Println("2")
}
func main() {
ch := make(chan int)
go hello(ch)
<-ch
fmt.Println("1")
close(ch)
}
如何在不修改原有函数的情况下,并行执行函数
可以用一个闭包嵌套以下
func hello() {
fmt.Println("2")
}
func main() {
ch := make(chan int)
for i := 1; i < 5; i++ {
// 嵌套一个闭包
go func() {
hello()
ch <- 1
}()
}
for i := 1; i < 5; i++ {
<-ch
}
fmt.Println("1")
close(ch)
}