思考从容器上该如何设置 GOMAXPROCS 大小引发,这个数字设置多少合理,其到底限制了什么,cpu 核数,系统线程数还是协程数?

背景

go

Go 协程

概念

进程: 操作系统分配系统资源(cpu 时间片,内存等)的最小单位

线程:轻量级进程,是操作系统调度的最小单位

协程:轻量级线程,协程的调度由程序控制

怎么理解

进程,线程的两个最小单位如何理解?

在早期面向进程设计的计算机结构中,进程就是操作系统分配系统资源与操作系统调度的最小单位

但在现代的计算结构中,进程升级为线程的容器,多个线程共享一个进程内的系统资源,cpu 执行(调度)对象是线程

轻量级进程与轻量级线程如何理解

轻量级进程:如下图各个进程拥有独立的虚拟内存空间,里面的资源包括 栈,代码,数据,堆... 而线程拥有独立的栈,但是共享进程全部的资源。在 linux 的实现中,进程与线程的底层数据结构是一致的,只是同一进程下线程会共享资源。



轻量级线程:线程栈大小固定 8 M,协程栈:2 KB ,动态增长。一对线程里对应多个协程,可以减少线程的数量



协程相对线程有什么优势

  • 轻量级 (MB vs KB)
  • 切换代价低 (调度由程序控制,不需要进入内核空间。需要保存上下文一般少一些)
  • 切换频率低,协程协作式调度,线程调度由操作系统控制,需要保证公平性,当线程数量一多,切换频率相对比较高

协程调度器

在 Golang 中,goroutine 调度是由 Golang 运行时(runtime)负责的,不需要程序员编写代码时关注协程的调度。

GM 模型

goroutine 的调度其实是一个生产者-消费者模型。

生产者:程序起 goroutine(G)

消费者:系统线程(M)去消费(执行)goroutine

Go 1.1



GM 模型问题

  • M 是并发的,每次访问这个全局队列需要全局锁,锁竞争比较严重
  • 忽略了 G 之间的关系,例如,M1 执行 G1 时,G1 创建了 G2,为了执行 G2 很有可能放到 M2 中执行。而 G1,G2 是相关的,缓存大概率是比较接近的,这样会导致性能下降
  • 每一个 M 都分配一块缓存 MCache,比较浪费

GOMAXPROCS:在这个版本代表了最多同时支持 GOMAXPROCS 个活跃的线程。

GMP 模型

  • G:go 协程
  • M:系统线程
  • P:逻辑处理器,负责提供相关的上下文环境,内存缓存的管理, Goroutine任务队列等

上述的 GM 模型性能问题比较严重,于是如下图所示, Go 将一个全局队列拆成了多个本地队列,这个管理本地队列的结构被称作 P。



P 结构特点

  • M 通过 P 取 G 时,并发访问大大降低,本地队列不需要全局锁了。
  • 每个 P 的本地 G 队列长度限定在 256,而 goroutine 的数量是不定的,因此 Go 还保留了一个无限长度的全局队列。
  • 本地队列数据结构是数组,全局队列数据结构是链表
  • P 中除了本地队列,还加了一个 runnext 的结构,为了优先执行刚创建的 goroutine
  • MCache 从 M 移到了P
  • 通过设置 GOMAXPROCS 控制 P 的数量

M 的消费逻辑(获取G)

  1. 先从绑定的 P 本地队列(优先 runnext)获取 G
  2. 定期从全局队列中获取 G:每执行 61 次调度会看一下全部队列(保证公平),并且在这个过程会把全局队列中 G 分给各个 P
  3. work stealing,若全局队列没有 G,则随机选择一个 P 偷一半的任务过来,若没有任务可偷,线程休眠。偷任务会导致并发访问本地队列,因此操作本地队列需要加自旋锁



G 的生产逻辑

go func

引入 P 解决的问题

  • 全局锁,P 中的协程是串行的
  • 数据局部性,G 创建时优先在 P 的本地队列,M 获取可用 P 时,优先之前绑定的 P
  • 内存消耗问题,多个线程共用 MCache

回到最开始的思考,容器上的 GOMAXPROCS 设置问题。GOMAXPROCS 代表着 P 的数量,也即代表着运行go 代码的最大线程数,默认为 CPU 的核数,有利于减少线程数量进而减少线程切换,但在容器中,不应该使用物理机的实际核数,应修改为容器限制的核数。

参考