Goroutine 的基础
Goroutine
如果请Gopher们阐述Golang的优势,并发性(Concurrency)是经常被提及的一个特点。这一特性的基础便是轻量且易于处理的goroutine。以下是对其进行的简要阐述。
并发性(Concurrency) vs 并行性(Parallelism)
在理解goroutine之前,我将首先澄清两个经常被混淆的概念。
- 并发性:并发性关注的是同时处理多个任务。这并不意味着任务必须实际同时执行,而是一种结构性、逻辑性的概念,通过将多个任务分解为小单元并交替执行,使得用户感知到多个任务似乎同时在处理。即使在单核处理器上也能实现并发性。
- 并行性:并行性是指“在多个核心上同时处理多个任务”。顾名思义,它指的是任务的并行推进,即同时执行不同的任务。
Goroutine通过Go运行时调度器,使得并发性易于实现,并通过GOMAXPROCS
设置自然地利用并行性。
Java中利用率较高的多线程(Multi thread)是并行性的典型代表概念。
Goroutine为何如此优秀?
轻量(lightweight)
相较于其他语言,其创建成本极低。这里可能会产生一个疑问:为什么Golang能如此低成本地使用goroutine?原因在于其创建位置由Go运行时内部管理。这是因为它是一种轻量级逻辑线程,小于OS线程单位,初始堆栈仅需约2KB大小,并可根据用户的实现动态扩展堆栈。
由于以堆栈单位进行管理,其创建和销毁速度极快且成本低廉,即使运行数百万个goroutine也不会造成处理负担。因此,Goroutine得益于运行时调度器,能够最大限度地减少OS内核的介入。
性能优异(performance)
首先,如上所述,Goroutine对OS内核的介入较少,在用户级别(User-Level)进行上下文切换时,其成本低于OS线程,因此能够快速切换任务。
此外,它利用M:N模型将任务分配并管理到OS线程。通过创建OS线程池,无需大量线程,少量线程即可完成处理。例如,当系统调用等陷入等待状态时,Go运行时会在OS线程上执行其他goroutine,从而使OS线程不间断地高效利用CPU,实现快速处理。
因此,Golang在I/O操作方面能够比其他语言展现出更高的性能。
简洁(concise)
当需要并发时,只需一个go
关键字即可轻松处理函数,这也是一大优势。
使用Mutex
、Semaphore
等复杂的锁机制是必要的,而且在使用锁时,必须考虑死锁(DeadLock)状态,这使得从开发前的设计阶段就需要复杂的步骤。
Goroutine遵循“不要通过共享内存来通信,而要通过通信来共享内存”的哲学,推荐通过Channel
进行数据传递,并且SELECT
结合Channel
能够支持从已准备好数据的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() // 通知WaitGroup此goroutine已完成
12
13 fmt.Printf("Goroutine %d: 开始\n", name)
14 time.Sleep(10 * time.Millisecond) // 用于模拟工作的延迟
15 fmt.Printf("Goroutine %d: 结束\n", name) // 打印goroutine结束信息
16}
17
18func main() {
19 runtime.GOMAXPROCS(2) // 仅使用2个CPU核心
20 wg := sync.WaitGroup();
21 goroutineCount := 10
22 wg.Add(goroutineCount) // 设置需要等待的goroutine数量
23
24 for i := 0; i < goroutineCount; i++ {
25 go exe(i, &wg) // 启动goroutine
26 }
27
28 fmt.Println("所有 goroutine 已启动,等待它们完成...") // 打印等待信息
29 wg.Wait() // 等待所有goroutine完成
30 fmt.Println("所有工作已完成。") // 打印所有工作完成信息
31
32}
33
Goroutine的调度器(M:N模型)
前面提到M:N模型用于将任务分配并管理到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都能持续运行。
- 系统调用处理(Blocking):当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是语言的一大强项,除此之外还有许多优点,希望广大开发者都能喜欢Golang。
谢谢。