Go RunTime

Goroutine 原理
内存分配原理
GC 原理
Channel 原理

Goroutine
GMP 调度模型
Work-stealing 调度算法
Goroutine Lifecycle

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

goroutine 和 thread 的区别
内存占用,创建一个 goroutine 的栈内存消耗为 2 KB(Linux AMD64 Go v1.4后),运行过程中,如果栈空间不够用,会自动进行扩容。
创建一个 thread 为了尽量避免极端情况下操作系统线程栈的溢出,默认会为其分配一个较大的栈内存( 1 - 8 MB 栈内存,线程标准 POSIX Thread),而且还需要一个被称为 “guard page” 的区域用于和其他 thread 的栈空间进行隔离。而栈内存空间一旦创建和初始化完成之后其大小就不能再有变化,这决定了在某些特殊场景下系统线程栈还是有溢出的风险。
创建/销毁,线程创建和销毀都会有巨大的消耗,是内核级的交互(trap)。
POSIX 线程(定义了创建和操纵线程的一套 API)通常是在已有的进程模型中增加的逻辑扩展,所以线程控制和进程控制很相似。而进入内核调度所消耗的性能代价比较高,开销较大。goroutine 是用户态线程,是由 go runtime 管理,创建和销毁的消耗非常小。

调度切换
抛开陷入内核,线程切换会消耗 1000-1500 纳秒(上下文保存成本高,较多寄存器,公平性,复杂时间计算统计),一个纳秒平均可以执行 12-18 条指令。
所以由于线程切换,执行指令的条数会减少 12000-18000。goroutine 的切换约为 200 ns(用户态、3个寄存器),相当于 2400-3600 条指令。因此,goroutines 切换成本比 threads 要小得多。
复杂性
线程的创建和退出复杂,多个thread间通讯复杂(share memory)。
不能大量创建线程(参考早期的 httpd),成本高,使用网络多路复用,存在大量callback(参考twemproxy、nginx 的代码)。对于应用服务线程门槛高,例如需要做第三方库隔离,需要考虑引入线程池等。

Go 创建 M 个线程(CPU 执行调度的单元,内核的 task_struct),之后创建的 N 个 goroutine 都会依附在这 M 个线程上执行,即 M:N 模型。它们能够同时运行,与线程类似,但相比之下非常轻量。因此,程序运行时,Goroutines 的个数应该是远大于线程的个数的(phread 是内核线程?)。
同一个时刻,一个线程只能跑一个 goroutine。当 goroutine 发生阻塞 (chan 阻塞、mutex、syscall 等等) 时,Go 会把当前的 goroutine 调度走,让其他 goroutine 来继续执行,而不是让线程阻塞休眠,尽可能多的分发任务出去,让 CPU 忙。

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?

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

GM 调度模型的问题
单一全局互斥锁(Sched.Lock)和集中状态存储
导致所有 goroutine 相关操作,比如:创建、结束、重新调度等都要上锁。
Goroutine 传递问题
M 经常在 M 之间传递”可运行”的 goroutine,这导致调度延迟增大以及额外的性能损耗(刚创建的 G 放到了全局队列,而不是本地 M 执行,不必要的开销和延迟)。
Per-M 持有内存缓存 (M.mcache)
每个 M 持有 mcache 和 stack alloc,然而只有在 M 运行 Go 代码时才需要使用的内存(每个 mcache 可以高达2mb),当 M 在处于 syscall 时并不需要。运行 Go 代码和阻塞在 syscall 的 M 的比例高达1:100,造成了很大的浪费。同时内存亲缘性也较差。G 当前在 M运 行后对 M 的内存进行了预热,因为现在 G 调度到同一个 M 的概率不高,数据局部性不好。

GM 调度模型的问题

严重的线程阻塞/解锁
在系统调用的情况下,工作线程经常被阻塞和取消阻塞,这增加了很多开销。比如 M 找不到G,此时 M 就会进入频繁阻塞/唤醒来进行检查的逻辑,以便及时发现新的 G 来执行。
by Dmitry Vyukov “Scalable Go Scheduler Design Doc”

P
“Processor”是一个抽象的概念,并不是真正的物理 CPU。
Dmitry Vyukov的方案是引入一个结构 P,它代表了 M 所需的上下文环境,也是处理用户级代码逻辑的处理器。它负责衔接 M 和 G 的调度上下文,将等待执行的 G 与 M 对接。当 P 有任务时需要创建或者唤醒一个 M 来执行它队列里的任务。所以 P/M 需要进行绑定,构成一个执行单元。P 决定了并行任务的数量,可通过 runtime.GOMAXPROCS 来设定。在 Go1.5 之后GOMAXPROCS 被默认设置可用的核数,而之前则默认为1。
Tips: https://github.com/uber-go/automaxprocs
Automatically set GOMAXPROCS to match Linux container CPU quota.
mcache 从 M 移到了 P,而 G 队列也被分成两类,保留全局 G 队列,同时每个 P 中都会有一个本地的 G 队列。

引入了 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。

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

Work-stealing
光窃取失败时获取是不够的,可能会导致全局队列饥饿。P 的调度算法中还会每个 N 轮调度之后就去全局队列拿一个 G。
谁放入的全局队列呢?
新建 G 时 P 的本地 G 队列放不下已满并达到256个的时候会放半数 G 到全局队列去,阻塞的系统调用返回时找不到空闲 P 也会放到全局队列。

调用 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,强制解绑。

P1 和 M 脱离后目前在 idle list 中等待被绑定(处于 syscall 状态)。而 syscall 结束后 M 按照如下规则执行直到满足其中一个条件:
尝试获取同一个 P(P1),恢复执行 G
尝试获取 idle list 中的其他空闲 P,恢复执行 G
找不到空闲 P,把 G 放回 global queue,M 放回到 idle list

当使用了 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 就执行)
为了避免过多浪费 CPU 资源,自旋的 M 最多只允许 GOMAXPROCS (Busy P)。同时当有类型1的自旋 M 存在时,类型2的自旋 M 就不阻塞,阻塞会释放 P,一释放 P 就马上被类型1的自旋 M 抢走了,没必要。

严重的线程阻塞/解锁
通过引入自旋,保证任何时候都有处于等待状态的自旋 M,避免在等待可用的 P 和 G 时频繁的阻塞和唤醒。
by Dmitry Vyukov “Scalable Go Scheduler Design Doc”

sysmon
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 中。

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 到 g0 或从 g0 到 g 的切换是相当迅速的,它们只包含少量固定的指令。相反,对于调度阶段,调度程序需要检查许多资源以便确定下一个要运行的 G。
当前 g 阻塞在 chan 上并切换到 g0:1、PC 和堆栈指针一起保存在内部结构中;2、将 g0 设置为正在运行的 goroutine;3、g0 的堆栈替换当前堆栈;
g0 寻找新的 Goroutine 来运行
g0 使用所选的 Goroutine 进行切换: 1、PC 和堆栈指针是从其内部结构中获取的;2、程序跳转到对应的 PC 地址;

Go 有两个地方可以分配内存:一个全局堆空间用来动态分配内存,另一个是每个 goroutine 都有的自身栈空间。

栈区的内存一般由编译器自动进行分配和释放,其中存储着函数的入参以及局部变量,这些参数会随着函数的创建而创建,函数的返回而销毁。(通过 CPU push & release)。
A function has direct access to the memory inside its frame, through the frame pointer, but access to memory outside its frame requires indirect access.

Go 有两个地方可以分配内存:一个全局堆空间用来动态分配内存,另一个是每个 goroutine 都有的自身栈空间。

栈区的内存一般由编译器自动进行分配和释放,其中存储着函数的入参以及局部变量,这些参数会随着函数的创建而创建,函数的返回而销毁。(通过 CPU push & release)。
A function has direct access to the memory inside its frame, through the frame pointer, but access to memory outside its frame requires indirect access.

变量是在堆还是栈上?

写过其他语言,比如 C 的同学都知道,有明确的栈和堆的相关概念。而 Go 声明语法并没有提到栈和堆,而是交给 Go 编译器决定在哪分配内存,保证程序的正确性,在 Go FAQ 里面提到这么一段解释:
从正确的角度来看,你不需要知道。Go 中的每个变量只要有引用就会一直存在。变量的存储位置(堆还是栈)和语言的语义无关。
存储位置对于写出高性能的程序确实有影响。如果可能,Go 编译器将为该函数的堆栈侦(stack frame)中的函数分配本地变量。但是如果编译器在函数返回后无法证明变量未被引用,则编译器必须在会被垃圾回收的堆上分配变量以避免悬空指针错误。此外,如果局部变量非常大,将它存储在堆而不是栈上可能更有意义。
在当前编译器中,如果变量存在取址,则该变量是堆上分配的候选变量。但是基础的逃逸分析可以将那些生存不超过函数返回值的变量识别出来,并且因此可以分配在栈上。

内存管理

TCMalloc 是 Thread Cache Malloc 的简称,是Go 内存管理的起源,Go的内存管理是借鉴了TCMalloc:
内存碎片
随着内存不断的申请和释放,内存上会存在大量的碎片,降低内存的使用率。为了解决内存碎片,可以将2个连续的未使用的内存块合并,减少碎片。
大锁
同一进程下的所有线程共享相同的内存空间,它们申请内存时需要加锁,如果不加锁就存在同一块内存被2个线程同时访问的问题。

当程序里发生了 32kb 以下的小块内存申请时,Go 会从一个叫做的 mcache 的本地缓存给程序分配内存。这样的一个内存块里叫做 mspan,它是要给程序分配内存时的分配单元。
在 Go 的调度器模型里,每个线程 M 会绑定给一个处理器 P,在单一粒度的时间里只能做多处理运行一个 goroutine,每个 P 都会绑定一个上面说的本地缓存 mcache。当需要进行内存分配时,当前运行的 goroutine 会从 mcache 中查找可用的 mspan。从本地 mcache 里分配内存时不需要加锁,这种分配策略效率更高。

我们需要先知道几个重要的概念:
page: 内存页,一块 8K 大小的内存空间。Go 与操作系统之间的内存申请和释放,都是以 page 为单位的。
span: 内存块,一个或多个连续的 page 组成一个 span。
sizeclass: 空间规格,每个 span 都带有一个 sizeclass,标记着该 span 中的 page 应该如何使用。
object: 对象,用来存储一个变量数据内存空间,一个 span 在初始化时,会被切割成一堆等大的 object。假设 object 的大小是 16B,span 大小是 8K,那么就会把 span 中的 page 就会被初始化 8K / 16B = 512 个 object。

如果分配内存时 mcachce 里没有空闲的对口 sizeclass 的 mspan 了,Go 里还为每种类别的 mspan 维护着一个 mcentral。
mcentral 的作用是为所有 mcache 提供切分好的 mspan 资源。每个 central 会持有一种特定大小的全局 mspan 列表,包括已分配出去的和未分配出去的。 每个 mcentral 对应一种 mspan,当工作线程的 mcache 中没有合适(也就是特定大小的)的mspan 时就会从 mcentral 去获取。
mcentral 被所有的工作线程共同享有,存在多个 goroutine 竞争的情况,因此从 mcentral 获取资源时需要加锁。mcentral 里维护着两个双向链表,nonempty 表示链表里还有空闲的 mspan 待分配。empty 表示这条链表里的 mspan 都被分配了object 或缓存 mcache 中。

对于小于16字节的对象(且无指针),Go 语言将其划分为了tiny 对象。划分 tiny 对象的主要目的是为了处理极小的字符串和独立的转义变量。对 json 的基准测试表明,使用 tiny 对象减少了12%的分配次数和20%的堆大小。tiny 对象会被放入class 为2的 span 中。
首先查看之前分配的元素中是否有空余的空间
如果当前要分配的大小不够,例如要分配16字节的大小,这时就需要找到下一个空闲的元素
tiny 分配的第一步是尝试利用分配过的前一个元素的空间,达到节约内存的目的。

Garbage Collection

现代高级编程语言管理内存的方式分为两种:自动和手动,像 C、C++ 等编程语言使用手动管理内存的方式,工程师编写代码过程中需要主动申请或者释放内存;而 PHP、Java 和 Go 等语言使用自动的内存管理系统,有内存分配器和垃圾收集器来代为分配和回收内存,其中垃圾收集器就是我们常说的 GC。主流的垃圾回收算法:
引用计数
追踪式垃圾回收
Go 现在用的三色标记法就属于追踪式垃圾回收算法的一种。

STW
stop the world, GC 的一些阶段需要停止所有的 mutator 以确定当前的引用关系。这便是很多人对 GC 担心的来源,这也是 GC 算法优化的重点。
Root
根对象是 mutator 不需要通过其他对象就可以直接访问到的对象。比如全局对象,栈对象中的数据等。通过Root对象。可以追踪到其他存活的对象。

Mark Sweep 两个阶段:标记(Mark)和 清除(Sweep)两个阶段,所以也叫

这个算法就是严格按照追踪式算法的思路来实现的:
Stop the World
Mark:通过 Root 和 Root 直接间接访问到的对象, 来寻找所有可达的对象,并进行标记。
Sweep:对堆对象迭代,已标记的对象置位标记。所有未标记的对象加入freelist, 可用于再分配。
Start the Wrold
这个算法最大的问题是 GC 执行期间需要把整个程序完全暂停,朴素的 Mark Sweep 是整体 STW,并且分配速度慢,内存碎片率高。

垃圾收集器从 root 开始然后跟随指针递归整个内存空间。分配于 noscan 的 span 的对象, 不会进行扫描。然而,此过程不是由同一个 goroutine 完成的,每个指针都排队在工作池中 然后,先看到的被标记为工作协程的后台协程从该池中出队,扫描对象,然后将在其中找到的指针排入队列。

染色流程:
一开始所有对象被认为是白色
根节点(stacks,heap,global variables)被染色为灰色
一旦主流程走完,gc会:
选一个灰色对象,标记为黑色
遍历这个对象的所有指针,标记所有其引用的对象为灰色
最终直到所有对象需要被染色。

Write Barrier - 混合屏障

插入屏障和删除屏障各有优缺点,Dijkstra 的插入写屏障在标记开始时无需 STW,可直接开始,并发进行,但结束时需要 STW 来重新扫描栈,标记栈上引用的白色对象的存活;Yuasa 的删除写屏障则需要在 GC 开始时 STW 扫描堆栈来记录初始快照,这个过程会保护开始时刻的所有存活对象,但结束时无需 STW。
Golang 中的混合写屏障满足的是变形的弱三色不变式,同样允许黑色对象引用白色对象,白色对象处于灰色保护状态,但是只由堆上的灰色对象保护。

Sweep 让 Go 知道哪些内存可以重新分配使用,然而,Sweep 过程并不会处理释放的对象内存置为0(zeroing the memory)。而是在分配重新使用的时候,重新 reset bit。
每个 span 内有一个 bitmap allocBits,他表示上一次 GC 之后每一个 object 的分配情况,1:表示已分配,0:表示未使用或释放。
内部还使用了 uint64 allocCache(deBruijn),加速寻找 freeobject。

Go 提供两种方式来清理内存:
在后台启动一个 worker 等待清理内存,一个一个 mspan 处理
当开始运行程序时,Go 将设置一个后台运行的 Worker(唯一的任务就是去清理内存),它将进入睡眠状态并等待内存段扫描。
当申请分配内存时候 lazy 触发
当应用程序 goroutine 尝试在堆内存中分配新内存时,会触发该操作。清理导致的延迟和吞吐量降低被分散到每次内存分配时。

STW
处理器 P (无论是正在运行代码的处理器还是已在 idle 列表中的处理器), 都会被被标记成停止状态 (stopped), 不再运行任何代码。 调度器把每个处理器的 M 从各自对应的处理器 P 分离出来, 放到 idle 列表中去。
对于 Goroutine 本身, 他们会被放到一个全局队列中等待。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

技术学习分享

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值