GoSuda

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

By hamori views ...

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. 1.5之前:默认值为1,若需要大于1的值,必须通过类似runtime.GOMAXPOCS(runtime.NumCPU())的方式进行设置

  2. 1.5 ~ 1.24:更改为使用所有可用的逻辑核心数。从此以后,除非存在较大限制的情况,开发者无需进行设置

  3. 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运行过程如下。

  1. 当G(Goroutine)被创建时,会将其分配到P(Processor)的本地运行队列中。
  2. P(Processor)将本地运行队列中的G(Goroutine)分配给M(Machine)。
  3. M(Machine)会返回G(Goroutine)的状态,即block、complete或preempted。
  4. 工作窃取(Work-Stealing):若P的本地运行队列为空,其他P会先检查全局队列。若那里也没有G(Goroutine),则窃取其他本地P(Processor)的任务,使所有M都能不停地工作。
  5. 系统调用处理(阻塞):当G(Goroutine)在执行中发生阻塞时,M(Machine)会进入等待状态,此时P(Processor)会与被阻塞的M(Machine)分离,并与其他M(Machine)结合,执行下一个G(Goroutine)。因此在I/O作业等待期间也不会浪费CPU。
  6. 当某个G(Goroutine)长时间占用(preempted)时,会将执行机会让给其他G(Goroutine)。

Golang的GC(Garbage Collector)同样在Goroutine之上运行,能够在将应用程序的暂停(STW)降至最低限度的同时并行地清理内存,从而高效利用系统资源。

最后,Golang是一门拥有强大优势的语言,此外还有许多其他优点,希望众多开发者能够享受使用Go语言的乐趣。

谢谢。