GMP
Publish on 2024-09-19

Why goroutine

创建一个 goroutine 的栈内存消耗为 2 KB,实际运行过程中,如果栈空间不够用,会自动进行扩容。创建一个 thread 则需要消耗 1 MB 栈内存,而且还需要一个被称为 “a guard page” 的区域用于和其他 thread 的栈空间进行隔离。

对于一个用 Go 构建的 HTTP Server 而言,对到来的每个请求,创建一个 goroutine 用来处理是非常轻松的一件事。而如果用一个使用线程作为并发原语的语言构建的服务,例如 Java 来说,每个请求对应一个线程则太浪费资源了,很快就会出 OOM 错误(OutOfMemoryError)。

  • 创建和销毀

Thread 创建和销毀都会有巨大的消耗,因为要和操作系统打交道,是内核级的,通常解决的办法就是线程池。而 goroutine 因为是由 Go runtime 负责管理的,创建和销毁的消耗非常小,是用户级。

  • 切换

当 threads 切换时,需要保存各种寄存器,以便将来恢复:

16 general purpose registers, PC (Program Counter), SP (Stack Pointer), segment registers, 16 XMM registers, FP coprocessor state, 16 AVX registers, all MSRs etc.

而 goroutines 切换只需保存三个寄存器:Program Counter, Stack Pointer and BP。

一般而言,线程切换会消耗 1000-1500 纳秒,一个纳秒平均可以执行 12-18 条指令。所以由于线程切换,执行指令的条数会减少 12000-18000。

Goroutine 的切换约为 200 ns,相当于 2400-3600 条指令。

因此,goroutines 切换成本比 threads 要小得多。

scheduler

架构

Go 程序的执行由两层组成:Go Program,Runtime,即用户程序和运行时。它们之间通过函数调用来实现内存管理、channel 通信、goroutines 创建等功能。用户程序进行的系统调用都会被 Runtime 拦截,以此来帮助它进行调度以及垃圾回收相关的工作。

一个展现了全景式的关系如下图:

Untitled.png

为什么要 scheduler

Go scheduler 可以说是 Go 运行时的一个最重要的部分了。Runtime 维护所有的 goroutines,并通过 scheduler 来进行调度。Goroutines 和 threads 是独立的,但是 goroutines 要依赖 threads 才能执行。

Go 程序执行的高效和 scheduler 的调度是分不开的。

scheduler 底层原理

实际上在操作系统看来,所有的程序都是在执行多线程。将 goroutines 调度到线程上执行,仅仅是 runtime 层面的一个概念,在操作系统之上的层面。

有三个基础的结构体来实现 goroutines 的调度。g,m,p。

g 代表一个 goroutine,它包含:表示 goroutine 栈的一些字段,指示当前 goroutine 的状态,指示当前运行到的指令地址,也就是 PC 值。

m 表示内核线程 machine,包含正在运行的 goroutine 等字段。

p 代表一个虚拟的 processor,它维护一个处于 Runnable 状态的 g 队列,m 需要获得 p 才能运行 g

当然还有一个核心的结构体:sched,它总览全局。

Runtime 起始时会启动一些 G:垃圾回收的 G,执行调度的 G,运行用户代码的 G;并且会创建一个 M 用来开始 G 的运行。随着时间的推移,更多的 G 会被创建出来,更多的 M 也会被创建出来。

当然,在 Go 的早期版本,并没有 p 这个结构体,m 必须从一个全局的队列里获取要运行的 g,因此需要获取一个全局的锁,当并发量大的时候,锁就成了瓶颈。后来在大神 Dmitry Vyokov 的实现里,加上了 p 结构体。每个 p 自己维护一个处于 Runnable 状态的 g 的队列,解决了原来的全局锁问题。

Go scheduler 的目标:

For scheduling goroutines onto kernel threads.

Untitled.png

Go scheduler 的核心思想是:

  1. reuse threads;
  2. 限制同时运行(不包含阻塞)的线程数为 N,N 等于 CPU 的核心数目;
  3. 线程私有的 runqueues,并且可以从其他线程 stealing goroutine 来运行,线程阻塞后,可以将 runqueues 传递给其他线程。

为什么需要 P 这个组件,直接把 runqueues 放到 M 不行吗?

You might wonder now, why have contexts at all? Can’t we just put the runqueues on the threads and get rid of contexts? Not really. The reason we have contexts is so that we can hand them off to other threads if the running thread needs to block for some reason.

An example of when we need to block, is when we call into a syscall. Since a thread cannot both be executing code and be blocked on a syscall, we need to hand off the context so it can keep scheduling.

你想一想,当一个线程(M)阻塞的时候,将和它绑定的 P 上的 goroutines 转移到其他线程。

Go scheduler 会启动一个后台线程 sysmon,用来检测长时间(超过 10 ms)运行的 goroutine,将其调度到 global runqueues。这是一个全局的 runqueue,优先级比较低,以示惩罚。

Untitled.png

M:N model

细说M:N model

GMP调度模型

G、P、M 是 Go 调度器的三个核心组件,各司其职。在它们精密地配合下,Go 调度器得以高效运转,这也是 Go 天然支持高并发的内在动力。

调度模型

g0 & m0 & p0 及运行逻辑

go程序初始化时,会先

  • 创建一个g0协程,然后;
  • 创建一个m0,接着;
  • 将g0绑定到m0上,并初始化nproc个p;
  • 将p0和m0关联起来,并把p0之外的所有 p 放入到全局变量 sched 的 pidle 空闲队列之中。

以上所有的步骤都在newproc前完成。

newproc用于创建运行main func的goroutine,循环调用schedule函数直到函数结束。

Untitled.png

schedt

细说schedule

QA

1. 既然一开始只会初始化一个m0,那么其他的m是在什么时候被创建的?

建立在没有可复用的m的情况,当前在m运行的上运行的g因为syscall进入阻塞时,会创建新的m去抢占p。

抢占 p,需要同时满足几个条件:

  1. p 的本地运行队列里面有等待运行的 goroutine。这时 p 绑定的 g 正在进行系统调用,无法去执行其他的 g,因此需要接管 p 来执行其他的 g。
  2. 没有“无所事事”的 p。sched.nmspinningsched.npidle 都为 0,这就意味着没有“找工作”的 m,也没有空闲的 p,大家都在“忙”,可能有很多工作要做。因此要抢占当前的 p,让它来承担一部分工作。
  3. 从上一次监控线程观察到 p 对应的 m 处于系统调用之中到现在已经超过 10 毫秒。这说明系统调用所花费的时间较长,需要对其进行抢占,以此来使得 retake 函数返回值不为 0,这样,会保持 sysmon 线程 20 us 的检查周期,提高 sysmon 监控的实时性。

2. 一个P只能对应一个M吗?

是的,一个P只能同时绑定一个M,但是在调度器的管理下,一个P可以切换多个M。

P的数量和处理器数量是相同的,不会改变,变的是M的数量。

3. G阻塞了会发生什么

runtime.main() 函数中,执行 runtime_init() 前,会启动一个 sysmon 的监控线程,执行后台监控任务:

systemstack(func() {
    // 创建监控线程,该线程独立于调度器,不需要跟 p 关联即可运行
    newm(sysmon, nil)
})

sysmon 函数不依赖 P 直接执行,通过 newm 函数创建一个工作线程:

func newm(fn func(), _p_ *p) {
    // 创建 m 对象
    mp := allocm(_p_, fn)
    // 暂存 m
    mp.nextp.set(_p_)
    mp.sigmask = initSigmask

    // ……………………

    execLock.rlock() // Prevent process clone.
    // 创建系统线程
    newosproc(mp, unsafe.Pointer(mp.g0.stack.hi))
    execLock.runlock()
}

先调用 allocm 在堆上创建一个 m,接着调用 newosproc 函数启动一个工作线程:

// src/runtime/os_linux.go
// go:nowritebarrier
func newosproc(mp *m, stk unsafe.Pointer) {
    // ……………………

    ret := clone(cloneFlags, stk, unsafe.Pointer(mp), unsafe.Pointer(mp.g0), unsafe.Pointer(funcPC(mstart)))

    // ……………………
}

sysmon 执行一个无限循环,一开始每次循环休眠 20us,之后(1 ms 后)每次休眠时间倍增,最终每一轮都会休眠 10ms。

sysmon 中会进行 netpool(获取 fd 事件)、retake(抢占)、forcegc(按时间强制执行 gc),scavenge heap(释放自由列表中多余的项减少内存占用)等处理。

sysmon 会监控所有的P,并让所有运行超过10ms的 goroutine 滚去全局的G链表,给其他的G一个机会。

4. 被解绑的M在完成系统调用后,是怎么回归主线的?

首先去找原来抛弃它的P如果找不到,在空闲的P链表里找一个p:

  • 如果找到了,就和这个p绑定,然后继续运行完这个g。之后,就去偷袭全局的G链表和其他P的本地链表,偷不到就睡觉。
  • 如果没找到,就把G标记为 runable ,放入GRQ(global runable queue) ,自己去睡觉。

5. 在偷之前到底经历了什么?

  1. 先去LRQ(local runable queue) 找G,如果失败,则;
  2. 然后去GRQ(global runable queue) 找G,如果失败,则;

从全局队列取的G数量符合下面的公式:

$n = min(len(GQ)/GOMAXPROCS + 1, len(GQ)/2)$

至少从全局队列取1个g,但每次不要从全局队列移动太多的g到p本地队列,给其他p留点。这是从全局队列到P本地队列的负载均衡

  1. 最后从一个随机的P去偷一半的G。

6. M会被销毁吗?

不会,M会睡眠。相反,当M的数量超过阈值时,会直接报错。

7. 什么情况下会触发M的唤醒?

  1. 当前 M 上的 Goroutine 主动调用了 runtime.Gosched() 函数,显式地让出执行权,使调度器有机会重新调度其他 Goroutine。
  2. 当前 M 上的 Goroutine 执行了一个阻塞的系统调用(如网络 I/O、文件 I/O、等待锁等),此时该 M 会被唤醒,并由其他空闲的 M 来接管执行。
  3. 当前 M 上的 Goroutine 在执行期间发生了垃圾回收(GC)的触发,为了完成垃圾回收的工作,调度器会唤醒其他空闲的 M 来执行垃圾回收操作。
  4. 当前 M 上的 Goroutine 执行了一个需要等待的操作,例如通道操作(发送或接收),当有数据可用或通道被关闭时,调度器会重新调度该 Goroutine。
  5. 在并发抢占调度模式下,当一个 Goroutine 的执行时间超过一定阈值(例如 10 毫秒),调度器会中断该 Goroutine的执行,并唤醒其他 Goroutine 进行执行。

8. P的LRQ和新建G

新建的G会优先放入创造它的G所绑定的P的LRQ中,如果放不下了,则将P中一半的runable G打乱顺序后放到GRQ。这个过程仅由P调用实现。

9. 为什么需要tls

首先要明确,所谓的G运行在M上,实际上指的是G运行在M所对应的内核线程thread上,而每个内核线程都会有自己的tls内存区,用来存放自己的线程私有变量。在内核中,进行线程切换时,会将tls内存放到对应cpu中的fs寄存器中,方便取用。

这也就意味着,实际上,thread能够感知到的一直就是一个m,为了方便,我们将这个m称为m0。thread可以通过get_tls()获取thread当前感知到的m0的tls数组的首地址。而:

  • m.tls[0]=&g0: 储存了每个m绑定的g0的对象地址。
  • m.tls[1]=%fs: 储存了thread的线程私有变量的内存区域地址。

所以,线程thread通过get_tls(),可以获取到g0,而通过g0,可以获取到m。于是众神归位。

让我们来看看没有m.tls字段会怎么样:

  • 每次都需要使用系统调用arch_prctl去取基址的值
  • g0从宏观上来说也是局部私有变量的一部分,这样就会导致tls接口碎成了两部分,不符合直观逻辑。
那么为什么tls数组设计成6个,但是只用了2个呢?

我把这四个空位,赌在了新时代上。(为了以后的拓展留余地)

10. go中用到了LWP吗

在 Go 的运行时系统中,并没有直接使用 Light-weight Process (LWP) 模型。LWP 是一种操作系统级别的线程模型,它提供了轻量级的线程管理和调度机制。在某些操作系统中,LWP 是用于实现线程的基本单元。

Go 的运行时系统使用了一种自己的调度模型,其中 M(Machine)代表着一个逻辑的执行线程,G(Goroutine)则代表着并发的执行单元。M 和 G 之间的关系是一对多的关系,即一个 M 可以承载多个 G,并负责将这些 G 调度到可用的 CPU 上执行。

Go 的运行时系统使用自己的调度器来管理和调度 G,以实现高效的并发执行。调度器负责将 G 分配给 M,根据需要创建和销毁 M,以及在 M 上执行 G。这种调度模型在设计上具有高度的可扩展性和灵活性,并能够充分利用多核处理器的并发能力。

© 2024 humbornjo :: based on 
nobloger  ::  rss