《Go语言计划与实现》Runtime部分的一些知识总结
关于Golang的一些碎片知识因为寻常看的时间比力杂,没有体系的学Golang,最近在看Go语言计划与实现,感觉一些风趣,有难度的知识点就在这里用自己的话总结一下,写下来了,重要还是供自己复习用的,有些我以为比力清晰的地方就没有写太多,感觉很含糊可以去看原书,讲得很透彻。
1. GMP模型
GMP模型及运行原理
G代表goroutine,M代表machine也叫做线程,P代表processor,处理器(不是CPU!!),G在线程上执行,由P举行调度。
这里大概讲一下调度器的运行过程,其中的很多细节都值得去细致看,如果有需要,可以到《Go 语言计划与实现》上阅读:
[*]在最开始,我们的调度器启动,会根据GOMAXPROCS来更新P的数目,一个P绑定一个M,同一时刻每个MP只能执行一个G,多余的G,其状态为_Grunnable形成队列在后面等候,也就是说,一个线程可以管理多个协程!
[*]创建goroutine,使用go [ 函数 ]的方式来创建一个goroutine,固然如果之前已经创建过goroutine,而且谁人goroutine已经变成空闲状态的话,就会从这些空闲状态的goroutine取一个,也就是复用goroutine,而不是创建,执行完这一步之后,会将当前goroutine加入到运行队列。(tips:其实这部分的源码很风趣,还不熟悉gmp调度的同学可以先忽略括号的内容,通过go关键字来获取一个新的G的时候,会先获取当前正在运行的G,同时通过这个G拿到P,然后看这个P维护的goroutine队列是否存在空闲的goroutine,如果不存在,就再去全局的goroutine队列内里找,如果还是不存在,那就会自己创建一个新的goroutine,最后再将栈指针,程序计数器等参数生存到当前goroutine中,最后更新状态然后将其推送到运行队列中)
[*]注意,刚加入运行队列的时候,这个goroutine还处于睡眠状态,没有真正运行,当满意条件的时候,他会被唤醒,然后到队首执行。
[*]接下来要介绍一下调度循环,也就是真正的goroutine执行了
[*]调度器启动之后,会先初始化G0(这是一个特殊的goroutine,创建新 Goroutine、执行栈切换、执行垃圾回收,都有它的身影),然后再进入调度循环。
[*]调度循环中,每次调度都会从全局和本地的运行队列中查找goroutine,如果都没找到,就会举行阻塞性的查找:从本地,全局运行队列中找,从网络轮询器中找,或者窃取待运行的goroutine,总之,这里一定会返回一个goroutine。
[*]找到之后,就会将其调度到线程M上(此时,注意了,GMP轻量的关键之一表现在这里,他将goroutine结构体中生存的相关数据规复到cpu的寄存器上,从而实现了用户级调度,而不会触发体系调用)
[*]在当前goroutine的执行已经结束的时候,又会打扫其中的字段,转换成_GDead状态,而且加入处理器的空闲列表中,然后切换到下一个待执行的goroutine。
调度
[*]主动挂起:调用runtime.gopark令当前goroutine暂停,而且移除运行队列,进入休眠,当满意一定条件,会再度唤醒,而且被加入运行队列
[*]体系调用:go语言封装了体系调用,使得在执行体系调用的时候,Golang能够做出相应的准备和清理(生存和规复goroutine以及其他的处理)
[*]协作式:可以理解为“我苏息一会,你们先上”,就是让出处理器,将自己状态变成_Grunnable,放到全局队列,然后重新开始调度;另一种是函数调用时,会在前方插入一个触发抢占的函数,查抄是否有发出抢占哀求的goroutine。
[*]基于信号的抢占式调度:通过 SIGURG 信号和修改寄存器,强制让出 CPU,解决精密循环 Goroutine 无法被调度的问题,提高调度公平性和 GC 服从。
2. Context上下文
context不是很形象,就和他的名字一样,上下文,它可以根据上下文的参数传递,函数调用等来执行操作,和goroutine联系精密。
最佳实践
[*]合理使用 context.WithCancel 取消 Goroutine,避免泄漏,减少资源占用
[*]使用 context.WithTimeout 限制任务时间,防止超时阻塞
[*]使用 context.WithDeadline 确保任务在固定时间前完成
[*]使用 context.WithValue 传递哀求作用域数据(这个不常用)
[*]context 只应该作为参数传递,而不是结构体字段,更不是全局变量
3. 计时器
外貌上是切片,内部使用四叉堆维护timer(计时器),堆顶元素是隔断截止时间最近的计时器,四叉堆,顾名思义,就是每个节点只能有四个子节点,Go语言通过体系监控和调度器来查抄timer是否到期,如果到期,则会执行回调。
最佳实践
[*] 使用 time.Timer 或 time.Ticker 举行定时任务
timer := time.NewTimer(2 * time.Second)
<-timer.C // 2秒后触发
[*] 避免使用 time.Sleep 影响调度
select {
case <-time.After(2 * time.Second):
fmt.Println("2秒后执行")
}
这样不会阻塞当前 Goroutine,实用于需要超时控制的场景。
[*] 使用 time.AfterFunc 注册回调
time.AfterFunc(1*time.Second, func() {
fmt.Println("1秒后触发")
})
实用于触发一次的定时任务。
[*] 使用 Ticker 处理周期性任务
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for range ticker.C {
fmt.Println("每秒执行一次")
}
Ticker 实用于高效的循环任务,避免每次创建新的 Timer。
[*] 及时停止 Timer 和 Ticker 以开释资源
timer := time.NewTimer(5 * time.Second)
timer.Stop() // 停止计时器,防止 Goroutine 泄漏
[*] 注意 select 防止 Goroutine 泄漏(timer.After内部会创建一个goroutine)
select {//错误做法
case <-time.After(3 * time.Second):
fmt.Println("超时")
case result := <-someChannel:
fmt.Println("收到结果", result)
}
[*] 避免 time.After 造成的 Goroutine 泄漏(timer.After内部会创建一个goroutine)
timer := time.NewTimer(5 * time.Second)
defer timer.Stop()
select {//true做法
case <-timer.C:
fmt.Println("超时")
case <-done:
fmt.Println("任务完成")
}
tips:
我在看原书的时候这两部分直接让我大脑宕机了:
[*]因为现在的计时器由网络轮询器管理和触发,它能够让网络轮询器立即返回并让运行时查抄是否有需要触发的计时器。 --《网络轮询器》部分
[*]这里将分析器的触发过程,Go 语言会在两个模块触发计时器,运行计时器中生存的函数:
[*]调度器调度时会查抄处理器中的计时器是否准备就绪;
[*]体系监控会查抄是否有未执行的到期计时器; --《定时器》部分
最开始让我感觉很矛盾,毕竟上,我以为这些触发是相辅相成的,调度器作为主导的触发计时器的组件,而体系监控作为辅助,网络轮询器作为辅助,当网络轮询器陷入poll_wait状态时,当前M线程陷入休眠,此时这个M陷入体系调用,不会去执行其他的goroutine,与其让这个M睡死,不如直接唤醒它,让他来执行timer,也可以避免另外两种触发timer的方法会创建新的M造成的资源浪费。
4. 网络轮询器
阻塞 I/O 模型:
发出一个HTTP哀求或者读写文件时,程序就会陷入阻塞状态,直到这个操作结束。举个例子,当对文件执行read体系调用时,应用程序会从用户态陷入内核态,内核会查抄文件形貌符是否可读;当文件形貌符中存在数据时,操作体系内核会将准备好的数据拷贝给应用程序并交回控制权。
非阻塞I/O:
第一次发出http或者读写文件哀求时,会向文件形貌符读取数据,而且立即返回EAGAIN 错误,这表示文件形貌符还在等候缓冲区的数据,也就是没准备好的意思,在第一次发出哀求之后,应用程序会不停轮询调用这个哀求,直到返回精确的值,此时就可以读取数据举行操作了。**为什么要这样做?**因为像第一种I/O模型,有阻塞状态,浪费了大量时间去等候,而非阻塞I/O能够立即返回,在等候的过程中,去执行其他的操作,提高CPU使用率。
文件形貌符:
一个I/O操作,一个tcp连接一个websocket连接或者一个http哀求的抽象的表示,用来区分这些哀求,方便在唤醒这些事件时执行精确的操作。(tips:HTTP哀求大概会存在复用文件形貌符的环境,但是与之对应的另有Stream ID来区分它们,具体的步调是这样的:**1.**网络轮询器检测到该文件形貌符可读。**2.**用 netpoll读取文件形貌符数据。**3.**发现该形貌符属于http哀求,使用http解析器解析Sream ID来区分不同的哀求,并分发给goroutine)
GO中的网络轮询器:
是一个全局的对象,将所有的文件形貌符用一个链表来维护,可以实现快速的插入操作,至于查找操作,在发送I/O哀求或者创建HTTP连接的时候(注册fd文件形貌符)的时候,还会向epoll注册一份事件,此时也会生存对应fd节点的指针,当监听到返回的时候,则会返回这个指针,以此来实现快速查找。
tips:
在网络轮询器初始化时,会涉及到一个pipe管道的初始化,还会创建一个epoll的文件形貌符fd用于监听所有的事件,而在之后,在一定条件下会调用runtime.netpollBreak方法,这个方法会通过这个管道发送停止等候某一事件的信号,然后让这个epoll中进入到poll_wait阻塞的M行止理其他的事件。
之前提到了向epoll注册事件,现实上他是怎样实现的轮询操作的呢?毕竟上,在轮询的过程中,如果发现某一个事件没有准备好,则会调用gopark让出这个goroutine,陷入休眠状态,此后不会对其举行无用的轮询,从而减少CPU的消耗,而epoll负责监控事件,只有在事件发生时,才会通知Go程序然厥后唤醒这个goroutine,同时,调用netpoll方法的时候,如果当前没有待处理的事件,就会陷入阻塞,也就是epoll_wait,这大概会阻塞很久,如果休眠的时候有timer到期了,但是此时其他的所有的线程都陷入了休眠,怎么办?
这个时候就会通过我们之前初始化的管道,调用runtime.netpollBreak方法发出停止信号,唤醒这个M去执行这个timer,另外,当有goroutine需要执行,但是找不到M的时候,此时也会调用runtime.netpollBreak发出信号,让epoll_wait返回,来执行这个goroutine。
重新理一下发出I/O哀求的思路,当在一个goroutine中发出I/O哀求的时候,将fd注册到epoll中,同时当前goroutine被阻塞,等候fd可读,此时,当前goroutine陷入休眠,线程M执行其他goroutine,直到epoll监听到这个fd可读,再将这个goroutine放入运行队列,等候被执行。
**epoll是谁在执行?**除了之后我们会提到的体系监控,当线程M没有待运行的goroutine的时候,我们的M会调用netpoll来监听fd,此时还会传入一个超时时间,这个时间由最早过期的timer来决定,包管不会错过定时任务,但是另有一种环境,就是如果当前没有timer,也没有goroutine在M上运行,于是M启动了netpoll,陷入阻塞,那么之后资源告急起来,新创建的goroutine找不到可供使用的M怎么办?此时runtime.netpollBreak就被会调用,向管道中发出信号,来停止poll_wait的阻塞,以此来让goroutine得到M资源。
截止日期:为每个fd设置截止日期,每个fd都有一个对应的timer,超时则执行回调函数取消该事件,并唤醒goroutine,使其做出相应的处理(重试或取消)
其实这部分光看《Go语言计划与实现》确实能够理解不少,毕竟是带着你去读源码,但是看了一遍这部分,再自己去读一下源码,也会别有一番劳绩。
5. Channel
Go中的 Channel 收发操作均遵循了先辈先出的计划:
[*]先从 Channel 读取数据的 Goroutine 会先吸收到数据。
[*]先向 Channel 发送数据的 Goroutine 会得到先发送数据的权利。
虽然现在go盼望能够实现无锁的channel来提高性能,但是现在底层的channel依旧使用锁(Lock)来维护channel队列先辈先出的特性。
数据结构
底层的数据结构中,有几个字段值得一提:
[*]buf — Channel 的缓冲区数据指针;
[*]sendx — Channel 的发送操作处理到的位置;
[*]recvx — Channel 的吸收操作处理到的位置;
[*]dataqsiz - Channel的缓冲区巨细
这几个字段,维护了Channel的信息担当与发出,buf指向缓冲区的首地点,sendx指向接下来要如果执行ch <- msg时,向管道发出信息存储的位置,随后sendx++,移动到下一个位置,recvx则指向接下来会将要被发出的信息的位置,然后recvx++,也就是移动到下一个位置。
看起来貌似sendx和recvx会移动?别担心,当sendx或者recvx超出了这个缓冲区的范围,也就是超出了dataqsiz的巨细,此时会归0,也就是移动到第一个字段,下面有个图资助理解:
初始状态:
[ _, _, _ ] sendx=0, recvx=0
ch <- 1
[ 1, _, _ ] sendx=1, recvx=0
ch <- 2
[ 1, 2, _ ] sendx=2, recvx=0
ch <- 3
[ 1, 2, 3 ] sendx=3, recvx=0(sendx == 3, 下一次会回绕)
<- ch
[ _, 2, 3 ] sendx=3, recvx=1
<- ch
[ _, _, 3 ] sendx=3, recvx=2
ch <- 4
[ 4, _, 3 ] sendx=0 (回绕), recvx=2
ch <- 5
[ 4, 5, 3 ] sendx=1, recvx=2
这样就是我们的一个循环队列了。
tips:
当队列已满,我们需要向其中发送信息怎么办?此时会将当前goroutine陷入休眠,此时如果没有其他goroutine担当channel中的数据,此时也就形成了死锁。
固然,如果当前channel为空,而我们要从中谁人担当消息,当前goroutine也会陷入休眠,直到有其他goroutine向channel中发送了信息。
Q:如果有多个goroutine发送或担当信息,是怎么排队的?A:底层的Channel数据结构中另有一个成员recvq和sendq其中存储了goroutine的队列,以此来确保先后顺序,其中另有一个sudog的结构体对象,用来真正存储goroutine的链表和相关信息。
当发生发生这种环境时,如果关闭这个channel,就会解决死锁的问题,但是如果时向已经满的channel发送数据造成的死锁,那么会引发panic。
非阻塞channel
以上是阻塞式的channel,但是如果想要实现非阻塞的怎么办?很简单,和select一起使用就行,举个例子:
select {
case ch <- value:
fmt.Println("发送成功")
case val := <-ch:
fmt.Println("收到数据:", val)
default:
fmt.Println("无操作,通道不可读也不可写")
}
最佳实践
最佳实践示例实用场景非阻塞 channelselect { case <-ch: }轮询、日记网络精确关闭 channelclose(ch) + for range ch生产者-消耗者模型使用 sync.WaitGroupwg.Add(1) + wg.Wait()等候所有 Goroutine 完成使用缓冲 channelmake(chan int, 3)提高吞吐量避免 nil channelch := make(chan int)确保 channel 被精确初始化使用 context 控制退出而不是channelcontext.WithCancel任务取消、超时控制使用 fan-in 和 fan-out(不知道可以自己搜一下)merge() / worker()并发处理任务 6. for和range
[*]如果使用for-range遍历切片,并不停在后面追加元素,并不会造成无限循环,只会遍历到切片最初的长度,而如果使用for i := 0; i < len(slice); i ++的时候,则会陷入无限循环。
[*]如果使用for i, v := range slice,此时的i和v均为暂时变量,如果将地点v的地点加入一个新的数组,则会出问题。
[*]for range遍历哈希表的时候,顺序随机。
7. select
select,与switch类似,但是select必须与channel搭配使用,他会在满意条件的case中随机选择一个举行执行,和select搭配,channel就可以实现非阻塞的收发数据。
值得一提的是,如果不存在满意条件的case,就会阻塞下去,这时候可以加一个default字段,表示所有的case都不满意条件的case,此时select中的channel会非阻塞的执行
数据结构
select不存在相应的结构体,但是case存在对应的数据结构:
type scase struct {
c *hchan // chan
elem unsafe.Pointer // data element
}
实现
[*]将所有的 case 转换成包含 Channel 以及类型等信息的 runtime.scase 结构体;
[*]调用运行时函数 runtime.selectgo 从多个准备就绪的 Channel 中选择一个可执行的 runtime.scase 结构体;
[*]通过 for 循环生成一组 if 语句,在语句中判断自己是不是被选中的 case;
runtime.selectgo是怎样实现的?让我们来分析分析:
起首最开始,我们要先确定这些case执行的顺序(随机确定),然后决定每个通道加锁的顺序,随后再加锁完成之后就会进入selectgo函数的主循环:
[*]第一阶段是探求已经就绪的case,如果case不包含channel,就会跳过,也就是不会吸收到数据,如果当前case有channel的时候,就会尝试去拿数据,但是如果没有数据可以读,而且管道已经关闭了。如果需要向channel中发送数据,会先查抄通道是否关闭,如果关闭,则会触发panic,否则,正常判断。如果遇到了default,表示之前的cas而都没有执行,所以会直接执行default事件。
[*]如果没有default,而且没有找到对应的已经就绪的case,那么就会进入到第二阶段,将当前的goroutine加入到各个管道的等候队列中举行等候,进入休眠状态。
[*]一旦其中的一些channel就绪了,就会唤醒goroutine,进入第三阶段。我们需要先从goroutine中拿取节点sudog来找到对应的就绪的channel,如果找到一个channel,剩下的case都不会被执行,但是由于其他的channel大概也是就绪状态,我们还需要将这些废弃的节点sudog从这些没有执行的channel内里删除(如果不知道sudog是什么,可以回头看看channel的内容),但是此时也依旧是通过遍历去查找所有的case,比对,然后对选择的case的索引举行生存,其余的则删除。这个时候,我们就完成了我们的选择了。
8. 体系监控
体系监控,是Go语言内部一个随着程序的启动而启动的循环,也会随着程序的停止而停止,它独立运行在一个线程中,在循环的内部会轮询网络、抢占长期运行或者处于体系调用的 Goroutine 以及触发垃圾回收。
查抄死锁
体系监控通过runtime.checkdead 来查抄是否出现了死锁,具体分为以下三步
[*]查抄是否有正在运行的线程
[*]查抄是否有正在运行的goroutine
[*]查抄是否存在正在等候的timer
为什么是这三步应该很好想,这里就不作介绍了。
运行计时器
计时器现实上也可以由这个体系监控来触发。
怎么行止理计时器?体系监控会通过遍历所有P去探求他们的timers的堆顶元素,而且返回最早到期的时间,如果没有需要触发的计时器,那么会陷入休眠,但是如果这个时间极短,会等候直到时间过期。除此之外,如果发现当前timer已经过期,这说明timer对应的P中所有goroutine都在忙碌,此时会启动一个新的M线程去执行这个timer,以避免计时器延迟过大。
轮询网络
如果上一次轮询网络已经过去了 10ms,那么体系监控还会在循环中轮询网络,查抄是否有待执行的文件形貌符,此时会非阻塞的调用netpoll(与之前讲的阻塞调用netpoll不一样)来查抄是否有fd准备就绪,这里的体系监控也是一种轮询网络的方法。
抢占处理器
体系监控还会遍历全局的处理器,来查抄防止某一个goroutine占用M的时间过长,以此防止饥饿问题。
垃圾回收
这个在垃圾回收的部分详细解释吧。
9. 内存分配器
大多数编程语言的内存分配重要有两种方法:线性分配和空闲链表分配,各有优缺点。Go 语言的内存管理联合了这两种方法的长处,并采用 多级缓存策略 来提高内存分配的服从。
计划
Go 语言的内存管理采用 HeapArena 作为根本单元,将一整块连续的大内存划分成多个 HeapArena,并通过多级结构举行管理,其层级如下:
HeapArena → mspan → 偏移量
其中,HeapArena 是堆管理的根本单元,每个 HeapArena 会被进一步划分为多个 mspan,而 mspan 负责管理巨细相近的对象。
内存分配的多级缓存
Go 语言的内存分配涉及多个缓存层级,以减少直接向操作体系申请内存的开销,提高分配服从。重要的缓存层级包括:
[*]线程缓存(mcache):每个线程(M)持有一个独立的 mcache,用于快速分配小对象,避免频繁加锁。
[*]中心缓存(mcentral):全局 136 个 mcentral 共享所有 mcache,当线程缓存中的内存不足时,会从 mcentral 哀求新的内存块。但由于多个线程共享中心缓存,访问时需要加锁。
[*]页堆(heap):整个堆的核心管理结构,负责管理所有 HeapArena,当 mcentral 无法满意内存哀求时,会从 heap 申请新的 mspan。如果 heap 也无法满意,则会向 操作体系 申请新的内存。
在 中心缓存(mcentral) 中,mspan 负责管理不同巨细的对象,而在页堆级别,则使用 HeapArena 作为更大粒度的管理单元。当 mcentral 向 heap 申请内存时,现实上是将 HeapArena 进一步划分为 mspan。
此外,线程缓存(mcache) 还包含一个微分配器,通过偏移量来快速定位和分配微对象的内存
小对象与大对象的分配路径
[*]小对象(<= 32KB):优先从 mcache 分配,如果 mcache 不足,则依次从 mcentral 和 heap 申请。终极如果 heap 也无法满意,则哀求操作体系分配新的 HeapArena。
[*]大对象(> 32KB):不会经过 mcache 或 mcentral,而是直接在 heap 上分配内存。
这样计划的好处是,在高并发场景下,绝大多数的小对象分配都可以在 mcache 层完成,避免了全局加锁,提高了内存分配的服从。
10. 垃圾网络器
三色标记法
组成
黑色:活跃的对象,包括不存在引用任何外部指针或根节点可达的对象。
灰色:活跃的对象,被其他对象引用,同时也大概引用了其他白色的元素,所以需要对他举行遍历查抄。
白色:大概为垃圾,所以需要遍历查抄。
工作原理
[*]从灰色对象中选一个变成黑色。
[*]将黑色对象指向的所有对象标记成灰色。
[*]循环上述过程
终极,所有引用和被引用的对象都变成了黑色,剩下的白色没有被任何对象引用,也没有引用其他对象,所以白色可以视为垃圾,应当被垃圾网络器(GC)回收。
到现在为止,一切正常,但是如果我们思量到并发性,仅仅是这样的三色标记法是无法包管垃圾被精确回收的,比如我们来看一个例子,此时有A,B,C三个对象,A引用了B,C现在是垃圾:
[*]三色标记法执行,将A标记为黑色,B为灰色,C为白色。
[*]程序运行,A引用C,并取消对B的引用,B为垃圾。
[*]三色标记法继续执行,ABC终极全都为黑色,无法精确回收垃圾。
怎么解决?
虽然可以用STW(Stop The World),但是过于影响性能,导致程序卡顿,所以这里我们就可以引入我们的屏障技术
屏障技术
为了在并发中的精确标记垃圾,有两种三色不变性需要满意:
[*]强三色不变性 — 黑色对象不会指向白色对象,只会指向灰色对象或者黑色对象;
[*]弱三色不变性 — 黑色对象指向的白色对象必须包含一条从灰色对象经由多个白色对象的可达路径
Dijkstra 插入写屏障:在用户执行写操作的时候,即修改了指针的指向,此时会触发写屏障将被指向的对象标记为黑色,包管精确被删除,虽然简单且实现了强三色不变性,但是也有一定局限性,比如说已经被标记为灰色的对象,在取消引用之后不会被标记为垃圾。
Yuasa 删除写屏障:在用户执行删操作的时候,即删除了A对B的引用,如果此时B为白色,那么就会将其标记为灰色,因为此时这个B另有一定大概被使用,不能直接删除,所以通过这个屏障,包管了弱三色不变性,防止被某一对象被错误回收,但是局限性依旧和上面的插入写屏障一样,大概会导致垃圾无法精确被回收。
垃圾网络的方法
[*]增量垃圾网络:配合三色标记法,不使用STW的方案,而是将STW分成n个时间片,与应用程序交替执行,同时也要开启写屏障,相比STW,性能肯定是更好了。
[*]并发垃圾网络:开启读写屏障,使用多核优势与程序并行,能够最大程度的减少对应用程序的影响,但是并发垃圾网络并不总是并发的,而且在部分阶段还是会暂停应用程序的。同时,并发执行垃圾网络引起的开销也是不能忽略的一点。
说了这么多,Go的垃圾网络器是怎么实现的?
Go中的垃圾网络器(GC)
混合写屏障
联合了删除写和插入写屏障,同时,如果当前栈还没有被扫描,新分配的对象标记为黑色,删操作,标记被删对象为灰色,由此一来,我们的在栈上的新对象在分配时都会主动被标记为黑色,所以不需要Stop The World去扫描整个栈,同时,也只有在栈未扫描是才会做标记,避免了无用的操作。
垃圾网络过程
[*]暂停程序,确保没有运行中的goroutine干扰,如果当前GC是强制触发,还需要清理还未被清理的内存管理单元,然后将状态切换为_GCmark,进入标记状态,随后开启用户协助程序和写屏障,并将根对象入队。
[*]规复程序,并发地开始标记。
[*]暂停程序,确保不会再有对象改变,状态切换至 _GCmarktermination 并关闭辅助标记的用户程序,并清理处理器上的线程缓存。
[*]将状态切换至 _GCoff 开始清理阶段,关闭写屏障。
[*]规复程序,新创建的对象标记为白色(不会影响当前GC),后台并发回收垃圾,当goroutine申请新的内存管理单元就会触发清理。
垃圾网络的触发方式
前情提要:所有出现 runtime.gcTrigger 结构体的位置都是触发垃圾网络
[*] 程序启动时,会启动一个goroutine:go forcegchelper(),
这个goroutine负责强制触发垃圾回收,简单来说,就是调用 runtime.gcStart 尝试启动新一轮的垃圾网络,但是并不只是单纯的for循环,这个goroutine在循环中会调用 runtime.goparkunlock 主动陷入休眠,大多数时间,这个goroutine都是睡眠的状态,怎样将它唤醒呢?
之前提到的体系监控, runtime.sysmon 会构会构建 runtime.gcTrigger 并调用 runtime.gcTrigger.test 方法判断是否需要触发垃圾回收,如果需要触发,体系监控会将 runtime.forcegc 状态中持有的 Goroutine 加入全局队列等候调度器的调度。
[*] 手动触发,用户程序会通过 runtime.GC 函数在程序运行期间主动通知运行时执行,该方法在调用时会阻塞调用方直到当前垃圾网络循环完成,在垃圾网络期间也大概会通过 STW 暂停整个程序。
[*] 申请内存时,之前内存分配器的章节提到了微对象,小对象,大对象,这三类对象创建时都大概触发垃圾回收
垃圾网络的运行过程
之前提到了垃圾网络的大致运行过程和触发方式,接下来细说一下:
[*]在启动垃圾网络的时候,会调用 runtime.gcStart方法,这很复杂,显然不能一句话解决,起首会调用 runtime.gcTrigger.test 查抄是否满意了垃圾网络的条件,与此同时,还会调用 runtime.sweepone 清理已经被标记的内存单元,试图清理已经被标记的内存单元,并不一定会清理完,固然,这只是开始。
[*]当验证完成了,会调用 runtime.stopTheWorldWithSema 来暂停程序(这是真的StopTheWorld),并调用 runtime.finishsweep_m 包管清理完成之前的被标记的内存单元的工作,**为什么会有之前的垃圾没有清理?**这是因为我们之前GC完成之后,仅仅是将垃圾标记了出来,并没有真正的去清理它,而真正的去清理它需要在一定条件下触发,而且是并发运行的,这种环境下,我们第二次GC的时候,垃圾很大概是没有清理完的,所以我们需要去将上一次遗留的垃圾清理掉。
[*]在完成了全部的准备工作,接下来就是执行了,此时我们会将全局的垃圾网络状态修改到 _GCmark ,然后举行一系列初始化我们的标记环境(这里初始化了什么环境可以回到原书去看看),最后会重启我们的程序,在后台并发地去标记我们的事件了。
[*]**后台标记干了些什么?**在上一步完成之后,我们的程序会调用 runtime.gcBgMarkStartWorkers 启动与M数目对等的标记任务goroutine,这些goroutine不会无意义的循环,而是会陷入休眠等候调度器的唤醒,执行标记任务的goroutine有三种模式,专用模式(独占处理器),分时模式(cpu使用率低,启动该类型goroutine使其到达使用率),空闲模式(当前处理器没有可以运行的goroutine,会运行垃圾网络的任务直到被抢占)
[*]固然,每一种模式都会去调用 runtime.gcDrain 扫描并标记所有可达对象,一旦所有的工作都陷入等候,就可以以为标记工作已经完成,此时会调用 runtime.gcMarkDone,除此之外,写操作都会调用 runtime.gcWriteBarrier,也就是写屏障;而新创建的对象,则是通过调用 runtime.gcmarknewobject 完成标记,这部分另有更详细的讲述,可以看原文的这个地方。
[*]最后,当所有可达对象都被标记后,该函数会将垃圾网络的状态切换至_GCmarktermination,固然如果本地队列中还存在没有处理完的任务,则会被放入全局队列等候处理。随后所有的任务都处理完成,我们便会举行我们标记阶段最后的处理,关闭写屏障,切换状态,唤醒所有协助垃圾网络的用户程序,规复goroutine的调度而且调用 runtime.gcMarkTermination 进入标记停止阶段。
[*]最后的最后,我们会在一定条件下并发执行垃圾的清理工作,我们之前的标记阶段,重要是为了将垃圾标记出来,而现在重要的任务就是将垃圾清理了,但是此时并不会主动的去调用GC举行垃圾处理,而是在一定条件下会执行相应的垃圾处理,比如说我们再执行一次GC,这就是一种触发垃圾回收的条件,另有就是用户申请内存的时候也会触发垃圾的回收,而所有的回收工作终极都是靠 runtime.mspan.sweep 完成的。
Other
垃圾回收工作会在扫描的时候,将扫描的对象分配给不同的P维护的gcWork,以此来提高垃圾处理的并行性,只管云云,也大概会遇到负载不均衡的问题。这个时候,当一个P维护的gcWork的持有的任务过多时,runtime.gcWork.balance 会将P的部分任务放到全局队列。
标记辅助技术:
该技术是为了防止应用程序分配内存的速率超出标记的速率,标记辅助的原则是:
分配多少内存,就需要完成多少标记任务。
具体来讲,每一个goroutine在分配内存的时候,都相称于欠下了债务,如果它的债务欠的太多,变成了负数,这个goroutine就需要暂停自己的任务,去执行标记工作,直到债务还清(弥补了自己分配的内存巨细的标记),但是如果在GC负载很低的环境下,这不是会拖慢goroutine的运行吗?现实上,这里另有一个全局信用字段,如果当前GC负载低,goroutine分配内存的速率远比不上GC标记的速率,那么此时的全局信用的值就会很高,否则,就会很低,当一个goroutine欠下债务的时候,会起首去用全局信用来弥补,如果全局信用不够,那么只能这个goroutine自己去执行标记工作了。
由此一来,就实现了高吞吐,低延迟的平衡。
11. 栈内存管理
逃逸分析
在当前函数返回的时候,其中的本地变量会被回收,而下面这个C语言的函数例子,就是错误的返回方式,由于i在函数返回时会被回收,所以当前返回的指针是不正当的,显然,这个语法也通不过编译阶段。
int *dangling_pointer() {
int i = 2;
return &i;
}
回到Go语言,go的编译器使用逃逸分析来确定当前的内存分配应该分配到栈上,还是堆上(包括new,make和字面量等方法隐式分配的内存),由此一来,便能很轻松的避免上面的问题。
Go 语言的逃逸分析遵循以下两个不变性:
[*]指向栈对象的指针不能存在于堆中。
[*]指向栈对象的指针不能在栈对象回收后存活。
逃逸分析是静态分析的一种,而静态分析不是我们今天的重点,这里简要介绍一下,就是在不运行代码的环境下,分析程序的结构、逻辑和大概行为的方法。它通常在编译期完成,通过查抄代码的语法、类型、数据流、控制流等信息,发现大概的错误、优化点或安全隐患。
在编译器解析了整个go语言源文件之后,会构造出一个抽象语法树,编译器则可以通过这个抽象语法树来分析数据流,然后构建一个有向图表示变量间的分配关系,然后根据逃逸分析的不变性,决定变量分配到栈还是堆。
栈的结构
分段栈:
分段栈是Go 语言在 v1.3 版本之前的实现,栈空间通过链表的情势串起来,长处是能够按需为当前goroutine分配内存,而且可以及时减少内存占用。
但是缺点也很明显,热分裂,如果当前栈已经靠近满的状态,而在一个循环内里反复调用一个函数,此时就会不停触发栈扩容和栈开释,造成极大的消耗,而且,在触发栈扩容紧缩的时候,都会出现额外的工作量。
连续栈:
当前Go语言的实现,初始巨细为2KB,当栈空间不足时,分配一个更大的栈空间,并将原栈的数据拷贝到新分配的栈中,会履历以下几个步调:
[*]分配新的栈空间。
[*]旧栈数据复制到新栈。
[*]将指向旧栈中的数据的指针指向新栈中的数据。(放在堆上的元素不需要管)
[*]销毁并回收旧栈的空间。
连续栈由于数据的拷贝和指针的转向增长了扩容时的开销,但也解决了热分裂引起的性能问题,在GC回收时,如果发现当前栈只被使用了四分之一,那就将内存减小一半,这样就不会频繁的触发扩容缩容机制了。
工作原理
type stack struct {
lo uintptr
hi uintptr
}
hi表示栈的起始位置,lo表示结束位置。
栈的内存由mspan来追踪,最后由堆举行统一管理,只管云云,栈内存依旧遵循着先辈后出的原理,也就是作为栈来使用由此一来,我们也可以理解为什么作者说可以将go中的栈内存视为在堆上举行分配的了。如果还是不太明确,可以去思考一下runtime.stackinit的源代码以及其中涉及到的结构体。
固然,栈内存分配的时候也和堆一样有内存缓存策略,当分配小内存的时候,从全局栈缓存和本地缓存获取内存;分配大内存时,会优先向全局的打栈缓存 runtime.stackLarge 中获取内存空间,否则就会自己去堆上申请一块空间。
在每一次调用函数的时候,都会查抄当前的栈空间是否充足,如果不够,就会触发扩容,会生存一些栈的相关数据并调用 runtime.newstack 创建新的栈,在这里还需要做一些准备工作,完成之后,才会真正的拷贝,调整指针需要调用 runtime.adjustpointer,而最后通过 runtime.stackfree 开释原始栈的内存空间。
而栈缩容,毕竟上也是通过创建新的栈,并拷贝数据来实现的。
12. 锁
这部分是我最开始看的,当时还没写笔记,索性最后写了。
Mutex
数据结构
type Mutex struct {
state int32
semauint32
}
state是mutex的状态,由四个字段组成:
mutexLocked — 表示互斥锁的锁定状态
mutexWoken — 表示是否有被唤醒的等候者,防止重复唤醒
mutexStarving — 当前的互斥锁是否进入饥饿模式
waitersCount — 当前互斥锁上等候的 Goroutine 个数
Lock
锁虽然存在自旋的部分,但是长时间自旋只会消耗cpu的资源,所以go语言中对自旋做出了一些限制:
[*]处于普通模式
[*]运行在多cpu呆板上
[*]当前goroutine自旋次数小于4
[*]当前呆板上存在一个正在运行的而且运行队列为空的P。
自旋后,还会计算当前的锁的状态(state),随后使用CAS(Compare And Swap)来更新状态,如果更新失败,表示互换失败,此时由另一个goroutine已经更新了这个状态,所以无法更新,随后继续下一次循环。
如果此时CAS乐成了,我们并不能说他就可以拿到锁了,如果当前锁未处于饥饿模式或者锁定状态,就说明锁定乐成,否则进入另一条分支:排队,随后调用 runtime_SemacquireMutex 让当前 goroutine 挂起,等候信号量将他唤醒。
被唤醒之后,会判断是否处于饥饿状态,如果是,更新状态,进入下一轮循环,值得一提的是,当unlock的时候,会直接判断当时是否由处于饥饿状态的goroutine,然后才会发出信号唤醒相应的goroutine,所以这一轮设置的饥饿模式,在下一轮开释锁的时候才会生效。
UnLock
这部分实现逻辑相对比力简单,起首会判断当前处于饥饿模式,如果有,则会才去手递手的情势唤醒饥饿模式的goroutine,否则,直接发出信号,唤醒一个goroutine
读写锁
读写锁是一种更细粒度的锁,当使用读锁时,写锁会被阻塞,而读锁不会,当使用写锁时,读写锁都会被阻塞。
这部分逻辑和Mutex差不多,只是多了一个读锁数目的字段,比力简单,实用于读多写少的环境,在这里不多赘述,有爱好可以去原书阅读。
WaitGroup
sync.WaitGroup 对外暴露了三个方法:sync.WaitGroup.Add、sync.WaitGroup.Wait 和 sync.WaitGroup.Done。
相信使用过wg的朋友都知道这些方法有什么用,Done仅仅是向Add方法传入了-1,这里不多说,而sync.WaitGroup.Add和sync.WaitGroup.Wait 的实现也比力简单
Add方法会更新wg中的计数器的数目,与此同时,在Add方法的最后,会有一个for循环,向所有正在等候的goroutine发送一个信号来唤醒他们,举个例子,比如当传入的参数为负数,也就是Done方法,此时wg的计数为1,所有调用wait方法的goroutine陷入睡眠,然后通过传入负数,可以唤醒这些goroutine,继续工作。
Wait方法重要就是等候wg的计数器归零,陷入休眠,等候信号,一旦被唤醒,就会退出。
Once
sync.Once.Do 是 sync.Once 结构体对外唯一暴露的方法,传入一个函数,如果当前结构体的do方法已经被执行,那么则不会执行,如果还没有执行过,就会执行,简单来说,就是这个Do方法只会生效一次,这里有一个误区,部分人以为这里只是让某一个函数只能执行一次,现实上无论你传入什么函数,终极只会有一个函数被调用。
另外,在do方法中,还会调用doslow这个方法,在内部会加上互斥锁包管同一时刻只有一个goroutine执行。
Cond
sync.Cond 对外暴露的 sync.Cond.Wait 方法会将当前 Goroutine 陷入休眠状态,这个方法内部很简单,先将计数器加一,随后被加入等候的goroutine链表,陷入睡眠,等候唤醒。
怎样唤醒?sync.Cond.Signal 和 sync.Cond.Broadcast 就是用来唤醒陷入休眠的 Goroutine 的方法,Signal会唤醒队列最前面的一个goroutine,而broadcast,如其名,会按次序唤醒所有等候的goroutine。
感觉这玩意不常用。
剩下的同步原语感觉真不熟
ErrGroup
联合了wg,once的精华,通过调用 golang/sync/errgroup.Group.Go 方法启动goroutine,然后使用golang/sync/errgroup.Group.Wait 等候所有goroutine执行完毕,并查看是否存在错误。
Semaphore
信号量,也可以用来作为流量控制之类的事情,感觉跟Sentinel挺像的。
[*]golang/sync/semaphore.NewWeighted 用于创建新的信号量。
[*]golang/sync/semaphore.Weighted.Acquire 阻塞地获取指定权重的资源,如果当前没有空闲资源,会陷入休眠等候。
[*]golang/sync/semaphore.Weighted.TryAcquire 非阻塞地获取指定权重的资源,如果当前没有空闲资源,会直接返回 false。
[*]golang/sync/semaphore.Weighted.Release 用于开释指定权重的资源,同时会按照先辈先出的策略唤醒因为无法获取充足的资源而阻塞的goroutine。
SingleFlight
虽然作者说用这个可以处理缓存击穿的问题,但是感觉不太常用。
重要就是通过调用 golang/sync/singleflight.Group.Do方法,来限制同一时间重复的哀求次数,阻塞的等候参数返回,而golang/sync/singleflight.Group.DoChan会返回一个管道,让你只会去取拿到的返回值,相称于异步执行。
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。
页:
[1]