各位同仁,各位技术爱好者,大家好!
今天,我们齐聚一堂,共同探讨一个前瞻性且极具挑战性的议题:“M:N 调度器的未来:当核心数达到万级时,Go 的 GMP 模型是否需要向分层调度演进?”。这不仅仅是一个理论探讨,更是对我们当前高性能计算范式,以及未来软件运行时设计的一次深刻反思。
随着摩尔定律的持续演进,以及异构计算和大规模并行处理技术的普及,我们正在迈入一个“万核时代”。无论是超算中心的巨型节点,还是未来可能出现的单片集成万级核心的处理器,都预示着处理器核心数量的爆炸式增长。在这种极端规模下,传统的操作系统调度,乃至当前许多用户态调度器的设计,都将面临前所未有的挑战。
Go 语言,以其原生并发支持和高效的 Goroutine 调度器(即 GMP 模型)而闻名。它成功地将数以百万计的轻量级 Goroutine 高效地映射到少量的操作系统线程上,极大地简化了并发编程。然而,当物理核心数量从数十、数百跃升至数万时,GMP 模型赖以成功的一些基本假设和设计哲学,是否还能保持其卓越的性能和扩展性?或者说,它是否需要进行根本性的变革,向更复杂的“分层调度”模型演进?这正是我们今天讲座的核心。
1. Go 的 GMP 模型:卓越的 M:N 调度器回顾
在深入探讨万核场景之前,让我们快速回顾一下 Go 语言的 GMP 模型,理解其工作原理、优势以及当前的设计边界。GMP 是 Go 运行时实现 M:N 调度的核心机制,它将 M 个用户态 Goroutine 调度到 N 个操作系统线程上执行,其中 M 远大于 N。
1.1 M:N 调度器的基本理念
M:N 调度器旨在解决两个核心问题:
- 操作系统线程的开销:创建、销毁、切换操作系统线程的成本较高,且操作系统调度器对应用层语义知之甚少,难以做最优决策。
- 并发编程的复杂性:直接管理 OS 线程容易出错,且缺乏轻量级并发原语。
M:N 调度器通过引入用户态的轻量级线程(如 Goroutine)来抽象并发单元,并通过一个用户态调度器将这些轻量级线程映射到一组 OS 线程上。这种模型的好处显而易见:
- 极低的上下文切换开销:Goroutine 切换通常只需要保存和恢复少量寄存器,无需陷入内核。
- 高并发能力:可以轻松创建百万级别的 Goroutine。
- 高效的资源利用:OS 线程在 Goroutine 阻塞时可以执行其他 Goroutine,避免资源浪费。
- 更细粒度的控制:用户态调度器可以根据应用需求进行更智能的调度决策。
1.2 GMP 模型的核心组件
Go 的 GMP 模型由三个关键组件构成:
- G (Goroutine):这是 Go 语言中的并发执行单元,一个轻量级的协程。每个 Goroutine 都有自己的栈和程序计数器。当一个 Goroutine 阻塞时,Go 调度器会切换到另一个可运行的 Goroutine。
- M (Machine/OS Thread):M 代表一个操作系统线程。Go 运行时会创建一定数量的 M 来执行 Goroutine。M 是真正执行机器指令的载体,它与操作系统内核交互,执行系统调用。
- P (Processor/Context):P 是一个逻辑处理器,它代表了 M 执行 Goroutine 所需的上下文。P 的数量通常由
GOMAXPROCS环境变量控制,默认为 CPU 核心数。P 的主要职责是:- 维护一个本地可运行 Goroutine 队列(
runq)。 - 为 M 提供执行 Goroutine 的资源。
- 在 M 阻塞时,P 会将 M 解绑,并寻找另一个空闲的 M 来绑定,或者创建一个新的 M。
- 维护一个本地可运行 Goroutine 队列(
1.3 GMP 的工作流程
GMP 的调度核心是一个循环:
- G 的创建与入队:新创建的 Goroutine 会被放入当前 P 的本地运行队列
runq中。如果本地队列满了,则放入全局运行队列globalRunQ。 - M 与 P 的绑定:一个 M 必须绑定一个 P 才能执行 Goroutine。P 为 M 提供了执行上下文和本地队列。
- 调度循环:一个 M 在绑定 P 后,会从 P 的本地
runq中取出 Goroutine 并执行。 - 工作窃取 (Work Stealing):如果一个 P 的本地
runq为空,它不会立即闲置。它会尝试从全局runq中获取 Goroutine。如果全局runq也为空,它会随机选择一个其他 P,从其本地runq中“窃取”一半的 Goroutine。这确保了负载均衡,避免了某些 M 忙碌而其他 M 空闲的情况。 - 系统调用处理:当一个 Goroutine 执行系统调用(如文件 I/O、网络操作)时,它所在的 M 可能会阻塞。Go 运行时会将该 M 与 P 解绑。如果此时有其他 M 空闲,它会绑定到该 P 上继续执行其他 Goroutine。如果所有 M 都忙碌,Go 会尝试创建一个新的 M 来服务该 P。当系统调用返回时,阻塞的 Goroutine 会重新排队等待调度。
- 协作式抢占:Go 1.14 引入了基于信号的异步抢占,以及 Go 1.21 引入的非协作式抢占,以防止长时间运行的 Goroutine 霸占 CPU,保证调度公平性。
1.4 示例:简化的 GMP 调度循环
为了更好地理解,我们来看一个高度简化的 GMP 调度核心逻辑的伪代码:
// 概念性结构,不代表真实的Go runtime实现细节
type G struct {
// ... Goroutine 状态,栈指针等
}
type P struct {
id int
runq []*G // P 的本地运行队列
m *M // 当前与 P 绑定的 M
// ... 其他统计信息、锁等
}
type M struct {
id int
p *P // 当前 M 绑定的 P
// ... OS 线程句柄,系统调用相关
}
var (
allPs []*P // 全局 P 列表
globalRunQ []*G // 全局运行队列 (Go 实际实现更复杂,有锁保护)
// ... 其他调度器状态
)
// func init() {
// // 初始化 allPs 和 globalRunQ
// // 创建 M 和 P,并进行初始绑定
// }
// 核心调度函数,由 M 调用
func (m *M) schedule() {
for {
// 1. 尝试从当前 P 的本地队列获取 Goroutine
g := m.p.popLocalRunQueue()
if g != nil {
m.execute(g) // 执行 Goroutine
continue
}
// 2. 尝试从全局队列获取 Goroutine
g = popGlobalRunQueue() // 假设有互斥保护
if g != nil {
m.p.pushLocalRunQueue(g) // 放入本地队列,然后执行
m.execute(g)
continue
}
// 3. 尝试进行工作窃取
g = m.p.stealWork()
if g != nil {
m.p.pushLocalRunQueue(g)
m.execute(g)
continue
}
// 4. 如果没有可运行的 Goroutine,M 可能会尝试解绑 P,或进入休眠
m.park() // 简化处理,实际更复杂,可能将 P 切换给其他 M
// 当有新的 Goroutine 就绪时,M 会被唤醒
}
}
// P 尝试窃取其他 P 的工作
func (p *P) stealWork() *G {
// 遍历所有 P,尝试从其他 P 的本地队列窃取
for _, otherP := range allPs {
if otherP == p {
continue // 不从自己这里窃取
}
// 假设 stealHalfRunQueue 能够原子地窃取一半 Goroutine
if stolenGs := otherP.stealHalfRunQueue(); len(stolenGs) > 0 {
// 将窃取到的 Goroutine 放入自己的本地队列
for _, g := range stolenGs[1:] {
p.pushLocalRunQueue(g)
}
return stolenGs[0] // 返回一个 Goroutine 立即执行
}
}
return nil
}
1.5 GMP 的优势与当前边界
GMP 模型在当前主流的数十到数百核心的服务器环境中表现出色。其优势在于:
- 高效的局部性:Goroutine 优先在本地 P 上执行,减少了缓存失效。
- 低竞争:大部分调度操作发生在 P 的本地队列,减少了全局锁的竞争。
- 优秀的负载均衡:工作窃取机制确保了所有核心都能得到充分利用。
- 对 OS 线程的抽象:简化了开发者的心智负担。
然而,GMP 的设计隐含了一些对硬件拓扑的假设:
- 所有 P 都可以相对均匀地访问所有 Goroutine 和全局数据结构。
- 工作窃取在任何 P 之间都是“等价”的,即窃取一个 Goroutine 并从另一个 P 迁移到当前 P 上执行的开销是相对固定的。
- 缓存一致性和 NUMA 架构的影响在当前规模下可以被容忍或通过其他机制缓解。
当核心数达到数万时,这些假设将被打破。
2. 万核挑战:GMP 在极限规模下的潜在瓶颈
“万核”不仅仅是数字上的增加,更是质的飞跃。它意味着我们不再面对一个扁平化的计算环境,而是必须直面深层次的硬件拓扑结构——特别是 NUMA(Non-Uniform Memory Access,非统一内存访问)架构和复杂的缓存层级。
2.1 万核环境的硬件特性
- 极端的 NUMA 效应:一个拥有数万核心的系统几乎必然是多 NUMA 节点的。例如,一个大型服务器可能包含数十个甚至上百个 CPU 插槽,每个插槽就是一个 NUMA 节点,拥有自己的内存控制器和本地内存。访问本地内存速度快,访问远程 NUMA 节点的内存则会显著变慢(延迟增加数倍,带宽降低)。
- 复杂的缓存层次:L1、L2、L3 缓存层级更深,共享范围更广。跨核心、跨 NUMA 节点的缓存一致性协议开销巨大。
- 内存带宽与延迟:虽然核心数增加,但总的内存带宽和单个核心可获得的内存带宽可能不成比例。内存墙问题更加突出。
- 互连网络:核心之间的通信,尤其是跨 NUMA 节点的通信,依赖于高速互连总线(如 Intel UPI/QPI, AMD Infinity Fabric),但这些总线也有其带宽和延迟限制。
2.2 GMP 模型在万核下的潜在瓶颈
在万核、多 NUMA 节点的环境下,GMP 模型的一些设计可能会成为性能瓶颈:
-
全局运行队列 (Global Run Queue) 竞争:
- 虽然 GMP 优先使用 P 的本地队列,但当本地队列为空时,会尝试访问全局队列。
- 随着 P 的数量(即
GOMAXPROCS)达到数万,对全局队列的原子操作(如加锁、CAS)将导致严重的缓存行争用(cache line contention)和互斥开销。 - 即使是无锁队列,其内部也可能需要原子操作,这些操作在跨 NUMA 节点时会变得异常昂贵。
-
工作窃取 (Work Stealing) 的效率与 NUMA 不敏感:
- 当前 GMP 的工作窃取机制是“扁平化”的,它随机选择一个 P 进行窃取。
- 在万核多 NUMA 环境下,如果一个 P 从另一个远程 NUMA 节点的 P 窃取 Goroutine,被窃取的 Goroutine 及其相关数据很可能存储在远程 NUMA 节点的内存中。
- 当这个 Goroutine 在新的 NUMA 节点上运行时,它将频繁地进行远程内存访问,导致严重的 NUMA 延迟和带宽瓶颈,从而大幅降低执行效率。这被称为“NUMA 颠簸”。
- 缓存失效也会加剧,因为 Goroutine 的数据在不同的 NUMA 节点之间移动,可能需要刷新和重加载缓存。
-
调度器自身的数据结构开销:
allPs列表:维护数万个 P 对象的列表本身就需要大量内存,并且对这个列表的遍历操作也会变慢。- 各种调度器内部的锁和原子操作:保护这些全局数据结构的锁,在数万个 M 同时竞争时,将成为严重的性能热点。
-
OS 调度器的挑战:
- Go 的 M 对应 OS 线程。当
GOMAXPROCS达到数万时,Go 运行时会尝试创建数万个 OS 线程(尽管通常 M 的数量不会完全等于 P 的数量,但也会非常多)。 - 操作系统自身的调度器在管理数万个线程时,其调度算法、上下文切换、优先级管理等都会面临巨大的挑战,可能导致 OS 调度器成为新的瓶颈。
- Go 的 M 对应 OS 线程。当
-
资源利用率问题:
- NUMA 不敏感的调度可能导致一些 NUMA 节点过载,而另一些节点空闲,或者所有节点都在等待远程内存访问,导致整体 CPU 利用率不高。
为了更好地说明,我们可以将当前 GMP 的工作窃取简化为一个扁平的环形或列表:
P1 --- P2 --- P3 --- P4 --- ... --- P_N
| | | | |
G's G's G's G's G's
当 P_i 窃取时,它可能从 P_j 窃取,而 P_j 可能与 P_i 位于不同的 NUMA 节点。
3. 分层调度:万核时代的演进方向
面对上述挑战,一种自然而然的演进方向便是“分层调度”(Hierarchical Scheduling)。分层调度的核心思想是将一个大型的、扁平的调度问题分解为多个更小、更易管理的子调度问题,并以层级结构组织起来。
3.1 什么是分层调度?
分层调度器将系统资源(如 CPU 核心、内存)划分为多个层级,每个层级都有自己的调度策略和调度器实例。高层调度器负责在宏观层面分配资源和协调任务,而低层调度器则负责在微观层面管理其分配到的资源和任务。
你可以将其类比为一个大型跨国公司的组织架构:
- 顶层:全球 CEO(全局调度器),负责战略规划、跨区域资源调配。
- 中层:各大洲或国家区域经理(NUMA 节点调度器),负责区域内的业务发展和资源分配。
- 基层:各部门经理或团队负责人(Go 的 P/M 调度),负责团队内部任务的分配和执行。
这种分层结构带来了诸多优势:
- 提升扩展性:通过将全局竞争分解为局部竞争,显著降低了调度器核心数据结构的争用。
- 改善局部性:调度器可以感知硬件拓扑,优先在本地资源上调度任务,从而提高缓存命中率和 NUMA 局部性。
- 增强隔离性:一个子调度器的故障或性能问题通常不会影响到整个系统。
- 更灵活的策略:不同层级的调度器可以采用不同的调度策略,以适应其特定层级的需求。
3.2 分层 GMP 的潜在架构
对于万核系统中的 Go GMP 模型,最直接且有效的演进方向是引入 NUMA 感知的分层调度。我们可以设想一个如下的层级结构:
层次结构设计:
- 顶层 (Global Scheduler / NUMA Node Coordinator):
- 职责:负责整个系统的宏观负载均衡,协调 Goroutine 在不同 NUMA 节点之间的迁移(仅在极端不均衡或冷启动时进行)。感知 NUMA 拓扑。
- 数据结构:可能维护一个非常轻量级的全局 Goroutine 队列,或仅仅是一个 NUMA 节点间的负载指标列表。
- 触发条件:长时间的 NUMA 节点间负载不均衡。
- 中层 (NUMA Node Scheduler):
- 职责:每个 NUMA 节点拥有一个独立的调度器实例。它负责管理该 NUMA 节点内的所有 P 和 M,以及 Goroutine。它优先在该 NUMA 节点内部进行调度和工作窃取。
- 数据结构:一个 NUMA 节点内共享的 Goroutine 队列(类似于现在的
globalRunQ但范围限定在 NUMA 节点内),以及对该节点内所有 P 的引用。 - 触发条件:该 NUMA 节点内的 P 出现空闲,需要从本地队列、全局队列(NUMA 节点内)或进行 NUMA 节点内的工作窃取。
- 底层 (Enhanced P/M Scheduler):
- 职责:与现有 GMP 的 P/M 调度器类似,但其工作窃取范围首先限定在本 NUMA 节点内部。
- 数据结构:P 的本地运行队列
runq。 - 触发条件:P 的本地队列为空。
3.3 分层 GMP 的工作流程设想
-
Goroutine 的创建与初始放置:
- 新创建的 Goroutine 优先被放置到当前 M 绑定的 P 的本地队列中,该 P 所在的 NUMA 节点。
- 如果 P 的本地队列满了,则将其放入该 NUMA 节点的共享队列中。
- Go runtime 可以尝试通过一些启发式算法(例如,基于创建 Goroutine 的 Goroutine 所在的 NUMA 节点)来“亲和性”地选择 Goroutine 所在的 NUMA 节点。
-
NUMA 节点内的调度与工作窃取:
- 一个 M 绑定到一个 P 后,会首先尝试从 P 的本地队列获取 Goroutine。
- 如果本地队列为空,M 会尝试从其所在的 NUMA 节点的共享队列中获取 Goroutine。
- 如果该 NUMA 节点的共享队列也为空,M 会在该 NUMA 节点内部的其他 P 之间进行工作窃取。这是最频繁的窃取操作,因为它保持了良好的 NUMA 局部性。
-
跨 NUMA 节点的工作窃取与负载均衡:
- 只有当一个 NUMA 节点内的所有 P 都空闲,且该 NUMA 节点的共享队列也为空时,才会考虑进行跨 NUMA 节点的工作窃取。
- 这种窃取操作由中层或顶层调度器协调,它会选择一个负载相对较高的远程 NUMA 节点进行窃取。
- 关键点:跨 NUMA 窃取应该是一个“高成本操作”,尽量少发生。一旦发生,Go 运行时需要评估被窃取 Goroutine 的数据亲和性,可能需要进行 Goroutine 的数据迁移或标记 Goroutine 为“远程访问”,以优化后续调度。
3.4 示例:概念性的分层 GMP 调度逻辑
// 概念性结构,不代表真实的Go runtime实现细节
// 引入 NUMANode 结构来表示一个 NUMA 节点
type NUMANode struct {
id int
ps []*P // 属于这个 NUMA 节点的所有 P
nodeRunQ []*G // 这个 NUMA 节点内部共享的 Goroutine 队列
nodeMutex sync.Mutex // 保护 nodeRunQ
// ... 其他 NUMA 特定的统计信息,负载指标等
}
// 修改 P 结构,使其能够感知所在的 NUMA 节点
type P struct {
id int
runq []*G // P 的本地运行队列
m *M // 当前与 P 绑定的 M
numaNode *NUMANode // P 所在的 NUMA 节点
// ... 其他字段
}
// HierarchicalScheduler 将协调所有 NUMA 节点
type HierarchicalScheduler struct {
numaNodes []*NUMANode
// ... 全局负载均衡和协调机制
}
var (
globalScheduler *HierarchicalScheduler
// ... 其他调度器状态
)
// func init() {
// // 探测 NUMA 拓扑,创建 NUMANode 实例
// // 将 P 分配到对应的 NUMANode
// // 初始化 globalScheduler
// }
// 核心调度函数,由 M 调用
func (m *M) schedule() {
for {
// 1. 尝试从当前 P 的本地队列获取 Goroutine
g := m.p.popLocalRunQueue()
if g != nil {
m.execute(g)
continue
}
// 2. 尝试从当前 NUMA 节点的共享队列获取 Goroutine
g = m.p.numaNode.popNodeRunQueue() // 假设有互斥保护
if g != nil {
m.p.pushLocalRunQueue(g) // 放入本地队列,然后执行
m.execute(g)
continue
}
// 3. 尝试进行 NUMA 节点内部的工作窃取 (高频操作)
g = m.p.stealWorkWithinNUMANode()
if g != nil {
m.p.pushLocalRunQueue(g)
m.execute(g)
continue
}
// 4. 如果 NUMA 节点内部仍无 Goroutine,尝试进行跨 NUMA 节点窃取 (低频操作)
g = globalScheduler.stealWorkAcrossNUMANodes(m.p.numaNode)
if g != nil {
// 将窃取到的 Goroutine 放入本地队列,并更新其 NUMA 亲和性
m.p.pushLocalRunQueue(g)
m.execute(g)
continue
}
// 5. 如果仍然没有可运行的 Goroutine,M 可能会尝试解绑 P,或进入休眠
m.park()
}
}
// P 尝试窃取本 NUMA 节点内其他 P 的工作
func (p *P) stealWorkWithinNUMANode() *G {
for _, otherP := range p.numaNode.ps { // 只遍历本 NUMA 节点内的 P
if otherP == p {
continue
}
if stolenGs := otherP.stealHalfRunQueue(); len(stolenGs) > 0 {
for _, g := range stolenGs[1:] {
p.pushLocalRunQueue(g)
}
return stolenGs[0]
}
}
return nil
}
// 全局调度器协调跨 NUMA 节点窃取
func (hs *HierarchicalScheduler) stealWorkAcrossNUMANodes(localNUMA *NUMANode) *G {
// 这是一个更复杂的逻辑,需要考虑负载、数据亲和性等
// 1. 评估各个 NUMA 节点的负载
// 2. 选择一个负载较重且距离当前节点相对较近的远程 NUMA 节点
for _, remoteNUMA := range hs.numaNodes {
if remoteNUMA == localNUMA {
continue
}
// 假设 remoteNUMA 提供了 tryStealGoroutineFromNodeQ 或 tryStealFromAnyP
if g := remoteNUMA.tryStealGoroutineFromNodeQ(); g != nil {
// 重要:标记 Goroutine 已从远程 NUMA 窃取,可能需要更新其亲和性信息
return g
}
}
return nil
}
3.5 潜在的优势
- 极高的 NUMA 局部性:绝大多数调度和工作窃取操作都发生在 NUMA 节点内部,最大限度地减少了远程内存访问。
- 降低全局竞争:全局调度器(如果存在)的活跃度极低,主要负责协调,真正的调度压力分散到各个 NUMA 节点调度器中。NUMA 节点内的共享队列竞争范围也大大缩小。
- 更好的缓存利用:Goroutine 倾向于在同一个 NUMA 节点内执行,其数据也倾向于驻留在该节点的本地内存和缓存中,减少了缓存失效和缓存一致性协议的开销。
- 提升扩展性:调度器的性能不再受限于单个全局锁或数据结构,理论上可以扩展到任意多的核心和 NUMA 节点。
4. 实现分层 GMP 的挑战与考量
将 GMP 模型演进为分层调度并非易事,它将带来一系列工程和设计上的挑战:
4.1 复杂度增加
- 调度器逻辑:多层调度器会使 Go 运行时的调度逻辑变得更加复杂,调试和维护的难度也会增加。
- 数据结构:需要引入新的数据结构来表示 NUMA 节点、NUMA 节点间的通信队列、负载均衡指标等。
- 初始化:Go 运行时需要在启动时探测硬件 NUMA 拓扑,并据此初始化分层调度器。
4.2 性能开销与权衡
- 调度器自身的开销:额外的调度层级本身会引入一定的运行时开销。我们需要确保这些开销远小于因 NUMA 优化带来的性能收益。
- Goroutine 迁移开销:跨 NUMA 节点窃取 Goroutine 时,如果 Goroutine 携带大量数据或频繁访问其原始 NUMA 节点的数据,迁移成本会非常高。需要设计策略来决定何时值得迁移,以及如何降低迁移成本(例如,只迁移轻量级 Goroutine,或在迁移后主动将数据预取到新 NUMA 节点)。
- 锁粒度与并发:即使是 NUMA 节点内的共享队列也需要锁保护,如何设计高效、低竞争的锁机制(例如,分段锁、NUMA 感知自旋锁)至关重要。
4.3 操作系统交互
- M 与 OS 调度器:Go 的 M 依然是 OS 线程。操作系统调度器是否能感知 NUMA 拓扑,并尽可能将 M 调度到与 P 对应的 NUMA 节点上?如果 OS 调度器将 M 从一个 NUMA 节点迁移到另一个,可能会破坏 Go 运行时建立的 NUMA 亲和性。
- CPU 亲和性:Go 运行时可能需要通过
sched_setaffinity等系统调用,将 M 绑定到特定的 CPU 核心或 NUMA 节点,以强化 NUMA 亲和性。但这会增加 OS 调度的复杂性。
4.4 API 兼容性与用户透明性
- 理想情况下,这种调度器的演进应该对 Go 应用程序开发者是完全透明的,不应改变现有的并发 API(如
go关键字、sync包)。 - 然而,如果用户需要更细粒度的控制(例如,指定 Goroutine 的 NUMA 亲和性),Go 运行时可能需要暴露一些高级的调优接口。
4.5 动态适应性
- 系统负载是动态变化的,NUMA 节点之间的负载可能频繁波动。调度器需要能够动态地调整其策略,以适应这些变化。
- 对于一些高度依赖数据局部性的应用,调度器可能需要更智能的 Goroutine 放置策略,甚至在 Goroutine 的生命周期内,根据其内存访问模式进行动态迁移。
4.6 内存管理与垃圾回收
- NUMA 环境对内存分配器和垃圾回收器也提出了挑战。Go 的内存分配器(
tcmalloc演变而来)需要是 NUMA 感知的,优先在 Goroutine 所在的 NUMA 节点分配内存。 - 垃圾回收器在遍历堆时,如果 Goroutine 的数据分散在多个 NUMA 节点上,会导致更多的远程内存访问。
5. 总结与展望
当处理器核心数达到万级规模时,Go 语言当前扁平化的 GMP 调度模型将面临严峻的挑战。全局数据结构的竞争、NUMA 效应导致的性能下降,以及工作窃取的低效率,都将成为其扩展性的瓶颈。因此,Go 的 GMP 模型确实需要向分层调度演进,以适应未来的万核计算环境。
分层调度,特别是 NUMA 感知的分层调度,提供了一个优雅的解决方案。它通过将调度器的职责分层,将全局竞争分解为局部竞争,并优先在硬件拓扑的局部范围内进行调度和资源管理,从而显著提升了调度器的扩展性、NUMA 局部性和整体系统性能。
当然,这种演进并非一蹴而就,它将是一项复杂的工程。Go 团队以其务实和渐进的开发风格而闻名,我们可以预见,如果这种需求变得迫切,他们将逐步引入 NUMA 感知和分层调度的特性。这可能首先从改进内存分配器开始,接着是工作窃取策略的优化,最终形成一个健壮的分层调度架构。
未来的 Go 运行时,将在保持其简洁高效并发模型的同时,在底层实现更复杂的、硬件拓扑感知的调度机制,以驾驭万核时代的强大计算能力。这将是一个激动人心的技术旅程,值得我们共同期待和探索。
谢谢大家!