Go 语言中的 Goroutine - 轻松理解并掌握协程

silverwq
2022-07-13 / 0 评论 / 268 阅读 / 正在检测是否收录...

概述

当代计算机的多进程、多线程模型

现在计算机的 cpu 核心数是有限的,比如 8 核心 cpu,同一时刻只能并行运行 8 个进程。而同一时间操作系统上要运行的程序要远远多于 cpu 的核心数。面对这个问题,操作系统的解决办法是不停的切换进程来执行,每个进程占用一小段时间片

由于进程拥有太多的资源(虚拟内存占用 4Gb),进程的创建、切换、销毁会占用很长的时间。线程虽然占用的资源少了一点(也要占用 4MB),但是多线程开发会比较复杂,开发者要考虑很多同步竞争问题,比如锁,资源竞争。

由于进程和线程的切换成本高,当进程和线程的数量比较多的时候,会导致 cpu 很大一部分时间都浪费在切换进程和线程上,没有真正的用在执行逻辑程序上。

引入协程模型

协程类似于线程,属于用户态的线程,系统的是线程是内核态的线程;用户态的比内核态的轻量(goroutine 只占用几 Kb),切换的执行的时候,比较快。

一个用户态线程,必须绑定一个内核态线程,内核态线程可以由协程调度创建。

线程和协程的区别是:

  1. 线程由 cpu 调度,是抢占式的
  2. 协程是用户动态调度的,是协作式的,一个协程让出 cpu 后才会执行下一个协程

GPM 调度器模型

GPM 的模型中:

  1. G 指的是 go 协程 goroutine
  2. P 是协程调度器 processer,G 是放在 P 里,然后 M 执行 P 里的 G
  3. M 是 machine,也就是线程,用于执行 G

在 go 中,线程是运行 goroutine 的实体,调度器是把 goroutine 分配到线程上执行。在 GPM 模型中,有几个重要的概念,如下图所示:

l5iatxdp.png

  1. 全局队列:存放等待运行的 G,可以被任意的 P 获取执行,相当于整个 GPM 模型里的共用资源,所以需要加入互斥锁
  2. P 的本地队列:存放的也是等待执行的 G,但是存放的数量有限,最多 256 个。该队列里的 G 新建的 G,会优先放到本队列,如果本队列的 G 满了,再放到全局队列中,具体见下文
  3. P 列表:在程序启动的时候创建,最多可以有 GOMAXPORCS 个
  4. M 线程:线程是由 P 创建,要执行 G 的话,M 首先要绑定 P,从 P 的本地队列获取 G,如果 P 的本地队列为空,则尝试从全局队列获取一批 G,放到本地队列,全局队列再为空的话再从其他 P 的本地队列一半放到本地队列。

有关 p 和 m 的数量的问题

  1. P 的个数是启动的时候,环境变量 $GOMAXPROCS 或 runtime 的 GOMAXPROCS() 方法决定的。也就是说,程序在运行的时候,只有 $GOMAXPROCS 个 goroutine 同时在运行。这个数量一般设置成内核的一半
  2. M 的数量,由 Go 语言本身的限制决定的,Go 在启动的时候,会设置 M 的最大数量为 1 万个,但是操作系统很难支撑这么多的线程,所以这个限制忽略不计。runtime/deBug 中的 SetMaxThreads() 方法,可以设置 M 的最大数量。当一个 M 阻塞的时候,会创建新的 M。

总得来说,P 和 M 的数量没有绝对关系,一个 M 阻塞,P 会创建新的 M 或切换到其它空闲的 M 上。

GPM 的设计策略

复用线程

GMP 会对线程复用,避免频繁的创建和销毁线程,也就避免让线程闲着。

偷取机制

线程 M 绑定到 P,当一个 P 上没有可执行的 G 的时候,本 P 就会从其他 P 的本地队列上偷去一半的 G 到本队列执行。这样就不会导致空闲的 P 无事可做了。

移交机制

当 M 正在执行的 G 发生了阻塞,那么 P 就会和绑定的 M 发生分离,和一个空闲的 M 再次绑定,如果没有空闲的 M, P 新创建一个 M,这样就不会因为一个 G 阻塞,影响到队列上的其他 G 没法执行。

利用并行

通过 GOMAXPROCS 来设置 P 的数量,一般设置为核心数的一半,表示最多利用核心数的一半在并行的运行

抢占

go 中的协程,一个 goroutine 最多执行 10ms ,就会获取下一个可执行状态的 G 来执行。

全局 G 队列

当 P 去其他 P 的本地队列上偷不到 G 的时候,它可以从全局队列获取 G。

创建 goroutine 的流程

当我们在代码中执行 go func() 的时候,GPM 会进行哪些操作呢?

当创建协程后,G 会优先保存到 P 的本地队列中,当 P 的本地队列满了,就会保存到全局队列中。执行流程如下图的序号所示:

lnebbixb.png

调度器的生命周期

在 GPM 里,还有两个比较重要的角色,分别是 M0 和 G0。

M0 线程

  1. 启动程序后编号为 0 的主线程
  2. 在全局命令 runtime.m() 中,不需要在 heap 堆上分配
  3. 负责执行初始化操作,和启动第一个 G
  4. 启动 1 个 G 后,M0 就和其他 M 一样了

G0 协程

  1. 每启动一个 M,创建第一个 goroutine 就是 G0
  2. G0 负责调度 G
  3. G0 不指向任何一个可执行的函数
  4. 每个 M 都会有一个自己的 G0
  5. 在调度或者系统调度的时候,M 会切换到 G0,再通过 G0 取获取其他 G
  6. M0 和 G0 会放在全局空间

gpm 可视化 trace

go tool trace 的方式

例如:

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

func main() {
 f, err := os.Create("trace.out")
  if err != nil {
     panic(err)
  }
   defer f.Close()

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

  fmt.Println("hello world")
}

运行完上面代码,会得到 trace. out 文件,可以用下列命令打开

go tool trace trace.out

然后浏览器打开 http://127.0.0.1:34646/trace , 可以得到下图:

lnecuzh3.png

debug trace

如下例代码


package main

import (
   "fmt"
 "time"
)

func main() {
  for i := 0; i < 5; i++ {
        time.Sleep(time.Second)
     fmt.Println("hello world")
    }
}

执行下面命令,得到 main.exe

go build main.go

然后执行

GODEBUG=schedtrace=1000 ./main.exe

输出

SCHED 0ms: gomaxprocs=16 idleprocs=14 threads=5 spinningthreads=1 idlethreads=0 runqueue=0 [1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0]
SCHED 1007ms: gomaxprocs=16 idleprocs=16 threads=6 spinningthreads=0 idlethreads=3 runqueue=0 [0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0]
hello world
SCHED 2016ms: gomaxprocs=16 idleprocs=16 threads=6 spinningthreads=0 idlethreads=3 runqueue=0 [0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0]
hello world
SCHED 3018ms: gomaxprocs=16 idleprocs=16 threads=6 spinningthreads=0 idlethreads=3 runqueue=0 [0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0]
hello world
SCHED 4027ms: gomaxprocs=16 idleprocs=16 threads=6 spinningthreads=0 idlethreads=3 runqueue=0 [0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0]
hello world
SCHED 5030ms: gomaxprocs=16 idleprocs=16 threads=6 spinningthreads=0 idlethreads=3 runqueue=0 [0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0]
hello world

上面输出的含义:

  1. SCHED: 代表本行是调度器的输出日志
  2. 0ms: 从程序启动,到输出这行日志的时间
  3. gomaxprocs: 设置的 processor 的数量
  4. idlprocs: 处于 idl 状态的 P 的数量,gomaxprocs-idlprocs=正在运行的协程的数量
  5. threads:总的线程的数量,包括 M 和 go 里自用的一些内部线程
  6. spinningthreads:处于自旋状态的 os 线程数量
  7. idlethreads:出于 idle 转头的 os 线程数量
  8. runqueue=0:全局队列中 G 的数量
  9. [1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0] :为 16 个 P 的本地队列的 G 的数量

场景

场景 1:G1 创建 G2,G2 会存在那个队列

G1 使用 go func () 创建 G2,为了局部性,因为 G1 和 G2 所保存的内存和堆栈信息最为相同,切换成本比较低,所以 G2 优先加入到 G1 的本地队列

场景 2 :G1 执行完成后,如何获取新的 G2

当 G1 执行完成后,M1 上的 goroutine 切换到 G0,G0 就可以取获取 G2 (先从本地队列,然后从全局队列,最后从其他 P 的本地队列获取),然后切换到 G2 来执行。

场景 3:如果 P 的本地队列只能存放 4 个 G,G1 创建了 6 个 G,此时多出来的 1 个 G 放在哪里?

因为 P 上可以放下 4 个 G,所以 G2,G3,G4,G5 会放在 G1 的本地队列里,G6 会放在全局队列里,然后还会讲 G1 本地队列的前面一半放到 G2,G3 放到全局队列里,然后把 G4,G5 移到队列的头部。此时 G1 的本地队列只有 G4,G5。

需要注意的是:G2, G3, G5 会以乱序的方式放到全局队列里。

场景 4:线程空闲的时候,会和 P 分离,并且休眠,如何重新唤醒休眠的线程呢?

空闲的线程,操作系统不会立刻回收,而是休眠,当尝试创建一个 G 的时候,就会尝试从休眠线程中获取 M,然后 M 尝试去绑定可以被利用的 P,绑定完成后,M ,P,G0 组成自旋线程,G0 会去寻找 G 放到自己的本地队列里

场景 5:自旋线程从全局队列获取多少个 G

n = min(全局队列的G数量/GOMAXPROCS+1,cap(P的本地队列)/2)

场景 6:全局队列已经没有 G 了,自旋线程需要从其他 P 的本地队列偷取多少个 G

从其他 G 的 P 那里偷去一半 G,放到自己的本地队列里。

场景 7:G1 如果发生了阻塞的系统调用,这时候会发生什么呢

如果 G1 发生了阻塞,那么相应的 M1 和 P1 就会立即解绑。

  1. 此时如果 P1 本地队列或者全局对了有 G 待执行,如果有休眠的进程,P1 会唤醒一个线程和 P1 绑定,没有空闲的线程的话,就会创建一个新的线程和 P1 绑定。
  2. 否则,P1 会加入空闲的 P 列表,等待 M 获取可用的 P

当 G1 的阻塞状态解除后,M1 会尝试获取之前记住的 P1,如果 P1 是可获取状态 (也就是说此时 P1 没有和其他 M 绑定,在空闲 P 列表中),M1 和 P1 会重新绑定,否则就会从空闲的 P 列表里寻找可以用的 P。如果找不到空闲的 P,则 G1 会被标记为可以运行状态,并且加入到全局队列中。然后 M1 因为没有 P 的绑定变成休眠状态,长时间的休眠,会被 GC 回收

Goroutine 编程

Goroutine 是 go 程序并发的执行体。

在程序启动的时候,只有一个 goroutine 调用 main 函数,叫做主 goroutine。新的 goroutine 需要用 go 语句创建,go 语句本身执行是立即完成:

f()
go f()

需要注意的是,当 main 函数返回的时候,所有的 goroutine 都会被暴力的直接终止退出。

func hello() {
  fmt.Println("2")
}
func main() {
    go hello() // 这个有时候不会打印,因为有时候main很快就结束了
 fmt.Println("1")
}

我们可以用 sync.WaitGroup 来解决父 goroutine 过早的退出的问题

var wg sync.WaitGroup

func hello() {
 fmt.Println("2")
  wg.Done()
}

func main() {
 wg.Add(1)
   go hello()
  fmt.Println("1")
  wg.Wait()
}

还可以通过同步通道来解决

func hello(ch chan int) {
 ch <- 0
 fmt.Println("2")
}

func main() {
    ch := make(chan int)
    go hello(ch)
    <-ch
    fmt.Println("1")
  close(ch)
}

如何在不修改原有函数的情况下,并行执行函数

可以用一个闭包嵌套以下

func hello() {
  fmt.Println("2")
}

func main() {
    ch := make(chan int)
    for i := 1; i < 5; i++ {
        // 嵌套一个闭包
       go func() {
         hello()
         ch <- 1
     }()
 }
   for i := 1; i < 5; i++ {
        <-ch
    }
   fmt.Println("1")
  close(ch)
}
0

评论 (0)

取消