Go 协程(goroutine)为啥比线程轻量?

4 阅读5分钟

今天我们来聊聊一个很多Golang新手经常会好奇的问题:

Go 协程(goroutine)为啥比线程轻量?轻在哪?牛在哪?

我前阵子正好研究了下 goroutine 的底层实现,结合我这些年写 Go 项目的踩坑经历,这篇文章来细说下。建议耐心看完,后面几个点真的有点“封神”级的设计!

先说结论:轻量的不是协程本身,而是调度系统

Go 语言的 goroutine 并不是比线程小那么一点点,而是从设计哲学上就不一样的物种。

一个线程通常绑定一个系统资源,启动成本高,占用内存多。 一个 goroutine 是用户态的"线程",内存开销极小,调度也是 Go 自己控制。

我在老项目里有个接口并发处理任务,用 Java 的时候线程池最多起到几百个就感觉卡卡的,用 Go 则直接上万个 goroutine 不带眨眼的,稳定得很。

协程到底“轻”在哪?

这里我们拆开讲几个核心点:

2.1 占用内存小,创建速度快

线程:默认栈空间就要几百 KB,甚至 1MB。

goroutine:初始栈只有 2KB,运行时可以动态扩展!

举个例子:

for i := 0; i < 100000; i++ {  go func() {   // do something  }() }

这段代码在 Go 里运行基本没问题,但你要在 Java 或 C++ 用线程这么搞,分分钟把内存打爆!

我之前测过一次,用 Go 创建 10 万个 goroutine,大概只用了 200MB 左右内存,真的离谱地轻!

2.2 不依赖内核线程,Go 自己造了个“线程调度器”

Go 里面有一套独特的 M:N 调度模型:

M:内核线程(Machine)

N:用户态的 goroutine

P:处理器上下文(Processor),控制 goroutine 到线程的调度过程

这套机制跟操作系统线程调度一毛钱关系没有,完全用户态切换,不陷入系统调用,这就避免了频繁的线程上下文切换开销。

简单理解就是:

goroutine 切换不用操作系统参与!很快!几乎0成本!

对比 Java、Python 这些依赖系统线程的语言,Go 自己玩了套高性能调度系统,这真是“惊艳我一整年”。

2.3 调度逻辑优化得很牛

Go 的调度器做了很多底层优化:

work stealing(工作窃取),每个 P 有自己队列,空闲了就去偷别人的任务

G-P-M 模型非常适合 CPU 多核并发利用

遇到阻塞(如 IO、syscall)自动让出线程,不影响别的 goroutine

我自己遇到过一个生产级问题:一批 goroutine 访问 Redis 时可能被 block(网络延迟),但其他 goroutine 不受影响,依然执行正常。Go 会自动把这些被 syscall 卡住的 goroutine 暂存起来,等 IO 好了再恢复执行!

goroutine 是如何扩展栈的?

对了,还有一个细节特别绝:

goroutine 栈空间是动态扩展的!

刚开始只有 2KB(这就是为啥能启动那么多),但当你函数调用层级多了,它会偷偷帮你扩容,比如变成 4KB、8KB……

扩容用的是“copy stack + 迁移”的策略,成本很低,绝大多数情况下不会有啥性能瓶颈。

而 C/C++ 线程栈默认是固定大小,撑爆了就 crash。

协程调度和抢占机制也在进化

Go 早期的 goroutine 是非抢占式调度,意思是说 goroutine 自己不让出 CPU 就没法切换。这个有时候会出问题,比如某个协程 CPU 密集跑死了,别的都饿死。

后来 Go 1.14 开始加了抢占式调度,插入了 runtime 检查点,强制让出 CPU,这就把调度变得更加智能和公平了。

goroutine 是不是没有缺点?

也不是。

5.1 使用不当容易内存泄露

比如没有关闭通道、协程跑出 panic 后没人 recover,这些都可能造成 goroutine 泄露,最终内存吃光。

我线上踩过这种坑,一个协程卡在 select {} 里没人清理,日积月累几十万个 goroutine 还活着……

5.2 trace/debug 较麻烦

因为 goroutine 数量非常多,调度动态变化大,写 profiling 工具或 debug 的时候不太好跟踪。有些复杂并发场景你抓堆栈看个头都大。

总结一下:协程封神了没?

真心讲,封神了!

为什么说 goroutine 封神?

极小内存占用(2KB 起步)

M:N 调度,高效分发

自主调度系统,不依赖 OS

抢占机制+阻塞感知,不卡主流程

栈空间可动态扩展,避免爆栈

结合 Go 的 runtime,一整套调度系统闭环,适合大规模并发任务处理,甚至可以取代线程池

最后再说几个使用建议

1)别滥用 goroutine,虽然轻,但资源总是有限的,控制数量,必要时加限流(如 golang.org/x/sync/semaphore)

2)记得处理协程 panic,建议都包一层 recover

go func() {  defer func() {   if r := recover(); r != nil {    // log it   }  }()  doSomething() }()

3)需要控制退出用 context.Context,别搞死循环卡死

4)要 debug goroutine 数量可以用 runtime.NumGoroutine() 监控下

感兴趣的朋友可以试试启动十万个 goroutine 做 IO 模拟操作,再试试 Java 的线程池你就懂了。

这就是 Go 的魅力,协程 + 通道 + runtime 调度,直接做到了轻量、高并发、低成本。