后端实践选题方向三 | 豆包MarsCode AI 刷题

50 阅读4分钟

方向3 实践记录以及工具使用

Go语言调度器goroutine

Go语言的调度器是其并发模型的核心,实现了 Goroutine 的高效调度和管理。与传统的线程相比,Goroutine 是轻量级的、由 Go 运行时管理的协程,它避免了使用操作系统线程直接进行并发编程的复杂性。

1. 什么是 Goroutine

Goroutine 是 Go 中的基本并发单位,由 go 关键字启动。与线程类似,Goroutine 是独立运行的函数。 • Goroutine 的主要特性: • 轻量级:每个 Goroutine 初始栈大小只有约 2KB,远小于线程的 1MB 栈。 • 动态扩展:栈内存可以根据需要动态扩展,最大可达 1GB。 • 由 Go 调度器管理:不直接依赖操作系统线程。

示例代码:

package main
import (
    "fmt"
    "time"
)

func sayHello() {
    for i := 0; i < 5; i++ {
        fmt.Println("Hello, Goroutine!")
        time.Sleep(100 * time.Millisecond)
    }
}

func main() {
    go sayHello() // 启动一个 Goroutine
    for i := 0; i < 5; i++ {
        fmt.Println("Main function running")
        time.Sleep(100 * time.Millisecond)
    }
}

2. Go 调度器概述

Go 使用了一种称为 GMP 模型 的调度器来管理 Goroutine 的运行。

GMP 模型G:代表 Goroutine。 • M:代表内核线程(操作系统线程)。 • P:代表调度器上下文,包含待运行的 Goroutine 队列。

结构关系:P 是调度的核心,每个 P 拥有一个本地队列存储待运行的 Goroutine。 • M 绑定一个 P,运行其 Goroutine 队列。 • Goroutine 由 G 映射到 M,通过 P 提供的调度支持来运行。

关键点: • 每个 P 可以同时绑定一个 M,当 M 执行阻塞操作时,P 可以切换到其他 M。 • 系统中 P 的数量由 GOMAXPROCS 决定,默认值为 CPU 核心数。

调度过程: 1. Goroutine 被创建后,加入某个 P 的本地队列。 2. 调度器从队列中取出 Goroutine,并分配给 M 运行。 3. 如果 P 的本地队列为空,尝试从全局队列或其他 P 的队列中窃取 Goroutine。

3. 调度器的工作原理 调度器采用 协作式抢占抢占式调度 的结合。

1) 协作式调度 • Goroutine 自愿让出 CPU。 • 常见的让出时机: • runtime.Gosched():主动调用让出。 • 通道阻塞:Goroutine 等待某个通道的结果。 • I/O 操作:如文件读写、网络请求等。

示例:

package main

import (
    "fmt"
    "runtime"
)

func main() {
    go func() {
        for i := 0; i < 5; i++ {
            fmt.Println("Goroutine 1")
            runtime.Gosched() // 主动让出 CPU
        }
    }()


    for i := 0; i < 5; i++ {
        fmt.Println("Main Goroutine")
        runtime.Gosched() // 主动让出 CPU
    }
}

2) 抢占式调度 • 从 Go 1.14 开始,Go 支持抢占式调度,可以在 Goroutine 执行时间过长时中断它。 • 当 Goroutine 长时间运行时,调度器会通过信号触发抢占机制,强制让出 CPU。

4. Goroutine 的栈管理 Goroutine 的栈管理是其轻量化的关键: 1. 初始大小:2KB • Goroutine 的栈从 2KB 开始,远小于线程的初始栈大小(通常为 1MB)。 2. 动态扩展: • Goroutine 的栈会随着调用深度动态扩展,可扩展至 1GB。 • 当栈空间不足时,Go 会复制栈到更大的空间,调整栈指针。 3. 高效的内存利用: • 动态栈避免了线程栈的固定分配,节约了大量内存。

5. Goroutine 调度中的抢占机制 Go 的抢占式调度器会在以下场景下中断 Goroutine 的执行: • Goroutine 运行时间过长。 • 垃圾回收(GC)过程中,为了避免暂停时间过长。 抢占机制通过 检查点 实现: • 调度器在特定的指令点插入检查代码。 • 当 Goroutine 运行到这些点时,会检查是否需要被抢占。

6. GOMAXPROCS 的作用 GOMAXPROCS 决定了系统中允许并发执行的最大 Goroutine 数量。 • 默认值为 CPU 核心数。 • 可以通过 runtime.GOMAXPROCS 或环境变量设置。

package main

import (
    "fmt"
    "runtime"
)

func main() {
    fmt.Println("Default GOMAXPROCS:", runtime.GOMAXPROCS(0))


    // 设置 GOMAXPROCS 为 2
    runtime.GOMAXPROCS(2)
    fmt.Println("Updated GOMAXPROCS:", runtime.GOMAXPROCS(0))
}

7. 优化 Goroutine 使用 1. 避免 Goroutine 泄漏: • 确保 Goroutine 能在必要时退出。 • 在通道关闭或信号处理后退出 Goroutine。 2. 合理使用 GOMAXPROCS: • 如果任务是计算密集型,可设置为 CPU 核心数。 • 如果任务是 I/O 密集型,可适当提高 GOMAXPROCS。 3. 控制 Goroutine 数量: • 使用 Goroutine 池限制 Goroutine 并发数量。 • 例如,通过第三方库(如 antchfx/goroutine)或手动实现限流机制。

示例:手动限流:

package main

import (
    "fmt"
    "sync"
)

func worker(id int, wg *sync.WaitGroup) {
    defer wg.Done()
    fmt.Printf("Worker %d started\n", id)
    // 模拟任务执行
    fmt.Printf("Worker %d finished\n", id)
}

func main() {
    const maxWorkers = 3
    var wg sync.WaitGroup
    tasks := 10
    semaphore := make(chan struct{}, maxWorkers)

    for i := 1; i <= tasks; i++ {
            wg.Add(1)
            semaphore <- struct{}{} // 占用一个槽
            go func(id int) {
                    defer func() { <-semaphore }() // 释放槽
                    worker(id, &wg)
            }(i)
    }

    wg.Wait()
}

个人总结 Goroutine 和调度器是 Go 并发编程的核心,通过 GMP 模型高效管理 Goroutine 的执行。 • 优势:轻量、高效、易用。 • 调度机制:结合协作式和抢占式调度。 • 注意事项:合理控制 Goroutine 数量,避免资源泄漏。