GO Scheduler:现在不合作吗?

如果您阅读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) //    main   ,         

	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)
// unsafe-point start
u := uintptr(p)
//do some stuff here
p = unsafe.Pointer(u)
// unsafe-point end

要了解问题所在,让我们尝试一下垃圾收集器的外观。每次上班时,我们都需要找出根节点(堆栈和寄存器中的指针),并以此开始进行标记。由于无法在运行时说出内存中的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)
}

现在,我们在运行时代码中找到发送信号的位置:

  1. 当goroutine处于暂停状态时(如果它处于运行状态)。挂起请求来自垃圾收集器。在这里,也许我不会添加代码,但是可以在文件runtime / preempt.go(suspendG)中找到它
  2. 如果调度程序确定goroutine工作时间过长,则运行时/ proc.go(重新执行)
    
    if pd.schedwhen+forcePreemptNS <= now {
    	signalM(_p_)
    }
    

    forcePreemptNS-等于10ms的常量,pd.schedwhen-上次调用pd流的调度程序的时间
  3. 以及在紧急情况,StopTheWorld(GC)和其他一些情况下发送的所有信号流(我不得不绕过,因为文章的大小已经超出范围)

我们弄清楚了运行时如何以及何时向M发送信号。现在,让我们找到该信号的处理程序,并查看流在接收时的作用。


func doSigPreempt(gp *g, ctxt *sigctxt) {
	if wantAsyncPreempt(gp) && isAsyncSafePoint(gp, ctxt.sigpc(), ctxt.sigsp(), ctxt.siglr()) {
		// Inject a call to asyncPreempt.
		ctxt.pushCall(funcPC(asyncPreempt))
	}
}

通过此功能,很明显,要“锁定”您需要进行2次检查:

  1. wantAsyncPreempt-我们检查G是否要被强制退出,例如,此处将检查当前goroutine状态的有效性。
  2. isAsyncSafePoint-检查它是否现在可以被挤出。此处最有趣的检查是G是否处于安全点或不安全点。此外,我们必须确保运行G的线程也准备抢占G。

如果两项检查均通过,将从可执行代码中调用保存状态G并调用调度程序的指令。

还有更多关于不安全的信息


我建议分析一个新的示例,它将说明另一个不安全点的情况:

另一个无尽的程序

//go:nosplit
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。

All Articles