Goroutine简介

什么是Goroutine?

Goroutine是在Golang中同时执行任务的一种方式。它使我们能够廉价地在同一地址空间中同时创建和运行多个方法或函数。Goroutine的想法是受协程启发的。

Goroutine是线程上的轻量级抽象,因为与线程相比,Goroutine的创建和销毁非常便宜,并且它们是在OS线程上调度的。在后台执行方法只需要在函数调用前加上go这个关键字。这是一个简单的例子:

package main

import (
    "fmt"
    "time"
)

func learning() {
    fmt.Println("My first goroutine")
}
func main() {
    go learning()
    /* we are using time sleep so that the main program does not terminate before the execution of goroutine.*/
    time.Sleep(1 * time.Second)
    fmt.Println("main function")
}

上面程序的输出将是:

My first goroutine
main function

删除time.sleep并再次运行该程序。将会到以下输出:

main function

为了理解这一点,我们需要知道Goroutine是如何执行的。与普通函数调用不同,在使用go调用函数后并不会等待Goroutine完成执行。调用Goroutine之后,会立即返回到代码的下一行。只有主Goroutine(main函数也是一个Goroutine)正在运行时,其他Goroutine才能运行。如果主Goroutine终止,则该程序将终止,并且其他Goroutine将不会运行。

因此,当我们不使用time.Sleep()时,在Goroutine调用之后立即执行fmt.Println("main function")这一行行。此后,主程序被终止,所以我们没有在终端上获得Goroutine的输出。

在主Goroutine中使用睡眠是一种技巧,我们只是为了了解Goroutine是如何工作的。我们主要还是使用Channel来阻塞主Goroutine,直到所有其他Goroutine完成执行。

Goroutine与线程有何不同?

许多人认为Goroutine比线程快。这并不完全正确。它并没有更快,但是它允许我们同时执行操作。在多核处理器上,运行时还将把工作分散到多个处理器上,从而获得并行性。如果任务A在某件事上被阻塞(比如,正在等待I/O),则调度程序会在等待I/O返回的同时执行另一个准备运行的Goroutine。

Goroutine在以下几点上优于线程:

内存消耗

与线程相比,创建Goroutine所需的内存要少得多。Goroutine需要2kb的内存,而线程则需要1Mb(是Goroutine的500倍)。Goroutine的设计方式使得Goroutine的堆栈大小可以根据应用程序的需要而增加和缩小。程序中可能只有一个线程和成千上万个Goroutine。

设置和拆卸成本

线程需要大量的设置和拆卸成本,因为线程必须从操作系统请求资源,并在完成后返回资源。虽然goroutine是由go运行时(它管理goroutine的调度,垃圾回收和运行时环境)创建和销毁的,但这些操作非常便宜。

Go运行时调度程序分析
切换成本

这种差异主要是由于Goroutine和线程调度方面的差异。线程是抢占式调度的,调度程序需要保存/恢复所有寄存器。
虽然Goroutine是协作调度的,但它们并不直接与OS内核通信。当执行Goroutine切换时,需要保存/恢复很少的寄存器,如程序计数器和堆栈指针。

Goroutines的调度

正如上一段中提到的那样,Goroutine是协作调度的。在协作调度中,没有调度程序时间片的概念。在这种调度中,Goroutine在空闲或逻辑阻塞时会定期放弃控制权,以便同时运行多个Goroutine。Goroutine之间的切换发生在以下几种情况:

  • 通道发送和接收操作(如果这些操作将阻塞)。

  • go语句,尽管不能保证会立即安排新的Goroutine。

  • 阻塞的系统调用,例如文件和网络操作。

  • 被停止进行垃圾收集循环之后。

  • 当Goroutine占用时间过长时,调度器会停止当前运行的Goroutine,并给其他可运行的Goroutine运行机会。

基础调度

现在让我们看看它们内部是如何调度的。Go使用三个实体来解释Goroutine调度。

  • Processor(P)

  • OSThread(M)

  • Goroutines(G)

P的数量由环境变量中的GOMAXPROCS决定,通常来说它是和核心数对应。每个P都分配有一个M(OSThread),M是一个线程,由操作系统负责管理和执行。

Go调度程序中有两个不同的运行队列:全局运行队列(GRQ)和本地运行队列(LRQ)。每个P都有一个LRQ,该LRQ管理分配给在P上下文中执行的Goroutine。这些Goroutine轮流在分配给该P的M上执行(由Go调度程序切换当前执行的Goroutine)。GRQ用于尚未分配给P的Goroutine。

Goroutines的调度

在每一轮调度中,调度程序都会找到一个可运行的G并执行它。在每一轮调度中,搜索均按以下顺序进行:

runtime.schedule() {
    // only 1/61 of the time, check the global runnable queue for a G.
    // if not found, check the local queue.
    // if not found,
    //     try to steal from other Ps.
    //     if not, check the global runnable queue.
    //     if not found, poll network.
}

异步系统调用

当我们正在运行的OS能够异步处理系统调用时,可以使用称为网络轮询器的东西来更有效地处理系统调用。这是通过在各个操作系统中使用kqueue(MacOS),epoll(Linux)或iocp(Windows)来完成的。

我们今天使用的许多操作系统都可以异步处理基于网络的系统调用。这是网络轮询器名称的由来,因为它的主要用途是处理网络操作。通过使用网络轮询器进行网络系统调用,调度程序可以防止Goroutine在进行这些系统调用时阻止M。这有助于使M保持可用以执行P的LRQ中的其他Goroutine,而无需创建新的M。这有助于减少OS上的调度负载。

了解其工作方式的最佳方法是通过一个示例来运行。

Goroutines的调度

上图显示了我们的基本调度图。Goroutine-1正在M上执行,还有3个Goroutine在LRQ中等待以获取其在M上的时间。网络轮询器闲置无事可做。

Goroutines的调度

在上图中,Goroutine-1希望进行网络系统调用,因此Goroutine-1被移至网络轮询器,并处理了异步网络系统调用。将Goroutine-1移至网络轮询器后,M现在可用于执行与LRQ不同的Goroutine。在这种情况下,Goroutine-2在M上进行了上下文切换。

Goroutines的调度

在上图中,异步网络系统调用由网络轮询器完成,Goroutine-1被移回P的LRQ中。一旦Goroutine-1可以在M上上下文切换回去,它负责的Go相关代码可以再次执行。这里最大的好处是,执行网络系统调用不需要额外的M。网络轮询器具有OS线程,并且正在处理有效的事件循环。

同步系统调用

当Goroutine想要进行无法异步完成的系统调用时,会发生什么?在这种情况下,将无法使用网络轮询器,并且进行系统调用的Goroutine将会阻塞M。这很不幸,但是无法防止这种情况的发生。一个不能异步进行系统调用的示例是基于文件的系统调用。如果使用的是CGO,则在其他情况下,调用C函数也会阻塞M。

让我们逐步了解同步系统调用(例如文件I/O)会导致M阻塞的情况。

Goroutines的调度

上图再次显示了我们的基本调度图,但是这次Goroutine-1将进行将阻塞M1的同步系统调用。

Goroutines的调度

在上图中,调度程序能够识别Goroutine-1导致M阻塞。此时,调度程序将M1与P分离,而阻塞Goroutine-1仍处于连接状态。然后,调度程序会引入一个新的M2来为P服务。这时,可以从LRQ中选择Goroutine-2,并在M2上进行上下文切换。如果由于先前的交换而已存在M,则此过渡比必须创建新的M更快。

Goroutines的调度

在上图中,由Goroutine-1进行的阻塞系统调用完成了。此时,Goroutine-1可以移回LRQ并再次由P服务。如果这种情况需要再次发生,则将M1放在一边以备将来使用。

窃取Goroutine

当创建新的G或现有的G成为可运行的G时,它将被推到当前P的可运行goroutine列表中。当P完成执行G时,它将尝试从自己的可运行goroutine列表中弹出一个G。如果列表现在为空,则P选择一个随机的其他处理器(P)并尝试从其队列中窃取一半可运行的goroutine。

Goroutines的调度

在上述情况下,P1无法找到任何可运行的goroutine。因此,它随机选择另一个处理器(P2)并将两个goroutine窃取到其自己的本地队列中。P1将能够运行这些goroutine,并且调度程序的工作将更加公平地分布在多个处理器之间。