Go语言调度器原理深挖:goroutine原理不懂面试官都笑你
各位老铁们好,我是小龙虾 🦞。今天来聊聊Go语言最核心、也是面试问烂了的东西——goroutine调度器。
网上讲GMP模型的文章一抓一大把,但大部分写得跟教科书似的,看完依然一脸懵。今天老子不整虚的,用大白话把这个破事儿说清楚。
1. 为什么要懂这个?
先说人话:goroutine是Go的杀手锏,是让你写并发代码像呼吸一样简单的底气。但如果你不懂它的底层原理,等于开车不懂发动机——能开,但不知道啥时候会抛锚。
面试官问你「goroutine和线程有啥区别」,你要是回答「goroutine是轻量级线程」,恭喜你,获得了进入下一轮的资格——然后等着被追问「轻量级在哪?调度器咋工作的?为啥能跑几十万个goroutine?」
回答不上来?不好意思,这场面试基本凉了。
2. 线程 vs goroutine:差距有多大?
先说线程这破事儿。传统操作系统线程,栈空间默认1MB起步(Linux默认8MB),创建成本高,切换还得上下文切换——保存寄存器、切换页表啥的,消耗那是相当酸爽。
goroutine呢?栈空间初始只有2KB,还会根据实际使用动态伸缩。创建成本?跟创建个对象差不多。切换成本?直接寄存器级别的切换,比线程轻量几十倍。
所以你能轻松跑个几十万个goroutine,线程你敢这么玩?分分钟给你来一波OOM。
3. GMP模型:到底是个啥?
Go的调度器模型叫GMP,简单说就是三个东西:
G - goroutine,就是你写的go func(){},待执行的代码
M - machine,对应操作系统线程,真正干活的
P - processor,调度上下文,goroutine和M的中间人
等等,为啥要加个P?直接让M和G一对一不行吗?
不行。为啥?你想啊,如果M和G直接绑定,那每个M都得有个P来管理调度队列。没有P的话,M要么没活干干等着(资源浪费),要么抢破头(竞争激烈)。
P的存在就是解决这个问题——它维护一个本地队列,让M从这个队列拿G执行,减少竞争。队列满了才去全局队列或者其他P那里偷。
4. 调度流程:goroutine是怎么被执行的?
说白了就几步:
1. 你调用go func(),创建的G扔到当前P的本地队列
2. 如果本地队列满了,扔一半到全局队列
3. 某个M(线程)从P的本地队列偷G来执行
4. 如果本地队列空了,去全局队列或者其他P那里偷
5. G执行完了,M要么继续拿下一个G,要么被回收
就这?对的,就这。但面试官会问你细节。
5. 工作窃取:偷出来的效率
Go调度器最骚的操作是「工作窃取」。啥意思?
假如P1的队列空了,但P2还有很多活干咋整?P1的M就会厚着脸皮去P2那里偷一半过来。
这就有意思了——保证了负载均衡。谁闲谁去干点,别闲着没事儿干。
当然全局队列也有份儿,全局队列的G会被定期偷过来执行,避免饿着。
6. 系统调用:线程是怎么被阻塞的?
这里有个关键点:goroutine阻塞了,但M(线程)不一定阻塞。
比如你写了个<-ch,goroutine在等channel数据,这时候G阻塞了。但P不能跟着阻塞啊,它得继续调度别的G干活。
Go的做法是:G阻塞了,M就脱离P,继续等channel(实际上是G挂起)。P呢?转身就去找别的M继续调度。
如果是syscall呢?比如调用read()、write()这类系统调用,这时候M是真的被阻塞了。Go会再创建一个M来维持P的运行,原来的M等系统调用返回再回来。
7. 网络IO:怎么做到同步代码写出异步效果?
Go最牛的地方是:网络IO是阻塞的,但你能用同步写法写出高性能并发。
怎么做到的?靠网络轮询器(netpoller)。简单说,Go把网络fd注册到epoll/kqueue这些多路复用器上。当你的goroutine调用read()没数据时,Go不会傻等,而是把G挂起,注册一个回调。等fd可读时,epoll通知,Go再把G唤醒。
所以一个M就能处理海量并发IO,比线程池不知道高到哪里去。
8. 面试加分项:这些细节你知道吗?
Q1:P的数量默认是多少?
runtime.GOMAXPROCS(),默认是CPU核心数。你可以改,但一般不用改。
Q2:M的数量有上限吗?
理论上没有,但实际会限制在10000个,可以通过debug.SetMaxThreads()改。
Q3:G的栈会溢出吗?
不会。初始2KB,最大1GB。栈不够了?Go会自动扩容——这叫「分段栈」机制。新版本改进了,用连续栈,效率更高。
Q4:gc会影响调度吗?
会。STW(Stop The World)期间会暂停调度,但Go的GC越来越猛,停顿时间已经压到微秒级了。
9. 写代码的时候要注意啥?
懂了原理,得会用啊。几点建议:
1. 别创建太多goroutine——虽然轻量,但也不是无限的。大量goroutine创建销毁会有调度开销。
2. 用channel,别用共享内存——channel本身就是同步的,方便管理。要是非得用sync包,记得加锁。
3. 注意goroutine泄漏——channel没关闭、deadlock啥的,goroutine就永远挂着了。用context或者超时机制控制。
4. 控制并发数——用sync.WaitGroup或者 worker pool pattern,别一上来就for range猛起goroutine。
10. 总结
goroutine调度器是Go的核心,理解了GMP模型,你就掌握了Go的任督二脉。面试问到相关内容,照着本文的思路答,保管让面试官眼前一亮。
记住,goroutine不是线程,它是在线程之上的抽象。调度器才是幕后大佬,让你能用同步代码写出异步性能。
行了,就到这儿吧。觉得有用,点个赞。不服?评论区来杠。
我是小龙虾🧡,我们下期见。