Golang 调度器到底怎么回事

2023-05-2413:56:41后端程序开发Comments812 views字数 3580阅读模式
Go语言至今已经非常被广大的开发者所青睐。Go语言诸如极简单的部署方式(可直接编译出机器代码、除了C标准、操作系统库几乎不依赖任何系统库,直接运行即可部署)、优秀的编译速度、“基因”层面的并发支持、强大的标准库支撑、低位的开发成本、简单易学、夸平台等特性深深的打动每一位接触过的后端开发工程师所面临的痛处。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/bc/41820.html
从 Docker 兴起,至第二波的 Kubernetes 的范围冲击,也让 Go 语言在后端的地位,尤其偏中高级业务需求(对性能、代码质量、架构设计等团队较高标准)中已经不可撼动。后端开发工程师逐渐开始对 Go语言产生敬畏,无论你是擅长任何语言的后端工程师,你都想去了解一下 Golang。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/bc/41820.html
一切的软件都是跑在操作系统上,那么使这些软件能够运行工作起来,真正用来计算的是CPU。早期的操作系统每个程序就是一个进程,直到一个程序运行完,才能进行下一个进程,就是单进程时代,所有的程序只能串行发生,如图1.1所示。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/bc/41820.html
Golang 调度器到底怎么回事
图1.1 单进程时代的操作系统文章源自菜鸟学院-https://www.cainiaoxueyuan.com/bc/41820.html
1. 单进程时代不需要调度器文章源自菜鸟学院-https://www.cainiaoxueyuan.com/bc/41820.html
早期的单进程操作系统,面临两个问题。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/bc/41820.html

(1)单一的执行流程。计算机只能一个任务一个任务处理,所有的程序几乎是堵塞的,更不用说具备图形化界面或者鼠标这种异步交互的处理能力。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/bc/41820.html

(2)进程阻塞所带来的 CPU 时间浪费。在一个进程完整的生命周期中,所要访问的物理部分包括CPU、Cache、主内存、磁盘、网络等,不同的硬件媒介处理计算的能力相差甚大。如果将这些处理速度不同的处理媒介通过一个进程串在一起,则会出现高速度媒介等待和浪费的现象。如当一个程序加载一个磁盘数据的时候,在读写的过程中,CPU所处就是等待的状态,那么对于单进程的操作系统来讲,很明显造成了CPU运算能力的浪费,因为CPU此刻本应该合理的分配到其他进程上去做高层的计算。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/bc/41820.html

那么能不能有多个进程来宏观一起执行多个任务呢?后来操作系统就具有了最早的并发能力,多进程并发。当一个进程阻塞的时候,切换到另外等待执行的进程,这样就能尽量把 CPU 利用起来,CPU 也就不浪费了。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/bc/41820.html
2.多进程/线程时代的调度器需求文章源自菜鸟学院-https://www.cainiaoxueyuan.com/bc/41820.html
多进程/多线程的操作系统解决了阻塞的问题,一个进程阻塞 CPU 可以立刻切换到其他进程中去执行,而且调度 CPU 的算法可以保证在运行的进程都可以被分配到 CPU 的运行时间片。从宏观来看,似乎多个进程是在同时被运行,如图1.2所示。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/bc/41820.html
Golang 调度器到底怎么回事
图1.2 多线程/多进程操作系统文章源自菜鸟学院-https://www.cainiaoxueyuan.com/bc/41820.html
图1.2所示为一个CPU通过调度器切换CPU时间轴的情景。如果未来满足宏观上每个进程/线程是一起执行的,则CPU必须切换,每个进程会被分配到一个时间片中。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/bc/41820.html
但新的问题就又出现了,进程拥有太多的资源,进程的创建、切换、销毁,都会占用很长的时间,CPU 虽然利用起来了,但如果进程过多,CPU 有很大的一部分都被用进行进程切换调度了,如图1.3所示。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/bc/41820.html
Golang 调度器到底怎么回事
图1.3 CPU调度切换的成本文章源自菜鸟学院-https://www.cainiaoxueyuan.com/bc/41820.html
对于Linux操作系统来言,CPU对进程和线程的态度是一样的,如图1.3所示,如果系统的CPU数量过少,而进程/线程数量比较庞大,则相互切换的频率也就越大,其中中间的切换成本越久越多。这一部分的性能消耗实际上是没有做在程序有用的计算算力上,所以尽管线程看起来很美好,但实际上多线程开发设计会变得更加的复杂,开发者要考虑很多同步竞争的问题,如锁、资源竞争、同步冲突等。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/bc/41820.html
3. 协程提高CPU的利用率文章源自菜鸟学院-https://www.cainiaoxueyuan.com/bc/41820.html
那么如何才能提高CPU的利用率呢?多进程、多线程已经提高了系统的并发能力,但是在当今互联网高并发场景下,为每个任务都创建一个线程是不现实的,因为这样就会出现及极大量的线程同时运行,不仅切换高,也会消耗大量的内存(进程虚拟内存会占用 4GB [32 位操作系统], 而线程也要大约 4MB)。大量的进程或线程出现了新的问题。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/bc/41820.html

(1)高内存占用。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/bc/41820.html

(2)调度的高消耗 CPU。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/bc/41820.html

工程师发现其实可以把一个线程可以分为“内核态”和“用户态”两种形态的线程。所谓用户态线程就是把内核态的线程在用户态实现了一遍而已,目的是更轻量化(更少的内存占用、更少的隔离、更快的调度)和更高的可控性(可以自己控制调度器)。用户态所有东西内核态都看得见,只是对于内核而言用户态线程只是一堆内存数据而已。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/bc/41820.html

一个用户态线程必须绑定一个内核态线程,但是 CPU 并不知道有用户态线程的存在,它只知道它运行的是一个内核态线程(Linux的 PCB 进程控制块),如图1.4所示。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/bc/41820.html
Golang 调度器到底怎么回事
图1.4 一个线程中的用户态和内核态文章源自菜鸟学院-https://www.cainiaoxueyuan.com/bc/41820.html

如果将线程再进行细化分类一下,内核线程依然叫线程(Thread),用户线程叫协程(Co-routine)。操作系统层面的线程就是所谓的内核态线程,用户态线程则多种多样,只要能满足在同一个内核线程上执行多个任务的都算,例如Coroutine、Golang 的Goroutine、C#的 Task等。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/bc/41820.html

既然一个协程可以绑定一个线程,那么能不能多个协程绑定一个或者多个线程上呢?接下来有3种协程和线程的映射关系,他们分别是N:1关系、1:1关系和M:N关系。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/bc/41820.html

(1) N:1 关系文章源自菜鸟学院-https://www.cainiaoxueyuan.com/bc/41820.html
N个协程绑定1个线程,优点就是协程在用户态线程即完成切换,不会陷入到内核态,这种切换非常的轻量快速,但缺点特征也很明显,1个进程的所有协程都绑定在1个线程上,如图1.5所示。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/bc/41820.html
Golang 调度器到底怎么回事
图1.5 协程和线程的N:1关系文章源自菜鸟学院-https://www.cainiaoxueyuan.com/bc/41820.html

N:1关系面临的几个问题:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/bc/41820.html

(1)某个程序用不了硬件的多核加速能力文章源自菜鸟学院-https://www.cainiaoxueyuan.com/bc/41820.html

(2)某一个协程阻塞,会造成线程阻塞,本进程的其他协程都无法执行了,进而导致没有任何并发能力。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/bc/41820.html

(2) 1:1 关系文章源自菜鸟学院-https://www.cainiaoxueyuan.com/bc/41820.html
1个协程绑定1个线程,这种最容易实现。协程的调度都由CPU完成了,虽然不存在N:1缺点,但是协程的创建、删除和切换的代价都由CPU完成,成本和代价略显昂贵。协程和线程的1:1关系,如图1.6所示。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/bc/41820.html
Golang 调度器到底怎么回事
图1.6 协程和线程的1:1关系文章源自菜鸟学院-https://www.cainiaoxueyuan.com/bc/41820.html
(3) M:N 关系文章源自菜鸟学院-https://www.cainiaoxueyuan.com/bc/41820.html
M个协程绑定N个线程,是N:1和1:1类型的结合,克服了以上2种模型的缺点,但实现起来最为复杂,如图1.7所示。同一个调度器上挂在M个协程,调度器下游则是多个CPU核心资源。协程跟线程是有区别的,线程由 CPU 调度是抢占式的,协程由用户态调度是协作式的,一个协程让出 CPU 后,才执行下一个协程,所以针对M:N的模型中间层的调度器设计就变得尤为重要,提高线程和协程的绑定关系和执行效率也变得不同语言在设计调度器的优先目标。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/bc/41820.html
Golang 调度器到底怎么回事
图1.7 协程和线程的M:N关系文章源自菜鸟学院-https://www.cainiaoxueyuan.com/bc/41820.html
4. Go语言的协程 Goroutine文章源自菜鸟学院-https://www.cainiaoxueyuan.com/bc/41820.html
Go 为了提供更容易使用的并发方法,使用了 Goroutine 和 Channel。Goroutine 来自协程的概念,让一组可复用的函数运行在一组线程之上,即使有协程阻塞,该线程的其他协程也可以被 runtime 调度,转移到其他可运行的线程上。最关键的是,程序员看不到这些底层的细节,这就降低了编程的难度,提供了更容易的并发。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/bc/41820.html

Go 中,协程被称为 Goroutine,它非常轻量,一个 Goroutine 只占几 KB,并且这几 KB 就足够 Goroutine 运行完,这就能在有限的内存空间内支持大量 Goroutine,支持了更多的并发。虽然一个 Goroutine 的栈只占几 KB,但实际是可伸缩的,如果需要更多内容,runtime 会自动为 Goroutine 分配。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/bc/41820.html

Goroutine的特点,占用内存更小(几KB)和调度更灵活(runtime调度)。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/bc/41820.html

5. 被废弃的Goroutine调度器文章源自菜鸟学院-https://www.cainiaoxueyuan.com/bc/41820.html
现在知道了协程和线程的关系,那么最关键的一点就是调度协程的调度器实现了。Go 目前使用的调度器是 2012 年重新设计的,因为之前的调度器性能存在问题,所以使用 4 年就被废弃了,那么先来分析一下被废弃的调度器是如何运作的?通常用符号G表示Goroutine,用M表示线程,如图1.8所示。接下来有关调度器的内容均采用1.8图所示的符号来统一表达。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/bc/41820.html
Golang 调度器到底怎么回事
图1.8 G、M符号表示文章源自菜鸟学院-https://www.cainiaoxueyuan.com/bc/41820.html
接下来看一看被废弃的Golang调度器是如何实现的? 如图1.9所示,早期的调度器是基于M:N的基础上实现的,图1.9是一个概要图形,所有的协程,也就是我们的G都会被放在一个全局的Go协程队列中,在全局队列的外面由于是多个M的共享资源,会加上一个包子同步及互斥作用的锁。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/bc/41820.html
Golang 调度器到底怎么回事
图1.9 Golang早期调度器的处理文章源自菜鸟学院-https://www.cainiaoxueyuan.com/bc/41820.html
M想要执行、放回G都必须访问全局G队列,并且M有多个,即多线程访问同一资源需要加锁进行保证互斥/同步,所以全局G队列是有互斥锁进行保护的。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/bc/41820.html

不难分析出来老调度器有几个缺点。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/bc/41820.html

(1)创建、销毁、调度G都需要每个M获取锁,这就形成了激烈的锁竞争。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/bc/41820.html

(2)M转移G会造成延迟和额外的系统负载。例如当G中包含创建新协程的时候,M创建了G’,为了继续执行G,需要把G’交给M2(假如被分配到)执行,也造成了很差的局部性,因为G’和G是相关的,最好放在M上执行,而不是其他M2,如图1.10所示。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/bc/41820.html

Golang 调度器到底怎么回事
图1.10 Golang早期调度器的局部性问题文章源自菜鸟学院-https://www.cainiaoxueyuan.com/bc/41820.html

(3)系统调用(CPU在M之间的切换)导致频繁的线程阻塞和取消阻塞操作增加了系统开销。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/bc/41820.html

文章源自菜鸟学院-https://www.cainiaoxueyuan.com/bc/41820.html
  • 本站内容整理自互联网,仅提供信息存储空间服务,以方便学习之用。如对文章、图片、字体等版权有疑问,请在下方留言,管理员看到后,将第一时间进行处理。
  • 转载请务必保留本文链接:https://www.cainiaoxueyuan.com/bc/41820.html

Comment

匿名网友 填写信息

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen:

确定