golang协程详解

1 简介

Go语言中goroutine的概念类似于线程,但 goroutine是由Go的运行时(runtime)调度和管理的。Go程序会智能地将 goroutine 中的任务合理地分配给每个CPU,在语言层面已经内置了调度和上下文切换的机制。

在Go语言编程中你不需要去自己写进程、线程、协程,你的技能包里只有一个技能–goroutine,当你需要让某个任务并发执行的时候,你只需要把这个任务包装成一个函数,开启一个goroutine去执行这个函数就可以了,就是这么简单粗暴。

1.1 Go协程对比线程的优点

与线程相比,Go协程的开销非常小。Go协程的堆栈大小只有几kb,它可以根据应用程序的需要而增长和缩小,而线程必须指定堆栈的大小,并且堆栈的大小是固定的。

Go协程被多路复用到较少的OS线程。在一个程序中数千个Go协程可能只运行在一个线程中。如果该线程中的任何一个Go协程阻塞(比如等待用户输入),那么Go会创建一个新的OS线程并将其余的Go协程移动到这个新的OS线程。所有这些操作都是 runtime 来完成的,而我们程序员不必关心这些复杂的细节,只需要利用 Go 提供的简洁的 API 来处理并发就可以了。

Go 协程之间通过信道(channel)进行通信。信道可以防止多个协程访问共享内存时发生竟险(race condition)。信道可以想象成多个协程之间通信的管道。我们将在下一篇教程中介绍信道。

2 如何使用协程

Go语言中使用goroutine非常简单,只需要在调用函数的时候在前面加上go关键字,就可以为一个函数创建一个goroutine。

一个goroutine必定对应一个函数,可以创建多个goroutine去执行相同的函数。

2.1 启动协程

启动线程,只需要在调用function之前,添加 go 关键字即可

package main
import (
    "fmt"
    "time"
)
func numbers() {
    for i := 1; i <= 5; i++ {
        time.Sleep(250 * time.Millisecond)
        fmt.Printf("%d ", i)
    }
}
func main() {
    // 调用普通函数启动协程
    go numbers()
    // 通过匿名函数启动协程
    go func() {
        for i := 'a'; i <= 'e'; i++ {
            time.Sleep(400 * time.Millisecond)
            fmt.Printf("%c ", i)
        }
    }()
    time.Sleep(3000 * time.Millisecond)
    fmt.Println("main terminated")
}

如上代码中,主协程在16,19行启动了两个子协程,一个用于打印数字1~5,一个用于打印字母a~e。主协程开启等待3000毫秒后退出。

2.2 协程之间的关系

  1. 协程是独立的执行单元
  • 每个协程在运行时都是独立的,不存在严格的“父协程”和“子协程”关系。
  • 如果“父协程”退出,“子协程”并不会受到直接影响,仍会继续运行。
  1. 程序的生命周期由主协程决定
  • 整个 Go 程序的生命周期是由 main 函数(主协程)控制的。
  • 当主协程(main 函数)退出时,所有协程都会被强制终止,程序结束。

3 协程的底层实现

3.1 专业术语

在进行协程实现原理之前,先了解如下关键术语的概念:

  • 并发(concurrency):并发是任务的逻辑同时性,多个任务在时间片上交替执行,尽管在微观上是顺序运行,但宏观上看起来像是同时运行。并发可以发生在单个 CPU 或多个 CPU 上,主要依赖任务的调度和切换。。
  • 并行(parallelism):并行是任务的物理同时性,指多个 CPU 或计算单元在同一时刻真正同时运行多个任务,互不干扰。并行需要硬件支持(如多核 CPU 或 GPU)。
  • 进程(process):进程是操作系统分配资源的基本单位,它是运行中的程序实例,包含代码、数据段、堆栈、打开的文件等资源。进程切换涉及上下文切换,包括寄存器、内存页表等状态的保存和恢复。
  • 线程(thread):线程是操作系统调度的基本单位。线程共享同一进程的资源,如内存和文件描述符,因此线程切换的开销低于进程切换。线程切换主要保存和恢复寄存器上下文,而无需完全隔离的资源切换。
  • 协程:协程是一种轻量级的用户级线程,拥有自己的寄存器上下文和栈。协程的切换由用户程序控制,不依赖操作系统内核,因此切换开销低。协程可以在运行时保留状态,每次重入时恢复上一次的逻辑流位置。

3.2 调度模型GPM

Go 语言的协程(Goroutine)采用了高效的调度模型,称为 GPM 模型,用于实现 Go 程序的高并发能力。这一模型由以下三个核心部分组成:

  • G: 表示Goroutine,每个Goroutine对应一个G结构体,G存储Goroutine的运行堆栈、状态以及任务函数,可重用。G并非执行体,每个G需要绑定到P才能被调度执行。。
  • P: Processor,表示逻辑处理器, 对G来说,P相当于CPU核,G只有绑定到P(在P的local runq中)才能被调度。对M来说,P提供了相关的执行环境(Context),如内存分配状态(mcache),任务队列(G)等,P的数量决定了系统内最大可并行的G的数量(前提:物理CPU核数 >= P的数量),P的数量由用户设置的GOMAXPROCS决定,但是不论GOMAXPROCS设置为多大,P的数量最大为256。
  • M: Machine,操作系统线程抽象,代表着真正执行计算的资源,在绑定有效的P后,进入schedule循环;而schedule循环的机制大致是从Global队列、P的Local队列以及wait队列中获取G,切换到G的执行栈上并执行G的函数,调用goexit做清理工作并回到M,如此反复。M并不保留G状态,这是G可以跨M调度的基础,M的数量是不定的,由Go Runtime调整,为了防止创建过多OS线程导致系统调度不过来,目前默认最大限制为10000个。;

在Go中,线程是运行goroutine的实体,调度器的功能是把可运行的goroutine分配到工作线程上

  • 全局队列(Global Queue):存放等待运行的G
  • P的本地队列:同全局队列类似,存放的也是等待运行的G,存的数量有限,不超过256个。新建G’时,G’优先加入到P的本地队列,如果队列满了,则会把本地队列中一半的G移动到全局队列
  • P列表:所有的P都在程序启动时创建,并保存在数组中,最多有GOMAXPROCS(可配置)个
  • M:线程想运行任务就得获取P,从P的本地队列获取G,P队列为空时,M也会尝试从全局队列拿一批G放到P的本地队列,或从其他P的本地队列一半放到自己P的本地队列。M运行G,G执行之后,M会从P获取下一个G,不断重复下去

P和M的数量问题 P的数量:环境变量$GOMAXPROCS;在程序中通过runtime.GOMAXPROCS()来设置M的数量: GO语言本身限定一万 (但是操作系统达不到);通过runtime/debug包中的SetMaxThreads函数来设置;有一个M阻塞,会创建一个新的M;如果有M空闲,那么就会回收或者休眠 M与P的数量没有绝对关系,一个M阻塞,P就会去创建或者切换另一个M,所以,即使P的默认数量是1,也有可能会创建很多个M出来

3.2.1 GPM的调度过程

Go 的调度器是基于work-stealing和协作式抢占的多级调度机制,主要包括以下步骤:

  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会被放入全局队列中

3.3 调度器的设计策略

golang调度器的设计策略思想主要有以下几点:

  • 复用线程
  • 利用并行
  • 抢占
  • 全局G队列

3.3.1 复用线程

golang在复用线程上主要体现在

  • work stealing机制和
  • hand off机制(偷别人的去执行,和自己扔掉执行)

3.3.1.1 work stealing 机制

动图

当m没有可用的g时(本地队列),它会随机挑选另外一个P,偷取它的本地队列的一半协程,而不是销毁线程。

干完活的线程与其等着,不如去帮其他线程干活,于是它就去其他线程的队列里窃取一个任务来执行。而在这时它们会访问同一个队列,所以为了减少窃取任务线程和被窃取任务线程之间的竞争,通常会使用双端队列,被窃取任务线程永远从双端队列的头部拿任务执行,而窃取任务的线程永远从双端队列的尾部拿任务执行

PS:老版本的Golang优先从全局队列去取,取不到才从别的p本地队列取(1.17版本)。新的调度器中依然有全局G队列,但功能已经被弱化了,当M执行work stealing从其他P偷不到G时,它可以从全局G队列获取G

3.3.1.2 hand off 机制

动图

当本线程因为G进行系统调用阻塞时,线程释放绑定的P,把P转移给其他空闲的线程执行,此时M1如果长时间阻塞,可能会执行睡眠或销毁。

当 M1 系统调用(或阻塞)结束时,G1 会尝试获取一个空闲的 P 执行,并放入到这个 P 的本地队列,如果获取不到 P,那么 G1 加入到全局 G 里,这个 M 会加入到空闲线程列表中,重新可以进入调度循环.

3.3.1.3 充分利用并行

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

3.3.1.4 抢占

  • 1对1模型的调度器,需要等待一个co-routine主动释放后才能轮到下一个进行使用
  • golang中,如果一个goroutine使用10ms还没执行完,CPU资源就会被其他goroutine所抢占

4 调度器的生命周期

在了解调度器生命周期之前,我们需要了解两个新的角色M0G0

M0(跟进程数量绑定,一比一):

  • 启动程序后编号为0的主线程
  • 在全局变量runtime.m0中,不需要在heap上分配
  • 负责执行初始化操作和启动第一个G
  • 启动第一个G之后,M0就和其他的M一样了

G0(每个M都会有一个G0):

  • 每次启动一个M,都会第一个创建的gourtine,就是G0
  • G0仅用于负责调度G
  • G0不指向任何可执行的函数
  • 每个M都会有一个自己的G0
  • 在调度或系统调用时会使用M切换到G0,再通过G0进行调度

M0和G0都是放在全局空间的

5 通过trace查看GMP数据

trace记录了运行时的信息,能提供可视化的Web页面。在这里我们需要使用trace编程,三步走:

  1. 创建trace文件:f, err := os.Create(“trace.out”)
  2. 启动trace:err = trace.Start(f)
  3. 停止trace:trace.Stop()

然后再通过go tool trace工具打开trace文件

go tool trace trace.out

5.1 trace编程的一个例子

5.1.1 修改代码,收集trace信息

如下代码中,main函数创建trace,trace会运行在单独的goroutine中,main打印”hello GMP”后退出。

package main
import (
    "fmt"
    "os"
    "runtime/trace"
    "sync"
)
// trace的编码过程
// 1. 创建文件
// 2. 启动
// 3. 停止
func main() {
    // runtime.GOMAXPROCS(2)
    // 1.创建一个trace文件
    f, err := os.Create("trace.out")
    if err != nil {
        panic(err)
    }
    defer func(f *os.File) {
        err := f.Close()
        if err != nil {
            panic(err)
        }
    }(f)
    // 2. 启动trace
    err = trace.Start(f)
    if err != nil {
        panic(err)
    }
    // 模拟正常要调试的业务
    // 此处模拟启动了20个协程
    wg := sync.WaitGroup{}
    wg.Add(20)
    for i := 0; i < 20; i++ {
        go func(i int) {
            for i := 0; i < 10000; i++ {
                _ = make([]byte, 1<<20)
            }
            wg.Done()
        }(i)
    }
    for i := 0; i < 10000; i++ {
        _ = make([]byte, 1<<20)
    }
    wg.Wait()
    fmt.Println("hello GMP")
    // 3. 停止trace
    trace.Stop()
}

执行如上代码后,会在当前目录生成trace.out 文件。

5.1.2 分析trace

执行go tool trace trace.out 命令分析文件,会生成一个链接

$  go tool trace .\trace.out
2024/12/18 15:16:21 Preparing trace for viewer...
2024/12/18 15:16:22 Splitting trace for viewer...
2024/12/18 15:16:25 Opening browser. Trace viewer is listening on http://127.0.0.1:49249

5.2 浏览器中查看trace信息

通过chrome浏览器,打开链接

该页面显示了多个维度的跟踪信息,包括:

  • View trace by proc:process维度跟踪
  • View trace by thread:线程维度跟踪
  • Goroutine analysis:Goroutine 分析
  • Network blocking profile:网络阻塞概况
  • Synchronization blocking profile:同步阻塞概况
  • Syscall blocking profile:系统调用阻塞概况
  • Scheduler latency profile:调度延迟概况
  • User defined tasks:用户自定义任务
  • User defined regions:用户自定义区域
  • Minimum mutator utilization:最低 Mutator 利用率

点击相关链接,可以进入相关页面进行查看

5.2.1 查看process信息

点击 View trace by proc可以查看process维度的跟踪信息。如下图:

含义说明:

  • 时间线: 显示执行的时间,上图可以看出程序整体运行时间在 0s~20s之间,可以用过放大查看具体某个时间点,运行情况。
  • Goroutines: 显示在执行期间的每个Goroutine 运行阶段有多少个协程在运行,其包含 GC 等待(GCWaiting)、可运行(Runnable)、运行中(Running)这三种状态。
  • 堆内存:包含当前堆使用量(Allocated)和下一次垃圾回收的目标大小(NextGC)统计。
  • 系统线程:显示在执行期间有多少个线程在运行,其包含正在调用 Syscall(InSyscall)、运行中(Running)这两种状态。
  • GC: 执行垃圾回收的次数和具体时间点,由上图可以看出每次执行GC时,堆内存都会下降。
  • 逻辑处理器: 默认显示系统核心数量,可以通过runtime.GOMAXPROCS(n)来控制数量。

快捷键: w(放大)、s(缩小)、a(左移)、d(右移)

在图中点击彩色的区域,能够在下方显示相关的信息

5.2.2 某一时刻堆内存

5.2.3 某一时刻系统线程

5.2.4 查看垃圾回收

单个垃圾回收

5.2.5 查看多个垃圾回收

选中多个垃圾回收,则可查看多个垃圾回收信息

5.2.6 部分协程功能说明

  • proc start: 代表启动新线程或从系统调用恢复。
  • proc stop: 代表线程在系统调用中被阻塞或线程退出。
  • GC(dedicated、fractional、idle):在标记阶段GC分为三种不同的 mark worker 模式; 它们代表着不同的专注程度,其中 dedicated 模式最专注,是完整的 GC 回收行为,fractional 只会干部分的 GC 行为,idle 最轻松。
  • MARK ASSIST: 在分配内存过程中重新标记内存(mark the memory)的goroutine
  • STW (sweep termination): 代表STW扫描阶段终止。
  • STW (mark termination): 代表STW标记阶段终止。
  • runtime.gcBgMarkWorker: 帮助标记内存的专用后台goroutine
  • runtime.bgsweep: 执行垃圾清理的goroutine。
  • runtime.bgscavenge: gc碎片清理的goroutine。
  • syscall: 代表goroutine在进行系统调用。
  • sysexit: 代表goroutine在syscall中被取消或阻塞。

5.2.7 查看具体协程运行信息

含义说明:

字段名 说明
Start 开始时间
Wall Duration 持续时间
Self Time 执行时间
Start Stack Trace 开始时的堆栈信息
End Stack Trace 结束时的堆栈信息
Incoming flow 输入流
Outgoing flow 输出流
Preceding events 之前的事件
Following events 之后的事件
All connected 所有连接的事件

5.3. 协程分析(Goroutine analysis)

5.3.1 详情查看

5.4 用户自定义任务

编辑代码,创建自定义任务

package main
import (
    "context"
    "fmt"
    "os"
    "runtime/trace"
    "sync"
)
func main() {
    file, err := os.Create("./mytask.out")
    if err != nil {
        fmt.Printf("%v\n", err)
        return
    }
    defer file.Close()
    err = trace.Start(file)
    if err != nil {
        fmt.Printf("%v\n", err)
        return
    }
    defer trace.Stop()
    // 创建自定义任务
    ctx, task := trace.NewTask(context.Background(), "myTask")
    defer task.End()
    var wg sync.WaitGroup
    for i := 0; i < 10; i++ {
        gn := i
        // 启动协程
        wg.Add(1)
        go func() {
            defer wg.Done()
            trace.WithRegion(ctx, fmt.Sprintf("goroutine-%d", gn), func() {
                sum := 0
                for n := 0; n < 1000000; n++ {
                    sum = sum + n
                }
                fmt.Println("sum = ", sum)
            })
        }()
    }
    wg.Wait()
    fmt.Println("run ok!")
}

程序运行生成myTask.out文件,通过go tool trace myTask.out分析后,在页面查看相关信息。

参考资料

https://www.cnblogs.com/guyouyin123/p/15961042.html

https://github.com/0voice/Introduction-to-Golang

https://www.jianshu.com/p/609125edc95f

https://www.topgoer.com/%E5%B9%B6%E