欢迎光临
我们一直在努力

怎样利用 runtime.Gosched 解决长耗时任务对调度器造成的饥饿问题

在 Go 语言的并发模型中,goroutine 的调度是自动且高效的。然而,当遇到极端 CPU 密集型(CPU-bound)任务,并且这些任务在执行过程中从不进行系统调用、网络I/O或锁操作时,可能会导致一个问题:调度器饥饿(Scheduler Starvation)。n
一个长时间运行的紧密循环(busy loop)会霸占分配给它的逻辑处理器 P,从而阻止其他处于可运行状态(runnable)的 goroutine 及时获得执行机会。为了解决这个问题,我们可以主动调用 runtime.Gosched()

什么是 runtime.Gosched()?n

runtime.Gosched() 是 Go 运行时包提供的一个函数。调用这个函数的作用是:n
1. 将当前的 goroutine 暂停执行。
2. 将该 goroutine 重新放回运行队列(run queue)。
3. 立即触发调度器,让出当前 M/P(机器线程/逻辑处理器)资源,允许调度器运行队列中的下一个 goroutine。

简单来说,它是一种显式地让出 CPU 时间片的机制。

案例:没有 yield 的饥饿问题

我们首先来看一个没有使用 runtime.Gosched() 的例子。一个短任务需要快速响应,但被一个极长的紧密计算任务所阻碍。

package main

import (
    "fmt"
    "time"
)

func shortTask() {
    for i := 0; i < 5; i++ {
        time.Sleep(10 * time.Millisecond)
        fmt.Println("\t[短任务] 正在运行...")
    }
}

func longRunningTask() {
    fmt.Println("[长任务] 开始,将进行密集计算...")
    // 假设这是一个耗费大量 CPU 资源的紧密循环
    const N = 5000000000 // 50亿次迭代
    for i := 0; i < N; i++ {
        // 模拟计算
    }
    fmt.Println("[长任务] 计算完成。")
}

func main() {
    // 启动两个 goroutine
    go longRunningTask()
    go shortTask()

    time.Sleep(3 * time.Second) // 确保主 goroutine 不提前退出
    fmt.Println("主程序退出。")
}

观察结果: 在大多数单核或少核的配置下,或者即使在多核环境中,如果长任务抢占了一个 P 且没有让出,你会发现短任务的输出会严重延迟,甚至在长任务结束后才开始集中输出,表现出明显的饥饿现象。

解决方案:引入 runtime.Gosched()n

通过在长耗时任务的循环中周期性地调用 runtime.Gosched(),我们可以确保调度器有机会介入,并让其他等待中的 goroutine 获得执行时间。

package main

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

func shortTask() {
    for i := 0; i < 5; i++ {
        time.Sleep(10 * time.Millisecond)
        fmt.Println("\t[短任务] 正在运行...")
    }
}

func yieldingTask() {
    fmt.Println("[长任务] 开始,将进行密集计算并周期性地让出 CPU...")
    const N = 5000000000 // 50亿次迭代

    for i := 0; i < N; i++ {
        // 模拟计算

        // 每 1 亿次迭代,主动让出 CPU 时间片
        if i%100000000 == 0 {
            // 调用 Gosched,将当前 goroutine 重新排队,允许其他 goroutine 运行
            runtime.Gosched()
        }
    }
    fmt.Println("[长任务] 计算完成。")
}

func main() {
    fmt.Println("GOMAXPROCS:", runtime.GOMAXPROCS(0))
    // 启动两个 goroutine
    go yieldingTask()
    go shortTask()

    time.Sleep(3 * time.Second)
    fmt.Println("主程序退出。")
}

观察结果: 引入 runtime.Gosched() 后,短任务的输出将与长任务的计算交替出现,系统响应性得到了显著改善,饥饿问题得以解决。

注意事项

  1. 开销: 频繁调用 runtime.Gosched() 会引入额外的调度开销(上下文切换)。因此,你需要根据任务的特性和系统需求,选择一个合理的让出频率(例如,每进行数百万次计算后让出一次)。
  2. 适用场景: runtime.Gosched() 主要用于那些不涉及 I/O 或同步原语(如 mutex)的纯 CPU 密集型循环,因为 I/O 和同步原语本身就可能触发调度器的让出操作(称为抢占式调度)。
  3. Go 1.14+ 的抢占式调度: 现代 Go 版本(1.14及以后)已经引入了基于信号的非协作式抢占调度(针对循环函数调用)。然而,对于那种在一个极其庞大、紧密且单一的循环内部执行的代码,如果循环体本身不包含函数调用,调度器仍然难以有效抢占。在这种极端情况下,手动使用 runtime.Gosched() 仍然是一种有效的优化手段。
【本站文章皆为原创,未经允许不得转载】:汤不热吧 » 怎样利用 runtime.Gosched 解决长耗时任务对调度器造成的饥饿问题
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址