Go语言调度模型G、M、P的数量多少合适?

百度一下Go语言优势,几乎所有文章都包含并发性好,作为一名老PHPer,一番学习实践下来,真香。

在当今这个多核时代,并发编程的意义不言而喻。当然,很多语言都支持多线程、多进程编程,但遗憾的是,实现和控制起来并不是那么令人感觉轻松和愉悦。Golang不同的是,语言级别支持协程(goroutine)并发(协程又称微线程,比线程更轻量、开销更小,性能更高),操作起来非常简单,语言级别提供关键字(go)用于启动协程,并且在同一台机器上可以启动成千上万个协程。

不管作为初学者还是久经沙场的老Gopher,Golang的协程调度器原理及 GMP 设计思想都是有必要去掌握的,而且面试必问:),推荐阅读丹冰大佬的 Golang修养之路GMP章节,图文并茂非常Nice。想深入学习的推荐欧神的 并发调度,结合源码和流程图讲解,膜拜。本文结束?Too young too simple,真正的重头戏才刚刚开始。

G 的数量:

无限制,理论上受内存的影响,创建一个 G 的初始栈大小为2-4K,配置一般的机器也能简简单单开启数十万个 Goroutine ,而且Go语言在 G 退出的时候还会把 G 清理之后放到 P 本地或者全局的闲置列表 gFree 中以便复用。

那是不是为了提高“并发能力”,就可以为所欲为的开启 Goroutine 呢,答案是否定的。

如果 Goroutine 中只有简单的逻辑,比如输出Hello world:),那肯定是没什么问题,但是如果Goroutine 中存在频繁请求 HTTP,MySQL,打开文件等,那假设短时间内有几十万个协程在跑,那肯定就不大合理了(可能会导致 too many files open)。常见的 Goroutine 泄露所导致的 CPU、Memory 上涨等,所以还是得看你的 Goroutine 里具体在跑什么东西。

一开始写 GO ,不管多大数据处理全部丢进去进行循环,认为全部都并发使用 Goroutine 去做一件事情,效率比较高,但这样的话,噩梦般的事情就开始了,服务器系统资源利用率不断上涨,到最后程序自动killed。这里比较好的解决方案是,引入线程池,限制 Goroutine 的数量,复用资源,保障系统稳定的同时提高处理能力。避免重复造轮子,推荐使用 ants,源码也不多可以学习学习,亲测好用- -

M 的数量:

限制10000,Go语言运行时初始化的时候在runtime.schedinit()中通过下面代码设置了 M 的最大数。

sched.maxmcount = 10000

通过debug.SetMaxThreads(n),可以调整。如果超出(手动调成10)会panic:

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

因为 M 必须持有 P 才能运行 G,通常情况 P 的数量很有限,如果 M 还超过 10000,基本上就是程序写的有问题。

P 的数量:

有限制,默认是CPU核心数,由启动时环境变量$GOMAXPROCS或者是由runtime.GOMAXPROCS()决定,runtime初始化建议阅读煎鱼大佬的 详解 Go 程序的启动流程,你知道 g0,m0 是什么吗?,以及 Go语言调度器之创建main goroutine

func schedinit() {
     ...
     procs := ncpu    // osinit 中获取 CPU 核心数
    if n, ok := atoi32(gogetenv("GOMAXPROCS")); ok && n > 0 {
        procs = n
    }
    // P 初始化
    if procresize(procs) != nil {
        throw("unknown runnable goroutine during bootstrap")
    }
     ...
}
func GOMAXPROCS(n int) int {
    ...

    stopTheWorldGC("GOMAXPROCS")

    // newprocs will be processed by startTheWorld
    newprocs = int32(n)      // 重新设置的 P 数量

    startTheWorldGC()
    return ret
}

startTheWorldGC() -> startTheWorld() -> startTheWorldWithSema()

func startTheWorldWithSema(emitTraceEvent bool) int64 {
    ...
    procs := gomaxprocs
    if newprocs != 0 {
        procs = newprocs
        newprocs = 0
    }
    // 扩容或者缩容全局的处理器
    p1 := procresize(procs)
    ...
}

在任何情况下,Go运行时并行执行(注意,不是并发)的 goroutines 数量是小于等于 P 的数量的。为了提高系统的性能,P 的数量肯定不是越小越好,所以官方默认值就是 CPU 的核心数,设置的过小的话,如果一个持有 P 的 M,由于 P 当前执行的 G 调用了 syscall 而导致 M 被阻塞,那么此时关键点:GO 的调度器是迟钝的,它很可能什么都没做,直到 M 阻塞了相当长时间以后,才会发现有一个 P/M 被 syscall 阻塞了。然后,才会用空闲的 M 来强这个 P。通过 sysmon 监控实现的抢占式调度,最快在20us,最慢在10-20ms才会发现有一个 M 持有 P 并阻塞了。操作系统在 1ms 内可以完成很多次线程调度(一般情况1ms可以完成几十次线程调度),Go 发起 IO/syscall 的时候执行该 G 的 M 会阻塞然后被OS调度走,P什么也不干,sysmon 最慢要10-20ms才能发现这个阻塞,说不定那时候阻塞已经结束了,宝贵的P资源就这么被阻塞的M浪费了。
补充说明:调度器迟钝不是 M 迟钝,M 也就是操作系统线程,是非常的敏感的,只要阻塞就会被操作系统调度(除了极少数自旋的情况)。但是 GO 的调度器会等待一个时间间隔才会行动,这也是为了减少调度器干预的次数。也就是说,如果一个 M 调用了什么 API 导致了操作系统线程阻塞了,操作系统立刻会把这个线程M调度走,挂起等阻塞解除。这时候,Go 调度器不会马上把这个 M 持有的 P 抢走。这就会导致一定的 P 被浪费了。
开源数据库项目https://github.com/dgraph-io/dgraph中,特意将 GOMAXPROCS 调整到 128 增加 IO 处理能力,提高吞吐量。

https://github.com/dgraph-io/dgraph/blob/master/dgraph/main.go

func main() {
    rand.Seed(time.Now().UnixNano())
    // Setting a higher number here allows more disk I/O calls to be scheduled, hence considerably
    // improving throughput. The extra CPU overhead is almost negligible in comparison. The
    // benchmark notes are located in badger-bench/randread.
    runtime.GOMAXPROCS(128)
}

那 P 的数量太大会有什么影响呢?
一个runtime findrunnable 时产生的损耗,另一个是线程引起的上下文切换。如果是cpu密集的业务,增加多个processor也没用,毕竟cpu计算资源就这些,来回切换反而拖慢程序。

runtime的 findrunnable 方法是解决 M 找可用的协程的函数,当从绑定 P 本地runq上找不到可执行的goroutine后,尝试从全局链表中拿,再拿不到从 netpoll 和事件池里拿,最后会从别的 P 里偷任务。全局 runq 是有锁操作,其他偷任务使用了atomic 原子操作来规避futex竞争下陷入切换等待问题,但 lock free 在竞争下也会有忙轮询的状态,比如不断的尝试(自旋)。

随着调多 runtime processor 数量,相关的 M 线程自然也就跟着多了起来。linux 内核为了保证可执行的线程在调度上雨露均沾,按照内核调度算法来切换就绪状态的线程,切换又引起上下文切换。上下文切换也是性能的一大杀手。findrunnable 的某些锁竞争也会触发上下文切换。

结论:常规项目直接使用默认的核心数就好了,GOMAXPROCS 开太多的时候,针对计算密集型的处理性能提升反而没那么大,IO 密集(或者 syscall 较多)的 Go 程序,至少应该配置到CPU核心数目的5倍以上, 最大1024。

个人学习笔记,方便自己复习,有不对的地方欢迎评论哈!

参考资料:

Golang修养之路GMP章节
并发调度
详解 Go 程序的启动流程,你知道 g0,m0 是什么吗?
Go语言调度器之创建main goroutine
Go 群友提问:Goroutine 数量控制在多少合适,会影响 GC 和调度?
Go开发中,如何有效控制Goroutine的并发数量
golang gomaxprocs调高引起调度性能损耗
[GO语言]合理配置GOMAXPROCS提升一倍以上的性能

推荐阅读更多精彩内容