当前位置: 首页 > news >正文

Golang 的协程调度小结

Golang 调度器的由来

一切的软件都跑在操作系统上,早期的操作系统每个程序就是一个进程,一个程序运行完才能进行下一个进程,所有的程序只能串行发生,这就是单进程时代。

在这里插入图片描述

早期的单进程操作系统,存在两个问题:

  • 单一的执行流程。计算机只能串行处理任务,所有的程序几乎都是阻塞的。
  • 进程阻塞带来的 CPU 时间浪费。

多进程/多线程的操作系统解决了阻塞的问题,一个进程阻塞 CPU 时可以立刻切换到其他进程中去执行,而且调度 CPU 的算法可以保证在运行的进程都可以被分配到 CPU 的运行时间片。从宏观来看,似乎就是多个进程在同时被运行。

在这里插入图片描述

但是,进程拥有太多的资源,进程的创建、切换、销毁,都会占用很长的时间,虽然 CPU 利用起来了,但如果进程数太多,CPU 很大程度上都被用来进行进程切换调度。

同时,多线程设计还要考虑很多的同步竞争问题,比如:锁、资源竞争、同步冲突等等。

那么如何才能提高 CPU 的利用率呢?

多进程/多线程已经提高了系统的并发能力,但是在当今的互联网高并发场景下,为每一个任务都创建一个进程是不现实的,因为这样会出现大量的线程同时运行,不仅切换成本高,也会消耗大量的内存。

后来工程师发现一个线程可以分为"内核态"和"用户态"两种形态,所谓的用户态线程就是把内核态的线程在用户态实现了一遍而已,目的是更轻量化(更少的内存占用、更少的隔离、更快的调度)和更好的可控性(可以自己控制调度器)。用户态的所有东西内核态都看得见,只是对于内核而言,用户态线程只是一堆内存数据而已。

一个用户态线程必须要绑定一个内核态线程,但是 CPU 并不知道有用户态线程的存在,它只知道它运行的是一个内核态线程。

在这里插入图片描述

如果将线程再进行细化分类,内核线程依然叫线程(Thread),用户线程则叫协程(Coroutine),操作系统层面的线程就是内核态线程,用户态线程则多种多样,只要能满足在同一个内核线程上执行多个任务就算。

既然一个协程可以绑定一个线程,那么多个协程也可以绑定多个线程,即协程和线程的映射关系。

N:1

N 个协程绑定一个线程,优点是协程在用户态线程即完成切换,不会陷入到内核态,这种切换非常轻量快速;缺点是一个进程的所有协程都绑定在一个线程上,某一个协程阻塞会导致线程阻塞,本进程的其他协程都无法执行,进而导致没有任何并发能力。

在这里插入图片描述

1:1

一个协程绑定一个线程,协程的调度都由 CPU 完成,虽然不存 N:1 的缺点,但是协程的创建、删除和切换的代价都由 CPU 完成,成本和代价略显昂贵。

在这里插入图片描述

M:N

M 个协程绑定一个线程,是 N:1 和 1:1 的结合,解决了上述两种方式的缺点,但实现更为复杂。同一个调度器上挂着 M 个协程,调度器下游则是多个 CPU 核心资源。

协程和线程是有区别的,线程由 CPU 调度,是抢占式的;协程由用户态调度,是协作式的。一个协程让出 CPU 后,才执行下一个协程,所以对于 M:N 的模型,提高线程和协程的绑定关系和执行效率就是优先考虑的目标。

在这里插入图片描述

Golang 的协程 Goroutine

Goroutine 的特点:占用更小的内存;更灵活的调度(runtime 调度)。

Go 为了提供更容易使用的并发方法,使用了 Goroutine 和 Channel。Goroutine 来自协程的概念,让一组可复用的函数运行在一组线程上,即使有协程阻塞,该线程的其他协程也可以被 runtime 调度,转移到其他可运行的线程上。

一个 Goroutine 只占用几 KB,并且这几 KB 就足够支撑 Goroutine 运行完,这就能在有限的内存空间中支持大量的 Goroutine,即支持更多的并发。

虽然一个 Gouroutine 的栈只占几 KB,但实际是可伸缩的,如果需要更多内容,runtime 会自动为 Goroutine 分配。

被废弃的 Goroutine 调度器

Go 目前使用的调度器是 2012 年重新设计的,因为之前的调度器性能存在问题。在 Go 的调度器中通常用 G 表示 Goroutine,用 M 表示线程。

早期的调度器基于 M:N 模型,所有的协程(G)都会被放在一个全局的 Go 协程队列中,由于在全局队列的外面是多个线程(M)的共享资源,所以需要加上一个用于同步互斥的锁。M 想要执行、放回 G 都必须访问全局队列,并且 M 有多个,即多线程访问同一资源时需要加锁。

所以早期的调度器存在以下缺点:

  • 创建、销毁、调度 G 都需要每个 M 获取锁,这就形成了激烈的锁竞争。
  • M 转移 G 会造成延迟和额外的系统负载。比如当 G 中包含创建新的协程时,M 创建了 G’,为了继续执行 G,需要把 G’ 交给 M2(假如被分配到)执行,这就造成了很差的局部性,因为 G’ 和 G 是相关的,最好是放在 M 上执行。
  • 系统调用(CPU 在 M 之间的切换)导致频繁的线程阻塞和取消阻塞操作增加了系统开销。

在这里插入图片描述

Goroutine 调度器 GPM 模型

针对上述问题,Go 重新设计了新的调度器,在原有的基础上,增加了处理器(P)。处理器包含了运行 Goroutine 的资源,如果线程想运行 Goroutine,必须先获取 P,P 中还包含了可运行的 G 队列。

在这里插入图片描述

在 Go 中,线程是运行 Goroutine 的实体,调度器的功能是把可运行的 Goroutine 分配到工作线程上。Goroutine 调度器和 OS 调度器是通过 M 结合起来的,每个 M 都代表了一个内核线程,OS 调度器负责把内核线程分配到 CPU 的核上执行。

在这里插入图片描述

在 GPM 模型中存在以下重要概念:

  • 全局队列(Global Queue):存放等待运行的 G。全局队列可能被任意的 P 去获取里面的 G,所以全局队列相当于整个模型中的全局资源,自然对于队列中的读写操作需要加入互斥动作。
  • P 的本地队列:和全局队列类似,存放的也是等待运行的 G,存放的数量有限,不超过 256 个。新建 G’ 时,G’ 会优先加入到 P 的本地队列里,如果本地队列满了,则会把本地队列中一半的 G 移动到全局队列。
  • P 列表:所有的 P 都在程序启动时创建,并且保存在数组中,最多有 GOMAXPROCS 个。
  • M:线程想运行任务就必须获取 P,从 P 的本地队列中获取 G,P 队列为空时,M 会尝试从全局队列拿一批 G 放到 P 的本地队列中,或者从其他 P 的本地队列中偷取一半的 G 放到自己的 P 的本地队列。M 运行 G,G 执行之后,M 会从 P 获取下一个 G,不断循环。

P 和 M 的个数

  • P 的数量由启动时环境变量 ¥GOMAXPROCS 或者由 runtime 的方法 GOMAXPROCS() 来决定,这意味着在程序执行的任意时刻都只有 $GOMAXPROCS 个 Goroutine 在同时运行。
  • M 的数量由 Go 语言本身决定,Go 程序在启动时会设置 M 的最大数量,默认为 10000,但是内核很难支持这么多的线程数,所以这个限制可以忽略。runtime/debug 中的 SetMaxThreads() 函数可以设置 M 的最大数量,当一个 M 阻塞时会创建新的 M。
  • M 和 P 的数量没有绝对关系,一个 M 阻塞,P 就会去创建或者切换另一个 M,所以即使 P 的默认数量是 1,也可能会创建很多个 M。

P 和 M 何时被创建

  • P 创建的时机在确定了 P 的最大数量之后,运行时系统会根据这个数量去创建对应个 P。
  • M 创建的时机在当没有足够的 M 来关联 P 并运行其中的可运行的 G 时,比如此时所有的 M 都阻塞了,而 P 中还有很多就绪任务,就会去寻找空闲中的 M,如果没有空闲的,就会创建新的 M。

调度器的设计策略

策略一:复用线程

避免频繁地创建、销毁线程,采用对线程的复用。

  • 偷取机制:当本线程无可运行的 G 时,尝试从其他线程绑定的 P 偷取 G,而不是销毁线程。这里偷取的动作一定是由 P 发起的而非 M,因为 P 的数量是固定的,如果一个 M 得不到一个 P,那么这个 M 是没有执行的本地队列的,就不可能向其他的 P 队列偷取。

    在这里插入图片描述

  • 移交机制:当本线程因为 G 进行系统调用陷入阻塞时,线程释放绑定的 P,并把 P 转移给其他空闲的线程执行。如果在 M1 的 GPM 模型中,G1 正在被调度并且已经发生了阻塞,那么此时就会触发移交机制。GPM 模型为了更大程度的利用 M 和 P 的性能,不会让一个 P 永远被一个阻塞的 G1 耽误后续的工作,所以移交机制的设计概念就是立刻将此时的 P 释放出来。为了释放 P,所以将 P 和 M1、G1 分离,M1 由于正在执行当前的 G1,全部的程序栈空间都在 M1 中保存,所以此时 M1 应该与 G1 一同进入阻塞状态。已经被释放的 P 需要和另一个 M 进行绑定,那么就会选择出一个 M3(如果此时没有 M3 则会创建一个新的或者唤醒一个正在睡眠的)进行绑定,此时新的 P 就会继续工作,接受新的 G 或对其他队列中实施偷取机制。

    在这里插入图片描述

    在这里插入图片描述

策略二:利用并行

GOMAXPROCS 设置 P 的数量,最多有 GOMAXPROCS 个线程分布在多个 CPU 上同时运行。

策略三:抢占

在 Coroutine 中要等待一个协程主动让出 CPU 后,才能执行下一个协程。在 Go 中,一个 Goroutine 最多占用 CPU 10ms,防止其他 Goroutine 无资源可用。

在这里插入图片描述

策略四:全局 G 队列

在新的调度器中,依然有全局 G 队列,但是功能已经被弱化。当 M 执行偷取机制,但无法从其他 P 获取 G 时,可以从全局 G 队列中获取。

在这里插入图片描述

go func() 的调度流程

  1. 通过 go func() 创建一个 Goroutine。

    在这里插入图片描述

  2. 有两个存储 G 的队列,一个是局部调度器 P 的本地队列、一个是全局 G 队列。新创建的 G 会先保存在 P 的本地队列中,如果 P 的本地队列已经满了,就会保存在全局队列中。

    在这里插入图片描述

  3. G 只能运行在 M 中,一个 M 必须持有一个 P,M 和 P 的关系是 1:1 的。M 会从 P 的本地队列中弹出一个可执行状态的 G 来执行,如果 P 的本地队列为空,则会从全局队列进行获取,如果全局队列也获取不到,则会从其他的 MP 组合偷取一个可执行的 G 来执行。

    在这里插入图片描述

  4. 一个 M 调度 G 执行的过程是一个循环机制。

    在这里插入图片描述

  5. 当 M 执行某一个 G 时,如果发生了 syscall 或其余阻塞操作,此时 M 会被阻塞,如果当前有一些 G 在执行,runtime 会把这个 M 从 P 中摘除,然后再创建一个新的操作系统线程(如果有空闲的线程可复用就用可复用的)来服务这个 P。

    在这里插入图片描述

  6. 当 M 系统调用结束时,这个 G 会尝试获取一个空闲的 P 执行,并且放入到这个 P 的本地队列中。如果获取不到 P,则这个 M 变成睡眠状态,加入到空闲线程中,这个 G 会被放入全局队列中。

调度器的生命周期

在 Golang 的调度器的 GPM 模型中还有两个特殊的角色,分别是 M0 和 G0。

M0:

  • 启动程序后编号为 0 的主线程。
  • 在全局变量 runtime.m0 中,定义在 Go runtime 的全局作用域中;不需要在堆上分配,不像其他动态创建的 M,M0 是静态分配的,效率更高,不涉及垃圾回收。
  • 负责执行初始化操作和启动第一个 G。
  • 启动第一个 G 后,M0 就和其他的 M 一样。

G0:

  • 每次启动一个 M,创建的第一个 Goroutine,就是 G0。
  • G0 是仅用于负责调度的 G。
  • G0 不指向任何可执行的函数。
  • 每个 M 都有一个自己的 G0。
  • 在调度或系统调度时,会使用 M 切换到 G0,再通过 G0 进行调度。
  • M0 的 G0 会放在全局空间。

在这里插入图片描述

参考文档

深入理解Golang协程调度GPM[Go三关典藏版]

相关文章:

  • 原子操作(C++)
  • 初等数论--Garner‘s 算法
  • crash常用命令
  • JavaScripts API(应用程序编程接口)
  • 提问:鲜羊奶是解决育儿Bug的补丁吗?
  • 2025河北CCPC 题解(部分)
  • 人工智能如何协助老师做课题
  • A-9 OpenCasCade读取STEP文件中的NURBS曲面
  • MySQL日志文件有哪些?
  • PDF电子发票数据提取至Excel
  • AI时代新词-人工智能伦理审查(AI Ethics Review)
  • cannot access ‘/etc/mysql/debian.cnf‘: No such file or directory
  • Vue 核心技术与实战day04
  • LitCTF2025 WEB
  • 项目管理进阶:详解项目管理办公室(PMO)实用手册【附全文阅读】
  • Windows环境下Redis的安装使用与报错解决
  • CMake指令:set()
  • 深度思考、弹性实施,业务流程自动化的实践指南
  • 【Dify系列教程重置精品版】第十章:Dify与RAG
  • 2025密云马拉松复盘
  • wordpress 主题库/seo网络推广优势
  • b2b网站优化怎么做/c盘优化大师
  • 做网站运营有前景么/搜索引擎营销的方法包括
  • 老铁推荐个2021网站好吗/兰州seo快速优化报价
  • 富连网网站开发/深圳seo
  • 网站滑动效果怎么做/企业网站制作模板