go语言面试之Goroutine 数量控制, GC回收 和任务调度

Go 语言的并发模型基于 goroutine,goroutine 是 Go 中最基本的并发单元。相比于传统的线程,goroutine 的创建和销毁更为轻量,因此它在处理大量并发任务时具有显著的性能优势。然而,当 goroutine 的数量过多时,也可能对程序的性能、垃圾回收(GC)以及调度产生负面影响。因此,如何合理控制 goroutine 的数量是 Go 编程中一个重要的优化问题。

一、Goroutine 的创建和调度

Goroutine 是 Go 的核心特性之一,其开销较小,创建和销毁速度远远快于传统的线程。Go 的运行时调度器使用 M(机器)、P(处理器)和 G(goroutine)模型来管理 goroutine 的执行。

  • M(机器) :表示操作系统的线程。
  • P(处理器) :表示 Go 调度器内部的一个抽象概念,用于调度执行 goroutine。每个 P 可以在一个 M 上执行多个 goroutine。
  • G(goroutine) :是 Go 语言的并发执行单元。

Goroutine 的调度采用抢占式调度,因此在一台机器上可以有多个 goroutine 被调度到同一个操作系统线程上运行。这使得在高并发场景下,Go 可以高效地执行大量任务,而无需直接操作系统线程。

然而,当 goroutine 的数量非常庞大时,会带来一些问题。

Goroutine 是什么

Go 语言作为一个新生编程语言,其令人喜爱的特性之一就是 goroutine。Goroutine 是一个由 Go 运行时管理的轻量级线程,一般称其为 "协程"。

scss 复制代码
go f(x, y, z)

操作系统本身是无法明确感知到 Goroutine 的存在的,Goroutine 的操作和切换归属于 "用户态" 中。

Goroutine 由特定的调度模式来控制,以 "多路复用" 的形式运行在操作系统为 Go 程序分配的几个系统线程上。

同时创建 Goroutine 的开销很小,初始只需要 2-4k 的栈空间。Goroutine 本身会根据实际使用情况进行自伸缩,非常轻量。

go 复制代码
func say(s string) {
 for i := 0; i < 9999999; i++ {
  time.Sleep(100 * time.Millisecond)
  fmt.Println(s)
 }
}

func main() {
 go say("煎鱼")
 say("你好")
}

人称可以开几百几千万个的协程小霸王,是 Go 语言的得意之作之一。

调度是什么

既然有了用户态的代表 Goroutine,操作系统又看不到他。必然需要有某个东西去管理他,才能更好的运作起来。

这指的就是 Go 语言中的调度,最常见、面试最爱问的 GMP 模型。因此接下来将会给大家介绍一下 Go 调度的基础知识和流程。

下述内容摘自煎鱼和 p 神写的《Go 语言编程之旅》中的章节内容。

调度基础知识

Go scheduler 的主要功能是针对在处理器上运行的 OS 线程分发可运行的 Goroutine,而我们一提到调度器,就离不开三个经常被提到的缩写,分别是:

  • G:Goroutine,实际上我们每次调用 go func 就是生成了一个 G。

  • P:Processor,处理器,一般 P 的数量就是处理器的核数,可以通过 GOMAXPROCS 进行修改。

  • M:Machine,系统线程。

这三者交互实际来源于 Go 的 M: N 调度模型。也就是 M 必须与 P 进行绑定,然后不断地在 M 上循环寻找可运行的 G 来执行相应的任务。

调度流程

我们以 GMP 模型的工作流程图进行简单分析,官方图如下:

  1. 当我们执行 go func() 时,实际上就是创建一个全新的 Goroutine,我们称它为 G。

  2. 新创建的 G 会被放入 P 的本地队列(Local Queue)或全局队列(Global Queue)中,准备下一步的动作。需要注意的一点,这里的 P 指的是创建 G 的 P。

  3. 唤醒或创建 M 以便执行 G。

  4. 不断地进行事件循环

  5. 寻找在可用状态下的 G 进行执行任务

  6. 清除后,重新进入事件循环

在描述中有提到全局和本地这两类队列,其实在功能上来讲都是用于存放正在等待运行的 G,但是不同点在于,本地队列有数量限制,不允许超过 256 个。

并且在新建 G 时,会优先选择 P 的本地队列,如果本地队列满了,则将 P 的本地队列的一半的 G 移动到全局队列。

这可以理解为调度资源的共享和再平衡。

窃取行为

我们可以看到图上有 steal 行为,这是用来做什么的呢,我们都知道当你创建新的 G 或者 G 变成可运行状态时,它会被推送加入到当前 P 的本地队列中。

其实当 P 执行 G 完毕后,它也会 "干活",它会将其从本地队列中弹出 G,同时会检查当前本地队列是否为空,如果为空会随机的从其他 P 的本地队列中尝试窃取一半可运行的 G 到自己的名下。

官方图如下:

在这个例子中,P2 在本地队列中找不到可以运行的 G,它会执行 work-stealing 调度算法,随机选择其它的处理器 P1,并从 P1 的本地队列中窃取了三个 G 到它自己的本地队列中去。

至此,P1、P2 都拥有了可运行的 G,P1 多余的 G 也不会被浪费,调度资源将会更加平均的在多个处理器中流转。

有没有什么限制

在前面的内容中,我们针对 Go 的调度模型和 Goroutine 做了一个基本介绍和分享。

接下来我们回到主题,思考 "goroutine 太多了,会不会有什么影响"。

在了解 GMP 的基础知识后,我们要知道在协程的运行过程中,真正干活的 GPM 又分别被什么约束

煎鱼带大家分别从 GMP 来逐步分析。

M 的限制

第一,要知道在协程的执行中,真正干活的是 GPM 中的哪一个

那势必是 M(系统线程) 了,因为 G 是用户态上的东西,最终执行都是得映射,对应到 M 这一个系统线程上去运行。

那么 M 有没有限制呢?

答案是:有的。在 Go 语言中,M 的默认数量限制是 10000,如果超出则会报错:

arduino 复制代码
GO: runtime: program exceeds 10000-thread limit

通常只有在 Goroutine 出现阻塞操作的情况下,才会遇到这种情况。这可能也预示着你的程序有问题。

若确切是需要那么多,还可以通过 debug.SetMaxThreads 方法进行设置。

G 的限制

第二,那 G 呢,Goroutine 的创建数量是否有限制?

答案是:没有。但理论上会受内存的影响,假设一个 Goroutine 创建需要 4k(via @GoWKH):

  • 4k * 80,000 = 320,000k ≈ 0.3G内存

  • 4k * 1,000,000 = 4,000,000k ≈ 4G内存

以此就可以相对计算出来一台单机在通俗情况下,所能够创建 Goroutine 的大概数量级别。

注:Goroutine 创建所需申请的 2-4k 是需要连续的内存块。

P 的限制

第三,那 P 呢,P 的数量是否有限制,受什么影响?

答案是:有限制。P 的数量受环境变量 GOMAXPROCS 的直接影响

环境变量 GOMAXPROCS 又是什么?在 Go 语言中,通过设置 GOMAXPROCS,用户可以调整调度中 P(Processor)的数量。

另一个重点在于,与 P 相关联的的 M(系统线程),是需要绑定 P 才能进行具体的任务执行的,因此 P 的多少会影响到 Go 程序的运行表现。

P 的数量基本是受本机的核数影响,没必要太过度纠结他。

那 P 的数量是否会影响 Goroutine 的数量创建呢?

答案是:不影响。且 Goroutine 多了少了,P 也该干嘛干嘛,不会带来灾难性问题。

何为之合理

在介绍完 GMP 各自的限制后,我们回到一个重点,就是 "Goroutine 数量怎么预算,才叫合理?"。

"合理" 这个词,是需要看具体场景来定义的,可结合上述对 GPM 的学习和了解。得出:

  • M:有限制,默认数量限制是 10000,可调整。

  • G:没限制,但受内存影响。

  • P:受本机的核数影响,可大可小,不影响 G 的数量创建。

Goroutine 数量在 MG 的可控限额以下,多个把个、几十个,少几个其实没有什么影响,就可以称其为 "合理"。

真实情况

在真实的应用场景中,没法如此简单的定义。如果你 Goroutine:

  • 在频繁请求 HTTP,MySQL,打开文件等,那假设短时间内有几十万个协程在跑,那肯定就不大合理了(可能会导致 too many files open)。

  • 常见的 Goroutine 泄露所导致的 CPU、Memory 上涨等,还是得看你的 Goroutine 里具体在跑什么东西。

还是得看 Goroutine 里面跑的是什么东西。

总结

在这篇文章中,分别介绍了 Goroutine、GMP、调度模型的基本知识,针对如下问题进行了展开:

  • 单机的 goroutine 数量控制在多少比较合适?

  • goroutine 太多了会影响 gc 和调度吧,主要是怎么预算这个数是合理的呢?

单机的 goroutine 数量只要控制在限额以下的,都可以认为是 "合理"。

二、Goroutine 数量过多的潜在问题

  1. 调度开销

    Goroutine 需要由 Go 运行时调度器来管理,调度器会将 goroutine 分配给 P。虽然 Go 的调度器在设计上很高效,但如果 goroutine 数量过多,调度的开销也会增加,导致程序的性能下降。具体表现为:

    • Goroutine 在调度时可能会发生频繁的上下文切换。
    • 调度器需要在多个 goroutine 之间进行资源竞争,尤其在多核 CPU 上,调度时的协调开销也会增加。
  2. 内存占用和 GC 开销

    每个 goroutine 都有自己的栈空间,默认情况下,每个 goroutine 的栈大小大约是 2KB。当创建大量 goroutine 时,栈空间的开销会迅速累积,从而增加内存占用。此外,Go 的垃圾回收机制(GC)也会受到影响。GC 需要扫描所有的堆对象和栈对象来回收不再使用的内存。当 goroutine 数量过多时,GC 的负担会增加,可能会导致频繁的垃圾回收,从而影响程序的性能。

  3. 系统资源限制

    操作系统对每个进程的线程数量有一定的限制。虽然 goroutine 比线程更轻量,但大量 goroutine 仍然可能受到操作系统资源限制的影响,导致系统资源耗尽,甚至出现崩溃。

  4. 竞争条件和死锁

    如果并发编程没有合理的同步机制(例如使用 sync.Mutexsync.RWMutex 等),大量 goroutine 的并发执行可能会引发竞争条件和死锁问题,从而导致程序逻辑错误和不可预期的行为。

三、如何控制 Goroutine 数量

  1. 合理的工作池模型(Worker Pool) 通过使用工作池模式,可以有效地控制 goroutine 的数量。工作池模型通过预先创建固定数量的 goroutine 来执行任务,避免一次性创建大量 goroutine。例如,使用带缓冲的通道来传递任务,限制 goroutine 的数量。

    示例:

    go 复制代码
    go
    package main
    
    import (
        "fmt"
        "sync"
    )
    
    func worker(id int, jobs <-chan int, wg *sync.WaitGroup) {
        defer wg.Done()
        for job := range jobs {
            fmt.Printf("Worker %d processing job %d\n", id, job)
        }
    }
    
    func main() {
        var wg sync.WaitGroup
        jobs := make(chan int, 100)
        numWorkers := 5
    
        // 启动工作池
        for i := 1; i <= numWorkers; i++ {
            wg.Add(1)
            go worker(i, jobs, &wg)
        }
    
        // 添加任务
        for j := 1; j <= 50; j++ {
            jobs <- j
        }
        close(jobs)
    
        // 等待所有工作完成
        wg.Wait()
    }

    在这个例子中,工作池有 5 个 goroutine 来处理 50 个任务,而不是创建 50 个 goroutine 来处理所有任务。这可以有效地控制并发任务数量,减少调度开销。

  2. 通过限制 goroutine 数量控制负载 如果任务可以分解为多个并发的工作单元,可以通过限制 goroutine 的数量来避免过度创建。例如,可以根据机器的 CPU 核心数来合理设置并发 goroutine 数量。

    示例:

    go 复制代码
    go
    package main
    
    import (
        "fmt"
        "runtime"
        "sync"
    )
    
    func main() {
        numCPU := runtime.NumCPU()
        runtime.GOMAXPROCS(numCPU) // 设置最大可用 CPU 核心数
        fmt.Printf("Using %d CPU cores\n", numCPU)
    
        var wg sync.WaitGroup
        for i := 0; i < numCPU*2; i++ {
            wg.Add(1)
            go func(id int) {
                defer wg.Done()
                fmt.Printf("Goroutine %d is running\n", id)
            }(i)
        }
        wg.Wait()
    }

    这里,我们根据系统的 CPU 核心数来创建 goroutine 的数量。这有助于保持调度和 GC 的负载在合理范围内。

    1. 控制并发的深度和任务分配 除了限制 goroutine 数量外,还可以通过合理的任务分配来控制每个 goroutine 执行的工作量。例如,如果某些任务可以并行处理,可以将它们分配给少数几个 goroutine 来执行。
  3. 监控和调整 在高并发的程序中,监控系统资源(如 CPU 和内存的使用情况)以及 goroutine 的数量是非常重要的。通过适时的调整,可以避免出现过多 goroutine 导致的资源浪费。

四、总结

合理控制 goroutine 数量是 Go 编程中的一个重要性能优化方向。在创建大量 goroutine 时,需要考虑到调度开销、内存占用、GC 负担以及系统资源的限制。通过使用工作池、控制并发数量和任务分配,可以有效地避免过多 goroutine 带来的性能问题。同时,监控系统资源和合理调整 goroutine 数量,也是确保程序高效运行的关键。

相关推荐
WAsbry5 小时前
Android 屏幕适配系列开篇:核心概念、官方机制与主流方案
android·面试
C++chaofan5 小时前
Spring Task快速上手
java·jvm·数据库·spring boot·后端·spring·mybatis
Czi.6 小时前
无网络安装来自 GitHub 的 Python 包
开发语言·python·github
围巾哥萧尘6 小时前
$100M Money Models: How to Get Customers to Buy🧣
面试
星星点点洲7 小时前
【Golang】 项目启动方法
开发语言·后端·golang
抹茶酸奶7 小时前
告别满屏的console.log!教你几种高级JavaScript调试技巧,效率直接拉满
后端
Github项目推荐7 小时前
你的中间件一团糟-是时候修复它了-🛠️
前端·后端
David爱编程7 小时前
高并发业务场景全盘点:电商、支付、IM、推荐系统背后的技术挑战
java·后端
DBLens数据库管理和开发工具7 小时前
MySQL 8 的 SQL 语法新特性
后端