MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Go 语言 Goroutine 的调度机制与并发模型

2024-03-151.9k 阅读

Go 语言并发编程基础

在深入探讨 Go 语言的 Goroutine 调度机制与并发模型之前,我们先来回顾一下 Go 语言并发编程的基础概念。

并发与并行

在计算机科学中,并发(Concurrency)和并行(Parallelism)是两个容易混淆但含义不同的概念。

并发:指的是在一个时间段内,多个任务交替执行。在单核 CPU 环境下,操作系统通过时间片轮转等调度算法,让多个任务在同一时间段内都能得到执行机会,宏观上看起来像是多个任务同时在执行,但微观上它们还是串行执行的。

并行:指的是在同一时刻,有多个任务真正地同时执行。这需要多核 CPU 的支持,每个核心可以同时处理一个任务。

Go 语言天生支持并发编程,通过 Goroutine 和 Channel 等特性,开发者可以很方便地编写出高并发的程序,并且在多核环境下能够充分利用多核 CPU 的性能。

Goroutine 简介

Goroutine 是 Go 语言中实现并发编程的核心概念。它类似于线程,但又有所不同。Goroutine 非常轻量级,创建和销毁的开销极小。

在 Go 语言中,使用 go 关键字来启动一个 Goroutine。下面是一个简单的示例:

package main

import (
    "fmt"
    "time"
)

func hello() {
    fmt.Println("Hello, Goroutine!")
}

func main() {
    go hello()
    time.Sleep(1 * time.Second)
    fmt.Println("Main function")
}

在上述代码中,go hello() 语句启动了一个新的 Goroutine 来执行 hello 函数。主函数在启动 Goroutine 后不会等待 hello 函数执行完毕,而是继续向下执行。这里通过 time.Sleep 让主函数等待 1 秒钟,以确保 hello 函数有足够的时间被调度执行。如果不使用 time.Sleep,主函数可能在 hello 函数执行之前就结束了,从而导致 hello 函数中的 fmt.Println 语句不会被执行。

Go 语言的并发模型 - CSP

Go 语言的并发模型基于 CSP(Communicating Sequential Processes)理论。CSP 是一种并发编程模型,它强调通过通信来共享内存,而不是通过共享内存来通信。

在 Go 语言中,Channel 就是实现 CSP 模型的关键数据结构。Channel 可以理解为一个管道,用于在不同的 Goroutine 之间传递数据。通过 Channel 传递数据时,发送和接收操作是同步的,这确保了数据的一致性和安全性。

Channel 的基本使用

下面是一个简单的示例,展示了如何使用 Channel 在两个 Goroutine 之间传递数据:

package main

import (
    "fmt"
)

func sendData(ch chan int) {
    for i := 0; i < 5; i++ {
        ch <- i
    }
    close(ch)
}

func receiveData(ch chan int) {
    for num := range ch {
        fmt.Println("Received:", num)
    }
}

func main() {
    ch := make(chan int)

    go sendData(ch)
    go receiveData(ch)

    // 防止主函数提前退出
    select {}
}

在上述代码中:

  1. sendData 函数通过 ch <- i 语句向 Channel ch 发送数据,发送完 5 个数据后,通过 close(ch) 关闭 Channel。
  2. receiveData 函数通过 for num := range ch 循环从 Channel ch 接收数据,直到 Channel 被关闭。
  3. main 函数中,创建了一个 Channel ch,并启动了两个 Goroutine 分别执行 sendDatareceiveData 函数。select {} 语句用于阻塞主函数,防止主函数提前退出,确保两个 Goroutine 有足够的时间完成数据的发送和接收。

Channel 的类型

  1. 无缓冲 Channel:创建时没有指定缓冲区大小,如 ch := make(chan int)。在无缓冲 Channel 上进行发送和接收操作是同步的,即发送操作会阻塞,直到有其他 Goroutine 在该 Channel 上进行接收操作;接收操作也会阻塞,直到有其他 Goroutine 在该 Channel 上进行发送操作。
  2. 有缓冲 Channel:创建时指定了缓冲区大小,如 ch := make(chan int, 5)。有缓冲 Channel 在缓冲区未满时,发送操作不会阻塞;在缓冲区未空时,接收操作不会阻塞。只有当缓冲区满了再进行发送操作,或者缓冲区空了再进行接收操作时,才会阻塞。

Goroutine 的调度机制

调度器的组成

Go 语言的调度器主要由三个部分组成:M、P、G。

  1. M(Machine):代表操作系统线程,是真正执行代码的实体。每个 M 都对应一个操作系统线程,M 会从 P 的本地运行队列或者全局运行队列中获取 G 来执行。
  2. P(Processor):代表逻辑处理器,它的主要作用是管理 G 的运行队列,并将 G 调度到 M 上执行。每个 P 都维护着一个本地运行队列,用于存放待执行的 G。P 的数量可以通过 runtime.GOMAXPROCS 函数来设置,默认值是 CPU 的核心数。
  3. G(Goroutine):代表协程,是用户级别的轻量级线程。每个 G 都有自己的栈空间、程序计数器等上下文信息。

调度流程

  1. 创建 Goroutine:当使用 go 关键字启动一个新的 Goroutine 时,会创建一个新的 G 结构体,并将其放入到某个 P 的本地运行队列中。如果 P 的本地运行队列已满,则会将 G 放入到全局运行队列中。
  2. M 与 P 的绑定:M 在启动时会尝试绑定一个 P。如果没有可用的 P,则 M 会进入休眠状态,直到有 P 可用。一旦 M 绑定了 P,M 就会从 P 的本地运行队列中获取 G 来执行。
  3. 执行 Goroutine:M 从 P 的本地运行队列中取出一个 G 并开始执行。在执行过程中,G 可能会因为系统调用、I/O 操作等原因进入阻塞状态。当 G 进入阻塞状态时,M 会将 G 从 P 的本地运行队列中移除,并将其放入到与该阻塞操作相关的等待队列中。然后 M 会解绑当前的 P,并尝试从其他 P 的本地运行队列或者全局运行队列中获取新的 G 来执行。如果没有可用的 G,M 会再次进入休眠状态,直到有 G 可用。
  4. Goroutine 的唤醒:当阻塞操作完成后,对应的 G 会被唤醒,并重新放入到某个 P 的本地运行队列中,等待被 M 调度执行。

调度策略

  1. 协作式调度:Go 语言的调度器采用协作式调度(Cooperative Scheduling)策略。在这种调度策略下,Goroutine 不会被操作系统强制抢占,而是在执行过程中主动让出 CPU 资源。例如,当 G 执行系统调用、I/O 操作、调用 runtime.Gosched 函数等时,会主动暂停自己的执行,让调度器有机会调度其他 G。
  2. 时间片轮转调度:虽然 Go 语言采用协作式调度,但为了防止某个 G 长时间占用 CPU 资源,调度器也引入了时间片轮转调度的概念。每个 G 在执行一段时间(默认 10ms)后,调度器会强制暂停该 G 的执行,并将其放回 P 的本地运行队列,让其他 G 有机会执行。

示例分析 - 深入理解调度机制与并发模型

示例 1:多个 Goroutine 的调度

package main

import (
    "fmt"
    "runtime"
    "time"
)

func worker(id int) {
    fmt.Printf("Worker %d started\n", id)
    for i := 0; i < 3; i++ {
        fmt.Printf("Worker %d: %d\n", id, i)
        runtime.Gosched()
    }
    fmt.Printf("Worker %d finished\n", id)
}

func main() {
    runtime.GOMAXPROCS(1)

    for i := 0; i < 3; i++ {
        go worker(i)
    }

    time.Sleep(2 * time.Second)
    fmt.Println("Main function")
}

在这个示例中:

  1. runtime.GOMAXPROCS(1) 设置了逻辑处理器 P 的数量为 1,即只有一个 M 可以同时执行 G。
  2. worker 函数中通过 runtime.Gosched 主动让出 CPU 资源,让调度器有机会调度其他 G。
  3. main 函数中启动了 3 个 Goroutine,每个 Goroutine 执行 worker 函数。通过 time.Sleep 让主函数等待 2 秒钟,以确保所有 Goroutine 有足够的时间执行。

运行这个程序,你会看到输出结果中,3 个 Goroutine 会交替执行,这体现了调度器的协作式调度策略。

示例 2:Channel 与 Goroutine 的协同工作

package main

import (
    "fmt"
    "time"
)

func producer(ch chan int) {
    for i := 0; i < 5; i++ {
        ch <- i
        fmt.Printf("Produced: %d\n", i)
        time.Sleep(100 * time.Millisecond)
    }
    close(ch)
}

func consumer(ch chan int) {
    for num := range ch {
        fmt.Printf("Consumed: %d\n", num)
        time.Sleep(200 * time.Millisecond)
    }
}

func main() {
    ch := make(chan int)

    go producer(ch)
    go consumer(ch)

    time.Sleep(2 * time.Second)
    fmt.Println("Main function")
}

在这个示例中:

  1. producer 函数向 Channel ch 发送数据,并在每次发送后休眠 100 毫秒。
  2. consumer 函数从 Channel ch 接收数据,并在每次接收后休眠 200 毫秒。
  3. 由于 consumer 接收数据的速度比 producer 发送数据的速度慢,所以 producer 在发送数据时会阻塞,直到 consumer 接收数据。这体现了 Channel 的同步特性,以及 Goroutine 之间通过 Channel 进行通信和协作的机制。

总结与注意事项

通过以上内容,我们详细介绍了 Go 语言 Goroutine 的调度机制与并发模型。在实际开发中,需要注意以下几点:

  1. 资源泄漏:如果一个 Goroutine 一直阻塞且没有合理的退出机制,可能会导致资源泄漏。例如,在使用 Channel 时,如果没有正确关闭 Channel,可能会导致接收端一直阻塞。
  2. 死锁:死锁是并发编程中常见的问题。在 Go 语言中,死锁通常发生在多个 Goroutine 相互等待对方释放资源的情况下。例如,两个 Goroutine 通过 Channel 进行通信,A Goroutine 等待 B Goroutine 发送数据,而 B Goroutine 又等待 A Goroutine 发送数据,这样就会形成死锁。为了避免死锁,需要仔细设计并发逻辑,确保资源的获取和释放顺序合理。
  3. 性能优化:虽然 Goroutine 非常轻量级,但在高并发场景下,如果创建过多的 Goroutine,可能会导致调度器的压力增大,从而影响程序的性能。因此,需要根据实际情况合理控制 Goroutine 的数量。

通过深入理解 Go 语言的调度机制与并发模型,并注意以上问题,开发者可以编写出高效、稳定的并发程序。