面试官:"说说 Go 语言中如何查询当前有多少个协程在执行?"

这个问题看似简单,实际上考察的是候选人对 Go 运行时调度模型的掌握程度。根据我的理解,这篇文章来聊聊这个面试题背后的知识点。

先来看答案

在 Go 语言中,查询当前正在执行的协程数量非常简单,只需要调用一个函数:

import "runtime"

num := runtime.NumGoroutine()
fmt.Printf("当前协程数量: %d\n", num)

runtime.NumGoroutine() 会返回当前 Go 程序中存在的 goroutine 数量,包括正在运行的和你刚刚创建但还没来得及运行的。

面试官为什么问这个?

表面上是问函数调用,实际上他想考察的是:

  1. 你是否知道 Go 的协程调度是由 runtime 管理的
  2. 你是否理解 goroutine 和线程的区别
  3. 你是否有过排查协程泄漏的经验

让我们逐一展开。

goroutine 的创建与调度

当你在 Go 中使用 go 关键字启动一个协程时:

go func() {
    // 模拟工作
    time.Sleep(2 * time.Second)
}()

这段代码并不会立即创建线程来执行。Go 的调度器会将这个协程放入运行队列,等待 M(Machine,操作系统线程)从队列中取出执行。

runtime.NumGoroutine() 返回的是所有存活的 goroutine 总数,包括正在运行的、就绪的、阻塞的(如 channel 操作、syscall、time.Sleep)以及刚启动还没来得及调度的。它并不是单纯指"正在 CPU 上执行"的数量。

实战:看看有多少协程在跑

让我们通过一个完整的例子来理解:

package main

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

func main() {
    fmt.Printf("主函数启动,当前协程数: %d\n", runtime.NumGoroutine())

    var wg sync.WaitGroup
    for i := 0; i < 5; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            fmt.Printf("协程 %d 开始执行\n", id)
            time.Sleep(1 * time.Second)
            fmt.Printf("协程 %d 执行完成\n", id)
        }(i)
    }

    fmt.Printf("启动 5 个协程后,当前协程数: %d\n", runtime.NumGoroutine())
    wg.Wait()
    fmt.Printf("所有协程执行完毕,当前协程数: %d\n", runtime.NumGoroutine())
}

运行结果类似这样:

主函数启动,当前协程数: 1
启动 5 个协程后,当前协程数: 6
协程 0 开始执行
协程 2 开始执行
协程 3 开始执行
协程 4 开始执行
协程 1 开始执行
协程 2 执行完成
协程 0 执行完成
协程 4 执行完成
协程 3 执行完成
协程 1 执行完成
所有协程执行完毕,当前协程数: 1

可以看到:

  • 程序启动时只有 1 个协程(主函数 main)
  • 创建 5 个子协程后,总数变成 6
  • 所有子协程执行完毕后,又回到 1

常见的面试追问

追问一:协程泄漏怎么排查?

协程泄漏是指协程被创建后永远无法结束,导致协程数持续增长。这时 runtime.NumGoroutine() 就派上用场了:

// 定期打印协程数量,用于监控
func monitorGoroutines() {
    ticker := time.NewTicker(30 * time.Second)
    for range ticker.C {
        fmt.Printf("[监控] 当前协程数量: %d\n", runtime.NumGoroutine())
    }
}

如果发现协程数量持续增长没有下降,就需要检查是否存在死锁、channel 阻塞或 time.Sleep 忘记删除等常见问题。

追问二:如何查看协程的堆栈信息?

除了数量,你可能还想知道这些协程都在做什么。Go 提供了 pprof 工具:

import _ "net/http/pprof"

func main() {
    go func() {
        log.Println(http.ListenAndServe("localhost:6060", nil))
    }()
    // 访问 http://localhost:6060/debug/pprof/goroutine?debug=1
    // 查看所有协程的堆栈信息
}

追问三:协程和线程有什么区别?

这是必问的基础题。简单来说:

  • 线程:操作系统级别的执行单元,创建和切换成本较高
  • 协程:用户态的轻量级执行单元,由 Go runtime 调度

一个 Go 程序可以轻松创建上万个协程,但线程数通常只有几百个。Go 的调度器会自动将协程分配到有限的线程上执行,实现高效的并发。

面试加分项

如果你能说出下面这些,肯定能给面试官留下深刻印象:

  1. G-P-M 模型:Goroutine 要跑在 P(Processor)上,P 需要关联 M(Machine)才能执行
  2. 工作窃取:当某个 P 的队列为空时,会从其他 P 那里偷任务
  3. MNO 限制:可以通过 GOMAXPROCS 设置 P 的数量,影响调度器的行为
// 查看和设置 GOMAXPROCS
fmt.Printf("GOMAXPROCS: %d\n", runtime.GOMAXPROCS(0))
runtime.GOMAXPROCS(4) // 设置为 4

总结

runtime.NumGoroutine() 是 Go 语言中查询协程数量的标准方式,背后反映的是 Go 运行时调度系统的设计理念。掌握这个知识点,不仅是应对面试,更是实际工作中排查并发问题的基本功。

下次面试官问到这个问题,你可以自信地说出:不仅仅是调用一个函数那么简单,它背后是 Go 精心设计的调度模型。


思考题:如果协程中使用了 runtime.Gosched() 让出执行权,会影响 runtime.NumGoroutine() 的返回值吗?