Go并发调度-调度器设计理念从何而来?为何如此高效?

简介: Go并发调度-调度器设计理念从何而来?为何如此高效?

640.png

Go并发调度进阶



1. 调度器的基本设计原则和概念


我们首先了解一下调度器的设计原则及一些基本概念来建立对调度器较为宏观的认识。理解调度器涉及的主要概念包括以下三个:


  • G: Goroutine,即我们在 Go 程序中使用 go 关键字创建的执行体;
  • M: Machine,或 worker thread,即传统意义上进程的线程;
  • P: Processor,即一种人为抽象的、用于执行 Go 代码的处理器。只有当 M 与一个 P 关联后才能执行 Go 代码。除非 M 发生阻塞或在进行系统调用时间过长时,没有与之关联的 P。


1. M工作线程的暂止和复始


运行时调度器的任务是给不同的工作线程 (worker thread) 分发可供运行的(ready-to-run)Goroutine。我们不妨设每个工作线程总是贪心的执行所有存在的 Goroutine,那么当运行进程中存在 n 个线程(M),且 每个 M 在某个时刻有且只能调度一个 G。


那么会有以下性质:

  • 性质 1:当用户态代码创建了p(p>n)个G时,则必定存在p−n个 G 尚未被 M 调度执行;
  • 性质 2:当用户态代码创建的q(q<n)时,则必定存在 n-q 个 M 不存在正在调度的 G。


这两条性质分别决定了工作线程的 暂止(park)复始(unpark),复始就是解除暂止。

我们不难发现,调度器的设计需要在性质1和性质2之间进行权衡:即既要保持足够的运行工作线程来利用有效硬件并发资源,又要暂止过多的工作线程来节约 CPU 能耗。如果我们把调度器想象成一个系统,则寻找这个权衡的最优解意味着我们必须求解调度器系统中 每个 M 的状态,即系统的全局状态。这是非常困难的,不妨考虑以下两个难点:


难点 1: 在多个 M 之间不使用屏障的情况下,得出调度器中多个 M 的全局状态是不可能的

我们都知道计算的局部性原理,为了利用这一原理,调度器所需调度的 G 都会被放在每个 M 自身对应的本地队列中。换句话说,每个 M 都无法直接观察到其他的 M 所具有的 G 的状态,存在多个 M 之间的共识问题。这本质上就是一个分布式系统。显然,每个 M 都能够连续的获取自身的状态,但当它需要获取整个系统的全局状态时却不容易, 原因在于我们没有一个能够让所有线程都同步的时钟。换句话说, 我们需要依赖屏障来保证多个 M 之间的全局状态同步。更进一步,在不使用屏障的情况下, 能否利用每个 M 在不同时间中记录的本地状态中计算出调度器的全局状态呢,答案是不可能的。


难点 2: 为了获得最佳的线程管理,我们必须得获得未来的信息,即当一个新的 G 即将就绪(ready)时,则不再停止一个工作线程


举例来说,目前我们的调度器存在 4 个 M,并其中有 3 个 M 正在调度 G,则其中有 1 个 M 处于空闲状态。这时为了节约 CPU 能耗,我们希望对这个空闲的 M 进行暂止操作。但是,正当我们完成了对此 M 的暂止操作后, 用户态代码正好执行到了需要调度一个新的 G 时,我们又不得不将刚刚暂止的 M 重新启动,这无疑增加了开销。我们当然有理由希望,如果我们能知晓一个程序生命周期中所有的调度信息, 提前知晓什么时候适合对 M 进行暂止自然再好不过了。尽管我们能够对程序代码进行静态分析,但这显然是不可能的:考虑一个简单的 Web 服务端程序,每个用户请求 到达后会创建一个新的 G 交于调度器进行调度。但请求到达是一个随机过程,我们只能预测可能到达的请求数,而不能完整知晓所有的调度需求。

那么我们又应该如何设计一个通用且可扩展的调度器呢?我们很容易想到三种平凡的做法:

设计 1: 集中式管理所有状态


显然这种做法自然是不可取的,在多个并发实体之间集中管理所有状态这一共享资源,需要锁的支持, 当并发实体的数量增大时,将限制调度器的可扩展性。


设计 2: 每当需要就绪一个 G1 时,都让出一个 P,直接切换出 G2,再复始一个 M 来执行 G2


因为复始的 M 可能在下一个瞬间又没有调度任务,则会发生线程颠簸(thrashing),进而我们又需要暂止这个线程。另一方面,我们希望在相同的线程内保存维护 G,这种方式还会破坏计算的局部性原理。


设计 3: 任何时候当就绪一个 G、也存在一个空闲的 P 时,都复始一个额外的线程,不进行切换


因为这个额外线程会在没有检查任何工作的情况下立即进行暂止,最终导致大量 M 的暂止和复始行为,产生大量开销。


基于以上考虑,目前的 Go 的调度器实现中设计了工作线程的自旋(spinning)状态,放弃了上述三种设计


  1. 如果一个工作线程的本地队列、全局运行队列或网络轮询器(netpoller)中均没有可调度的任务,则该线程成为自旋线程;
  2. 满足该条件、被复始的线程也被称为自旋线程,对于这种线程,运行时不做任何事情。


自旋线程在进行暂止之前,会尝试从任务队列中寻找任务。当发现任务时,则会切换成非自旋状态, 开始执行 Goroutine。而找到不到任务时,则进行暂止。


当一个 Goroutine 准备就绪时,会首先检查自旋线程的数量,而不是去复始一个新的线程。

如果最后一个自旋线程发现了任务并且停止自旋时,则复始一个新的自旋线程。这个方法消除了不合理的线程复始峰值,且同时保证最终的最大 CPU 并行度利用率。


我们可以通过下图来直观理解工作线程的状态转换:


如果存在空闲的 P,且存在暂止的 M,并就绪 G
          +------+
          v      |
执行 --> 自旋 --> 暂止
 ^        | 自旋期间没有任务就将M暂止
 +--------+
  如果发现task就执行 执行完成就自旋


总的来说,调度器的方式可以概括为:如果存在一个空闲的 P 并且没有自旋状态的工作线程 M,则当就绪一个 G 时,就复始一个额外的线程 M。这个方法消除了不合理的线程复始峰值,且同时保证最终的最大 CPU 并行度利用率


因此,就绪(ready)一个 G 的通用流程为:

  • 提交一个 G 到 per-P 的本地工作队列 // 后续文章探讨per-P
  • 执行 StoreLoad 风格的写屏障 // x86架构的写屏障:
  • 检查 sched.nmspinning 数量 // nm:n个M自旋的数量


而从自旋到非自旋转换的一般流程为:

  • 减少 nmspinning 的数量
  • 执行 StoreLoad 风格的写屏障
  • 在所有 per-P 本地任务队列检查新的任务


当然,此种复杂性在全局任务队列中并不适用,因为当给一个全局队列提交任务时, 不进行线程的复始操作。


下篇从源码角度来分析下调度流程,感受不一样的底层设计魅力。

相关文章
|
5月前
|
人工智能 安全 算法
Go入门实战:并发模式的使用
本文详细探讨了Go语言的并发模式,包括Goroutine、Channel、Mutex和WaitGroup等核心概念。通过具体代码实例与详细解释,介绍了这些模式的原理及应用。同时分析了未来发展趋势与挑战,如更高效的并发控制、更好的并发安全及性能优化。Go语言凭借其优秀的并发性能,在现代编程中备受青睐。
166 33
|
4月前
|
存储 Go 开发者
Go 语言中如何处理并发错误
在 Go 语言中,并发编程中的错误处理尤为复杂。本文介绍了几种常见的并发错误处理方法,包括 panic 的作用范围、使用 channel 收集错误与结果,以及使用 errgroup 包统一管理错误和取消任务,帮助开发者编写更健壮的并发程序。
97 4
Go 语言中如何处理并发错误
|
2月前
|
数据采集 Go API
Go语言实战案例:多协程并发下载网页内容
本文是《Go语言100个实战案例 · 网络与并发篇》第6篇,讲解如何使用 Goroutine 和 Channel 实现多协程并发抓取网页内容,提升网络请求效率。通过实战掌握高并发编程技巧,构建爬虫、内容聚合器等工具,涵盖 WaitGroup、超时控制、错误处理等核心知识点。
|
2月前
|
数据采集 消息中间件 编解码
Go语言实战案例:使用 Goroutine 并发打印
本文通过简单案例讲解 Go 语言核心并发模型 Goroutine,涵盖协程启动、输出控制、主程序退出机制,并结合 sync.WaitGroup 实现并发任务同步,帮助理解 Go 并发设计思想与实际应用。
|
3月前
|
人工智能 Java Linux
Go 调度器:一个线程的执行流程
本文详细解析了Go语言运行时调度器的初始化流程,重点介绍了GMP模型的构建过程。内容涵盖调度器初始化函数`runtime·schedinit`、线程与处理器的绑定、P结构体的创建与初始化,以及主Goroutine的启动流程。通过源码分析,帮助读者深入理解Go运行时的底层机制。
|
4月前
|
数据采集 安全 Go
Go 语言并发编程基础:Goroutine 的创建与调度
Go 语言的 Goroutine 是轻量级线程,由 runtime 管理,具有启动快、占用小、支持高并发的特点。本章介绍 Goroutine 的基本概念、创建方式(如使用 `go` 关键字或匿名函数)、M:N 调度模型及其工作流程,并探讨其在高并发场景中的应用,帮助理解其高效并发的优势。
|
6月前
|
数据采集 监控 Go
用 Go 实现一个轻量级并发任务调度器(支持限速)
本文介绍了如何用 Go 实现一个轻量级的并发任务调度器,解决日常开发中批量任务处理的需求。调度器支持最大并发数控制、速率限制、失败重试及结果收集等功能。通过示例代码展示了其使用方法,并分析了核心组件设计,包括任务(Task)和调度器(Scheduler)。该工具适用于网络爬虫、批量请求等场景。文章最后总结了 Go 并发模型的优势,并提出了扩展功能的方向,如失败回调、超时控制等,欢迎读者交流改进。
221 25
|
6月前
|
SQL 监控 Go
新一代 Cron-Job分布式调度平台,v1.0.8版本发布,支持Go执行器SDK!
现代化的Cron-Job分布式任务调度平台,支持Go语言执行器SDK,多项核心优势优于其他调度平台。
108 8
|
8月前
|
存储 缓存 安全
Go 语言中的 Sync.Map 详解:并发安全的 Map 实现
`sync.Map` 是 Go 语言中用于并发安全操作的 Map 实现,适用于读多写少的场景。它通过两个底层 Map(`read` 和 `dirty`)实现读写分离,提供高效的读性能。主要方法包括 `Store`、`Load`、`Delete` 等。在大量写入时性能可能下降,需谨慎选择使用场景。
|
存储 监控 算法
golang 重要知识:golang 调度
Go 的调度机制相当于我们微服务里的基础组件。很多运行时操作都涉及到了调度的关联。本文会细聊调度概念,策略,以及它的机制。当然,也少不了最常提及的 GMP 模型。
444 0
golang 重要知识:golang 调度

热门文章

最新文章