Go并发机制解密:Goroutine调度

开发 前端
CPU 在多个进程之间切换时,由于需要进入内核模式并读取用户模式数据,切换开销较大。随着进程数量增加,CPU 调度会消耗大量资源。为了解决这一问题,引入了线程的概念。

Goroutine 是 Go 编程语言中一个极具特色的设计,也是其并发能力的核心亮点之一。Goroutine 本质上是一种协程(Coroutine),是实现并行计算的关键。使用 Goroutine 非常简单,只需通过 go 关键字即可启动一个协程,协程会以异步方式运行。程序无需等待 Goroutine 完成即可继续执行后续代码。

go func() // 使用 go 关键字启动一个协程

II. Goroutine 的内部原理

概念介绍

并发(Concurrency)

在单个 CPU 上,可以同时执行多个任务。在极短的时间内,CPU 会在任务之间快速切换(例如,先执行一小段程序 A,然后迅速切换到程序 B)。从宏观上看,这种任务的时间上有重叠,似乎是同时执行的,但从微观上看,实际上是顺序执行的。这种现象称为并发。

并行(Parallelism)

当系统拥有多个 CPU 时,每个 CPU 可以同时运行任务,且各自不需要争夺资源。多个任务真正同时运行,这种现象称为并行。

进程(Process)

当 CPU 在多个程序之间切换时,如果不保存之前程序的状态(即上下文),直接切换到下一个程序,那么之前程序的一系列状态会丢失。为了解决这个问题,引入了进程的概念。进程为程序执行分配所需的资源,因此进程是程序运行的基本资源单位(也可以看作程序执行的实体)。例如,运行一个文本编辑器时,该进程会管理所有资源,如文本缓冲区的内存空间、文件操作资源等。

线程(Thread)

CPU 在多个进程之间切换时,由于需要进入内核模式并读取用户模式数据,切换开销较大。随着进程数量增加,CPU 调度会消耗大量资源。为了解决这一问题,引入了线程的概念。线程本身消耗的资源很少,它们共享进程内的资源。线程的调度开销比进程小得多。例如,在一个 Web 服务器应用中,可以使用多个线程同时处理不同的客户端请求,这些线程共享服务器进程的资源(如网络连接和内存缓存)。

协程(Coroutine)

协程拥有自己的寄存器上下文和栈。当协程被调度切换时,会保存当前的寄存器上下文和栈;当切换回来时,则恢复之前保存的上下文和栈。因此,协程可以保留上一次调用的状态(即所有局部状态的特定组合)。每次重新进入协程时,相当于返回到上次调用时的状态,即逻辑流程中上次退出的位置。

线程和进程的操作由系统接口触发,最终由系统执行;而协程的操作由用户程序自身执行。Goroutine 就是一种协程。

调度模型简介

Goroutine 的强大并发能力通过 GPM 调度模型实现。以下是 Goroutine 调度模型的核心结构:

调度器中的四个重要结构

  1. M(Machine)表示内核级线程。每个 M 对应一个线程,Goroutine 运行在 M 上。例如,当一个 Goroutine 被启动以执行复杂计算时,该 Goroutine 会被分配到一个 M 上执行。M 是一个较大的结构,包含小对象内存缓存(mcache)、当前正在执行的 Goroutine、随机数生成器等信息。
  2. G(Goroutine)表示 Goroutine。它有自己的栈,用于存储函数调用信息,还有一个指令指针,用于指定执行位置。此外,G 还包含其他信息(如等待的通道信息),这些信息用于调度。例如,当一个 Goroutine 等待从通道接收数据时,该信息会存储在 G 结构中。
  3. P(Processor)全称为 Processor,主要用于执行 Goroutine。可以将其视为任务分发器。P 维护一个 Goroutine 队列,存储需要由其执行的所有 Goroutine。例如,当创建多个 Goroutine 时,这些 Goroutine 会被添加到 P 的队列中等待调度。
  4. Sched(Scheduler)表示调度器。可以看作是中央调度中心,维护 M 和 G 的队列,以及调度器的一些状态信息,确保整个系统的高效调度。

调度的实现

调度模型图调度模型图

如图所示,有两个物理线程 M,每个 M 绑定一个处理器 P,并运行一个 Goroutine。

  • P 的数量可以通过 GOMAXPROCS() 设置。它实际上表示真正的并发级别,即可以同时运行的 Goroutine 数量。
  • 图中灰色的 Goroutine 尚未运行,处于就绪状态,等待被调度。P 维护了这些 Goroutine 的队列(称为运行队列 runqueue)。
  • 在 Go 语言中,启动一个 Goroutine 非常简单:只需使用 go function。每次执行 go 语句时,都会将一个 Goroutine 添加到运行队列末尾。在下一个调度点,会从运行队列中取出一个 Goroutine 执行。

当某个操作系统线程(如 M0)被阻塞时(如下图所示),P 会切换到另一个线程(如 M1)。M1 可能是新创建的,也可能是从线程缓存中取出的。

线程阻塞切换图线程阻塞切换图

当 M0 返回时,它需要尝试获取一个 P 来运行 Goroutine。如果无法获取 P,它会将 Goroutine 放入全局运行队列,并进入休眠状态(进入线程缓存)。所有 P 会定期检查全局运行队列,并运行其中的 Goroutine;否则,全局运行队列中的 Goroutine 将永远无法执行。

III. Goroutine 的使用

基本用法

设置 Goroutine 的运行 CPU 数量。Go 的最新版本默认会自动设置。

num := runtime.NumCPU() // 获取主机的逻辑 CPU 数量
runtime.GOMAXPROCS(num) // 根据主机 CPU 数量设置 Goroutine 的最大并发级别

使用示例

示例 1:简单的 Goroutine 计算

package main

import (
    "fmt"
    "time"
)

func cal(a int, b int) {
    c := a + b
    fmt.Printf("%d + %d = %d\n", a, b, c)
}

func main() {
    for i := 0; i < 10; i++ {
        go cal(i, i+1) // 启动 10 个 Goroutine 进行计算
    }
    time.Sleep(time.Second * 2) // 等待所有任务完成
}

运行结果:

8 + 9 = 17
9 + 10 = 19
4 + 5 = 9
...

Goroutine 异常捕获

当启动多个 Goroutine 时,如果其中一个发生异常且未处理,整个程序会终止。因此,建议在每个 Goroutine 的函数中添加异常处理。可以使用 recover 函数捕获异常。

package main

import (
    "fmt"
    "time"
)

func addele(a []int, i int) {
    deferfunc() {
        if err := recover(); err != nil {
            fmt.Println("add ele fail")
        }
    }()
    a[i] = i
    fmt.Println(a)
}

func main() {
    Arry := make([]int, 4)
    for i := 0; i < 10; i++ {
        go addele(Arry, i)
    }
    time.Sleep(time.Second * 2)
}

运行结果:

add ele fail
[0 0 0 0]
[0 1 0 0]
...

Goroutine 的同步

由于 Goroutine 是异步执行的,主程序可能在 Goroutine 完成前退出。为确保所有 Goroutine 完成后再退出,Go 提供了 sync 包和 channel 来解决同步问题。

示例 1:使用 sync.WaitGroup 同步 Goroutine

package main

import (
    "fmt"
    "sync"
)

func cal(a int, b int, n *sync.WaitGroup) {
    c := a + b
    fmt.Printf("%d + %d = %d\n", a, b, c)
    defer n.Done()
}

func main() {
    var go_sync sync.WaitGroup
    for i := 0; i < 10; i++ {
        go_sync.Add(1)
        go cal(i, i+1, &go_sync)
    }
    go_sync.Wait()
}

运行结果:

9 + 10 = 19
2 + 3 = 5
...

Goroutine 间的通信

Goroutine 本质上是协程,可以通过 channel 实现通信或数据共享。

示例:使用 channel 模拟生产者-消费者模式

package main

import (
    "fmt"
    "sync"
)

func Productor(mychan chan int, data int, wait *sync.WaitGroup) {
    mychan <- data
    fmt.Println("product data:", data)
    wait.Done()
}

func Consumer(mychan chan int, wait *sync.WaitGroup) {
    a := <-mychan
    fmt.Println("consumer data:", a)
    wait.Done()
}

func main() {
    datachan := make(chanint, 100)
    var wg sync.WaitGroup
    for i := 0; i < 10; i++ {
        wg.Add(1)
        go Productor(datachan, i, &wg)
    }
    for j := 0; j < 10; j++ {
        wg.Add(1)
        go Consumer(datachan, &wg)
    }
    wg.Wait()
}

运行结果:

product data: 0
consumer data: 0
...

责任编辑:武晓燕 来源: 源自开发者
相关推荐

2023-09-21 22:02:22

Go语言高级特性

2021-09-30 09:21:28

Go语言并发编程

2023-11-20 22:55:00

Goroutine调度器

2021-06-29 23:40:19

Golang语言并发

2021-05-12 08:53:54

Go语言调度

2023-03-03 15:37:32

GMP 模型goroutine

2024-10-29 08:52:01

Go协作式调度

2020-02-27 21:03:30

调度器架构效率

2022-08-08 08:31:55

Go 语言闭包匿名函数

2022-08-08 06:50:06

Go语言闭包

2024-07-30 12:24:23

2014-01-06 17:09:10

ApacheMesos

2023-03-22 18:34:30

Flink调度部署

2024-12-04 11:31:41

Go编程技巧

2020-09-24 10:50:53

加密解密语言hmac

2019-07-02 14:05:23

Go语言高并发

2022-10-17 08:07:13

Go 语言并发编程

2021-07-06 07:46:07

Go语言编程

2024-09-02 09:00:59

2024-06-17 08:40:16

点赞
收藏

51CTO技术栈公众号