Golang Goroutine 调度原理与 Chanel 通信

简介

Go 中,每一个并发的活动称为一个 Goroutine 或者 协程。当一个程序启动时,只有一个 Goroutine 来调用 main 函数,称之为 主Goroutine。新的 Goroutine 通过 go 关键字进行创建。例如

1
go f() // 新建一个调用 f() 的 Goroutine

理论上 Goroutine 与其他编程语言的 线程OS线程 做类似。不过 Goroutine 与线程之间却有非常之大的区别。这个会在后面介绍。

相关术语

并发

在单个 CPU(或操作系统内核) 上同时执行多项任务。经操作系统调度,在极短的时间内对多个任务进行切换。所以实际上还是依照某一个顺序执行的。但是,由于调度切换的周期极快,人无法感知,所以看上去就像是同时在执行多个任务。成为并发

并行

如果系统中有多个 操作系统内核 时,在每个内核中同一时刻都在独立的执行各自任务,互不抢占。称为并行

进程

CPU 在进行程序切换时,如果不保存原先程序的上下文,便会丢失该进程的运行状态。于是引入了 进程 的概念。用以区分不同的程序运行的状态以及占用的资源。因此进程也可以理解为一个程序运行的基本资源单位。或者可以直接理解为便是一个程序的运行实体,包括运行时的资源以及环境信息。

线程

CPU在切换进程的周期通常比较耗时,因为切换进程需要进行上下文切换。因此引入 线程 的概念。线程本身几乎不占有资源,它们共享进程内的内存空间。如此,在线程间切换相对进程间切换的消耗更小。

协程

线程固然已经很好,但是每个 OS线程 都有一个固定内存(通常为2MB大小)。这个固定大小既太大又太小。且线程切换上下文耗时依然存在,固然已不如进程之间切换的花销大。因此引入了协程的概念。协程的运行依附于线程。协程的切换无需经过系统调度,而是由用户程序自身进行调度管理。Golang 中的 Goroutine 便是协程。

内部原理

Goroutine 与线程的区别

可增长的栈

每个OS线程都有固定的内存(通常为2MB)。Goroutine也有初始的栈内存,通常为2KB大小。这些栈内存通常用作存储其他函数调用期间那些正在执行或者暂停的函数中的局部变量。但与OS线程不同的是Goroutine的栈大小并不是固定的,它可以按需增大或缩小。最高上限可以达到1GB

调度

OS线程由系统内核调度。每隔几毫秒,一个硬件时钟中断发送到CPUCPU调用一个叫调度器的内核函数。这个函数暂停当前正在运行的线程,把它的寄存器信息保存到内存,查看线程列表并决定接下来运行哪一个线程,再从内存恢复线程的注册表信息,最后继续执行选中的线程。因为由系统内核调度,所以控制权限从一个线程到另一个线程需要一个完整的上下文切换(Context switch):即保存一个线程的状态到内存,再恢复另外一个线程的状态,最后更新调度器的数据结构。考虑这个操作设计的内存局域性以及涉及的内存访问数量,还有访问内存所需的 CPU 周期数量的增加,这个操作其实是很费时的。
Go 的 runtime 中包含一个自己的调度器,这个调度器使用一个 m:n 调度的技术(因为它可以复用/调度 mGoroutinen 个 OS 线程)。Go 调度器与内核调度器的工作类似,但 Go 调度器只需关心单个 Go 程序的 Goroutine 调度问题。
与操作系统内核不同的是,Go 调度器不是由硬件时钟触发,而是由特定的 Go 语言结构触发。比如当一个 Goroutine 调用 time.Sleep 或被通道阻塞或被一个互斥量进行操作时,调度器会将这个 Goroutine 设为休眠模式,并运行其他的 Goroutine。因为它不需要切换到内核语境,所以调用一个 Goroutine 比调度一个线程成本更低。

GOMAXPROCS

Go 调度器使用 GOMAXPROCS 参数确定使用多少个 OS 线程同时执行 Go 代码。默认为机器上的 CPU 数量。正在休眠或者正在被通道通信阻塞的 Goroutine 不需要占用线程。阻塞的 I/O 和其他系统调用中或调用非 Go 语言编写的函数的 Goroutine 需要独占一个 OS 线程,但这个线程不计算在 GOMAXPROCS 内。
可以使用 GOMAXPROCS 环境变量或者 runtime.GOMAXPROCS 函数显示的控制这个参数。

没有标识

在大部分支持多线程的编程语言中,当前线程都有一个独特的标识,这个值通常是一个整数或者指针。这个特性可以让程序员轻松构建一个线程的局部存储,它的本质上是一个全局的 map,以线程的标识作为主键,这样每个线程都可以独立地使用这个 map 存储和获取值,而不受其他线程干扰。
Goroutine 并没有提供这个可供访问的标识。这个设计主要是为了避免线程局部存储被滥用的倾向。例如,记得以前写 Java 时,可以在线程中存储一个值,然后,在其它地方使用这个值。如此,就造成了一种不健康的 超距作用,即函数的行为不仅取决于它的参数,还取决于运行它的线程标识。因此,在线程标识需要改变的场景下,这些函数的行为就会变得诡异莫测。

Goroutine 调度原理

Goroutine 模型

Goroutine 使用 GMP 调度模型实现。其主要由G、M、P三个内容组成。

  • G (Goroutine):代表使用 go func 创建的 Goroutine,每个都拥有自己的栈,Instruction pointer 和其他信息(正在等待的Channel)等,用于调度。
  • M (Work Thread):可以简单的理解为一个 OS 线程,一个 M 对应一个 OS 线程
  • P (Processor):主要负责调度、协调 nG 在某一个 M 中执行。

在 Go 的多线程模型中,使用者无法直接使用 OS 线程。只对外暴露一个轻量级的 Goroutine 或称为 协程 供做并发使用。而 协程 由 Go 的 runtime 完成调度。其优势在于无需在 用户态内核态 之间切换,避免了这方面的调度消耗。

调度

图片加载失败...

如上图中,有两个 OS 线程 M。每个 OS 线程都拥有一个独立的 处理器 P,每个 P 也都有一个在其中运行的 Goroutine。P 的数量由 GOMAXPROCS 设置。每个 P 都代表一个可以并发执行的 Goroutine。通常设置为系统 CPU 内核数量。
其中 P 执行的灰色的 G 表示在就绪状态的 Goroutine。它们暂存在由 P 维护的待调度的队列中,这个队列称之为 本地队列(local queue)。到达一个调度点时,便从队列中抽取一个 Goroutine 转移到 P 中进入执行状态。

图片加载失败...

由上图可以比较直观的看到 Goroutine 的调度架构。
首先,最基本的是 OS 线程,这个层面是内核级的。在 Go 的 runtime 中的 M 便是 OS 线程的封装。MP 之间相互引用。负责调度 G 在 OS 线程上运行。P 做为 调度器 每一个 P 都维护着一个 local queue (本地队列) 这个队列中保存着待调度执行的 G。在一个 G 出现阻塞时,则会将其放入队列中,并将队列中队头的 G 转为执行态。再则,当 G 执行一段时间之后,进入一个新的调度点时。同样,将正在执行的 G 放入队列中,再从队列中随机挑选一个 G 使其进入运行态。
当一个 P 中没有可供执行的 G 时,则会主动去全局队列中寻取可供执行的 G。同样,如果发现某一个 P 中的 G 过多,则会将其移入全局队列,供其他压力较轻的 P 进行调度。

阻塞

Goroutine 阻塞主要来自两方面:

  • syscall:调用 syscall 时,会出现阻塞的情况,这种情况下 G 会独占一个 OS 线程。
  • 通信阻塞:channel、锁导致的 Goroutine 阻塞。

图片加载失败...

上图表述了 Goroutine 调用 syscall 时的调度。这里可以看到 G0 被放到了一个独立的 M 中。这个 M 可能是专门创建出来的,也可能来自缓存的线程中。调用 syscall 的 Goroutine 需要独占一个 M,当 syscall 调用返回时会将该 Goroutine 放入到全局队列中。等待 P 轮询调度执行。

图片加载失败...

在 Goroutine 被锁、channel 阻塞时,runtime 则会将 Goroutine 调度入本地队列,并将处于队头的 Goroutine 调入 M 中进入运行状态。

图片加载失败...

如果一个上下文队列中的工作量不平衡,则会发生如上情况。如果有一个上下文队列中的 Goroutine 耗尽。为了保证系统性能最大化,上下文将在全局队列中取出可供执行的 Goroutine,但是如果全局队列中也耗尽。便会试图取从其他上下文中拿过一半的 Goroutine。这样就确保每个上下文始终都有工作可做,从而确保所有线程都以最大容量工作。

channel

channel 在 Golang 中的主要功能便是在协程之间通信。channel 使用 通信顺序进程(Communicating Sequential Process, CSP)模型实现。channel 作为协程间通信的载体,需要关联一个发送数据的类型。例如,一个 channel 发送 int 类型的数据。则需要指定这个 channel 绑定的类型为 int。

图片加载失败...

如上,一个 Goroutine 向 channel 中写入一个数据。另一个 Goroutine 从 channel 中读取数据,如果 channel 中没有可用的则从中读取的 G2 将会被阻塞。

channel 创建

Golang 中使用内建函数 make 创建一个 channel。例如:

1
c := make(chan int)
2
c := make(chan int, 5)

上面的两种创建方式:

  • 不带缓存:不带缓存的 channel 当中只能写入一个值。如果需要再写数据则会阻塞 channel。同样,在读取时,如果没有值,则读取的 Goroutine 也会阻塞。
  • 带缓存:带缓存的 channel 在 make 时,第二个参数指定缓存的个数,像上面的例子,创建的就是一个,拥有 5 个数值缓存空间的 channel,与不带缓存的最大区别在于写入数据。当写入数据不超过缓存空间数量的时候,则可以继续写入。但,如果已经满了继续写入则也会阻塞写入 Goroutine。读取与不带缓存的一致,channel 中没有数值时,继续读取也会造成阻塞。

channel 关闭

不用 channel 时,可以通过内建函数 close 来关闭一个 channel。

1
c := make(chan int)
2
close(c)

但是,在关闭 channel 的时候需要注意几点。

  • 关闭一个未初始化的 channel 时,将会抛出一个 panic
  • 重复关闭同一个 channel 也会抛出 panic
  • 向一个已关闭的 channel 中写入数据也会抛出一个 panic
  • 如果从一个已关闭的 channel 读取数据不会产生 panic,且能读取其中未被读取的内容。当 channel 中的数据被读完之后,则会返回绑定类型的 零值。而且,这个时候即使数据已经被读完了也不会阻塞 Goroutine。并且会返回一个为 falseok-idiom,可以用它判断 channel 是否已经关闭。
  • 关闭 channel 会产生一个广播,告知所有向 channel 中读取数据的 Goroutine。

channel 遍历

channel 可以使用 range 进行遍历,并且会不断的从 channel 中读取数据,直到显示的关闭 channel 之后才会停止。

1
c := make(chan int, 10)
2
for v := range c {
3
    fmt.Println(fmt.Sprintf("%d", v))
4
}

channel 配合 select 使用

channel 可以配合 select 监听多个 channel。

1
select {
2
    case v := <-c1 :
3
        fmt.Println(fmt.Sprintf("channel 1 : value = %d", v))
4
    case <-c2 :
5
        fmt.Println("channel 2")
6
    default :
7
        fmt.Println("default")
8
}
  • select 可以同时监听多个 channel 的读写。
  • 使用 select 时,只要有一个 case 通过,则不会造成阻塞,并进入这个 case 块执行。
  • 如果同时有多个 case 通过,则随机选取一个 case 块执行。
  • 如果所有的 case 都阻塞了,则会进入 default 块执行。如果没有定义 default 则会造成阻塞。
  • 与 switch 一样可以使用 break 跳出 case。

使用 channel 退出 Goroutine

可以使用一个 channel 作为 Goroutine 的退出信号。

1
msgCh := make(chan int)
2
quitCh := make(chan bool)
3
for {
4
    select {
5
        case msg := <-msgCh :
6
            handleMsg(msg)
7
        case <-quitCh :
8
            handleFinish()
9
    }
10
}

单向 channel

即只读或只写的 channel,事实上 channel 并不存在只读只写的定义,所谓的单向 channel 只是声明时用,例如:

1
func f(c chan<- int) <-chan int { ... }

chan<- 表示这个 channel 只能写入数据,<-chan 表示这个 channel 只能从中读取数据。上面的定义约束了 f 函数中只能向 channel 中写入数据,且必须返回一个只读的 channel。这样定义的好处在于可以防止 channel 被滥用,这种预防机制将在编译期间进行约束。