初始化
1 | /usr/local/go/src/runtime/runtime1.go |
runtime1.go
函数args整理命令行参数
1 | func args(c int32, v **byte) { |
os_linux.go
osinit确定cpu core数量
1 | func osinit() { |
proc.go
最关键的schedinit这里,几乎要关注的所有运行时环境初始化构造都在这里被调用
内存分配器、垃圾回收器、并发调度器的初始化细节需要涉猎很多专属特征
1 | // The bootstrap sequence is: |
接下来要执行runtime.main而不是用户逻辑入口函数main.main
1 | // The main goroutine. |
所有init函数都在同一个goroutine内执行
所有init函数结束后才会执行main.main函数
内存分配
在深入内存分配算法细节前,需了解基本概念
- 每次从操作系统申请一大块内存,减少系统调用
- 将申请到的大块内存按特定大小预先切分成小块,构成链表
- 为对象分配内存时,只须从大小合适的链表提取一个小块即可
- 回收对象内存时,将该小块内存重新归还原链表以供复用
- 如闲置内存过多,则归还部分内存给操作系统,降低整体开销
基础概念
申请到的内存块被分配了三个区域,在X64上分别是512MB,16GB,512GB大小。
arena
arena
区域就是我们所谓的堆区,Go动态分配的内存都是在这个区域,它把内存分割成8KB大小的页,一些页组合起来称为mspan。
bitmap
bitmap
区域标识arena
区域哪些地址保存了对象,并且用4bit标志位表示对象是否包含指针、GC标记信息。bitmap
中一个byte大小的内存对应arena
区域中4个指针大小(指针大小为 8B )的内存,所以bitmap区域的大小是512GB/(4*8B)=16GB
。
可以看到bitmap的高地址部分指向arena区域的低地址部分,也就是说bitmap的地址是由高地址向低地址增长的
spans
spans
区域存放mspan
(也就是一些arena分割的页组合起来的内存管理基本单元,后文会再讲)的指针
每个指针对应一页,所以spans
区域的大小就是512GB/8KB*8B=512MB
。
除以8KB是计算arena
区域的页数,而最后乘以8是计算spans
区域所有指针的大小。
创建mspan
的时候,按页填充对应的spans
区域,在回收object时,根据地址很容易就能找到它所属的mspan
。
内存管理单元
内存块
span: 是一个双端链表的形式,里面存储了它的一些位置信息组成的大块内存。
通过一个基地址+(页号*页大小),就可以定位到这个MSpan的实际内存空间。
- object: 将span按特定大小切分成多个小块,每个小块可存储一个对象
1 | mspan:Go中内存管理的基本单元,是由一片连续的8KB的页组成的大块内存。 |
Go1.9.2里mspan的Size Class共有67种,每种mspan分割的object大小是8*2n
的倍数,这个是写死在代码里的:
1 | // path: /usr/local/go/src/runtime/sizeclasses.go |
根据mspan的Size Class可以得到它划分的object大小。
比如Size Class等于3,object大小就是32B。
32B大小的object可以存储对象大小范围在17B~32B的对象。
而对于微小对象(小于16B),分配器会将其进行合并,将几个对象分配到同一个object中。
数组里最大的数是32768,也就是32KB,超过此大小就是大对象了,它会被特别对待这个稍后会再介绍。
顺便提一句类型Size Class为0表示大对象,它实际上直接由堆内存分配,而小对象都要通过mspan来分配。
对于mspan来说,它的Size Class会决定它所能分到的页数,这也是写死在代码里的:
1 | const _NumSizeClasses = 67 |
比如当我们要申请一个object大小为32B的mspan的时候,在class_to_size里对应的索引是3,而索引3在class_to_allocnpages数组里对应的页数就是1。
mspan结构体定义
1 | // path: /usr/local/go/src/runtime/mheap.go |
上图可以看到有两个S指向了同一个mspan,因为这两个S指向的P是同属一个mspan的。
所以通过arena上的地址可以快速找到指向它的S,通过S就能找到mspan,回忆一下前面我们说的mspan区域的每个指针对应一页。
1 | 假设最左边第一个mspan的Size Class等于10,根据前面的class_to_size数组 |
startAddr
直接指向arena区域的某个位置,表示这个mspan的起始地址allocBits
指向一个位图,每位代表一个块是否被分配了对象allocCount
则表示总共已分配的对象个数
内存管理组件
- cache: 每个运行期工作线程都会绑定一个cache,用于无所object分配
- central: 为所有cache提供切分好的后备span资源
- heap: 分配堆,主要是负责向系统申请大块的内存,为下层MCentral和MCache提供内存服务。他管理的基本单位是MSpan(若干连续内存页的数据结构)
Cache
mcache
:每个工作线程都会绑定一个mcache
,本地缓存可用的mspan
资源,这样就可以直接给Goroutine
分配,因为不存在多个Goroutine
竞争的情况,所以不会消耗锁资源。
mcache
的结构体定义:
1 | //path: /usr/local/go/src/runtime/mcache.go |
mcache
用Span Classes
作为索引管理多个用于分配的mspan
,它包含所有规格的mspan
。
它是_NumSizeClasses的2倍,也就是67*2=134
为什么有一个两倍的关系,前面我们提到过:为了加速之后内存回收的速度,数组里一半的mspan中分配的对象不包含指针,另一半则包含指针。
对于无指针对象的mspan在进行垃圾回收的时候无需进一步扫描它是否引用了其他活跃的对象。
mcache
在初始化的时候是没有任何mspan
资源的,在使用过程中会动态地从mcentral
申请,之后会缓存下来。
当对象小于等于32KB大小时,使用mcache
的相应规格的mspan
进行分配。
Central
mcentral
:为所有mcache
提供切分好的mspan
资源。
每个central
保存一种特定大小的全局mspan
列表,包括已分配出去的和未分配出去的。
每个mcentral
对应一种mspan
,而mspan
的种类导致它分割的object
大小不同。
当工作线程的mcache
中没有合适(也就是特定大小的)的mspan
时就会从mcentral
获取。
mcentral
被所有的工作线程共同享有,存在多个Goroutine竞争的情况,因此会消耗锁资源。
结构体定义:
1 | //path: /usr/local/go/src/runtime/mcentral.go |
empty
表示这条链表里的mspan
都被分配了object
,或者是已经被cache
取走了的mspan
这个mspan
就被那个工作线程独占了。而nonempty
则表示有空闲对象的mspan列表。每个central
结构体都在mheap
中维护。
简单说下mcache
从mcentral
获取和归还mspan的流程:
- 获取 加锁:从
nonempty
链表找到一个可用的mspan
;并将其从nonempty
链表删除;将取出的mspan
加入到empty
链表;将mspan
返回给工作线程;解锁。 - 归还 加锁:将
mspan
从empty
链表删除;将mspan
加入到nonempty
链表;解锁。
Heap
mheap
:代表Go程序持有的所有堆空间,Go程序使用一个mheap
的全局对象_mheap
来管理堆内存。
当mcentral
没有空闲的mspan
时,会向mheap
申请。
而mheap
没有资源时,会向操作系统申请新内存。
mheap
主要用于大对象的内存分配,以及管理未切割的mspan
,用于给mcentral
切割成小对象。
同时我们也看到,mheap
中含有所有规格的mcentral
,所以当一个mcache
从mcentral
申请mspan
时
只需要在独立的mcentral
中使用锁,并不会影响申请其他规格的mspan
。
mheap
结构体定义:
1 | //path: /usr/local/go/src/runtime/mheap.go |
分配流程
Go的内存分配器在分配对象时,根据对象的大小分成三类:小对象(小于等于16B)、一般对象(大于16B,小于等于32KB)、大对象(大于32KB)。
大体上的分配流程:
- 32KB 的对象,直接从
mheap
上分配; - <=16B 的对象使用
mcache
的tiny分配器分配; - (16B,32KB] 的对象首先计算对象的规格大小,然后使用
mcache
中相应规格大小的mspan
分配; - 如果
mcache
没有相应规格大小的mspan
,则向mcentral
申请 - 如果
mcentral
没有相应规格大小的mspan
,则向mheap
申请 - 如果
mheap
中也没有合适大小的mspan
,则向操作系统申请
并发调度
P、M、G关系
用户空间线程和内核空间线程之间的映射关系有:N:1、1:1和M:N
- N:1是说,多个(N)用户线程始终在一个内核线程上跑,context上下文切换确实很快,但是无法真正的利用多核。
- 1:1是说,一个用户线程就只在一个内核线程上跑,这时可以利用多核,但是上下文switch很慢。
- M:N是说,多个goroutine在多个内核线程上跑,这个看似可以集齐上面两者的优势,但是无疑增加了调度的难度。
Go的调度器内部有三个重要的结构:M,P,G
M:代表真正的内核OS线程,和POSIX里的thread差不多,真正干活的人
G:代表一个goroutine,它有自己的栈,instruction pointer和其他信息(正在等待的channel等等),用于调度。
P:代表调度的上下文,可以把它看做一个局部的调度器,使go代码在一个线程上跑,它是实现从N:1到N:M映射的关键。
图中看,有2个物理线程M,每一个M都拥有一个context(P),每一个也都有一个正在运行的goroutine。
P的数量可以通过runtime.GOMAXPROCS()来设置,它其实也就代表了真正的并发度,即有多少个goroutine可以同时运行。
图中灰色的那些goroutine并没有运行,而是出于ready的就绪态,正在等待被调度。P维护着这个队列(称之为runqueue),
Go语言里,启动一个goroutine很容易:go function 就行,所以每有一个go语句被执行,runqueue队列就在其末尾加入一个
goroutine,在下一个调度点,就从runqueue中取出(如何决定取哪个goroutine?)一个goroutine执行。
为何要维护多个上下文P?
因为当一个OS线程被阻塞时,P可以转而投奔另一个OS线程!
图中看到,当一个OS线程M0陷入阻塞时,P转而在OS线程M1上运行。调度器保证有足够的线程来运行所有的context P。
图中的M1可能是被创建,或者从线程缓存中取出。
当MO返回时它必须尝试取得一个context P来运行goroutine
一般情况下它会从其他的OS线程那里steal偷一个context过来,
如果没有偷到的话,它就把goroutine放在一个global runqueue
里然后自己就去睡大觉了(放入线程缓存里)。
Contexts们也会周期性的检查global runqueue
,否则global runqueue
上的goroutine永远无法执行。
另一种情况是P所分配的任务G很快就执行完了(分配不均),这就导致了一个上下文P闲着没事儿干而系统却任然忙碌。
但是如果global runqueue
没有任务G了,那么P就不得不从其他的上下文P那里拿一些G来执行。
一般来说如果上下文P从其他的上下文P那里要偷一个任务的话,一般就‘偷’runqueue的一半,这就确保了每个OS线程都能充分的使用。
调度流程简述
Go语言是原生支持语言级并发的,这个并发的最小逻辑单元就是goroutine。
goroutine就类似于Go语言提供的一种“用户态线程”
当然这种“用户态线程”是跑在内核级线程之上的。
当我们创建了很多的goroutine,并且它们都是跑在同一个内核线程之上的时候,就需要一个调度器来维护这些goroutine,确保所有的goroutine都使用CPU并且是尽可能公平的使用CPU资源。
这个调度器的原理以及实现值得我们去深入研究一下。
支撑整个调度器的主要有4个重要结构,分别是P、M、G、Sched前三个定义在runtime.h中,Sched定义在proc.c中。
- Sched结构就是调度器,它维护有存储M和G的队列以及调度器的一些状态信息等。
- M代表内核级线程,一个M就是一个线程,goroutine就是跑在M之上的;M是一个很大的结构,里面维护小对象内存cache(mcache)、当前执行的goroutine、随机数发生器等等非常多的信息。
- P全称是Processor,处理器,它的主要用途就是用来执行goroutine的,所以它也维护了一个goroutine队列,里面存储了所有需要它来执行的goroutine,这个P的角色可能有一点让人迷惑,一开始容易和M冲突,后面重点聊一下它们的关系。
- G就是goroutine实现的核心结构了,G维护了goroutine需要的栈、程序计数器以及它所在的M等信息。
理解M、P、G三者的关系对理解整个调度器非常重要,我从网络上找了一个图来说明其三者关系:
地鼠用小车运着一堆待加工的砖。
M就可以看作图中的地鼠,P就是小车,G就是小车里装的砖。
一图胜千言啊,弄清楚了它们三者的关系,下面我们就开始重点聊地鼠是如何在搬运砖块的。
启动过程
在关心绝大多数程序的内部原理的时候,我们都试图去弄明白其启动初始化过程,弄明白这个过程对后续的深入分析至关重要。
在asm_amd64.s文件中的汇编代码_rt0_amd64就是整个启动过程核心过程如下:
1 | CALL runtime.args(SB) |
启动过程做了调度器初始化runtime.schedinit
1 | 启动过程中的调度器初始化runtime.schedinit函数主要根据用户设置的GOMAXPROCS值来创建一批小车(P) |
再调用runtime.newproc
创建出第一个goroutine
1 | 这个goroutine将执行的函数是runtime.main,这第一个goroutine也就是所谓的主goroutine。 |
最后调用的runtime.mstart
就是内核线程M真正的执行上一步创建的主goroutine。
查看runtime.main
函数可以了解到主goroutine开始执行后,做的第一件事情是创建了一个新的内核级线程(地鼠M)
不过这个线程是一个特殊线程,它在整个运行期专门负责做特定的事情——系统监控(sysmon)。
接下来就是进入Go程序的main函数开始Go程序的执行。
至此,Go程序就被启动起来开始运行了。
一个真正干活的Go程序,一定创建有不少的goroutine,所以在Go程序开始运行后,就会向调度器添加goroutine,调度器就要负责维护好这些goroutine的正常执行。
创建goroutine(G)
在Go程序中,时常会有类似代码:
1 | go do_something() |
go关键字就是用来创建一个goroutine的,后面的函数就是这个goroutine需要执行的代码逻辑。
go关键字对应到调度器的接口就是runtime.newproc
。
runtime.newproc
干的事情很简单,就负责制造一块砖(G),然后将这块砖(G)放入当前这个地鼠(M)的小车(P)中。
每个新的goroutine都需要有一个自己的栈,G结构的 sched字段维护了栈地址以及程序计数器等信息,这是最基本的调度信息
也就是说这个goroutine放弃cpu的时候需要保存这些信息,待下次重新获得cpu的时候需要将这些信息装载到对应的cpu寄存器中。
假设这个时候已经创建了大量的goroutne,就轮到调度器去维护这些goroutine了。
创建内核线程(M)
Go程序中没有语言级的关键字让你去创建一个内核线程,你只能创建goroutine,内核线程只能由runtime根据实际情况去创建。
runtime什么时候创建线程?
以地鼠运砖图来讲,砖(G)太多了,地鼠(M)又太少了,实在忙不过来,刚好还有空闲的小车(P)没有使用,那就从别处再借些地鼠(M)过来直到把小车(p)用完为止。这
里有一个地鼠(M)不够用,从别处借地鼠(M)的过程,这个过程就是创建一个内核线程(M)。创建M的接口函数是:
1 | void newm(void (*fn)(void), P *p) |
newm函数的核心行为就是调用clone系统调用创建一个内核线程,每个内核线程的开始执行位置都是runtime.mstart函数。
参数p就是一辆空闲的小车(p)。
每个创建好的内核线程都从runtime.mstart
函数开始执行了,它们将用分配给自己小车去搬砖了。
调度核心
newm接口只是给新创建的M分配了一个空闲的P,也就是相当于告诉借来的地鼠(M)——“接下来的日子,你将使用1号小车搬砖,记住是1号小车;待会自己到停车场拿车。”
地鼠(M)去拿小车(P)这个过程就是acquirep
。
runtime.mstart
在进入schedul
之前会给当前M装配上P,runtime.mstart
函数中的代码
1 | } else if(m != &runtime.m0) { |
if分支的内容就是为当前M装配上P,nextp就是newm分配的空闲小车(P),只是到这个时候才真正拿到手罢了。
没有P,M是无法执行goroutine的,就像地鼠没有小车无法运砖一样的道理。
对应acquirep的动作是releasep,把M装配的P给载掉;活干完了,地鼠需要休息了,就把小车还到停车场,然后睡觉去。
地鼠(M)拿到属于自己的小车(P)后,就进入工场开始干活了,也就是上面的 schedule调用。简化schedule的代码如下:
1 | static void |
schedule函数被我简化了太多,主要是我不喜欢贴大段大段的代码,因此只保留主干代码了。
这里涉及到4大步逻辑:
runqget
地鼠(M)试图从自己的小车(P)取出一块砖(G),当然结果可能失败,也就是这个地鼠的小车已经空了没有砖了。findrunnable
如果地鼠自己的小车中没有砖,那也不能闲着不干活是吧,所以地鼠就会试图跑去工场仓库取一块砖来处理;工场仓库也可能没砖啊,出现这种情况的时候,这个地鼠也没有偷懒停下干活,而是悄悄跑出去,随机盯上一个小伙伴(地鼠),然后从它的车里试图偷一半砖到自己车里。如果多次尝试偷砖都失败了,那说明实在没有砖可搬了,这个时候地鼠就会把小车还回停车场,然后 睡觉休息了。如果地鼠睡觉了,下面的过程当然都停止了,地鼠睡觉也就是线程sleep了。wakep
到这个过程的时候,可怜的地鼠发现自己小车里有好多砖啊,自己根本处理不过来;再回头一看停车场居然有闲置的小车,立马跑到宿舍一看,你妹,居然还有小伙伴在睡觉,直接给屁股一脚,你妹,居然还在睡觉,老子都快累死了,赶紧起来干活,分担点工作。
,小伙伴醒了,拿上自己的小车,乖乖干活去了。有时候可怜的地鼠跑到宿舍却发现没有在睡觉的小伙伴,于是会很失望,最后只好向工场老板说:停车场还有闲置的车啊,我快干不动了,赶紧从别的工场借个地鼠来帮忙吧。
,最后工场老板就搞来一个新的地鼠干活了。execute
地鼠拿着砖放入火种欢快的烧练起来。
到这里貌似整个工场都正常的运转起来了,无懈可击的样子。
不对还有一个疑点没解决啊,假设地鼠的车里有很多砖,它把一块砖放入火炉中后,何时把它取出来,放入第二块砖呢?
难道要一直把第一块砖烧练好才取出来吗?那估计后面的砖真的是等得花儿都要谢了。
这里就是要真正解决goroutine的调度上下文切换问题。
调度点
runtime.park
当我们翻看channel
的实现代码可以发现,对channel
读写操作的时候会触发调用runtime.park
函数。
goroutine
调用park
后,这个goroutine
就会被设置位waiting状态放弃CPU。被park的goroutine
处于waiting
状态,并且这个goroutine
不在小车(P)中,如果不对其调用runtime.ready
,它是永远不会再被执行的。除了channel
操作外,定时器、网络poll等都有可能park goroutine
。
runtime.gosched
除了park
可以放弃cpu外,调用runtime.gosched
函数也可以让当前goroutine
放弃cpu,但和park
完全不同;gosched
是将goroutine
设置为runnable
状态,然后放入到调度器全局等待队列(也就是上面提到的工场仓库,这下就明白为何工场仓库会有砖块(G)了吧)。
除此之外就轮到系统调用了,有些系统调用也会触发重新调度。
Go语言完全是自己封装的系统调用,所以在封装系统调用的时候,可以做不少手脚,也就是进入系统调用的时候执行entersyscall
,退出后又执行exitsyscall
函数。
也只有封装了entersyscall
的系统调用才有可能触发重新调度,它将改变小车(P)的状态为syscall。
还记一开始提到的sysmon线程吗?
这个系统监控线程会扫描所有的小车(P),发现一个小车(P)处于了syscall的状态,就知道这个小车(P)遇到了goroutine在做系统调用,于是系统监控线程就会创建一个新的地鼠(M)去把这个处于syscall的小车给抢过来,开始干活,这样这个小车中的所有砖块(G)就可以绕过之前系统调用的等待了。
从goroutine的调度点可以看出,调度器还是挺粗暴的,调度粒度有点过大,公平性也没有想想的那么好。总之这个调度器还是比较简单的。
综上所述,goroutine上下文切换的调度时机可分为以下几个条件:
- goroutine阻塞(waiting)
- 显式调用runtime.gosched()
- 系统调用system call
1 | 协程一般都是这样工作的,但是从1.2开始为了避免饿死其它goroutine,就是在发生任意函数调用的时候,都有机会触发scheduler。 |
现场处理
goroutine
在cpu上换入换出,不断上下文切换的时候,必须要保证的事情就是保存现场
和恢复现场
;
保存现场就是在
goroutine
放弃cpu的时候,将相关寄存器的值给保存到内存中;恢复现场就是在
goroutine
重新获得cpu的时候,需要从内存把之前的寄存器信息全部放回到相应寄存器中去。
goroutine在主动放弃cpu的时候(park/gosched),都会涉及到调用runtime.mcall
函数,此函数也是汇编实现,主要将goroutine的栈地址和程序计数器保存到G结构的sched
字段中
mcall
就完成了现场保存。恢复现场的函数是runtime.gogocall
,这个函数主要在 execute
中调用,就是在执行goroutine
前,需要重新装载相应的寄存器。
垃圾回收
- v1.1 STW
- v1.3 Mark STW, Sweep 并行
- v1.5 三色标记法
- v1.8 hybrid write barrier
标记-清扫
标记-清扫算法是第一种自动内存管理,基于追踪的垃圾收集算法。
算法思想在 70 年代就提出了,是一种非常古老的算法。
内存单元并不会在变成垃圾立刻回收,而是保持不可达状态,直到到达某个阈值或者固定时间长度。
这个时候系统会挂起用户程序也就是 STW,转而执行垃圾回收程序。
垃圾回收程序对所有的存活单元进行一次全局遍历确定哪些单元可以回收。
算法分两个部分:标记(mark)
和清扫(sweep)
。
标记阶段表明所有的存活单元,清扫阶段将垃圾单元回收。可视化可以参考下图。
三色标记法
三色标记算法是对标记阶段的改进,原理如下:
- 1:起初所有对象都是白色。
- 2:从根出发扫描所有可达对象,标记为灰色,放入待处理队列。
- 3:从队列取出灰色对象,将其引用对象标记为灰色放入队列,自身标记为黑色。
- 4:重复 3,直到灰色对象队列为空。此时白色对象即为垃圾,进行回收。
何时触发 GC
在堆上分配大于32K byte
对象的时候进行检测此时是否满足垃圾回收条件,如果满足则进行垃圾回收。
1 | func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer { |
上面是自动垃圾回收,还有一种是主动垃圾回收,通过调用runtime.GC()
,这是阻塞式的。
1 | // GC runs a garbage collection and blocks the caller until the |
GC 触发条件
触发条件主要关注下面代码中的中间部分:forceTrigger || memstats.heap_live >= memstats.gc_trigger
。forceTrigger
是forceGC
的标志;
后面半句的意思是当前堆上的活跃对象大于我们初始化时候设置的 GC 触发阈值。在malloc
以及free
的时候heap_live
会一直进行更新,这里就不再展开了。
1 | // gcShouldStart returns true if the exit condition for the _GCoff |
垃圾回收流程
- 首先从 root 开始遍历,root 包括全局指针和 goroutine 栈上的指针。
- mark 有两个过程。
- 从 root 开始遍历,标记为灰色。遍历灰色队列。
- re-scan 全局指针和栈。因为 mark 和用户程序是并行的,所以在过程 1 的时候可能会有新的对象分配,这个时候就需要通过写屏障(write barrier)记录下来。re-scan 再完成检查一下。
- Stop The World 有两个过程。
- 第一个是 GC 将要开始的时候,这个时候主要是一些准备工作,比如 enable write barrier。
- 第二个过程就是上面提到的 re-scan 过程。如果这个时候没有 stw,那么 mark 将无休止。
源码步骤
标记 STW phase 1
在 GC 开始之前的准备工作。
1 | // gcStart 是 GC 的入口函数,根据 gcMode 做处理。 |
Mark
Mark 阶段是并行的运行,通过在后台一直运行 mark worker 来实现。
1 | func gcStart(mode gcMode, forceTrigger bool) { |
Mark 阶段的标记代码主要在函数 gcDrain() 中实现。
1 | // gcDrain scans roots and objects in work buffers, blackening grey |
Mark termination (STW phase 2)
mark termination 阶段会 stop the world。
函数实现在 gcMarkTermination()。1.8 版本已经不会再对 goroutine stack 进行 re-scan 了。细节有点多,这里不细说了。
1 | func gcMarkTermination() { |
清扫
清扫相对来说就简单很多了。
1 | func gcSweep(mode gcMode) { |
对于并行式清扫,在 GC 初始化的时候就会启动bgsweep()
,然后在后台一直循环。
1 | func bgsweep(c chan int) { |
不管是阻塞式还是并行式,都是通过 sweepone()函数来做清扫工作的。
内存管理都是基于 span 的, mheap_ 是一个全局的变量,所有分配的对象都会记录在 mheap_ 中。
在标记的时候,我们只要找到对对象对应的 span 进行标记,清扫的时候扫描 span,没有标记的 span 就可以回收了。
1 | // sweeps one span |
Select&Channel分析
Select
根据 select 中语句的不同选择了不同的优化路径:
- 空的 select 语句会被直接转换成 block 函数的调用,直接挂起当前 Goroutine;
- 如果 select 语句中只包含一个 case,就会被转换成 if ch == nil { block }; n; 表达式;
- 首先判断操作的 Channel 是不是空的;
- 然后执行 case 结构中的内容;
- 如果 select 语句中只包含两个 case 并且其中一个是 default,那么 Channel 和接收和发送操作都会使用 selectnbrecv 和 selectnbsend 非阻塞地执行接收和发送操作;
- 在默认情况下会通过 selectgo 函数选择需要执行的 case 并通过多个 if 语句执行 case 中的表达式;
在编译器已经对 select 语句进行优化之后,Go 语言会在运行时执行编译期间展开的 selectgo 函数,这个函数会按照以下的过程执行:
- 随机生成一个遍历的轮询顺序 pollOrder 并根据 Channel 地址生成一个用于遍历的锁定顺序 lockOrder;
- 根据 pollOrder 遍历所有的 case 查看是否有可以立刻处理的 Channel 消息;
- 如果有消息就直接获取 case 对应的索引并返回;
- 如果没有消息就会创建 sudog 结构体,将当前 Goroutine 加入到所有相关 Channel 的 sendq 和 recvq 队列中并调用 gopark 触发调度器的调度;
- 当调度器唤醒当前 Goroutine 时就会再次按照 lockOrder 遍历所有的 case,从中查找需要被处理的 sudog 结构并返回对应的索引;
然而并不是所有的 select 控制结构都会走到 selectgo 上,很多情况都会被直接优化掉,没有机会调用 selectgo 函数。
Channel
在 Go 语言中,一个最常见的也是经常被人提及的设计模式就是
不要通过共享内存的方式进行通信,而是应该通过通信的方式共享内存
Go 语言对于并发编程的设计与上述这种共享内存的方式完全不同,虽然我们在 Golang 中也能使用共享内存加互斥锁来实现并发编程,但是与此同时,Go 语言也提供了一种不同的并发模型,也就是 CSP,即通信顺序进程(Communicating sequential processes),
Goroutine 其实就是 CSP 中的实体,Channel 就是用于传递信息的通道,使用 CSP 并发模型的 Goroutine 就会通过 Channel 来传递消息。
上图中的两个 Goroutine,一个会负责向 Channel 中发送消息,另一个会负责从 Channel 中接收消息,它们两者并没有任何直接的关联,能够独立地工作和运行,但是间接地通过 Channel 完成了通信。
1 | type hchan struct { |
qcount、dataqsize、buf、sendx、recv 的主要作用就是构建底层的循环队列
- qcount 保存了当前 Channel 中的元素个数
- dataqsize 表示 Channel 中的循环队列的长度
- buf 指向了一个长度为 dataqsiz 的数组
- sendx 和 recvx 负责标识当前 Channel 的发送和接收已经处理到了数组中的哪个位置
- elemsize 和 elemtype 分别表示了当前 Channel 能够收发的元素类型和大小
- sendq 和 recvq 的主要作用就是存储当前 Channel 由于缓冲区空间不足而阻塞的 Goroutine 列表
1 | type waitq struct { |
Channel能够执行的操作其实也就只有创建、发送、接收和关闭几种
发送
channel发送数据类似ch <- i
表达式
这个表达式会被编译器解析成OSEND
节点,同样地在SSA
中间代码的生成期间,这些OSEND
节点也会被转换成chansend1
的函数调用
1 | func walkexpr(n *Node, init *Nodes) *Node { |
chansend
就是向Channel
中发送数据时最终会调用的函数,这个函数负责了发送数据的全部逻辑
在发送数据的逻辑执行之前会先为当前Channel
加锁,防止出现竞争条件的问题
如果当前Channel
结构已经通过closed
字段被标记成了关闭
那么在向该Channel
发送数据时就会直接 panic 报出一个非常常见的错误"send on closed channel"
并返回。
直接发送
如果目标Channel
被关闭并且已经有处于读等待的 Goroutine,那么chansend
函数会通过dequeue
从recvq
中取出最先先入等待的Goroutine
并直接向它发送数据
1 | if sg := c.recvq.dequeue(); sg != nil { |
随后的goready
函数会将等待接收数据的Goroutine
标记成Grunnable
并把该协程放到发送方所在的处理器P上等待执行
该处理器P在下一次调度时就会立刻唤醒消息接收方所在的协程。
缓冲区
向Channel
中发送数据时遇到的第二种情况就是创建的Channel
包含缓冲区并且Channel
中的数据没有装满,在这时就会执行下面的这段代码:
1 | func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool { |
在这里我们首先会使用chanbuf
计算出下一个可以放置待处理变量的位置,然后通过typedmemmove
将发送的消息拷贝到缓冲区中并增加sendx
索引和qcount
计数器,在函数的最后会释放持有的锁。
阻塞发送
最后要介绍的就是向Channel
发送但是遇到下游无法处理的『阻塞发送』了,当然如果传入的参数block=false
,那么就会直接释放持有的锁并返回false
表示这一次的发送不成功。
在常见的场景中,向Channel
发送消息的操作基本上都是阻塞的,在这时就会执行下面的代码,我们可以简单梳理一下这段代码的逻辑:
1 | func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool { |
- 调用
getg
获取发送操作时使用的Goroutine
协程; - 执行
acquireSudog
函数获取一个sudog
结构体并设置这一次阻塞发送的相关信息,例如发送的Channel
、是否在Select
控制结构中、发送数据所在的地址等; - 将刚刚创建并初始化的
sudog
结构体加入sendq
等待队列,并设置到当前Goroutine
的waiting
上,表示Goroutine
正在等待该sudog
准备就绪; - 调用
goparkunlock
函数将当前的Goroutine
更新成Gwaiting
状态并解锁,该Goroutine
可以被调用goready
再次唤醒; - 当前的
Goroutine
其实就会在这里陷入阻塞状态等待被调度器唤醒了; - 如果被调度器唤醒就会执行一些收尾的工作,将一些属性置零并且释放
sudog
结构体;
在最后,函数会返回 true 表示这一次发送的结束并继续运行当前 Goroutine 应该执行的逻辑。
接收
分析了Channel
发送数据的过程之后,我们就可以继续介绍数据处理的另一端,也就是数据的接收了,我们在 Go 语言中其实有两种不同的方式去接收管道中的数据:
1 | i <- ch |
这两种不同的方法经过编译器的处理都会变成ORECV
类型的节点,但是后者会在类型检查
阶段被转换成OAS2RECV
节点,我们可以简单看一下这里转换的路线图:
虽然这两种不同的接收方式会被转换成 chanrecv1 和 chanrecv2 两种不同函数的调用,但是这两个函数最终调用的还是 chanrecv。
chanrecv 处理数据接收时总共可以分成五种不同的情况,当我们从一个空 Channel 中接收数据时会直接调用 gopark 直接让出当前 Goroutine 处理器的使用权。
1 | func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) { |
如果当前的 Channel 已经被关闭并且缓冲区中不存在任何的数据,那么就会直接解锁当前的 Channel 并清除 ep 指针的数据。
直接接收
当 Channel 的 sendq 队列中包含处于等待状态的 Goroutine 时,我们其实就会直接取出队列头的 Goroutine,这里处理的逻辑和发送时所差无几,只是发送数据时调用的是 send 函数,而这里是 recv 函数:
1 | if sg := c.sendq.dequeue(); sg != nil { |
recv 函数的实现其实也与 send 非常相似,我们可以简单看一下这里执行的逻辑:
1 | func recv(c *hchan, sg *sudog, ep unsafe.Pointer, unlockf func(), skip int) { |
- 如果当前 Channel 中不存在未被处理的数据,就会调用
recvDirect
,这个函数会将sendq
中 Goroutine 存储的 elem 数据拷贝到目标内存地址中; - 如果当前 Channel 已经满了,就会通过
typedmemmove
将队列中的数据拷贝到接收方的内存地址中并将发送方的数据拷贝到队列中,这样我们可以释放一个阻塞的发送方 Goroutine; - 在最后会解锁 Channel 并调用 goready 函数将当前处理器的 runnext 设置成发送数据的 Goroutine,随后 <-ch 会返回并执行下面的逻辑;
上图展示了 Channel 在缓冲区已经没有空间并且 sendq 中存在等待的 Goroutine 时,使用 <-ch 发生的变化,sendq 队列中的第一个 sudog 结构中的元素会替换 sendx/recvx 索引所在位置的元素,原有的元素会被拷贝到接收 <-ch 结果的内存空间上。
缓冲区
另一种接收数据时遇到的情况就是,Channel 的缓冲区中已经包含了一些元素,在这时如果使用 <-ch 从 Channel 中接收元素,我们就会直接从缓冲区中 recvx 的索引位置中取出数据进行处理:
1 | func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) { |
如果接收数据的内存地不为空,那么就会直接使用typedmemmove
将缓冲区中的数据拷贝到内存中,在这之后会清除队列中的数据并完成收尾工作。
收尾工作就包括递增recvx
索引的数据,当发现索引超过了当前队列的容量时,由于这是一个循环队列,所以就会将它归零;除此之外,这个函数还会减少qcount
计数器并释放持有Channel
的锁。
阻塞接收
当 Channel 的sendq
队列中不存在等待的Goroutine
并且缓冲区中也不存在任何数据时,从管道中接收数据的操作在大多数时候就会变成一个阻塞的操作:
1 | func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) { |
这种阻塞的情况下其实有一个例外,也就是与select
语句结合使用时就可能会使用到非阻塞block=false
的接收操作,这段代码在这时就会获取一个 sudog 结构体设置到当前Goroutine
的waiting
上并将其入队到recvq
中。
除此之外,当前代码片段还会调用goparkunlock
函数立刻触发Goroutine
的调度,将当前Goroutine
的状态改成Gwaiting
并让出处理器的使用权,在这时Goroutine
就会处于休眠状态等待调度器的调度,重新执行时就会从gp.waiting = nil
处继续运行下面的代码对数据进行清理。
小结
我们简单梳理一下从 Channel 中接收数据时的几种情况:
- 如果 Channel 是空的,那么就会直接调用
gopark
挂起当前的Goroutine
; - 如果 Channel 已经关闭并且缓冲区没有任何数据,
chanrecv
函数就会直接返回; - 如果 Channel 上的
sendq
队列中存在挂起的Goroutine
,就会将recvx
索引所在的数据拷贝到接收变量所在的内存空间上并将 sendq 队列中 Goroutine 的数据拷贝到缓冲区中; - 如果 Channel 的缓冲区中包含数据就会直接从
recvx
所在的索引上进行读取; - 在默认情况下会直接挂起当前的
Goroutine
,将sudog
结构加入recvq
队列并更新Goroutine
的waiting
属性,最后陷入休眠等待调度器的唤醒;
在从管道中接收数据的过程中,其实会在两个时间点触发 Goroutine 的调度,首先空的 Channel 意味着永远接收不到消息,那么就会直接挂起当前 Goroutine,第二个时间点是缓冲区中不存在数据,在这时也会直接挂起当前的 Goroutine 等待发送方发送数据。
声明
文献资料摘录于
国内查看评论需要代理~