从 GMP 模型看如何设计一个线程池

在前几篇博客中,我分享了我的个人项目 symphony09/ograph。在设计实现中,我参考了 C++ 同类优秀项目 CGraph。在参考过程中,我发现很有意思的一点是 CGraph 的线程池设计基本是在重新发明 Go 的 GMP 调度模型。

比如,下面是 CGraph 作者分享的早期线程池设计示意图和 Go 早期的 GM 调度模型设计示意图(还没有引入P)

可以看到,如果把任务队列改成协程队列,加上M0, M1……其实就是线程,两张图几乎如出一辙。甚至后面的优化方向也和 Go 一样。

两者都引入了本地队列,可以参考下图。不同之处 CGraph 线程池直接使用了 ThreadLocal 绑定线程,而 Go 引入了调度 P。也正是因为这个不同,事情开始不一样了。

有什么不一样呢,可以看看这两者窃取机制的实现。下图中,CGraph 空闲线程从其他线程的本地队列中窃取任务,相应的,Go M 线程从其他线程绑定的 P 中窃取协程:

CGraph 的这一窃取机制是有遇到问题的,以下是作者原话:

有些时候,work-stealing机制甚至可能成为“累赘”。举个例子,pool中一共有100个线程吧,那当thread0中queue无任务的时候,thread0会去遍历其他的99个thread——就为了盗取一个任务。这个遍历有阻塞耗时不说,也会影响到thread0去执行新来的local task——像极了天天帮同事排查bug,但是自己一大堆bug却来不及修复的纯序员本员。

而 Go 这边就不会有这个问题,因为 P 的数量基本是固定的,一般就是 CPU 核数,这是一个很小的常量,所以查找几乎没什么开销。

Go 引入的 P 提供了一种介于全局和完全本地化的中间方案,我觉得这是系统设计中一个非常有价值的参考点。

除了窃取机制,P 也让线程扩缩容机制有所不同。

先看 CGraph 是怎么实现的,以下摘录了作者原话:

CGraph的实现逻辑中,包含了两种线程:PrimaryThread(主线程,简称PT)和SecondaryThread(辅助线程,简称ST

除了PT和ST,pool中还开辟了一个MonitorThread(监控线程,简称MT)。MT每隔固定的时间,会去轮询监测所有的PT是否都在running状态。如果是,就认定当前pool处于忙碌状态,则添加一个ST帮忙分担任务执行。同样的,MT还会去监测每个ST的状态。如果连续TTL次监测到ST没有在执行任务,则认为pool处于空闲状态,则会销毁当前ST。

这样就做到了线程数量随着pool的忙碌和空闲,动态调整了。

而 Go 是这么做的:

M 何时创建:没有足够的 M 来关联 P 并运行其中的可运行的 G。比如所有的 M 此时都阻塞住了,而 P 中还有很多就绪任务,就会去寻找空闲的 M,而没有空闲的,就会去创建新的 M。

浅显的看,Go 的 GMP 省去了轮询监测,做到了按需创建。不过做到这一点其实没有看上去那么简单,Go runtime 在背后还做了很多来实现按需创建,这里推荐一篇博客: golang 系统调用与阻塞处理 | 李乾坤的博客 (qiankunli.github.io)

我想说的

很多东西可能看上去风马牛不相及,其实本质上区别没有我们想象的那么大。相比那些玄而又玄的技术名称,更应该搞清楚的是用什么手段解决了什么问题。

Go 的 GMP 设计让我省下了精心设计线程池的时间,有更多的时间打磨 symphony09/ograph 的功能。相比那些用来少打几个字母的语法糖,这些才是真正能够让人节省心智的东西。

本作品采用《CC 协议》,转载必须注明作者和本文链接
讨论数量: 0
(= ̄ω ̄=)··· 暂无内容!

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!