插入写屏障
删除写屏障
混合写屏障
golang 混合写屏障实现
源码文件
写屏障代码触发点
对象置灰
暂停 goroutine,栈扫描
总结
网上很多文章对 golang 实现的插入写屏障说的挺详细,但是删除写屏障原理和混合写屏障的由来语焉不详,这篇文章目标是弄明白混合写屏障到底是怎么来的,解决什么问题,插入写屏障,删除写屏障的特点分别是什么?
基础知识复习前四篇文章:
首先明白一个前提,栈上,寄存器对象的赋值(插入,删除)不能 hook ,这个因为实力不允许(太复杂,做不到)。
Dijkstra屏障
伪代码:
writePointer(slot, ptr):
shade(ptr)
*slot = ptr
总结:
golang 1.5 之后实现的就是这种类型的插入写屏障。
Yuasa-style 屏障
伪代码:
writePointer(slot, ptr)
shade(*slot)
*slot = ptr
总结:
思考问题:我不整机暂停 STW 栈,而是一个栈一个栈的快照,这样也没有 STW 了,是否可以满足要求?(这个就是当前 golang 混合写屏障的时候做的哈,虽然没有 STW 了,但是扫描到某一个具体的栈的时候,还是要暂停这一个 goroutine 的)
不行,纯粹的删除写屏障,起始必须整个栈打快照,要把所有的堆对象都处于灰色保护中才行。
举例:如果没有把栈完全扫黑,那么可能出现丢数据,如下:
初始状态:
步骤一:g2 进行赋值变更,把 C 指向 D 对象,这个时候黑色的 C 就指向了白色的 D(由于是删除屏障,这里是不会触发hook的)
步骤二:把 B 指向 C 的引用删除,由于是栈对象操作,不会触发删除写屏障;
步骤三:清理,因为 C 已经是黑色对象了,所以不会再扫描,所以 D 就会被错误的清理掉。
解决办法有如下:
方法一:栈上对象也 hook,所有对象赋值(插入,删除)都 hook(这个就不实际了);
所有的插入,删除如果都 hook ,那么一定都不会有问题,虽然本轮精度很差,但是下轮回收可以回收了。但是还是那句话,栈,寄存器的赋值 hook 是不现实的。
方法二:起始快照整栈跨找,扫黑,使得整个堆上的在用对象都处于灰色保护;
整栈扫黑,那么在用的堆上的对象是一定处于灰色堆对象的保护下的,之后配合堆对象删除写屏障就能保证在用对象不丢失。
方法三:加入插入写屏障的逻辑,C 指向 D 的时候,把 D 置灰,这样扫描也没问题。这样就能去掉起始 STW 扫描,从而可以并发,一个一个栈扫描。
细品下,这不就成了当前在用的混合写屏障了,所以我觉得正确的理解方式应该是:混合写屏障 = 删除写屏障 + 插入写屏障,必须先理解下删除写屏障,你才能理解混合写屏障。
golang 1.5 之后已经实现了插入写屏障,但是由于栈对象赋值无法 hook 的原因,导致扫描完之后还有一次 STW 重新扫描栈的整机停顿,混合写屏障就是解决这个问题的。
论文里的伪代码:
writePointer(slot, ptr):
shade(*slot)
if current stack is grey:
shade(ptr)
*slot = ptr
golang 实际实现的伪代码:
writePointer(slot, ptr):
shade(*slot)
shade(ptr)
*slot = ptr
总结:
思考一个问题:混合写屏障是全面去除 STW 的一个改进,转而并发一个一个栈处理的方式(每个栈单独暂停),从而消除了整机 STW 的影响,带来了吞吐的提升。有没有想过,这种 STW 整个系统栈,而只暂停一个 goroutine 栈会不会有问题,我钻牛角尖的思考过一些奇怪的场景,比如下面:
初始状态:
步骤一:赋值器(g1)把 A 指向 C,赋值器(g2)把 B 指向 nil,删除 B -> C 的引用,由于A,B 都是栈上的对象,所以这里的赋值都不会触发写屏障;
这样操作下来,是不是就有问题了?
黑色的 A 指向了白色的 C,并且是唯一指向。由于我们说混合写屏障这里是黑色赋值器,黑色的 goroutine 1 栈是不会再扫描了,那等扫描完了,C 岂不是被错误回收了 !!!
这里不会哈。因为这种情况不会出现,证明如下:
如果要实现如上所述场景,需要满足条件:
goroutine 1 栈上的黑色对象 A 引用白色对象 C 只有两种场景:
(旁白,理解一句话,对象不是天上掉下来的,一定是有路可循的。)
撇开这个上面的假设,如果 A 非要引用到 C ,可以通过 A 下面的一个对象 X ( X 指向 C)来获取,但如此一来,C 一定会处于灰色保护下;
源码文件 下面以 golang 1.13.3 版本源码演示举例。
golang 当前实际实现的是混合写屏障。这部分的代码在 <span style="font-size: 14px;">src/runtime
这个下面,golang 是有运行时(runtime)的概念的,运行时是啥?就是语言层面就形成的管理逻辑,主要工作是 goroutine 创建,调度,销毁,内存分配,垃圾回收等逻辑。有了运行时,就可以保证 golang 业务代码的简单 和 语言本身的强大。
runtime 下有大量的汇编实现的代码,这部分主要是考虑极致的性能,其实大部分还是 golang 的逻辑代码,另外还有如下子目录,分别是 cgo,pprof 的支持等。
Go SDK 1.13.3
├── runtime
│ ├── cgo
│ ├── debug
│ ├── internal
│ ├── msan
│ ├── pprof
│ ├── race
│ └── trace
在 golang 源代码 mbarrier.go
里面有详细说明,但是提一点,golang 的实际实现其实做了一个简化,把 if 条件判断去掉了,这样的实现更简单,精度差一些。
怎么理解精度会差呢?
因为有了删除写屏障的加持,golang 的栈是黑色赋值器,扫描过是不需要再扫描的。所以如果栈式灰色的,才需要置灰色,否则是 pass 的。golang 处于短期实现的一个简单化处理,源码实现其实是没有这个判断的,所以自然精度就会差些,但是实际效果其实还行。
混合写屏障伪代码:
writePointer(slot, ptr):
// 旧值 置灰
shade(*slot)
if current stack is grey:
// 新值 置灰
shade(ptr)
*slot = ptr
golang 实际实现伪代码如下:
writePointer(slot, ptr):
shade(*slot)
shade(ptr)
*slot = ptr
也就是是说,如果在垃圾回收阶段,只要是堆上的一个赋值 *slot = ptr
那么都会被 hook 住,然后把旧值(*slot
)指向的对象,和新值(ptr)指向的对象都置灰(投到扫描队列)。
这个就是混合写屏障完整的逻辑了。
重点:
举例:
main.funcAlloc0
13 func funcAlloc0 (a *Tstruct) {
14 a.base = new(BaseStruct) // new 一个BaseStruct结构体,赋值给 a.base 字段
15 }
反汇编就能看到编译出的代码,如下:
在合适的位置,编译器给你程序添加的指令函数 runtime·gcWriteBarrier
,这个就是入口,每次堆对象赋值,如果开启了垃圾回收开关,都会去里面转一圈。
实现伪代码如下:
if runtime.writeBarrier.enabled {
runtime.gcWriteBarrier(ptr, val)
} else {
*ptr = val
}
注意参数 <span style="font-size: 14px;">gcWriteBarrier
传值在 golang 里是个特例,这里用的是寄存器( golang 的惯例是用栈来传递的,但是考虑性能原因,这里必须用寄存器了),详情见文章 [golang 垃圾回收(二)屏障技术] 。
runtime·gcWriteBarrier
这个函数是个纯汇编的函数,golang 按照不同的 cpu 指令集实现的,路径可以去看 src/runtime/asm_amd64.s
这个文件。
代码注释如下:
TEXT runtime·gcWriteBarrier(SB),NOSPLIT,$120
get_tls(R13)
MOVQ g(R13), R13
MOVQ g_m(R13), R13
MOVQ m_p(R13), R13
MOVQ (p_wbBuf+wbBuf_next)(R13), R14
LEAQ 16(R14), R14
MOVQ R14, (p_wbBuf+wbBuf_next)(R13)
// 检查 buffer 队列是否满?
CMPQ R14, (p_wbBuf+wbBuf_end)(R13)
// 赋值的前后两个值都会被入队
// 把 value 存到指定 buffer 位置
MOVQ AX, -16(R14) // Record value
// 把 *slot 存到指定 buffer 位置
MOVQ (DI), R13
MOVQ R13, -8(R14)
// 如果 wbBuffer 队列满了,那么就下刷处理,比如置灰,置黑等操作
JEQ flush
ret:
// 赋值:*slot = val
MOVQ 104(SP), R14
MOVQ 112(SP), R13
MOVQ AX, (DI)
RET
flush:
。。。
// 队列满了,统一处理,这个其实是一个批量优化手段
CALL runtime·wbBufFlush(SB)
。。。
JMP ret
混合写屏障的两个 shade 直接操作就是上面的两行汇编赋值了:
// 把 value 存到指定 buffer 位置
MOVQ AX, -16(R14) // Record value
// 把 *slot 存到指定 buffer 位置
MOVQ (DI), R13
MOVQ R13, -8(R14)
runtime·gcWriteBarrier
函数干啥的,这个函数其实只干两件事:
那为什么上面 gcWriteBarrier
这个函数怎么复杂?
其实是做的一个优化处理,每次触发写屏障的时候( hook ),我们当然可以直接shade(ptr),但是我们知道,毕竟这段写屏障的代码是比业务多出来的,这些都是开销,我们能快就快,每次这样做太零散,我们可以攒一批,一批队列满了,一批去入队,置灰色。这样效率更高。一般情况下,只需要简单入队就行了,buf 满了之后,才 flush 去批量置灰,这样写屏障对业务的影响就更小了,wbBuf 就是这个队列的实现。
wbBufFlush
这个函数 wbBufFlush 是 golang 实现的,在文件 src/runtime/mwbbuf.go
里。本质上是封装调用 wbBufFlush1 ,这个函数才是 hook 写操作想要做的事情,这个函数做两个事情:
说了这么久“置灰色”,那么到底写屏障是怎么置灰色的?实现如何。其实本质下就下面一行代码,在文件 src/runtime/mwbbuf.go
的函数 wbBufFlush1
:
// Enqueue the greyed objects.
gcw.putBatch(ptrs[:pos])
在 golang 里面,到底什么样的是灰色对象?
其实,白,灰,黑 三色这个是我们认为抽象出来的概念,也就是所谓的三色标记法,那么这个概念落到实处,又是怎么样的实现。
golang 内部对象并没有保存颜色的属性,三色只是对他们的状态的描述,是通过一个队列 + 掩码位图 来实现的:
扫描某个栈的函数 scang
,实现在文件 src/runtime/proc.go
里。scang
即为 scan goroutine 的意思,gc 扫描 goroutine 的时候,必须暂停 goroutine,也就是 block 住它,这段时间这个 goroutine 的业务是暂停的,直到扫描完 goroutine 的 stack 就可以放开了。
scang
在markroot
函数里调用(位于 src/runtime/mgcmark.go
),实现如下:
// 切系统调度栈
systemstack(func() {
userG := getg().m.curg
// 如果是在自己的 goroutine 运行的时候去协助处理 gc 任务,恰好处理到自己的时候,需要做些处理;
selfScan := gp == userG && readgstatus(userG) == _Grunning
if selfScan {
casgstatus(userG, _Grunning, _Gwaiting)
userG.waitreason = waitReasonGarbageCollectionScan
}
// 扫描 goroutine 栈
scang(gp, gcw)
if selfScan {
casgstatus(userG, _Gwaiting, _Grunning)
}
})
scang
这个函数是非常重要的事情,这个函数灰阻塞扫描一个 goroutine,直到扫描栈(注意了,只需要扫描完栈)完成就可以解锁了。
代码如下( src/runtime/proc.go
):
func scang(gp *g, gcw *gcWork) {
// 栈扫描是否完成的标识
gp.gcscandone = false
// 轮询的时长间隔
const yieldDelay = 10 * 1000
var nextYield int64
// 循环
loop:
for i := 0; !gp.gcscandone; i++ {
// 读取 goroutine 的状态标识;
switch s := readgstatus(gp); s {
default:
dumpgstatus(gp)
throw("stopg: invalid status")
// 如果是已经释放的 goroutine,那么跳出;
case _Gdead:
// No stack.
gp.gcscandone = true
break loop
// 拷贝栈的过程,等一下,稍后需要重试;
case _Gcopystack:
// Stack being switched. Go around again.
// 如果该是 goroutine 是已经挂起的状态(非运行状态)
case _Grunnable, _Gsyscall, _Gwaiting:
// 重要:设置扫描标识(GScan)这个标识会阻塞该 goroutine 的运行,直到栈扫描完成;
if castogscanstatus(gp, s, s|_Gscan) {
if !gp.gcscandone {
// 调用 scanstack 扫描栈
scanstack(gp, gcw)
gp.gcscandone = true
}
// 重启 goroutine ,这个 goroutine 又可以继续跑业务代码了;
restartg(gp)
break loop
}
// 如果已经是扫描状态了,那么说明别的地方已经在扫描这个g栈了,等别人完成就好了;
case _Gscanwaiting:
// newstack is doing a scan for us right now. Wait.
// 如果这个 goroutine 是一个 runing 状态,那么需要抢占调度,然后让它自己去扫描 g 栈,现场就等他自己扫描完之后就好了;
// 这里只需要设置抢占标识和扫描标识就可以了,真正的扫描现场在这个 gp 自己运行现场;
case _Grunning:
if gp.preemptscan && gp.preempt && gp.stackguard0 == stackPreempt {
break
}
// 打上 Scan 标识,通知 gp 抢占调度,让它自己扫描栈;
if castogscanstatus(gp, _Grunning, _Gscanrunning) {
if !gp.gcscandone {
// 打上抢占标识
gp.preemptscan = true
gp.preempt = true
// 设置魔数标识
gp.stackguard0 = stackPreempt
}
// 设置完抢占标识,就可以把 goroutine 的 Scan 去掉了,下面就是循环等待它自己处理完了;
casfrom_Gscanstatus(gp, _Gscanrunning, _Grunning)
}
}
if i == 0 {
nextYield = nanotime() + yieldDelay
}
if nanotime() < nextYield {
procyield(10)
} else {
osyield()
nextYield = nanotime() + yieldDelay/2
}
}
gp.preemptscan = false // cancel scan request if no longer needed
}
scang 处理非 running 三种状态转换:
scang 处理 running 状态:
总结下 scang 做的最重要的两个事情:
_Grunnable
,_Gsyscall
,<span style="font-size: 14px;">_Gwaiting
):a . 首先,goroutine 设置上 Gscan ;
b . 扫描栈 scanstack
;
c . 重启 goroutine 状态,去掉 Gscan 状态标识;
2 . 如果 goruotine 是 running 状态,那么就不是在现场扫描了,而是抢占通知 goroutine 本身去扫描,扫描完了,通过 gp.gcscandone
来识别;
a . goroutine 设置了这个抢占标识之后,goroutine 能立马识别到,然后自己扫描栈,扫描完了设置 gp.gcscandone = true
;
b . 设置 gp.preemptscan = true
,gp.preempt = true
,gp.stackguard0 = stackPreempt
;
c . 等待 gp.gcscandone 为 true ,为 true 的时候,说明 goroutine 自己已经扫描完了,scang 就可以退出了;
scanstack
这个函数简单就提一下,本质上是调用 scanblock
来扫描栈内存,接收参数为开始地址和结束边界,从而确定扫描范围。
扫描的实现非常朴实,就是一个个内存块,扫描分析,读一个,分析一个,发现指针投入队列,步进长度是以指针长度( 8 字节 )。
/*
bo :开始地址
n0 :内存块长度(结束边界),字节数
ptrmask :掩码
*/
func scanblock(b0, n0 uintptr, ptrmask *uint8, gcw *gcWork, stk *stackScanState) {
b := b0
n := n0
for i := uintptr(0); i < n; {
// 获取位图
bits := uint32(*addb(ptrmask, i/(sys.PtrSize*8)))
// 如果整个 bits 为 0 ,就不需要往下走了,跳过 8 个指针的长度( 8*8 = 64 );
// 这里也说明下面是每 8 个指针处理一批;
if bits == 0 {
i += sys.PtrSize * 8
continue
}
// 8 个指针处理
for j := 0; j < 8 && i < n; j++ {
//
if bits&1 != 0 {
// Same work as in scanobject; see comments there.
// 把对应内存地址里面存储的值取出来
p := *(*uintptr)(unsafe.Pointer(b + i))
if p != 0 {
// 如果是指针,那么就投入扫描队列,置灰色( greyobject 这个和前面 置灰 是一样的,只是封装的函数略有不同)
if obj, span, objIndex := findObject(p, b, i); obj != 0 {
greyobject(obj, b, i, span, gcw, objIndex)
} else if stk != nil && p >= stk.stack.lo && p < stk.stack.hi {
stk.putPtr(p)
}
}
}
// bit 位图移去一 bit
bits >>= 1
// 内存地址前进一个指针长度(8)
i += sys.PtrSize
}
}
}
golang goroutine 的抢占调度是协商性质的(旁白:抢占只针对文明人,粗鲁人你抢占不了),不过这个是在 golang 1.14 之前的历史了,因为 golang 1.14 实现了真正的抢占,是基于异步信号的抢占,这种就不管协程本身文不文明了。咱们这里说的是 golang 1.13 哈。
总结一句话:系统想让某个 goroutine 自己放弃执行权,会给这个协程设置一个魔数,协程调用函数的时候,会有机会看到这个魔数标识,识别这个魔数标识到了就可以切走 cpu 了。
go 1.13 抢占实现是:
a . scang
里面有这么一行代码:gp.stackguard0 = stackPreempt
;
2 . goroutine 协程调用函数的时候,会检查是否需要栈扩容,如果被设置了抢占标示,那么就会调用函数 newstack
;
3 . 在 newstack
里面判断是否是特殊值 stackPreempt ,这种特殊值目的不在于扩容,而在于抢占调度;
在 golang 里面,只要有函数调用,就会有感知抢占的时机,STW 就是基于这个实现的。
newstack
newstack
本质上是一个分配 goroutine 栈的函数,位于 src/runtime/stack.go
,但是里面有配合抢占调度的逻辑,这部分逻辑是非常重要的;
// src/runtime/stack.go: newstack
// 如果开启了抢占调度标识
if preempt {
// g0 栈是系统调度栈,不能抢占;
if gp == thisg.m.g0 {
throw("runtime: preempt g0")
}
if thisg.m.p == 0 && thisg.m.locks == 0 {
throw("runtime: g is running but p is not")
}
// 下面的逻辑就是和 scang 交互同步的;
// 设置 Gwaiting 标识,表明 goroutine 在做别的事情,业务程序已经被 block 了
casgstatus(gp, _Grunning, _Gwaiting)
if gp.preemptscan {
// goroutine 设置 Gscan 标识
for !castogscanstatus(gp, _Gwaiting, _Gscanwaiting) {
// ...
}
if !gp.gcscandone {
// 调用 scanstack 扫描栈(scang 那边就是在等这里扫描完成)
gcw := &gp.m.p.ptr().gcw
scanstack(gp, gcw)
// 扫描完成
gp.gcscandone = true
}
// 扫描完了,去掉抢占标识
gp.preemptscan = false
gp.preempt = false
// 扫描完了,去除掉 Gscan 标识
casfrom_Gscanstatus(gp, _Gscanwaiting, _Gwaiting)
// 扫描完了,goroutine 切换成 Grunning 状态
casgstatus(gp, _Gwaiting, _Grunning)
gp.stackguard0 = gp.stack.lo + _StackGuard
gogo(&gp.sched) // never return
}
// ...
}
总结
本文由哈喽比特于3年以前收录,如有侵权请联系我们。
文章来源:https://mp.weixin.qq.com/s/peIOZxEMnF7YoyxMZXYh6w
京东创始人刘强东和其妻子章泽天最近成为了互联网舆论关注的焦点。有关他们“移民美国”和在美国购买豪宅的传言在互联网上广泛传播。然而,京东官方通过微博发言人发布的消息澄清了这些传言,称这些言论纯属虚假信息和蓄意捏造。
日前,据博主“@超能数码君老周”爆料,国内三大运营商中国移动、中国电信和中国联通预计将集体采购百万台规模的华为Mate60系列手机。
据报道,荷兰半导体设备公司ASML正看到美国对华遏制政策的负面影响。阿斯麦(ASML)CEO彼得·温宁克在一档电视节目中分享了他对中国大陆问题以及该公司面临的出口管制和保护主义的看法。彼得曾在多个场合表达了他对出口管制以及中荷经济关系的担忧。
今年早些时候,抖音悄然上线了一款名为“青桃”的 App,Slogan 为“看见你的热爱”,根据应用介绍可知,“青桃”是一个属于年轻人的兴趣知识视频平台,由抖音官方出品的中长视频关联版本,整体风格有些类似B站。
日前,威马汽车首席数据官梅松林转发了一份“世界各国地区拥车率排行榜”,同时,他发文表示:中国汽车普及率低于非洲国家尼日利亚,每百户家庭仅17户有车。意大利世界排名第一,每十户中九户有车。
近日,一项新的研究发现,维生素 C 和 E 等抗氧化剂会激活一种机制,刺激癌症肿瘤中新血管的生长,帮助它们生长和扩散。
据媒体援引消息人士报道,苹果公司正在测试使用3D打印技术来生产其智能手表的钢质底盘。消息传出后,3D系统一度大涨超10%,不过截至周三收盘,该股涨幅回落至2%以内。
9月2日,坐拥千万粉丝的网红主播“秀才”账号被封禁,在社交媒体平台上引发热议。平台相关负责人表示,“秀才”账号违反平台相关规定,已封禁。据知情人士透露,秀才近期被举报存在违法行为,这可能是他被封禁的部分原因。据悉,“秀才”年龄39岁,是安徽省亳州市蒙城县人,抖音网红,粉丝数量超1200万。他曾被称为“中老年...
9月3日消息,亚马逊的一些股东,包括持有该公司股票的一家养老基金,日前对亚马逊、其创始人贝索斯和其董事会提起诉讼,指控他们在为 Project Kuiper 卫星星座项目购买发射服务时“违反了信义义务”。
据消息,为推广自家应用,苹果现推出了一个名为“Apps by Apple”的网站,展示了苹果为旗下产品(如 iPhone、iPad、Apple Watch、Mac 和 Apple TV)开发的各种应用程序。
特斯拉本周在美国大幅下调Model S和X售价,引发了该公司一些最坚定支持者的不满。知名特斯拉多头、未来基金(Future Fund)管理合伙人加里·布莱克发帖称,降价是一种“短期麻醉剂”,会让潜在客户等待进一步降价。
据外媒9月2日报道,荷兰半导体设备制造商阿斯麦称,尽管荷兰政府颁布的半导体设备出口管制新规9月正式生效,但该公司已获得在2023年底以前向中国运送受限制芯片制造机器的许可。
近日,根据美国证券交易委员会的文件显示,苹果卫星服务提供商 Globalstar 近期向马斯克旗下的 SpaceX 支付 6400 万美元(约 4.65 亿元人民币)。用于在 2023-2025 年期间,发射卫星,进一步扩展苹果 iPhone 系列的 SOS 卫星服务。
据报道,马斯克旗下社交平台𝕏(推特)日前调整了隐私政策,允许 𝕏 使用用户发布的信息来训练其人工智能(AI)模型。新的隐私政策将于 9 月 29 日生效。新政策规定,𝕏可能会使用所收集到的平台信息和公开可用的信息,来帮助训练 𝕏 的机器学习或人工智能模型。
9月2日,荣耀CEO赵明在采访中谈及华为手机回归时表示,替老同事们高兴,觉得手机行业,由于华为的回归,让竞争充满了更多的可能性和更多的魅力,对行业来说也是件好事。
《自然》30日发表的一篇论文报道了一个名为Swift的人工智能(AI)系统,该系统驾驶无人机的能力可在真实世界中一对一冠军赛里战胜人类对手。
近日,非营利组织纽约真菌学会(NYMS)发出警告,表示亚马逊为代表的电商平台上,充斥着各种AI生成的蘑菇觅食科普书籍,其中存在诸多错误。
社交媒体平台𝕏(原推特)新隐私政策提到:“在您同意的情况下,我们可能出于安全、安保和身份识别目的收集和使用您的生物识别信息。”
2023年德国柏林消费电子展上,各大企业都带来了最新的理念和产品,而高端化、本土化的中国产品正在不断吸引欧洲等国际市场的目光。
罗永浩日前在直播中吐槽苹果即将推出的 iPhone 新品,具体内容为:“以我对我‘子公司’的了解,我认为 iPhone 15 跟 iPhone 14 不会有什么区别的,除了序(列)号变了,这个‘不要脸’的东西,这个‘臭厨子’。