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 协程之间的关系
- 协程是独立的执行单元
- 每个协程在运行时都是独立的,不存在严格的“父协程”和“子协程”关系。
- 如果“父协程”退出,“子协程”并不会受到直接影响,仍会继续运行。
- 程序的生命周期由主协程决定
- 整个 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和协作式抢占的多级调度机制,主要包括以下步骤:
- 我们通过go func()来创建一个goroutine
- 有两个存储G的队列,一个是局部调度器P的本地队列、一个是全局G队列。新创建的G会先保存在P的本地队列中,如果P的本地队列已经满了就会保存在全局的队列中
- G只能运行在M中,一个M必须持有一个P,M与P是1:1的关系。M会从P的本地队列弹出一个可执行状态的G来执行,如果P的本地队列为空,就会想其他的MP组合偷取一个可执行的G来执行
- 一个M调度G执行的过程是一个循环机制
- 当M执行某一个G时候如果发生了syscall或者其他阻塞操作,M会阻塞,如果当前有一些G在执行,runtime会把这个线程M从P中摘除(detach),然后再创建一个新的操作系统的线程(如果有空闲的线程可用就复用空闲线程)来服务于这个P
- 当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 调度器的生命周期
在了解调度器生命周期之前,我们需要了解两个新的角色M0和G0
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编程,三步走:
- 创建trace文件:f, err := os.Create(“trace.out”)
- 启动trace:err = trace.Start(f)
- 停止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