likes
comments
collection
share

聊一聊 go 的调度器「GMP 模型」

作者站长头像
站长
· 阅读数 16

一 基本概念

进程

进程是操作系统进行资源分配的基本单位,它包含了程序运行过程中的所有状态。将不同的任务划分为不同进程,方便操作系统进行调度与管理,从而提升了系统的并发能力。

以个人的计算机为例,一边打开 WORD 写文章、一边打开 QQ 音乐听歌、同时还挂着微信接收消息。在这里,WORD、QQ、微信就是一个个独立的进程,它们之间相互独立,互不影响。在操作系统层面上,是将时间成了固定长度的小段,也称作时间片。操作系统轮流让每个进程运行一个时间片。如果进程在一个时间片内没有完成,它就被挂起,等待下一轮的调度。如下图所示,不同进程在操作系统上是串行执行的,只要时间片足够小,用户感知到的还是多个进程同时在执行。

聊一聊 go 的调度器「GMP 模型」

线程

线程是操作系统运算调度的最小单位。线程比进程更加轻量,创建、切换、销毁需要开销更小。同一个进程里的不同线程共享进程内全部资源。

已经有了进程的概念,为什么还需要线程?在进程内部,会同时存在多个子任务,这些子任务在某些场景下可以并发执行,从而提升 cpu 的利用率。例如:有一个文字处理进程,处理用户输入的数据,为了防止文件丢失,需要每分钟进行一次存档。为了实现这一能力可以创建两个进程,然而这就需要涉及到两个进程相互通信且额外带来了进程之间相互切换的开销。另一种方式是抽象出线程这一概念,使并发程序设计模型更加简单。只需要在同一个进程里创建两个线程即可,这两个线程既可以共享数据,也降低了系统进程的个数。

协程

协程是比线程更轻量的存在,切换成本更低,具有内核不可见的特性,是由开发者编写的程序来进行管理的。一个线程内的多个协程的运行是串行的,这点和多线程在多核 CPU 上并行执行时不同的。协程的出现主要是用来解决进程中线程过多而导致的线程切换资源浪费的问题。

以 web 服务器为例,通常会使用一个线程来处理每次连接,当连接过多时,就会创建大量的线程,从而造成非必要的开销。如果每个连接使用协程处理,线程数量就无需随着连接数的增加而增加。在最终执行的时候,协程需要绑定具体的线程中执行,而线程则要绑定在具体的 cpu 上执行指令。

含义优缺点
进程进程是操作系统资源分配的基本单位。每个进程都有自己独立的内存空间和系统资源。不同进程相互隔离,避免了相互影响。进程阻塞所带来的CPU时间浪费。进程切换开销过高,导致不能启动过多进程。
线程线程是操作系统能够进行运算调度的最小单位,被包含在进程中,是进程中的实际运作单位。线程可以在多个 cpu 上并行执行。创建、销毁和切换线程的代价比进程小。多线程编程复杂,需要处理同步和互斥。
协程协程是一种用户态的轻量级线程,协程的调度完全由用户控制。协程拥有自己的寄存器上下文和栈。 一个线程内的多个协程的运行是串行的。协程的切换开销极低。缺乏标准的系统级支持,大多需要依赖特定的语言和库才能使用。

调度

上面提到了进程、线程、协程等概念,其本质都是为了尽可能的压榨计算机资源,提升性能。在操作系统层面,需要调度算法协调不同进程使用系统资源。在进程内部,需要调度不同的线程执行。在线程内部,则需要调度不同的协程进行执行。

二 Go 调度器

在 go 中,使用 goroutine 来实现协程这一抽象概念。runtime 里实现了一套调度算法,将 goroutine 合理的调度到内核线程上,最大限度提升 CPU 利用率。

GMP 模型

在上一节中讲到,协程的概念的提出是为了解决线程创建、切换、销毁开销过高的问题。调度器的目标就是尽量降低线程创建、切换所带来的开销,同时保证程序的并发计算能力,避免 cpu 空闲。在 go 中使用 GMP 模型来实现调度算法:

G(goroutine) : 代表协程 goroutine,存储了goroutine 的执行栈信息、状态以及任务函数等。G 的数量无限制,理论上只受内存的影响,创建一个 G 的初始栈大小为2-4K,配置一般的机器也能简简单单开启数十万个 G,而且 Go 语言在 G 退出的时候还会把 G 清理之后放到 P 本地或者全局的闲置列表 gFree 中以便复用。

M(machine): 对操作系统线程(OS thread)的封装,可以看作操作系统内核线程,想要在 CPU 上执行代码必须有线程。M 在绑定有效的 P 后,进入一个调度循环,调度循环的机制大致是从 P 的本地运行队列以及全局队列中获取 G,切换到 G 的执行栈上并执行 G 的函数,调用 goexit 做清理工作并回到 M,如此反复。M 并不保留 G 状态,这是 G 可以跨 M 调度的基础。M 的数量有限制,默认数量限制是10000,可以通过debug.SetMaxThreads()方法进行设置,如果有 M 空闲,那么就会回收或者睡眠。

P(process): 虚拟处理器,M 执行 G 所需要的资源和上下文,只有将 P 和 M 绑定,才能让 P 的 runq 中的 G 真正运行起来。P 的数量决定了系统内最大可并行的 G 的数量,P 的数量受本机的 CPU 核数影响,可通过环境变量$GOMAXPROCS 或在 runtime.GOMAXPROCS() 来设置,默认为CPU核心数。P 的出现主要是将 G 与 M 解耦,让 M 只关注与 G 的执行。

聊一聊 go 的调度器「GMP 模型」

调度流程

  1. 使用 go 关键字创建一个 goroutine, 会被优先放在它所在 P 的本地队列中,如果本地队列已经满了「256」则会将本地队列的一半 G 放到全局队列中

  2. M 从绑定的 P 中获取可执行的 G

  3. 如果 P 本地队列中的 G 处于 running 状态,则直接在 M 上执行

    1. 如果 G 的时间片「10ms」用完,则会将其放到本地队列的队尾等待下次执行「抢占调度」
    2. 如果 G 因为系统调用被阻塞,则 P 会放弃当前 G 重新寻找一个空间 M 与之绑定;如果找不到空间的 M, 则会新建一个 M 与之绑定;当前的 G 则继续与原本的 M 绑定,等待满足条件重新调度「hand off 机制」
  4. 如果本地队列没有可以执行的 G, 则会尝试从全局队列中获取一批 G 放到本地队列中

    1. 如果从全局队列中没有获取到 G, 则尝试从其他 P 的本地队列中窃取 P「窃取机制」
    2. 如果没有窃取到,则再次查看全局队列
    3. 如果仍然没有,则暂停 M, 进入休眠状态并放到空间 M 池中
  5. 当 G 执行完后,更新状态并将其放入到空闲 G 列表中,同时开启新一轮的 goroutine 调度

三 常见问题

为什么说 go 天然支持高并发

和其他语言相比,go 通过 goroutine 这一概念实现了协程。在高并发场景下,相比于创建大量线程相比,goroutine 所占内存更低,切换开销少。此外,go 中增加了 channel 这一概念,用于不同 goroutine 之间进行通信,降低了并发编程中的数据竞态等问题。

当然,凡事有利必有弊。go 虽然使用了 goroutine、channel 等概念方便并发编程,但需要底层实现复杂的调度算法。且过多的创建 goroutine 也会导致资源使用过高,并发能力降低。

GMP 调试

利用 GODEBUG=schedtrace=1000环境变量调试调度信息。1000 表示每 1000ms 打印一次调度信息。当前 go 版本使用的是 go version go1.20.6 darwin/arm64。在下面代码中,启动了 10 个 goroutine 创建计算任务。

package main

import (
    _ "net/http/pprof"
    "sync"
    "time"
)

func main() {
    wg := sync.WaitGroup{}
    goroutineNum := 10
    wg.Add(goroutineNum)

    for i := 0; i < goroutineNum; i++ {
       go work(&wg)
    }
    wg.Wait()
    time.Sleep(time.Second)
}

func work(wg *sync.WaitGroup) {
    var counter int
    for i := 0; i < 1e10; i++ {
       counter++
    }
    wg.Done()
}

查看调度信息 GODEBUG=schedtrace=1000 GOMAXPROCS=1 ./main。每隔 1s 打印一次调度信息,设置并发执行线程数为 1,即使用一个 CPU。

SCHED 1010 ms 1010ms 时进程的调度信息

gomaxprocs=1: P 的数量,即并发执行的个数「真实并发执行的个数不会查过物理 CPU」

idleprocs=0: 空闲 P 的数量

threads=3: 线程数量

spinningthreads=0: 自旋状态的线程数量,用于寻找空闲的 P

needspinning=1: 用于同步线程状态从 non-spining 到 idle 的标记位

idlethreads=1: 空间线程数量

runqueue=8: 全局 goroutine 队列中的 goroutine 数量

[1]: 数组,每个数据表示 P 本地队列的 goroutine 数量,当前只有一个 P, 因此数组元素只有一个

SCHED 0ms: gomaxprocs=1 idleprocs=0 threads=3 spinningthreads=0 needspinning=1 idlethreads=0 runqueue=0 [2]
SCHED 1010ms: gomaxprocs=1 idleprocs=0 threads=3 spinningthreads=0 needspinning=1 idlethreads=1 runqueue=3 [6]
SCHED 2014ms: gomaxprocs=1 idleprocs=0 threads=3 spinningthreads=0 needspinning=1 idlethreads=1 runqueue=6 [3]
SCHED 3024ms: gomaxprocs=1 idleprocs=0 threads=3 spinningthreads=0 needspinning=1 idlethreads=1 runqueue=8 [1]

使用 scheddetail=1 参数,GODEBUG=schedtrace=1000,scheddetail=1 GOMAXPROCS=1 ./main。查看更详细的信息,可以用来查看某个 M、P、G 的具体状态。

SCHED 0ms: gomaxprocs=1 idleprocs=0 threads=3 spinningthreads=0 needspinning=1 idlethreads=0 runqueue=1 gcwaiting=false nmidlelocked=0 stopwait=0 sysmonwait=false
  P0: status=1 schedtick=0 syscalltick=0 m=2 runqsize=0 gfreecnt=0 timerslen=0
  M2: p=0 curg=nil mallocing=0 throwing=0 preemptoff= locks=1 dying=0 spinning=false blocked=false lockedg=nil
  M1: p=nil curg=nil mallocing=0 throwing=0 preemptoff= locks=2 dying=0 spinning=false blocked=false lockedg=nil
  M0: p=nil curg=nil mallocing=0 throwing=0 preemptoff= locks=1 dying=0 spinning=false blocked=false lockedg=1
  G1: status=1() m=nil lockedm=0
  G2: status=1() m=nil lockedm=nil
  
SCHED 1004ms: gomaxprocs=1 idleprocs=0 threads=3 spinningthreads=0 needspinning=1 idlethreads=1 runqueue=1 gcwaiting=false nmidlelocked=0 stopwait=0 sysmonwait=false
  P0: status=1 schedtick=47 syscalltick=12 m=0 runqsize=8 gfreecnt=0 timerslen=0
  M2: p=nil curg=nil mallocing=0 throwing=0 preemptoff= locks=0 dying=0 spinning=false blocked=true lockedg=nil
  M1: p=nil curg=nil mallocing=0 throwing=0 preemptoff= locks=2 dying=0 spinning=false blocked=false lockedg=nil
  M0: p=0 curg=6 mallocing=0 throwing=0 preemptoff= locks=0 dying=0 spinning=false blocked=false lockedg=nil
  G1: status=4(semacquire) m=nil lockedm=nil
  G2: status=4(force gc (idle)) m=nil lockedm=nil
  G3: status=4(GC sweep wait) m=nil lockedm=nil
  G4: status=4(GC scavenge wait) m=nil lockedm=nil
  G5: status=4(finalizer wait) m=nil lockedm=nil
  G6: status=2() m=0 lockedm=nil
  G7: status=1() m=nil lockedm=nil
  G8: status=1() m=nil lockedm=nil
  G9: status=1() m=nil lockedm=nil
  G10: status=1() m=nil lockedm=nil
  G11: status=1() m=nil lockedm=nil
  G12: status=1() m=nil lockedm=nil
  G13: status=1() m=nil lockedm=nil
  G14: status=1() m=nil lockedm=nil
  G15: status=1() m=nil lockedm=nil

G 的数量

在进行业务开发时,可以通过 go 关键字直接启动一个 goroutine。然而,goroutine 的数量并不是越大越好,开启越多的 goroutine 就需要额外的内存进行存储。同时, goroutine 的切换也带来一定的开销。下面是一个切片求和的例子,分别在切片大小不同的情况下比较使用不同数量的 goroutine 对性能的影响。

func BenchmarkAddSlice(b *testing.B) {
    for k := 0; k < 4; k++ {
       size := int(math.Pow(10, float64(k+2)))
       arr := make([]int, size)
       b.Run("arr size="+strconv.Itoa(size)+" without goroutines", func(b *testing.B) {
          for i := 0; i < b.N; i++ {
             addSlice(arr)
          }
       })
       for _, goroutines := range []int{1, 8, 16, 32, 64, 128, 256} { // 测试不同的 Goroutine 数量
          b.Run("arr size="+strconv.Itoa(size)+" goroutines="+strconv.Itoa(goroutines), func(b *testing.B) {
             for i := 0; i < b.N; i++ {
                addSliceWithGoroutines(goroutines, arr)
             }
          })
       }
    }
}

func addSliceWithGoroutines(goroutines int, arr []int) int64 {
    wg := sync.WaitGroup{}
    wg.Add(goroutines)
    sum := int64(0)
    stride := len(arr) / goroutines

    for i := 0; i < goroutines; i++ {
       go func(num int) {
          tmp := 0
          start := num * stride
          end := start + stride
          if num == goroutines-1 {
             end = len(arr)
          }
          for _, v := range arr[start:end] {
             tmp += v
          }
          atomic.AddInt64(&sum, int64(tmp))
          wg.Done()
       }(i)
    }
    wg.Wait()
    return sum
}

func addSlice(arr []int) int64 {
    res := 0
    for _, v := range arr {
       res += v
    }
    return int64(res)
}

执行go test -bench BenchmarkAddSlice,可以发现当前环境使用的 CPU 核数是 12,当切片容量较小时,不使用 goroutine 性能最好;当切片容量较大时,使用 goroutine 可以提升性能;在使用 goroutine 的情况下,goroutine 数量与 CPU 数量接近时性能最好。这些实验结果也与 GMP 模型相符,当函数本身开销较小时,使用 goroutine 只会增加创建、切换 goroutine 带来的成本。当函数本身开销较大时,使用 goroutine 可以充分利用多核 CPU;当 goroutine 的数量远大于 CPU 数量时,会增加 goroutine 调度成本。 如果想限制 goroutine 的数量,可以使用 goroutine 池,来限制 goroutine 的数量。

goos: darwin
goarch: arm64
pkg: demo
BenchmarkAddSlice/arr_size=100_without_goroutines-12            38061466                30.91 ns/op
BenchmarkAddSlice/arr_size=100_goroutines=1-12                   2434452               482.7 ns/op
BenchmarkAddSlice/arr_size=100_goroutines=8-12                    556946              2125 ns/op
BenchmarkAddSlice/arr_size=100_goroutines=16-12                   315516              3842 ns/op
BenchmarkAddSlice/arr_size=100_goroutines=32-12                   163213              6323 ns/op
BenchmarkAddSlice/arr_size=100_goroutines=64-12                   110236             11192 ns/op
BenchmarkAddSlice/arr_size=100_goroutines=128-12                   57002             21891 ns/op
BenchmarkAddSlice/arr_size=100_goroutines=256-12                   21338             50899 ns/op

BenchmarkAddSlice/arr_size=1000_without_goroutines-12            4007280               301.5 ns/op
BenchmarkAddSlice/arr_size=1000_goroutines=1-12                  1317637               907.8 ns/op
BenchmarkAddSlice/arr_size=1000_goroutines=8-12                   477314              2508 ns/op
BenchmarkAddSlice/arr_size=1000_goroutines=16-12                  325569              3722 ns/op
BenchmarkAddSlice/arr_size=1000_goroutines=32-12                  189703              6371 ns/op
BenchmarkAddSlice/arr_size=1000_goroutines=64-12                  107958             11425 ns/op
BenchmarkAddSlice/arr_size=1000_goroutines=128-12                  55629             22093 ns/op
BenchmarkAddSlice/arr_size=1000_goroutines=256-12                  24127             47388 ns/op

BenchmarkAddSlice/arr_size=10000_without_goroutines-12            400838              2959 ns/op
BenchmarkAddSlice/arr_size=10000_goroutines=1-12                  232154              5158 ns/op
BenchmarkAddSlice/arr_size=10000_goroutines=8-12                  249339              4726 ns/op
BenchmarkAddSlice/arr_size=10000_goroutines=16-12                 209990              6979 ns/op
BenchmarkAddSlice/arr_size=10000_goroutines=32-12                 161456              7452 ns/op
BenchmarkAddSlice/arr_size=10000_goroutines=64-12                 103725             12053 ns/op
BenchmarkAddSlice/arr_size=10000_goroutines=128-12                 58266             22484 ns/op
BenchmarkAddSlice/arr_size=10000_goroutines=256-12                 28209             44609 ns/op

BenchmarkAddSlice/arr_size=100000_without_goroutines-12            41133             29256 ns/op
BenchmarkAddSlice/arr_size=100000_goroutines=1-12                  26673             45086 ns/op
BenchmarkAddSlice/arr_size=100000_goroutines=8-12                  51926             23080 ns/op
BenchmarkAddSlice/arr_size=100000_goroutines=16-12                 60332             21005 ns/op
BenchmarkAddSlice/arr_size=100000_goroutines=32-12                 60586             20097 ns/op
BenchmarkAddSlice/arr_size=100000_goroutines=64-12                 43532             24130 ns/op
BenchmarkAddSlice/arr_size=100000_goroutines=128-12                41470             29292 ns/op
BenchmarkAddSlice/arr_size=100000_goroutines=256-12                25735             72536 ns/op

M 的数量

M 的数量表示程序中可用线程的数量,可以通过 debug.SetMaxThreads() 设置,默认值 10000。在 Go 中,当有 goroutine 无法被空闲的线程调度时就会创建新的线程。如果创建的线程数量超过设置的数量则会 panic。通常,在业务开发中无需显性设置 M 的数量,直接交给 Go 调度器处理即可。

func main() {
    debug.SetMaxThreads(1)
    fmt.Println("h w")
}

runtime: program exceeds 1-thread limit
fatal error: thread exhaustion

goroutine 1 [running]:
runtime.throw({0x10239f360?, 0x14000002228?})
        /Users/bytedance/go/go1.20.6/src/runtime/panic.go:1047 +0x40 fp=0x140000ebed0 sp=0x140000ebea0 pc=0x102258270
runtime.checkmcount()
        /Users/bytedance/go/go1.20.6/src/runtime/proc.go:790 +0x98 fp=0x140000ebf00 sp=0x140000ebed0 pc=0x10225bdb8
runtime/debug.setMaxThreads(0x1)
        /Users/bytedance/go/go1.20.6/src/runtime/proc.go:6343 +0x60 fp=0x140000ebf20 sp=0x140000ebf00 pc=0x102287910
runtime/debug.SetMaxThreads(...)
        /Users/bytedance/go/go1.20.6/src/runtime/debug/garbage.go:138

P 的数量

P 这一概念的提出主要是用于解决 G 和 M 的调度问题。在没有 P 之前,M 需要从一个全局队列中获取 G,需要使用锁来保证数据安全。由 P 来负责管理将 G 调度到 M 上执行,将 G 与 M 解耦,实现窃取和 hand off 机制。同时通过控制 P 的数量来限制并行数量。

P 的数量表示程序中 goroutine 并发执行的数量。如果 P 的数量过少,则无法充分利用 CPU;P 的数量过多,就是创建相关的线程与其绑定,会导致线程的不断切换造成非必要的开销。P 的默认值是 CPU 数量。

举一个简单的例子,当前系统的 CPU 数量是 12,使用 runtime.GOMAXPROCS(1)设置 P 的数量为 1。则程序同时只有一个线程在执行,即使用一个 CPU,可以看到两个协程是顺序执行。当然我们可以使用 time.Sleep(time.Millisecond * 10)设置 goroutine 执行时间,触发 goroutine 抢占调度,让两个 goroutine 交错执行。此外,使用 runtime.Gosched()也可以重新进行调度,执行其他 goroutine。

func main() {
    runtime.GOMAXPROCS(1)
    wg := sync.WaitGroup{}
    wg.Add(2)
    go func() {
       for i := 'a'; i < 'a'+26; i++ {
          fmt.Printf("%c", i)
          //time.Sleep(time.Millisecond * 10)
          //runtime.Gosched()
       }
       wg.Done()
    }()
    go func() {
       for i := 'A'; i < 'A'+26; i++ {
          fmt.Printf("%c", i)
          //time.Sleep(time.Millisecond * 10)
          //runtime.Gosched()
       }
       wg.Done()
    }()
    wg.Wait()
}

在 IO 密集性任务中,可以考虑将 M 的数量大于 CPU 的数量。我们假设 P 的数量等 CPU 的数量。在 IO 密集任务中,M 发生阻塞,这时操作系统进行线程切换;当 go 调度器感知到 M 发生阻塞时,会触发 hand off 机制,重新创建 M 与 P 进行绑定。如果我们提前创建较多的 P,当 M 发生阻塞时,操作系统会切换到其他的 M'-P' 上继续执行任务,而无需等待 Go 调度器为当前 P 创建新的 M。

相反,在计算密集任务中,只需使用默认值即可。如果创建过多的 P 会导致程序关联的 M 过多,造成不必要的 M 切换。IO 密集和计算密集的核心区别在于 IO 任务会频繁触发系统调用,造成线程阻塞,这时就需要创建新的线程来执行其他任务,如果提前设置过去 P 就可以直接绑定关联的 M 及时调度。

四 总结

本文介绍了一下 Go 的 GMP 模型,分析了一下整体的调度流程。最后,介绍如何通过 debug 查看 GMP 的详细信息,加深对 Go 调度器的理解。当然 Go 的 runtime 也会不断对调度算法进行优化。对于细节感兴趣的同学可以去阅读源码。