面试官:"说说 Go 语言中如何查询当前有多少个协程在执行?"
这个问题看似简单,实际上考察的是候选人对 Go 运行时调度模型的掌握程度。根据我的理解,这篇文章来聊聊这个面试题背后的知识点。
先来看答案
在 Go 语言中,查询当前正在执行的协程数量非常简单,只需要调用一个函数:
import "runtime"
num := runtime.NumGoroutine()
fmt.Printf("当前协程数量: %d\n", num)
runtime.NumGoroutine() 会返回当前 Go 程序中存在的 goroutine 数量,包括正在运行的和你刚刚创建但还没来得及运行的。
面试官为什么问这个?
表面上是问函数调用,实际上他想考察的是:
- 你是否知道 Go 的协程调度是由 runtime 管理的
- 你是否理解 goroutine 和线程的区别
- 你是否有过排查协程泄漏的经验
让我们逐一展开。
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 的调度器会自动将协程分配到有限的线程上执行,实现高效的并发。
面试加分项
如果你能说出下面这些,肯定能给面试官留下深刻印象:
- G-P-M 模型:Goroutine 要跑在 P(Processor)上,P 需要关联 M(Machine)才能执行
- 工作窃取:当某个 P 的队列为空时,会从其他 P 那里偷任务
- 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() 的返回值吗?