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 本地任务队列检查新的任务


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


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

相关文章
|
1月前
|
存储 负载均衡 监控
如何利用Go语言的高效性、并发支持、简洁性和跨平台性等优势,通过合理设计架构、实现负载均衡、构建容错机制、建立监控体系、优化数据存储及实施服务治理等步骤,打造稳定可靠的服务架构。
在数字化时代,构建高可靠性服务架构至关重要。本文探讨了如何利用Go语言的高效性、并发支持、简洁性和跨平台性等优势,通过合理设计架构、实现负载均衡、构建容错机制、建立监控体系、优化数据存储及实施服务治理等步骤,打造稳定可靠的服务架构。
36 1
|
1月前
|
Go 调度 开发者
探索Go语言中的并发模式:goroutine与channel
在本文中,我们将深入探讨Go语言中的核心并发特性——goroutine和channel。不同于传统的并发模型,Go语言的并发机制以其简洁性和高效性著称。本文将通过实际代码示例,展示如何利用goroutine实现轻量级的并发执行,以及如何通过channel安全地在goroutine之间传递数据。摘要部分将概述这些概念,并提示读者本文将提供哪些具体的技术洞见。
|
2月前
|
Java 大数据 Go
Go语言:高效并发的编程新星
【10月更文挑战第21】Go语言:高效并发的编程新星
55 7
|
1月前
|
并行计算 安全 Go
Go语言的并发特性
【10月更文挑战第26天】Go语言的并发特性
19 1
|
2月前
|
安全 Go 调度
探索Go语言的并发模式:协程与通道的协同作用
Go语言以其并发能力闻名于世,而协程(goroutine)和通道(channel)是实现并发的两大利器。本文将深入了解Go语言中协程的轻量级特性,探讨如何利用通道进行协程间的安全通信,并通过实际案例演示如何将这两者结合起来,构建高效且可靠的并发系统。
|
2月前
|
安全 Go 开发者
破译Go语言中的并发模式:从入门到精通
在这篇技术性文章中,我们将跳过常规的摘要模式,直接带你进入Go语言的并发世界。你将不会看到枯燥的介绍,而是一段代码的旅程,从Go的并发基础构建块(goroutine和channel)开始,到高级模式的实践应用,我们共同探索如何高效地使用Go来处理并发任务。准备好,让Go带你飞。
|
2月前
|
安全 程序员 Go
深入浅出Go语言的并发之道
在本文中,我们将探索Go语言如何优雅地处理并发编程。通过对比传统多线程模型,我们将揭示Go语言独特的goroutine和channel机制是如何简化并发编程,并提高程序的效率和稳定性。本文不涉及复杂的技术术语,而是用通俗易懂的语言,结合生动的比喻,让读者能够轻松理解Go语言并发编程的核心概念。
|
27天前
|
并行计算 安全 Go
Go语言中的并发编程:掌握goroutines和channels####
本文深入探讨了Go语言中并发编程的核心概念——goroutine和channel。不同于传统的线程模型,Go通过轻量级的goroutine和通信机制channel,实现了高效的并发处理。我们将从基础概念开始,逐步深入到实际应用案例,揭示如何在Go语言中优雅地实现并发控制和数据同步。 ####
|
1月前
|
存储 Go 开发者
Go语言中的并发编程与通道(Channel)的深度探索
本文旨在深入探讨Go语言中并发编程的核心概念和实践,特别是通道(Channel)的使用。通过分析Goroutines和Channels的基本工作原理,我们将了解如何在Go语言中高效地实现并行任务处理。本文不仅介绍了基础语法和用法,还深入讨论了高级特性如缓冲通道、选择性接收以及超时控制等,旨在为读者提供一个全面的并发编程视角。
|
29天前
|
安全 Serverless Go
Go语言中的并发编程:深入理解与实践####
本文旨在为读者提供一个关于Go语言并发编程的全面指南。我们将从并发的基本概念讲起,逐步深入到Go语言特有的goroutine和channel机制,探讨它们如何简化多线程编程的复杂性。通过实例演示和代码分析,本文将揭示Go语言在处理并发任务时的优势,以及如何在实际项目中高效利用这些特性来提升性能和响应速度。无论你是Go语言的初学者还是有一定经验的开发者,本文都将为你提供有价值的见解和实用的技巧。 ####