Go 协程(Goroutine)的运行原理:2KB 可变栈与 M:N GMP 调度器

Goroutine
提到Gopher们对golang优点的介绍时,经常出现的是与并发性(Concurrency)相关的内容。其基础是能够轻量且简便地处理任务的goroutine。在此,我简要地整理了相关内容。
并发性(Concurrency) vs 并行性(Parallelism)
在理解goroutine之前,我想先梳理两个经常被混淆的概念。
- 并发性:并发性是关于同时处理大量任务的概念。它并不一定意味着实际上同时执行,而是一种结构性、逻辑性的概念,即将多个任务划分为小单位并交替执行,从而使用户看来多个任务仿佛同时在进行。即使在单核中,并发性也是可以实现的。
- 并行性:并行性是指“在多个核心上同时处理多个任务”。字面意义上,它是并行地推进工作,同时执行其他任务。
goroutine通过Go运行时调度器轻松实现并发性,并通过GOMAXPROCS设置自然地利用并行性。
通常使用率较高的Java多线程(Multi thread)是并行性的代表性概念。
goroutine为什么好?
轻量(lightweight)
其创建成本与其他语言相比非常低。这里自然会产生疑问:为什么golang使用得较少呢?这是因为创建位置由Go运行时内部管理。原因在于它是上述的轻量级逻辑线程,比OS线程单位更小,初始栈大约需要2KB的大小,并且可根据用户的实现添加栈,从而实现动态可变。
由于以栈为单位进行管理,创建和销毁非常快速且成本低廉,因此即使运行数百万个goroutine也不会造成负担。由此,Goroutine借助运行时调度器,能够最大限度地减少对OS内核的介入。
性能良好(performance)
首先,正如上面所述,Goroutine对OS内核的介入较少,因此在用户级别(User-Level)进行上下文切换时,其成本比OS线程单位低廉,能够快速切换任务。
此外,它采用M:N模型,将goroutine分配到OS线程上进行管理。通过创建OS线程池,无需大量线程,仅用少量线程即可完成处理。例如,当发生系统调用等阻塞状态时,Go运行时会在该OS线程上执行其他goroutine,因此OS线程不会空闲,能够高效地利用CPU,实现快速处理。
由此,Golang尤其在I/O作业中能够比其他语言展现更高的性能。
简洁(concise)
需要并发处理时,仅用go关键字即可轻松处理函数,这也是一大优点。
使用Mutex、Semaphore等复杂的Lock时,必须考虑死锁(DeadLock)状态,因而在开发之前的设计阶段就不得不经历复杂的步骤。
Goroutine遵循“不要通过共享内存来通信,而要通过通信来共享内存”的理念,推荐通过通道(Channel)进行数据传递,SELECT还支持与通道(Channel)结合,使能够从已准备好数据的通道开始处理。此外,使用sync.WaitGroup可以简单地等待多个goroutine全部结束,从而轻松管理工作流程。得益于这些工具,可以防止线程间的数据竞争问题,更安全地进行并发处理。
另外,借助上下文(context),可以在用户级别(User-Level)控制生命周期、取消、超时、截止时间和请求范围,因此能够在一定程度上保证稳定性。
Goroutine的并行作业(GOMAXPROCS)
虽然已经介绍了goroutine在并发方面的优点,但您可能会疑惑:它是否不支持并行?这是因为近年来CPU的核心数量已不同于过去,达到两位数以上,家用PC也配备了相当多的核心。
然而,Goroutine同样进行并行作业,这就是GOMAXPROCS。
若不设置GOMAXPROCS,则会根据版本而有所不同。
1.5之前:默认值为1,若需要大于1的值,必须通过类似
runtime.GOMAXPOCS(runtime.NumCPU())的方式进行设置1.5 ~ 1.24:更改为使用所有可用的逻辑核心数。从此以后,除非存在较大限制的情况,开发者无需进行设置
1.25:作为在容器环境中以善于适应著称的语言,它会检查linux上的cGroup,确认容器中设置的
CPU限制。例如,若逻辑核心数为10个,而CPU限制值为5,则
GOMAXPROCS会设置为较低的数值5。
1.25的修改具有非常重大的意义,因为语言在容器环境中的可用性得到了提升。由此,可以减少不必要的线程创建和上下文切换,从而防止CPU节流(throttling)。
1package main
2
3import (
4 "fmt"
5 "math/rand"
6 "runtime"
7 "time"
8)
9
10func exe(name int, wg *sync.WaitGroup) {
11 defer wg.Done()
12
13 fmt.Printf("Goroutine %d: 시작\n", name) // 开始
14 time.Sleep(10 * time.Millisecond) // 用于模拟作业的延迟
15 fmt.Printf("Goroutine %d: 시작\n", name) // 开始
16}
17
18func main() {
19 runtime.GOMAXPROCS(2) // 仅使用2个CPU核心
20 wg := sync.WaitGroup();
21 goroutineCount := 10
22 wg.Add(goroutineCount)
23
24 for i := 0; i < goroutineCount; i++ {
25 go exe(i, &wg)
26 }
27
28 fmt.Println("正在等待所有goroutine结束...")
29 wg.Wait()
30 fmt.Println("所有作业已完成。")
31
32}
33
Goroutine的调度器(M:N模型)
关于前面提到的“采用M:N模型,将goroutine分配到OS线程上进行管理”这一点,若进一步具体说明,则存在goroutine GMP模型。
- G (Goroutine):Go中执行的最小作业单位
- M (Machine):OS线程(实际作业的位置)
- P (Processor):由Go运行时管理的逻辑处理器
P额外拥有本地运行队列(Local Run Queue),并承担将分配的G交给M的调度器角色。简而言之,goroutine的GMP运行过程如下。
- 当G(Goroutine)被创建时,会将其分配到P(Processor)的本地运行队列中。
- P(Processor)将本地运行队列中的G(Goroutine)分配给M(Machine)。
- M(Machine)会返回G(Goroutine)的状态,即block、complete或preempted。
- 工作窃取(Work-Stealing):若P的本地运行队列为空,其他P会先检查全局队列。若那里也没有G(Goroutine),则窃取其他本地P(Processor)的任务,使所有M都能不停地工作。
- 系统调用处理(阻塞):当G(Goroutine)在执行中发生阻塞时,M(Machine)会进入等待状态,此时P(Processor)会与被阻塞的M(Machine)分离,并与其他M(Machine)结合,执行下一个G(Goroutine)。因此在I/O作业等待期间也不会浪费CPU。
- 当某个G(Goroutine)长时间占用(preempted)时,会将执行机会让给其他G(Goroutine)。
Golang的GC(Garbage Collector)同样在Goroutine之上运行,能够在将应用程序的暂停(STW)降至最低限度的同时并行地清理内存,从而高效利用系统资源。
最后,Golang是一门拥有强大优势的语言,此外还有许多其他优点,希望众多开发者能够享受使用Go语言的乐趣。
谢谢。