如果您阅读GO 1.14版本的发行说明,您可能会注意到该语言在运行时中发生了一些非常有趣的变化。因此,我对该项目非常感兴趣:“ Goroutine现在可以异步抢占”。原来,GO调度程序(调度程序)现在不合作了吗?好吧,在对角阅读了相应的建议后,好奇心得到了满足。但是,一段时间后,我决定更详细地研究创新。我想分享这些研究的结果。
系统要求
读者除了需要了解GO语言知识外,还需要以下内容:- 了解调度程序的原理(尽管我会在下面尝试“在手指上”进行解释)
- 了解垃圾收集器的工作方式
- 了解什么是GO汇编程序
最后,我将保留几个链接,我认为这些链接很好地涵盖了这些主题。简要介绍规划师
首先,让我提醒您什么是协作式和非协作式多任务处理。使用非协作(挤出)多任务处理,我们都熟悉OS调度程序的示例。该调度程序在后台运行,根据各种启发式方法卸载线程,其他线程开始接收而不是卸载CPU时间。合作计划者的特征是行为不同-他睡觉直到一个goroutine明显地唤醒了他,并准备将自己的位置移交给另一个。然后,计划者将自己决定是否有必要从上下文中删除当前的goroutine,如果有必要,将替换谁。这就是GO调度程序的工作方式。另外,我们考虑调度程序与之配合使用的基石:- P-逻辑处理器(我们可以使用runtime.GOMAXPROCS函数更改其数量),在每个逻辑处理器上可以一次独立执行一个goroutine。
- M-OS线程。每个P在M的线程上运行。请注意,P并不总是等于M,例如,一个线程可以被syscall阻塞,然后另一个线程将为其分配P。还有CGO和其他细微差别。
- G-gorutins。好在这里很明显,必须在每个P上执行G,并且调度程序对此进行监视。
您需要了解的最后一件事是,调度程序何时真正调用goroutine?很简单,通常在编译器函数的主体(序言)的开头插入了有关调用的指令(稍后,我们将对此进行更详细的讨论)。到底是什么问题?
从本文开始,您已经了解到调度程序的工作原理已经在GO中发生了变化,让我们看一下进行这些更改的原因。看一下代码:在扰流板下func main() {
runtime.GOMAXPROCS(1)
go func() {
var u int
for {
u -= 2
if u == 1 {
break
}
}
}()
<-time.After(time.Millisecond * 5)
fmt.Println("go 1.13 has never been here")
}
如果使用GO <1.14版本进行编译,则行“ go 1.13从未在这里”不会在屏幕上显示。发生这种情况是因为,一旦调度程序通过无限循环将处理器时间分配给goroutine,它便会完全捕获P,在此goroutine中不会发生函数调用,这意味着我们将不再唤醒调度程序。而且只有对runtime.Gosched()的显式调用才能使我们的程序终止。这只是goroutine捕获P并长时间阻止其他goroutine在此P上执行的一个示例。当此行为导致问题时,可以通过阅读建议找到更多选项。解析提案
解决这个问题的方法非常简单。让我们做与OS调度程序相同的操作!只需让GO从P中运行goroutine,然后在其中放另一个goroutine,为此,我们将使用OS工具。好的,如何实现呢?我们将允许运行时将信号发送到goroutine工作所在的流。我们将在来自M的每个流上注册该信号的处理器,处理器的任务是确定是否可以替换当前的goroutine。如果是这样,我们将保存其当前状态(寄存器和堆栈的状态)并将资源提供给另一状态,否则我们将继续执行当前的goroutine。值得注意的是,带有信号的概念是针对基于UNIX的系统的解决方案,而例如Windows的实现则略有不同。顺便说一下,SIGURG被选作发送信号。此实现最困难的部分是确定是否可以强制使用goroutine。事实是,从垃圾收集器的角度来看,我们代码中的某些位置应该是原子的。我们称这些地方为不安全点。如果我们在执行代码时从不安全的角度挤压goroutine,然后启动GC,那么它将赶上在不安全的位置删除的goroutine的状态,并且可以执行操作。让我们仔细看看安全/不安全的概念。你去那儿了吗,GC?
在1.12之前的版本中,运行时Gosched在肯定可以调用调度程序的地方使用了安全点,而不必担心我们会出现在GC代码的原子部分中。正如我们已经说过的,安全点数据位于函数的序言中(请注意,不是每个函数的序言)。如果您反汇编go-shn汇编程序,则可能会提出反对-在此看不到明显的调度程序调用。是的,但是您可以在此处找到runtime.morestack调用指令,如果您在此函数中查找,将发现调度程序调用。在破坏器下,我将隐藏来自GO来源的注释,或者您可以自己找到用于更多堆栈的汇编器。在源代码中找到Synchronous safe-points are implemented by overloading the stack bound check in function prologues. To preempt a goroutine at the next synchronous safe-point, the runtime poisons the goroutine's stack bound to a value that will cause the next stack bound check to fail and enter the stack growth implementation, which will detect that it was actually a preemption and redirect to preemption handling.
显然,当切换到排挤概念时,排挤信号可以在任何地方捕获我们的gorutin。但是GO的作者决定不离开安全点,而是到处声明安全点!好吧,当然,实际上几乎每个地方都有一个陷阱。如上所述,在某些不安全的地方,我们不会驱逐任何人。让我们写一个简单的不安全点。
j := &someStruct{}
p := unsafe.Pointer(j)
u := uintptr(p)
p = unsafe.Pointer(u)
要了解问题所在,让我们尝试一下垃圾收集器的外观。每次上班时,我们都需要找出根节点(堆栈和寄存器中的指针),并以此开始进行标记。由于无法在运行时说出内存中的64个字节是指针还是数字,因此我们转到堆栈并注册卡(某些带有元信息的缓存),这些都是GO编译器提供的。这些地图中的信息使我们能够找到指针。因此,当GO执行第4行时,我们被唤醒并送去上班。到达该地点并查看卡片时,我们发现它是空的(这是正确的,因为从GC的角度来看uintptr是数字而不是指针)。好吧,昨天我们听说了j的内存分配,因为现在我们无法使用该内存-我们需要清理它,并在删除内存后进入睡眠状态。下一步是什么?好吧,当局醒了,到了晚上,你自己也明白了。理论上就是如此,我建议在实践中考虑所有这些信号,不安全点以及寄存器卡和堆栈的工作方式。让我们继续练习
我从Perf Profiler的文章开头双击运行了一个示例(执行1.14,然后执行1.13),以查看正在发生的系统调用并进行比较。在第14版中所需的syscall很快找到了:15.652 ( 0.003 ms): main/29614 tgkill(tgid: 29613 (main), pid: 29613 (main), sig: URG ) = 0
好吧,显然,运行时将SIGURG发送到了goroutine正在旋转的线程上。以这些知识为出发点,我去查看了GO中的提交,以找到此信号发送到的位置和原因,并找到安装信号处理程序的位置。让我们从发送开始,我们将在运行时/ os_linux.go中找到信号发送功能
func signalM(mp *m, sig int) {
tgkill(getpid(), int(mp.procid), sig)
}
现在,我们在运行时代码中找到发送信号的位置:- 当goroutine处于暂停状态时(如果它处于运行状态)。挂起请求来自垃圾收集器。在这里,也许我不会添加代码,但是可以在文件runtime / preempt.go(suspendG)中找到它
- 如果调度程序确定goroutine工作时间过长,则运行时/ proc.go(重新执行)
if pd.schedwhen+forcePreemptNS <= now {
signalM(_p_)
}
forcePreemptNS-等于10ms的常量,pd.schedwhen-上次调用pd流的调度程序的时间
- 以及在紧急情况,StopTheWorld(GC)和其他一些情况下发送的所有信号流(我不得不绕过,因为文章的大小已经超出范围)
我们弄清楚了运行时如何以及何时向M发送信号。现在,让我们找到该信号的处理程序,并查看流在接收时的作用。
func doSigPreempt(gp *g, ctxt *sigctxt) {
if wantAsyncPreempt(gp) && isAsyncSafePoint(gp, ctxt.sigpc(), ctxt.sigsp(), ctxt.siglr()) {
ctxt.pushCall(funcPC(asyncPreempt))
}
}
通过此功能,很明显,要“锁定”您需要进行2次检查:- wantAsyncPreempt-我们检查G是否要被强制退出,例如,此处将检查当前goroutine状态的有效性。
- isAsyncSafePoint-检查它是否现在可以被挤出。此处最有趣的检查是G是否处于安全点或不安全点。此外,我们必须确保运行G的线程也准备抢占G。
如果两项检查均通过,将从可执行代码中调用保存状态G并调用调度程序的指令。还有更多关于不安全的信息
我建议分析一个新的示例,它将说明另一个不安全点的情况:另一个无尽的程序
func infiniteLoop() {
var u int
for {
u -= 2
if u == 1 {
break
}
}
}
func main() {
runtime.GOMAXPROCS(1)
go infiniteLoop()
<-time.After(time.Millisecond * 5)
fmt.Println("go 1.13 and 1.14 has never been here")
}
您可能会猜到,题为“去1.13和1.14从来没有来过”,我们在GO 1.14中不会看到。这是因为我们已明确禁止中断infiniteLoop(go:nosplit)函数。仅使用不安全点(该功能的整个主体)来实现这种禁止。让我们看看编译器为infiniteLoop函数生成了什么。小心组装
0x0000 00000 (main.go:10) TEXT "".infiniteLoop(SB), NOSPLIT|ABIInternal, $0-0
0x0000 00000 (main.go:10) PCDATA $0, $-2
0x0000 00000 (main.go:10) PCDATA $1, $-2
0x0000 00000 (main.go:10) FUNCDATA $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x0000 00000 (main.go:10) FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x0000 00000 (main.go:10) FUNCDATA $2, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x0000 00000 (main.go:10) XORL AX, AX
0x0002 00002 (main.go:12) JMP 8
0x0004 00004 (main.go:13) ADDQ $-2, AX
0x0008 00008 (main.go:14) CMPQ AX, $3
0x000c 00012 (main.go:14) JNE 4
0x000e 00014 (main.go:15) PCDATA $0, $-1
0x000e 00014 (main.go:15) PCDATA $1, $-1
0x000e 00014 (main.go:15) RET
在我们的例子中,PCDATA指令是令人感兴趣的。当链接器看到此指令时,它不会将其转换为“真实的”汇编程序。取而代之的是,具有等于相应程序计数器的键的第二个参数的值(可以在函数名+行的左侧看到的数字)将被放置在寄存器或堆栈映射中(由第一个参数确定)。正如我们在第10和15行上看到的那样,我们分别在地图$ 0和$ 1中放置了$ 2和-1值。让我们记住这一刻,看一下isAsyncSafePoint函数,我已经引起您的注意。在那里,我们将看到以下几行:isAsyncSafePoint
smi := pcdatavalue(f, _PCDATA_RegMapIndex, pc, nil)
if smi == -2 {
return false
}
我们在这里检查goroutine当前是否在安全点。我们转到寄存器映射(_PCDATA_RegMapIndex = 0),并将当前pc传递给它检查值,如果-2则G不在安全点'e,这意味着它不能被挤出。结论
我停止了对此的“研究”,希望本文对您也有所帮助。我张贴了承诺的链接,但请小心,因为这些文章中的某些信息可能已过时。GO调度程序- 一次和两次。组装商GO。