2. 并发编程之 goroutine

goroutine 是Go语言中的轻量级线程实现,由Go运行时(runtime)管理。你将会发现,它的 使用出人意料得简单。

  • 并发的口号:不要通过共享内存来通信,而应该通过通信来共享内存。

2.1. 快速入门

1. 并发问题

假设我们需要实现一个函数Add(),它把两个参数相加,并将结果打印到屏幕上,具体代码 如下:

func Add(x, y int) {
	z := x + y
	fmt.Println(z)
}

那么,如何让这个函数并发执行呢?具体代码如下:

// 加入 go 关键字,开启协程
go Add(1, 1) 

是不是很简单?

你应该已经猜到,“go”这个单词是关键。与普通的函数调用相比,这也是唯一的区别。

在一个函数调用前加上go关键字,这次调用就会在一个新的 goroutine 中并发执行。当被调用 的函数返回时,这个 goroutine 也自动结束了。

需要注意的是,如果这个函数有返回值,那么这个 返回值会被丢弃

好了,现在让我们动手试一下吧,还是刚才Add()函数:

package main

import (
	"fmt"
)

func main() {
	for i := 1; i <= 10; i++ {
		go Add(i, i)
	}
}

func Add(x, y int) {
	z := x + y
	fmt.Println(z)
}

在上面的代码里,我们在一个for循环中调用了10次Add()函数,它们是并发执行的。可是 当你编译执行了上面的代码,就会发现一些奇怪的现象:

  • “什么?!屏幕上什么都没有,程序没有正常工作!

  • ” 是什么原因呢?明明调用了10次Add(),应该有10次屏幕输出才对。要解释这个现象,就涉 及Go语言的程序执行机制了。

Go语言执行机制:

Go程序从初始化main package并执行main()函数开始,当main()函数返回时,程序退出, 且程序并不等待其他 goroutine (非主 goroutine )结束。

对于上面的例子,主函数启动了10个 goroutine ,然后返回,这时程序就退出了,而被启动的 执行Add(i, i)的 goroutine 没有来得及执行,所以程序没有任何输出。

OK,问题找到了,怎么解决呢?

提到这一点,估计写过多线程程序的读者就已经恍然大悟, 并且摩拳擦掌地准备使用:

  • 类似 WaitForSingleObject 之类的调用

  • 或者写个自己很拿手的忙等待

  • 或者 稍微先进一些的 sleep 循环 等待来等待所有线程执行完毕

在Go语言中有自己推荐的方式,它要比这些方法都优雅得多。 要让主函数等待所有 goroutine 退出后再返回,如何知道 goroutine 都退出了呢?

这就引出了多个 goroutine 之间通信的问题。下面 我们将主要解决这个问题。

2. 并发通信

从上面的例子中可以看到,关键字go的引入使得在Go语言中并发编程变得简单而优雅,但 我们同时也应该意识到并发编程的原生复杂性,并时刻对并发中容易出现的问题保持警惕。别忘 了,我们的例子还不能正常工作呢。

事实上,不管是什么平台,什么编程语言,不管在哪,并发都是一个大话题。话题大小通常 也直接对应于问题的大小。并发编程的难度在于协调,而协调就要通过交流。从这个角度看来,并发单元间的通信是最大的问题。

在工程上,有两种最常见的并发通信模型:共享数据消息。

  • 共享数据 是指多个并发单元分别保持对同一个数据的引用,实现对该数据的共享。被共享的 数据可能有多种形式,比如内存数据块、磁盘文件、网络数据等。在实际工程应用中最常见的无疑是内存了,也就是常说的共享内存

  • 消息机制 认为每个并发单元是自包含的、独立的个体,并且都有自己的变量,但在不同并发 单元间这些变量不共享。每个并发单元的输入和输出只有一种,那就是消息。这有点类似于进程 的概念,每个进程不会被其他进程打扰,它只做好自己的工作就可以了。不同进程间靠消息来通 信,它们不会共享内存。Go语言提供的消息通信机制被称为channel。

2.2. 协程调度 与 GPM

1. 引言

GPM(Goroutine-Processor-Machine) Golang 语言的核心执行机制,为 Golang 提供了可靠的高并发、低开销、网络通信友好的协程机制,同时为 channel 机制提供了基础的保障。以 GPM 模型为树干,基本牵扯到了 golang 调度策略、 channel 、网络通信、内存管理、垃圾回收、IL设计等一系列核心内容。个人感觉是理解 Golang 设计/运行原理最好的入手点。

本次主要介绍 GPM 模型的演化和设计思想、执行流程和调度策略,如有分析不到位的地方欢迎指正。

2. 为什么需要调度器

我们知道 Golang 运行时有一个运行时。运行时在用户空间而不是内核中执行计划任务( goroutine ),因此它更轻量级。它在系统资源使用和性能之间做了更好的权衡,尤其是在 IO 任务中。

2.1 单进程时代

单进程时代并不需要调度器,所有的任务串行化的执行。

这种模式有两个明显的缺点:

1. 只有一个进程,计算机必须一项一项的处理任务
2. 进程阻塞带来的CPU 时间浪费

那么能不能有多个进程来宏观一起来执行多个任务呢?

后来操作系统就具有了最早的并发能力:多进程并发,当一个进程阻塞的时候,切换到另外等待执行的进程,这样就能尽量把CPU利用起来,CPU就不浪费了。

2.2 多进程/多线程时代

为了解决阻塞问题,我们可以让 cpu 在当前进程阻塞时执行其他任务。并且,我们创建了一个方法,将 cpu 时间划分为很小的时间片(大概 10ms ),并有时间限制地运行任务,以确保所有任务都可以执行。由于时间片的原因,所有任务似乎都在同一时间运行。

process scheduling

同时,CPU 还要处理进程所持有的上下文切换,创建、切换、销毁进程会消耗大量的系统资源。所以高并发情况下CPU有效使用率可能会偏低。在linux系统中,线程虽然重量更轻,但它们是CPU调度的基本单位。进度成本原则上类似于流程。

怎么才能提高CPU的利用率呢?

2.3 协程提高CPU利用率

多进程、多线程已经提高了系统的并发能力,但是在当今互联网高并发场景下,为每个任务都创建一个线程是不现实的,因为会消耗大量的内存 (进程虚拟内存会占用4GB[32位操作系统], 而线程也要大约4MB)。

更多进程/线程也会导致其它问题:

  1. 高内存占用。在 32 位系统中,每个进程将使用  4GB 虚拟内存。每个线程至少要花费 4MB

  2. 上下文切换时 CPU 使用率高。

工程师发现其实一个线程分为“内核态“线程和”用户态“线程。

大部分消耗发生在内核空间。我们知道一个进程有“用户空间”和“内核空间”,一个“用户态线程”必须要绑定一个“内核态线程”,但是CPU并不知道有“用户态线程”的存在,它只知道它运行的是一个“内核态线程”(Linux的PCB进程控制块)。

当系统调用或时间片触发时,进程会进入内核空间……但是从操作系统的角度来看,不管它是什么状态,由操作系统控制的进程使用一个数据结构称为PCB Process Control Block. OS看不到协程的工作状态,它只关心线程或PCB结构。

  • 我们可以将运行在内核空间的代码称为线程 (thread).

  • 可以将运行在用户空间的代码称为协程Coroutine

线程协程

那么为了减少内核空间的消耗,我们是否应该将多个协程绑定到一个线程?当然是的。如果我们schedule layer在线程和协程之间添加一个,将 N 个协程绑定到一个线程,我们得到一个N:1模式。

在这种情况下,我们可以在用户空间完成大部分工作,而不是频繁切换到内核。但是一旦线程被阻塞,所有的协程都不能工作。并且多核 CPU 不能仅在一个物理线程中全速运行。

  • 优点就是协程在用户态线程即完成切换,不会陷入到内核态,这种切换非常的轻量快速

  • 缺点就是1个进程的所有协程都绑定在1个线程上,一旦某协程阻塞,造成线程阻塞,本线程程的其他协程都无法执行了,根本就没有并发的能力了。

继续优化调度器

我们可以将N个协程绑定到M个物理线程。更复杂的调度器可以结合多线程的性能和协程的轻量级。

男:女

协程跟线程是有区别的,线程由CPU调度是抢占式的,协程由用户态调度是协作式的,一个协程让出CPU后,才执行下一个协程,协程应该主动释放CPU。

2.4 goroutine 协程

Go为了提供更容易使用的并发方法,使用了goroutine和channel。goroutine来自协程的概念,让一组可复用的函数运行在一组线程之上,即使有协程阻塞,该线程的其他协程也可以被runtime调度,转移到其他可运行的线程上。最关键的是,程序员看不到这些底层的细节,这就降低了编程的难度,提供了更容易的并发。

Go中,协程被称为goroutine,它非常轻量,一个goroutine只占几KB,并且这几KB就足够goroutine运行完,这就能在有限的内存空间内支持大量goroutine,支持了更多的并发。虽然一个goroutine的栈只占几KB,但实际是可伸缩的,如果需要更多内容,runtime会自动为goroutine分配。

在协程模式下,开发者应该主动释放 CPU。当一个协程长时间占用 CPU,而其他协程很饿时,这可能会导致问题。

所以在 golang中,scheduler是抢占式的, golang 中的tasks命名为goroutine,通过channel进行通信共享数据。

goroutines can run when other goroutine in the same thread blocked, runtime will help you do the scheduling, you have noting to do.. 它是为多并发场景实现的,比如多进程与单进程。 Goroutine 保留了协程的优点并具有更高的性能。

  • goroutine 只能通过4KB内存来设置。它非常轻巧。

3. 弃用的 GM 调度模型

好了,既然我们知道了协程和线程的关系,那么最关键的一点就是调度协程的调度器的实现了。线程和 goroutine 的关系我们已经知道了,这里的重点是The Scheduler.

现在在 golang中使用的调度器是2012年重新设计的,因为存在性能问题,所以弃用了。下面简单介绍一下这个调度器的工作原理。

  • G: 协程 【gotoutine】

  • M: 机器,内核空间中的线程 【thread】

下面我们来看看被废弃的golang调度器是如何实现的?

M想要执行、放回G都必须访问全局G队列,并且M有多个,即多线程访问同一资源需要加锁进行保证互斥/同步,所以全局G队列是有互斥锁进行保护的。

通用调度器

使用全局队列,老调度器的几个缺点:

  1. 要创建、销毁、调度 G ,M 需要进行激烈的锁定竞争

  2. 在 M 之间转移 G 会造成额外的系统负载。比如M创建了一个新的 goroutine G’,要执行G’,它应该被推入队列并在其他M’中运行,G与G’有关系,最好放在M上执行,而不是其他M’上执行。

  3. 频繁的系统调用(CPU在M之间的切换)导致频繁的线程阻塞和取消阻塞操作增加了系统开销。

4.  GPM 调度模型

面对之前调度器的问题,Go设计了新的调度器。

在新调度器中,出列M(thread)和G(goroutine),又引进了P(Processor) 处理器。

Processor,它包含了运行goroutine的资源,如果线程想运行goroutine,必须先获取P,P中还包含了可运行的G队列。

4.1 GPM 模型介绍

Goroutine 调度器和 OS 调度器使用 M 连接,每个 M 是一个物理 OS 线程,OS 调度器调度 M 运行在一个真实的 CPU 内核中。

在这种模式中,线程是物理工作者,调度程序应该将 goroutine 分派给一个线程。

GMP模式

G、P、M 职责

  • G: goroutine,go程序建立的用户线程。主要保存 goroutine 的运行时栈信息(stack结构体)以及 CPU 的一些寄存器的值( gobuf 结构体),还有关联的M,全局队列中下个G等信息。

  • P: processor 代表了M所需的上下文环境,也是处理用户级代码逻辑的处理器,可以看作一个局部调度器使go代码在一个线程上跑。

  • M: 线程想运行任务就得获取P,用于执行G。 M会优先从关联的P的本地队列中直接获取待执行的G,它保存了 M 自身使用的栈信息、当 前正在 M 上执行的 G 信息、与之绑定的 P 信息。

  • P列表:在创建程序的时候创建一个P列表, 最多有 $GOMAXPROCS个,这环境变量可以通过操作系统中的环境变量设置( 1W) ),也可以通过Go程序中的 runtime.GOMAXPROCS() 函数设置,默认为处理器的核心数,它代表了真正的并发度。

  • M列表:当前操作系统分配到当前go程序的内核线程数,可以通过go语言中runtime/debug包中的 SetMaxThreads 函数设置。当有一个M阻塞,会有一个新的M被创建;当有一个M空闲,会被回收或睡眠。

  • P的本地队列:P维护一个用来存放等待执行的 goroutine 本地队列,新创建的G会优先放在P的本地队列,当本地队列满( 256个 )时,会放入G的全局队列。

  • 全局队列:如果P的本地队列已满,待执行的G就会放在全局队列中,M会先从关联的P本地队列中获取待执行的G,没有的话,再到全局队列中获取;如果这里也没有了,就去其他P的本地队列中获取一些任务。

goroutine调度器和OS调度器是通过M结合起来的,每个M都代表了1个内核线程,OS调度器负责把内核线程分配到CPU的核上执行

GPM 模型 在原有的GM模型基础上,引入 P层,目标是解决

  1. 全局队列共享锁引发的性能问题,同时使得本地P中的G无锁化,空间局部性更好

  2. 将M 层 Mcache 下沉到P层,减少内存占用

  3. 因频繁挂起引发大切换代价大幅下降

关于 P和 M的数量设置

  • P的数量: 由运行时包中的GOMAXPROCS环境变量或GOMAXPROCS()函数决定。这意味着有 PRAGMATICS 协程在任何时候并发运行。

  • M的数量:

    • golang 支持的最大线程数是 10000,但是 OS 通常不能创建这么多线程。所以我们可以忽略这个限制。

    • SetMaxThreads() 运行时/调试包中的函数可以设置最大线程数

    • 一旦当前线程被阻塞,它将创建一个新线程 ,当有一个M空闲,会被回收或睡眠。

注意: M&P的编号没有关系。一旦当前 M 被阻塞,P 的 goroutine 将在其他 M 中运行或创建一个新 M。所以即使 P 的数量是 1,也可能有太多的 M。

**什么时候 创建 P、M **

  • PP的个数确定后,由runtime创建。

  • M: 如果没有足够的 M 来执行 P 的任务,则会创建它。例如,所有 M 都被阻止,将创建新的 M 来运行 P 的任务

4.2 GPM 调度策略

重用:重用线程,避免频繁创建、销毁线程。

  1. 工作窃取:当没有 G 运行时,从 P 绑定的 P 中窃取 G,而不是销毁

  2. Hand Off:当P被阻塞时,将P转移给其他空闲的M

并发GOMAXPROCS设置P的数量,最多有GOMAXPROCS个线程分布在多个CPU上同时运行。GOMAXPROCS也限制了并发的程度,比如GOMAXPROCS = 核数/2,则最多利用了一半的CPU核进行并行。

**Preemptive(先发制人) ** :协程必须主动放弃 cpu 时间。但是在 golang 中,一个 goroutine 最多可以运行 10ms ,避免其他 goroutine 饿死。

全局 Goroutines 队列:当工作窃取失败时,M 可以从全局队列中拉取 goroutines

4.3 GPM工作流程

go func(){} 之后发生了什么

img

从上图我们可以分析出几个结论:

  1. 通过 go func() {} 创建一个 goroutine

  2. 有两个存储G的队列,一个是局部调度器P的本地队列、一个是全局G队列。新创建的G会先保存在P的本地队列中,如果P的本地队列已经满了就会保存在全局的队列中;

  3. G只能运行在M中,一个M必须持有一个P,M与P是1:1的关系。M会从P的本地队列弹出一个可执行状态的G来执行,如果P的本地队列为空,就会想其他的MP组合偷取一个可执行的G来执行;

  4. 一个M调度G执行的过程是一个循环机制;

  5. 当M执行某一个G时候如果发生了syscall或则其余阻塞操作,M会阻塞,如果当前有一些G在执行,runtime会把这个线程M从P中摘除(detach),然后再创建一个新的操作系统的线程(如果有空闲的线程可用就复用空闲线程)来服务于这个P;

  6. 当M系统调用结束时候,这个G会尝试获取一个空闲的P执行,并放入到这个P的本地队列。如果获取不到P,那么这个线程M变成休眠状态, 加入到空闲线程中,然后这个G会被放入全局队列中。

4.4 GPM生命周期

和其它的语言设计类似,Golang 在启动时并不是从我们写的 main.main 启动,而是在外面包了一层 runtime.main 作为启动的入口。对于golang 本身来说:

1. 设置运行参数
2. 启动 sysmon 作为监控
3. 启动 GC 机制
4. 启动 panic 保护
5. 调用 main.main 执行用户代码
6. 竞争风险检查
7. 竞争 panic输出
8. 终止golang 运行环境
9. 退出
生命周期
  • M0:M0 是第一个创建的线程,由 引用runtime.m0,它做系统初始化,先启动 G,然后 M0 变成普通 M,和其他的一样。

  • G0:G0是每次启动一个M都会第一个创建的gourtine,G0仅用于负责调度的G,G0不指向任何可执行的函数, 每个M都会有一个自己的G0。在调度或系统调用时会使用G0的栈空间, 全局变量的G0是M0的G0。

我们来跟踪一段代码

package main
import "fmt"

func main() {
    fmt.Println("Hello world")
}

接下来我们来针对上面的代码对调度器里面的结构做一个分析。

也会经历如上图所示的过程:

  1. runtime创建最初的线程m0和goroutine g0,并把2者关联。

  2. 调度器初始化:初始化m0、栈、垃圾回收,以及创建和初始化由GOMAXPROCS个P构成的P列表。

  3. 示例代码中的main函数是main.mainruntime中也有1个main函数——runtime.main,代码经过编译后,runtime.main会调用main.main,程序启动时会为runtime.main创建goroutine,称它为main goroutine吧,然后把main goroutine加入到P的本地队列。

  4. 启动 M0,M0 已经绑定到 P,会从P的本地队列获取G,获取到main goroutine。

  5. G拥有栈,M根据G中的栈信息和调度信息设置运行环境

  6. 在 M 中运行 G

  7. G退出,再次回到M获取可运行的G,这样重复下去,直到main.main退出,runtime.main执行Defer和Panic处理,或调用runtime.exit退出程序。

调度器的生命周期几乎占满了一个Go程序的一生,runtime.main的goroutine执行之前都是为调度器做准备工作,runtime.main的goroutine运行,才是调度器的真正开始,直到runtime.main结束而结束。

5. 可视化GPM编程

有2种方式可以查看一个程序的GMP的数据。

  • go tool trace trace记录了运行时的信息,能提供可视化的Web页面。

  • Debug trace

5.1 go tool trace

简单测试代码:main函数创建trace,trace会运行在单独的goroutine中,然后main打印”Hello World”退出。

package main

import (
    "os"
    "fmt"
    "runtime/trace"
)

func main() {

    //创建trace文件
    f, err := os.Create("trace.out")
    if err != nil {
        panic(err)
    }

    defer f.Close()

    //启动trace goroutine
    err = trace.Start(f)
    if err != nil {
        panic(err)
    }
    defer trace.Stop()

    //main
    fmt.Println("Hello World")
}

运行程序

$ go run trace.go 
Hello World

会得到一个trace.out文件,然后我们可以用一个工具打开,来分析这个文件。

$ go tool trace trace.out 
2020/02/23 10:44:11 Parsing trace...
2020/02/23 10:44:11 Splitting trace...
2020/02/23 10:44:11 Opening browser. Trace viewer is listening on http://127.0.0.1:33479

https://img.kancloud.cn/ee/e8/eee828bc698d074e439f3e6929be74ef_2724x546.png

img

G 信息 点击Goroutines那一行可视化的数据条,我们会看到一些详细的信息。

img

一共有两个G在程序中,一个是特殊的G0,是每个M必须有的一个初始化的G,这个我们不必讨论。

其中G1应该就是main goroutine(执行main函数的协程),在一段时间内处于可运行和运行的状态。

6. 总结

总结,Go调度器很轻量也很简单,足以撑起goroutine的调度工作,并且让Go具有了原生(强大)并发的能力。Go调度本质是把大量的goroutine分配到少量线程上去执行,并利用多核并行,实现更强大的并发。

相关资料

  1. https://www.kancloud.cn/aceld/golang/1958305