小艾的自留地

Stay foolish, Stay hungry

并发原语(Goroutine、channel、select)是 Go 的核心,要学好 Go 并发,需要具备一些操作系统的基础知识。

所以正式学习 Goroutine 之前,先从什么是并发说起。

什么是并发

并发的概念:两个或者多个事情在同一时间间隔内发生

在单核 CPU 的时代,操作系统的基本调度和执行单元是进程(process),计算机可以“同时”运行多个程序,都是因为操作系统的并发性的存在。
这些程序宏观上是同时运行的,而微观上则是交替运行的。

这个时候,用户层的应用有两种设计方式:单进程和多进程,下面一一介绍。

单进程

一个应用对应一个进程,操作系统(CPU)每次只能为一个进程进行服务。

单进程应用的情况下,用户层应用、操作系统进程以及处理器之间的关系是这样的:

可以看到,因为 CPU 只有一个,因此CPU 会轮流地为各个进程进行服务,CPU 的运行速度非常快,所以宏观上这些进程是同时运行的。

总的来说,单进程应用的设计比较简单,它的内部仅有一条代码执行流,代码从头执行到尾,不存在竞态,无需考虑同步问题。

多进程

一个应用对应多个进程,操作系统(CPU)仍然每次只能为一个进程进行服务。

多进程应用的情况下,用户层应用、操作系统进程以及处理器之间的关系是这样的:

可以看到,App1 这个应用内部划分了多个模块,每一个模块内对应一个进程,每一个模块都是一个单独的代码执行流。

但是受限于单核 CPU,这些进程依旧只能并发运行,也就是轮流被单个CPU 服务。

这样看起来,多进程应用与单进程应用相比,似乎并没有什么质的提升,那为什么还要将应用设计成多进程?

这是因为,更多的是从应用的结构角度去考虑的,多进程应用将功能职责进行了划分(模块 1 对应 进程 1、模块2 对应进程 2 ),从结构上来看,要比单进程更为清晰简洁,可读性与可维护性也更好。

这种将程序分成多个可独立执行的部分的结构化程序的设计方法,就是并发设计。采用了并发设计的应用也可以看成是一组独立执行的模块的组合。

不过,进程并不适合用于承载并发设计的应用的模块执行流。因为进程是操作系统中资源拥有的基本单位,它不仅包含应用的代码和数据,还有系统级的资源,比如文件描述符、内存地址空间等等。进程的“包袱”太重,这导致它的创建、切换与撤销的代价都很大。

于是线程便诞生了。

线程

可以把线程理解为“轻量级进程”。

引入线程之后,线程就成了操作系统能够进行运算调度的最小单位

一个进程至少会包含一个线程,如果一个进程只包含一个线程,那么它里面的所有代码都只会被串行地执行。每个进程的第一个线程都会随着该进程的启动而被创建,它们可以被称为其所属进程的主线程。

如果一个进程中包含了多个线程,那么其中的代码就可以被并发地执行。除了进程的第一个线程之外,其他的线程都是由进程中已存在的线程创建出来的。

同时随着多核 CPU 的普及,让真正的并行成为了可能,于是主流的应用设计模型变成了这样:

可以看到,基于线程的应用通常采用的是单进程多线程的模型,一个应用对应一个进程,应用通过并发设计将自己划分为多个模块,每个模块由一个线程独立承载执行。多个线程共享这个进程所拥有的资源,此时,作为执行单元被 CPU 处理就由进程变成了线程。

线程的创建、切换与撤销的代价相对于进程是要小得多。当这个应用的多个线程同时被调度到不同的处理器核上执行时,就说这个应用是并行的。

讲到这里,可以对并发与并行两个概念做一些区分了。就像 Go 语言之父 Rob Pike 曾说过那样:并发不是并行,并发关乎结构,并行关乎执行

goroutine 基本概念

Go 并没有使用操作系统线程作为承载分解后的代码片段(模块)的基本执行单元,而是实现了goroutine 这一由 Go 运行时(runtime)负责调度的、轻量的用户级线程,为并发程序设计提供原生支持

相比传统操作系统线程来说,goroutine 的优势主要是:

  • 资源占用小,每个 goroutine 的初始栈大小仅为 2k
  • 由 Go 运行时而不是操作系统调度,goroutine 上下文切换在用户层完成,开销更小
  • 在语言层面而不是通过标准库提供,goroutine 由 go 关键字创建,一退出就会被回收或者销毁,开发者无需过多关注
  • 语言内置 channel 作为 goroutine 间通信原语,为并发设计提供了强大支持

和传统编程语言不同的是,Go 语言是面向并发而生的,所以,在程序的结构设计阶段,Go 的惯例是优先考虑并发设计。这样做的目的更多是考虑随着外界环境的变化,通过并发设计的 Go 应用可以更好地、更自然地适应规模化。

goroutine 调度器

提到“调度”,首先想到的就是操作系统对进程、线程的调度。操作系统调度器会将系统中的多个线程按照一定算法调度到物理 CPU 上去运行。

传统的编程语言,比如 C、C++ 等的并发实现,多是基于线程模型的,也就是应用程序负责创建线程,操作系统负责调度线程。

但是这种传统的并发方式,有很多不足,为了解决这些问题,Go 语言中的并发实现,使用了 Goroutine,代替了操作系统的线程,也不再依靠操作系统调度。

Goroutine 调度的切换不用陷入操作系统的内核层完成,开销很低,因此一个 Go 程序可以创建成千上万个并发的 Goroutine。
而将这些 Goroutine 按照一定算法放到 “CPU” 上执行的程序,则被成为 Goroutine 调度器,注意,这里的“CPU” 是打引号的,并不是真正意义上的 CPU。

一个 Go 程序对于操作系统来说只是一个用户层程序,操作系统眼中只有线程,它甚至不知道有 Goroutine 的存在。所以 Goroutine 的调度全要靠 Go 自己完成。
那么,实现 Go 程序内 Goroutine 之间“公平”竞争“CPU”资源的任务,就落到了 Go 运行时(runtime)头上了。要知道在一个 Go 程序中,除了用户层代码,剩下的就是 Go 运行时了。

于是,Goroutine 的调度问题就演变为,Go 运行时如何将程序内的众多 Goroutine,按照一定算法调度到“CPU”资源上运行的问题了。

在操作系统层面,线程竞争的“CPU”资源是真实的物理 CPU,而 Go 程序层面,各个 Goroutine 要竞争的“CPU” 资源到底是什么?

前面说过,Go 程序是用户层程序,它本身就是整体运行在一个或者多个操作系统线程上的。所以,Goroutine 要竞争的“CPU” 资源其实就是操作系统线程。
因此,Goroutine 调度器的任务也就明确了,将 Goroutine 按照一定算法放到不同的操作系统线程中去执行

GPM 模型

Goroutine 调度器目前使用的是 GPM 模型,它由三部分组成:

  • G(goroutine):Goroutine,存储了 Goroutine 的执行栈信息、Goroutine 状态以及 Goroutine 的任务函数等
  • P(processor):逻辑处理器,负责从全局运行对列中获取 G(Goroutine),放到对应的本地运行队列,会绑定唯一的操作系统线程,当 G 可以运行时,会被放入逻辑处理器的执行队列
  • M(machine):物理处理器,真正的执行计算资源

操作系统会在物理处理器上调度线程来运行,而 Go 语言的运行时会在逻辑处理器上调度 Goroutine 来运行。

G、P、M 三者的调度过程如下:

  1. 创建一个 Goroutine 并准备运行,这个 Goroutine 就会被放到调度器的全局运行队列中。
  2. 全局运行队列中的 Goroutine 依次出队,被调度器分配给队列的逻辑处理器 P,并放到这个逻辑处理器的本地运行队列中,本地运行队列中的 Goroutine 会一直等待,直到自己被分配的逻辑处理器执行。
  3. Go语言运行时默认会为每个可用的物理处理器分配一个逻辑处理器,物理处理器调度线程开始执行 Goroutine。
  4. 如果正在运行的 Goroutine 需要执行一个阻塞的系统调用,比如打开一个文件,当这类调用发生时,线程和 Goroutine 会从逻辑处理器上分离,该线程会继续阻塞,等待系统调用返回。
  5. 与此同时,这个逻辑处理器就失去了用来运行的线程。所以,调度器会创建一个新的线程,并将其绑定到该逻辑处理器上。
  6. 之后,调度器会从本地运行队列里选择另外一个 Goroutine 来运行,一旦被阻塞的系统调用执行完成并返回,对应的 Goroutine 会放回到本地运行队列,而之前的线程会保存好,以便之后继续使用。

下面是一个Goroutine 调度原理图,可以从全局进一步看到 G、P、M 三者之间的关系:

goroutine 的基本用法

Go 语言通过 go 关键字 + 函数/方法的方式“创建”一个 goroutine。

1
2
3
4
5
6
7
8
9
10
11
package main

import "fmt"

func main() {
for i := 0; i < 10; i++ {
go func() {
fmt.Println(i)
}()
}
}

运行上面的示例代码,可能会发现,什么都打印出来,这是怎么回事呢?

与一个进程总会有一个主线程类似,每一个独立的 Go 程序在运行时也总会有一个主 goroutine(main goroutine)。这个main goroutine 会在 Go 程序的运行准备工作完成后被自动地启用,并不需要做任何手动的操作。

每个 goroutine 一般都会携带一个函数调用,这个被调用的函数常常被称为go 函数。而main goroutine 的go函数就是那个作为程序入口的 main 函数。

这里一定要注意:go 函数真正被执行的时间,总会与其所属的 go 语句被执行的时间不同

当程序执行到一条 go 语句时,Go 语言运行时,会先试图从某个空闲的 G 的队列中获取一个 G(也就是 goroutine),它只有在找不到空闲 G 的情况下才会去创建一个新的 G。

这也是前面的“创建”打引号的原因,因为已存在的 goroutine 总是会被优先复用。

在拿到一个空闲的 G 之后,Go 语言运行时,会用这个 G 去包装当前的那个 go 函数(或者说该函数中的那些代码),然后再把这个 G 追加到某个存放可运行的 G 的队列中。

这类队列中的 G 总是按照先入先出的顺序,被 Groutine 调度器安排运行。

因此,go 函数的执行时间总是会明显滞后于它所属的go 语句的执行时间。这里所说的“明显滞后”是对于计算机的 CPU 时钟和 Go 程序来说的。我们在大多数时候都不会有明显的感觉。

还有一个与 main goroutine 有关的特性,一旦main goroutine 退出了,那么也意味着整个应用程序的退出

再次回到上面示例代码没有打印出结果的这个问题,这是因为
关键字 go 并非直接执行并发操作,而是“创建”一个 goroutine(并发任务单元)。新创建的 goroutine 被放置在队列中,等待调度器安排合适系统线程去获取执行权。该过程不会阻塞,因此不会等待该任务启动,而是继续执行后边的语句,如果直至 main goroutine 退出时,还有 goroutine 未得到执行,那么它们中的代码也不会被执行了,因为整个程序也要退出了, 所以没有任何内容打印出来。

Go 语言并不会保证 goroutine 会以怎样的顺序运行,由于main goroutine 会与手动使用 go 关键字创建的 goroutine 一起接受调度,调度器可能会在 goroutine 中代码执行一部分时暂停,因此,哪个 gorotuine 先执行完,哪个后执行完,往往是不可预知的。除非使用Go 语言提供的方式进行人为干预。

下面会简单介绍一下。

goroutine 执行规则

因为一旦 main goroutine 执行完成,当前 Go 程序就会结束运行,无论其他的 goroutine 是否已经在运行中,那么,有没有什么办法可以让其他的 goroutine 先执行完成之后,再让main goroutine 执行呢?

有很多办法可以做到,这里先用最简单粗暴的方式感受一下:

1
2
3
4
5
6
7
8
func main() {
for i := 0; i < 10; i++ {
go func() {
fmt.Println(i)
}()
}
time.Sleep(500 * time.Millisecond)
}

运行示例代码,输出一下结果:

1
2
3
4
5
6
7
8
9
10
11
10
10
10
10
10
7
10
10
8
7
// 延迟 500 ms 结束程序

其原理就是在 for 语句的后面,调用 time.Sleep 函数,让 main goroutine 延迟 500ms 结束。

这里延迟 500ms,足够其他的 goroutine 被调度器调度了,因此便可以看到打印结果。

这个办法虽然可行,但是 Sleep 的时间设置多久才合适呢?500ms、200ms、100ms?设置太短了,可能还没有打印就结束了,设置太长了,完全又浪费时间。

既然不好预估时间,那么能否让其他的 goroutine 在运行完成时通知一下呢?

这个思路是对的,使用Go 的 channel 就可以实现(后面的笔记会详细介绍 channel),这里有个印象就好。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package main

import "log"

func main() {
sign := make(chan int)

for i := 0; i < 10; i++ {
go func(i int) {
// 将变量 i 依次发送到 channel 类型变量中
sign <- i
}(i)
}

for j := 0; j < 10; j++ {
// 依次从channel 类型变量中取出值并打印
fmt.Println(<-sign)
}
}

运行示例代码,输出一下结果:

1
2
3
4
5
6
7
8
9
10
0
7
6
3
2
8
4
1
9
5

从输出结果可以看到,确实也能正常打印出来,而且没有“延迟”,打印完成,程序正好结束。

但是为什么这里输出的值不是顺序的呢?
其实这也是前面提到过的 goroutine 的执行规则所导致的。

创建 goroutine 是需要时间的,for 语句执行并不会停下来等待一个 goroutine 创建完成之后再开始下一次遍历。

因为这种异步并发执行的特性,10 个 goroutine 全部被创建完成之后,在队列中的顺序可能有 N 中组合,所以最后通过另一个 for 语句,依次从 channel 取出打印时,i 的值绝大多数情况下不是顺序的。

可以结合下图进行理解:


还有比 channel 更好的方式可以实现,比如 sync.WaitGroup,这里先不过多介绍,在后面的笔记中再详细讲解。

总结

  • 并发:逻辑上具备同时处理多个任务的能力
  • 并行:物理上在同一时刻执行多个并发任务
  • 单核 CPU 同一时刻只能执行一个程序,各个程序只能并发地运行
  • 多核 CPU 同一时刻可以同时执行多个程序,多个程序可以并行地运行
  • 并行的必要条件是具有多个处理器或多核处理器,否则无论是否是并发的设计,程序执行时都有且仅有一个任务可以被调度到处理器上执行
  • goroutine 在逻辑处理器上执行,而逻辑处理器具有独立的系统线程和运行队列
  • 关键字 go + 函数并不是表示直接并发执行该函数,而是创建一个 goroutine
  • 一旦main goroutine 退出了,当前 Go 程序就会结束运行
  • Go 不会保证 goroutine 的执行顺序,如果希望按照某个预期顺序执行,则需要“人为干预”

参考链接

评论