您的位置:首页 >聚焦 >

go 的运行时

2022-03-31 18:56:43    来源:程序员客栈
goroutine定义

“Goroutine是一个与其他 goroutines并行运行在同一地址空间的 Go函数或方法。一个运行的程序由一个或更多个 goroutine组成。它与线程、协程、进程等不同。它是一个 goroutine” —— Rob PikeGoroutines在同一个用户地址空间里并行独立执行 functions, channels则用于 goroutines间的通信和同步访问控制。

goroutine VS thread内存占用. 创建一个 goroutine的栈内存消耗为 2 KB(Linux AMD64Go v1.4后),运行过程中,如果栈空间不够用,会自动进行扩容。创建一个 thread为了尽量避免极端情况下操作系统线程栈的溢出,默认会为其分配一个较大的栈内存( 1 - 8 MB栈内存,线程标准POSIX Thread),而且还需要一个被称为 “guard page”的区域用于和其他 thread的栈空间进行隔离。而栈内存空间一旦创建和初始化完成之后其大小就不能再有变化,这决定了在某些特殊场景下系统线程栈还是有溢出的风险。创建/销毁,线程创建和销毀都会有巨大的消耗,是内核级的交互(trap)。POSIX线程(定义了创建和操纵线程的一套 API) 通常是在已有的进程模型中增加的逻辑扩展,所以线程控制和进程控制很相似。而进入内核调度所消耗的性能代价比较高,开销较大。goroutine是用户态线程,是由 go runtime管理,创建和销毁的消耗非常小。调度切换 抛开陷入内核,线程切换会消耗 1000-1500纳秒(上下文保存成本高,较多寄存器,公平性,复杂时间计算统计),一个纳秒平均可以执行 12-18条指令。所以由于线程切换,执行指令的条数会减少 12000-18000。goroutine的切换约为 200ns(用户态、3个寄存器),相当于 2400-3600条指令。因此, goroutines切换成本比  threads要小得多。复杂性 线程的创建和退出复杂,多个 thread间通讯复杂(share memory)。不能大量创建线程(参考早期的 httpd),成本高,使用网络多路复用,存在大量callback(参考twemproxy、nginx的代码) 。对于应用服务线程门槛高,例如需要做第三方库隔离,需要考虑引入线程池等。Goroutine运行原理

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

M:N模型

Go runtime会负责 goroutine的生老病死,从创建到销毁,都一手包办。Runtime会在程序启动的时候。Go创建 M个线程(CPU执行调度的单元,内核的 task_struct),之后创建的 N个 goroutine都会依附在这 M个线程上执行,即 M:N模型。它们能够同时运行,与线程类似,但相比之下非常轻量。因此,程序运行时,Goroutines的个数应该是远大于线程的个数的(phread是内核线程?)。

同一个时刻,一个线程只能跑一个 goroutine。当 goroutine发生阻塞 (chan阻塞、mutex、syscall等等) 时,Go 会把当前的 goroutine调度走,让其他 goroutine来继续执行,而不是让线程阻塞休眠,尽可能多的分发任务出去,让 CPU忙。

GM 调度模型

go在1.2版本之前,调度模型使用的是 GM调度模型。

G

goroutine的缩写,每次 go func()都代表一个 G,无限制。使用 struct runtime.g,包含了当前 goroutine的状态、堆栈、上下文。

M

工作线程(OS thread)也被称为 Machine,使用 struct runtime.m,所有 M是有线程栈的。如果不对该线程栈提供内存的话,系统会给该线程栈提供内存(不同操作系统提供的线程栈大小不同)。当指定了线程栈,则 M.stack→G.stack,M的 PC寄存器指向 G提供的函数,然后去执行。

GM 调度

Go 1.2前的调度器实现,限制了 Go并发程序的伸缩性,尤其是对那些有高吞吐或并行计算需求的服务程序。每个 goroutine对应于 runtime中的一个抽象结构:G,而 thread作为“物理 CPU”的存在而被抽象为一个结构:M(machine)。当 goroutine调用了一个阻塞的系统调用,运行这个 goroutine的线程就会被阻塞,这时至少应该再创建/唤醒一个线程来运行别的没有阻塞的 goroutine。线程这里可以创建不止一个,可以按需不断地创建,而活跃的线程(处于非阻塞状态的线程)的最大个数存储在变量 GOMAXPROCS中。

调用过程如下所示:

M想要执行、放回 G都必须访问全局 G队列,并且 M有多个,即多线程访问同一资源需要加锁进行保证互斥 / 同步,所以全局 G队列是有互斥锁进行保护的

GM 调度模型的问题单一全局互斥锁(Sched.Lock)和集中状态存储导致所有 goroutine相关操作,比如:创建、结束、重新调度等都要上锁。Goroutine传递问题M经常在 M之间传递”可运行”的 goroutine,这导致调度延迟增大以及额外的性能损耗(刚创建的 G放到了全局队列,而不是本地 M 执行,不必要的开销和延迟)。Per-M 持有内存缓存 (M.mcache)每个 M持有 mcache和 stackalloc,然而只有在 M运行 Go代码时才需要使用的内存(每个 mcache可以高达 2mb),当 M在处于 syscall时并不需要。运行 Go代码和阻塞在 syscall的 M的比例高达1:100,造成了很大的浪费。同时内存亲缘性也较差,G当前在 M运行后对 M 的内存进行了预热,因为现在 G调度到同一个 M的概率不高,数据局部性不好。严重的线程阻塞/解锁在系统调用的情况下,工作线程经常被阻塞和取消阻塞,这增加了很多开销。比如 M找不到G,此时 M就会进入频繁阻塞/唤醒来进行检查的逻辑,以便及时发现新的 G来执行。by Dmitry Vyukov “Scalable Go Scheduler Design Doc”GMP 调度模型

在 go 1.2版本及以后,go 引入 GMP调度模型

G

goroutine的缩写,每次 go func()都代表一个 G,无限制。使用 struct runtime.g,包含了当前 goroutine的状态、堆栈、上下文。

M

工作线程(OS thread)也被称为 Machine,使用 struct runtime.m,所有 M是有线程栈的。如果不对该线程栈提供内存的话,系统会给该线程栈提供内存(不同操作系统提供的线程栈大小不同)。当指定了线程栈,则 M.stack→G.stack,M的 PC寄存器指向 G提供的函数,然后去执行。

P

“Processor”是一个抽象的概念,并不是真正的物理 CPU。

Dmitry Vyukov的方案是引入一个结构 P,它代表了 M所需的上下文环境,也是处理用户级代码逻辑的处理器。它负责衔接 M和 G的调度上下文,将等待执行的 G与 M对接。当 P 有任务时需要创建或者唤醒一个 M来执行它队列里的任务。所以 P/M需要进行绑定,构成一个执行单元。P决定了并行任务的数量,可通过 runtime.GOMAXPROCS来设定。在 Go1.5之后 GOMAXPROCS被默认设置可用的核数,而之前则默认为1。

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

Tips: https://github.com/uber-go/automaxprocsAutomatically set GOMAXPROCS to match Linux container CPU quota.mcache/stackalloc从 M移到了 P,而 G队列也被分成两类,保留全局 G队列,同时每个 P中都会有一个本地的 G队列。

GMP调度

GMP调度模型, 引入了 local queue,因为 P的存在,runtime并不需要做一个集中式的 goroutine调度,每一个 M都会在 P"s local queue、global queue或者其他 P队列中找 G执行,减少全局锁对性能的影响。这也是 GMP Work-stealing调度算法的核心。注意 P的本地 G队列还是可能面临一个并发访问的场景,为了避免加锁,这里 P的本地队列是一个 LockFree的队列,窃取 G时使用 CAS原子操作来完成。关于LockFree和 CAS的知识参见 Lock-Free。

Work Stealing

当一个 P执行完本地所有的 G之后,并且全局队列为空的时候,会尝试挑选一个受害者 P,从它的 G队列中窃取一半的 G。否则会从全局队列中获取(当前个数/GOMAXPROCS)个 G。为了保证公平性,从随机位置上的 P开始,而且遍历的顺序也随机化了(选择一个小于 GOMAXPROCS,且和它互为质数的步长),保证遍历的顺序也随机化了。

光窃取失败时获取是不够的,可能会导致全局队列饥饿。P的调度算法中还会每个 N轮调度之后就去全局队列拿一个 G。如下图。

谁放入的全局队列呢

有两种情况会把G放到全局队列中。

新建 G时 P的本地 G队列放不下已满并达到256个的时候会放半数 G到全局队列去。阻塞的系统调用返回时找不到空闲 P也会放到全局队列。SysCall 系统调用

当 G调用 syscall后会解绑 P,然后 M和 G进入阻塞,而 P此时的状态就是 syscall,表明这个 P的 G正在 syscall中,这时的 P是不能被调度给别的 M的。如果在短时间内阻塞的 M就唤醒了,那么 M会优先来重新获取这个 P,能获取到就继续绑回去,这样有利于数据的局部性。系统监视器 (system monitor),称为 sysmon,会定时扫描。在执行 syscall时, 如果某个 P的 G执行超过一个 sysmon tick(10ms),就会把他设为 idle,重新调度给需要的 M,强制解绑。

P3和 M脱离后目前在 idle list中等待被绑定(处于 syscall状态)。而 syscall结束后 M按照如下规则执行直到满足其中一个条件:

尝试获取同一个 P(P3),恢复执行 G尝试获取 idle list中的其他空闲 P,恢复执行 G找不到空闲 P,把 G放回 global queue,M放回到 idle list

再举一个例子:如下图.

第一步:G35发生了系统调用,长时间没有返回。P1和 M解绑。(p1不会马上被推送到idle list, 而是经过一段时间才会推送到idle list.)第二步:G35系统调用完成,将G35推向了全局队列.第三步:G35被其他的P捞到了(可能P0经过1/61轮次正好check全局队列), 这样 G35就可以继续执行了。

需要注意的是:当使用了 Syscall,Go无法限制 Blocked OS threads的数量:The GOMAXPROCS variable limits the number of operating system threads that can execute user-level Go code simultaneously. There is no limit to the number of threads that can be blocked in system calls on behalf of Go code; those do not count against the GOMAXPROCS limit. This package’s GOMAXPROCS function queries and changes the limit.

Tips: 使用 syscall写程序要认真考虑 pthread exhaust问题。

Spining Thread.

线程自旋是相对于线程阻塞而言的,表象就是循环执行一个指定逻辑(调度逻辑,目的是不停地寻找 G)。这样做的问题显而易见,如果 G迟迟不来,CPU会白白浪费在这无意义的计算上。但好处也很明显,降低了 M 的上下文切换成本,提高了性能。在两个地方引入自旋:

类型1: M不带 P的找 P挂载(一有 P释放就结合)类型2: M带 P的找 G运行(一有 runable的 G就执行)。这种情况下会首先 按照 1/61轮次的查询 global Queue, 然后再查看 local Queue是否有 G. 如果没有,则去查看 Global Queue, 如果没有再去检查  net poller, 看看是否有可用的 goroutine.为了避免过多浪费 CPU资源,自旋的 M最多只允许 GOMAXPROCS(Busy P)。同时当有类型1的自旋 M存在时,类型2的自旋 M就不阻塞,阻塞会释放 P,一释放 P就马上被类型1的自旋 M抢走了,没必要。

在新 G被创建、M进入系统调用、M从空闲被激活这三种状态变化前,调度器会确保至少有一个自旋 M存在(唤醒或者创建一个 M),除非没有空闲的 P。

为什么呢?

当新 G创建,如果有可用 P,就意味着新 G可以被立即执行,即便不在同一个 P也无妨,所以我们保留一个自旋的 M(这时应该不存在类型1的自旋只有类型2的自旋)就可以保证新 G 很快被运行。当 M进入系统调用,意味着 M不知道何时可以醒来,那么 M对应的 P中剩下的 G就得有新的 M来执行,所以我们保留一个自旋的 M来执行剩下的 G(这时应该不存在类型2的自旋只有类型1的自旋)。如果 M从空闲变成活跃,意味着可能一个处于自旋状态的 M进入工作状态了,这时要检查并确保还有一个自旋 M存在,以防还有 G或者还有 P空着的。GMP模型问题总结单一全局互斥锁(Sched.Lock)和集中状态存储G被分成全局队列和 P的本地队列,全局队列依旧是全局锁,但是使用场景明显很少,P本地队列使用无锁队列,使用原子操作来面对可能的并发场景。Goroutine传递问题G创建时就在 P的本地队列,可以避免在 G之间传递(窃取除外),G对 P的数据局部性好; 当 G开始执行了,系统调用返回后 M会尝试获取可用 P,获取到了的话可以避免在 M之间传递。而且优先获取调用阻塞前的 P,所以 G对 M数据局部性好,G对 P的数据局部性也好。Per-M持有内存缓存 (M.mcache)内存 mcache只存在 P结构中,P最多只有 GOMAXPROCS个,远小于 M的个数,所以内存没有过多的消耗。严重的线程阻塞/解锁通过引入自旋,保证任何时候都有处于等待状态的自旋 M,避免在等待可用的 P和 G时频繁的阻塞和唤醒。syscon

sysmon也叫监控线程,它无需 P也可以运行,他是一个死循环,每20us~10ms循环一次,循环完一次就 sleep一会,为什么会是一个变动的周期呢,主要是避免空转,如果每次循环都没什么需要做的事,那么 sleep的时间就会加大。

释放闲置超过5分钟的 span物理内存;如果超过2分钟没有垃圾回收,强制执行;将长时间未处理的 netpoll添加到全局队列;向长时间运行的 G任务发出抢占调度;收回因 syscall长时间阻塞的 P;

当 P在 M上执行时间超过10ms,sysmon调用 preemptone将 G标记为 stackPreempt。因此需要在某个地方触发检测逻辑,Go当前是在检查栈是否溢出的地方判定(morestack()),M会保存当前 G的上下文,重新进入调度逻辑, 这样就不会死循环了。死循环:issues/11462信号抢占:go1.14基于信号的抢占式调度实现原理异步抢占,注册 sigurg信号,通过 sysmon检测,对 M对应的线程发送信号,触发注册的 handler,它往当前 G的 PC中插入一条指令(调用某个方法),在处理完 handler,G恢复后,自己把自己推到了 global queue中。

Network poller

Go所有的 I/O都是阻塞的。然后通过 goroutine + channel来处理并发。因此所有的 IO逻辑都是直来直去的,你不再需要回调,不再需要 future,要的仅仅是 step by step。这对于代码的可读性是很有帮助的。G发起网络 I/O操作也不会导致 M被阻塞(仅阻塞G),从而不会导致大量 M被创建出来。将异步 I/O转换为阻塞 I/O的部分称为 netpoller。打开或接受连接都被设置为非阻塞模式。如果你试图对其进行 I/O操作,并且文件描述符数据还没有准备好,G会进入 gopark函数,将当前正在执行的 G状态保存起来,然后切换到新的堆栈上执行新的 G。

那什么时候 G被调度回来呢?

sysmonschedule():M找 G的调度函数GC:start the world调用 netpoll()在某一次调度 G的过程中,处于就绪状态的 fd对应的 G就会被调度回来。G的 gopark状态:G置为 waiting状态,等待显示 goready唤醒,在 poller中用得较多,还有锁、chan等。Scheduler Affinity 调度亲和性

GM调度器时代的,chan操作导致的切换代价。

Goroutine#7正在等待消息,阻塞在 chan。一旦收到消息,这个 goroutine就被推到全局队列。然后,chan推送消息,goroutine#X将在可用线程上运行,而 goroutine#8将阻塞在 chan。goroutine#7现在在可用线程上运行。在 chan来回通信的 goroutine会导致频繁的 blocks,即频繁地在本地队列中重新排队。然而,由于本地队列是 FIFO实现,如果另一个 goroutine占用线程,unblock goroutine不能保证尽快运行。同时 Go亲缘性调度的一些限制:Work-stealing、系统调用。goroutine #9在 chan被阻塞后恢复。但是,它必须等待#2、#5和#4之后才能运行。goroutine #5将阻塞其线程,从而延迟goroutine #9,并使其面临被另一个 P窃取的风险。

针对 communicate-and-wait模式,进行了亲缘性调度的优化。Go 1.5在 P中引入了 runnext特殊的一个字段,可以高优先级执行 unblock G。goroutine #9现在被标记为下一个可运行的。这种新的优先级排序允许 goroutine在再次被阻塞之前快速运行。这一变化对运行中的标准库产生了总体上的积极影响,提高了一些包的性能。

Goroutine Lifecyclego 程序的启动

整个程序始于一段汇编,而在随后的 runtime·rt0_go(也是汇编程序)中,会执行很多初始化工作。

绑定 m0 和 g0,m0就是程序的主线程,程序启动必然会拥有一个主线程,这个就是 m0。g0 负责调度,即 shedule() 函数。创建 P,绑定 m0 和 p0,首先会创建 GOMAXPROCS 个 P ,存储在 sched 的 空闲链表(pidle)。新建任务 g 到 p0 本地队列,m0 的 g0 会创建一个 指向 runtime.main() 的 g ,并放到 p0 的本地队列。runtime.main(): 启动 sysmon 线程;启动 GC 协程;执行 init,即代码中的各种 init 函数;执行 main.main 函数。Os Thread 创建

准备运行的新 goroutine 将唤醒 P 以更好地分发工作。这个 P 将创建一个与之关联的 M 绑定到一个 OS thread。

go func()中 触发 Wakeup唤醒机制:有空闲的 P而没有在 spinning状态的 M 时候, 需要去唤醒一个空闲(睡眠)的 M或者新建一个。当线程首次创建时,会执行一个特殊的 G,即 g0,它负责管理和调度 G。

特殊的g0

Go基于两种断点将 G调度到线程上:

当 G阻塞时:系统调用、互斥锁或 chan。阻塞的 G进入睡眠模式/进入队列,并允许 Go安排和运行等待其他的 G。在函数调用期间,如果 G必须扩展其堆栈。这个断点允许 Go调度另一个 G并避免运行 G占用 CPU。在这两种情况下,运行调度程序的 g0将当前 G替换为另一个 G,即 ready to run。然后,选择的 G替换 g0并在线程上运行。与常规 G相反,g0有一个固定和更大的栈。Defer函数的分配GC收集,比如 STW、扫描 G的堆栈和标记、清除操作栈扩容,当需要的时候,由 g0进行扩栈操作Schedule

在 Go中,G的切换相当轻便,其中需要保存的状态仅仅涉及以下两个:

Goroutine在停止运行前执行的指令,程序当前要运行的指令是记录在程序计数器(PC)中的, G稍后将在同一指令处恢复运行;G的堆栈,以便在再次运行时还原局部变量;在切换之前,堆栈将被保存,以便在 G再次运行时进行恢复:

从 g到 g0或从 g0到 g的切换是相当迅速的,它们只包含少量固定的指令(9-10ns)。相反,对于调度阶段,调度程序需要检查许多资源以便确定下一个要运行的 G。当前 g阻塞在 chan 上并切换到 g0:

1、PC 和堆栈指针一起保存在内部结构中;2、将 g0 设置为正在运行的 goroutine;3、g0 的堆栈替换当前堆栈;

g0寻找新的 Goroutine来运行g0使用所选的 Goroutine进行切换:

1、PC和堆栈指针是从其内部结构中获取的;2、程序跳转到对应的 PC地址;Goroutine Recycle

goroutine重用

G很容易创建,栈很小以及快速的上下文切换。基于这些原因,开发人员非常喜欢并使用它们。然而,一个产生许多 shortlive的 G的程序将花费相当长的时间来创建和销毁它们。每个 P维护一个 freelist G,保持这个列表是本地的,这样做的好处是不使用任何锁来 push/get一个空闲的 G。当 G退出当前工作时,它将被 push到这个空闲列表中。

为了更好地分发空闲的 G,调度器也有自己的列表。它实际上有两个列表:一个包含已分配栈的 G,另一个包含释放过堆栈的 G(无栈)。锁保护 central list,因为任何 M 都可以访问它。当本地列表长度超过64时,调度程序持有的列表从 P获取 G。然后一半的 G将移动到中心列表(central list)。需求回收 G是一种节省分配成本的好方法。但是,由于堆栈是动态增长的,现有的G最终可能会有一个大栈。因此,当堆栈增长(即超过2K)时,Go不会保留这些栈。

最后

希望和你一起遇见更好的自己

看到这里啦,就点个关注再走吧~

关键词: 系统调用 数据局部性

相关阅读