常用线程调度模型

1、N:1,其中几个用户空间线程在一个OS线程上运行。这样做的优点是可以非常快速地进行上下文切换,但不能利用多核系统的优势。
2、1:1,即一个执行线程与一个OS线程匹配。它利用了机器上的所有内核,但是上下文切换很慢,因为它必须通过操作系统进行捕获。
3、M:N,也是Go目前使用的。它将任意数量的goroutines调度到任意数量的OS线程上。您可以快速切换上下文,并利用系统中的所有核心。这种方法的主要缺点是它增加了调度器的复杂性。

Golang线程调度模型详解

为了完成调度任务,Go调度器使用了3个主要实体:
在这里插入图片描述
三角形表示一个OS线程。它是由操作系统管理的执行线程,工作方式与标准POSIX线程非常相似。在运行时代码中,它被称为M表示机器。
圆圈代表goroutine。它包括堆栈,指令指针和其他重要的信息调度goroutines,就像任何通道可能被阻塞。在运行时代码中,它被称为G。
矩形表示调度上下文。您可以将它看作是在单个线程上运行Go代码的调度器的本地化版本。这是让我们从N:1调度器到M:N调度器的重要部分。在运行时代码中,它被称为P表示处理器。
在这里插入图片描述
这里我们看到两个线程(M),每个线程持有一个上下文( P ),每个线程运行一个goroutine ( G )。为了运行goroutine,一个线程必须持有一个上下文。
上下文的数量在启动时设置为GOMAXPROCS环境变量的值,或者通过运行时函数GOMAXPROCS()设置。通常情况下,这在程序执行期间不会改变。上下文的数量是固定的,这意味着只有GOMAXPROCS在任何时候都在运行Go代码。我们可以使用它来调优对单个计算机的Go进程调用,比如在4核的PC上运行4个线程的Go代码。
灰色的goroutines没有运行,但准备被安排。它们被安排在称为runqueue的列表中。每当goroutine执行go语句时,goroutine都会被添加到运行队列的末尾。一旦上下文在调度点之前运行了goroutine,它就会从运行队列中弹出一个goroutine,设置堆栈和指令指针,并开始运行goroutine。
为了减少互斥锁争用,每个上下文都有自己的本地运行队列。Go调度器的前一个版本只有一个全局运行队列,并有一个互斥锁保护它。线程经常被阻塞,等待互斥锁被解锁。当你有32个内核的机器,你想要尽可能地提高性能时,这就变得很糟糕了。
只要所有上下文都有goroutines运行,调度器就会在这种稳定状态下继续进行调度。然而,有两种情况可以改变这种情况。

一、sycall调用时

现在你可能会想,为什么要有上下文呢?难道我们不能把运行队列放在线程上,并摆脱上下文吗?不是真的。我们使用上下文的原因是,如果正在运行的线程由于某种原因需要阻塞,我们可以将它们传递给其他线程。
我们需要阻塞的一个例子是调用系统调用。由于一个线程不能在系统调用中同时执行代码和被阻塞,所以我们需要传递上下文,这样它才能保持调度。
在这里插入图片描述
这里我们看到一个线程放弃了它的上下文,以便另一个线程可以运行它。调度程序确保有足够的线程运行所有上下文。上面插图中的M1可能只是为了处理这个系统调用而创建的,或者它可能来自线程缓存。系统调用线程将保持生成系统调用的goroutine,因为它在技术上仍然在执行,尽管在操作系统中被阻塞了。
当系统调用返回时,线程必须尝试获取上下文,以便运行返回的goroutine。通常的操作模式是从其他线程中窃取上下文。如果它不能窃取一个,它将把goroutine放到一个全局运行队列中,把自己放到线程缓存中,然后进入睡眠状态。
全局运行队列是上下文在运行完其本地运行队列时从其拉出的运行队列。上下文还会定期检查全局运行队列中的goroutines。否则,全局运行队列上的goroutines可能会因为饥饿而永远不会运行。
这种对系统调用的处理是Go程序运行多个线程的原因,即使是在GOMAXPROCS为1时也是如此。运行时使用goroutines来调用系统调用,而将线程留在后面。

二、偷取工作任务

系统稳定状态改变的另一种方式是当上下文没有goroutines可调度时。如果上下文运行队列上的工作量不平衡,就会发生这种情况。这可能导致上下文耗尽它的运行队列,而系统中仍有工作要做。为了继续运行Go代码,上下文可以将goroutines从全局运行队列中取出,但如果其中没有goroutines,它将不得不从其他地方获取它们。
在这里插入图片描述
这是另一种情况。当一个上下文用完时,它会尝试从另一个上下文偷取大约一半的运行队列。这可以确保在每个上下文上都有工作要做,从而确保所有线程都在最大容量下工作。

调度程序还有很多细节,比如cgo线程、LockOSThread()函数以及与网络轮询器的集成。