文章目录
- 背景
- 协程
- 线程与协程的对比
- 线程(Thread)
- 协程(Coroutine)
- 运作
- 线程模型
- goroutine调度模型与演进过程
- G-M模型
- G-P-M模型
- 抢占式调度器
- 其他优化
- 调度策略
- 队列轮转
- 系统调用
- 工作量窃取
- 抢占式调度
- GOMAXPROCS 对性能的影响
Go在语言层面直接提供对协程的支持称为goroutine
背景
多核处理器硬件成为数据中心的主流
传统单线程的应用是难以发挥出多核硬件的威力的。相比于传统硬件的单个高主频处理器,传统单线程应用运行在多核硬件上之后,性能反而下降很多,这是因为它仅仅能使用到一颗物理多核处理器中的一个核(计算引擎)。
要想充分利用多核的强大计算能力,一般有两种方案,并行和并发,为了帮助更好的理解这两个的区别,下面给出一个地铁安检的例子:
假设凤起路地铁站只有一个安检通道,里面一共三个人,分别负责:随身物品安检、身体安检、液体安检。乘客只有完整走过一个流程之后,才能有下一个乘客,当客流量上来时,他们迫不得已的三个人同时工作,这就是并发;然后这个时候身后的队伍已经排到了龙翔桥,只能打开闲置的安检通道,这就是并行。
接下来用专业术语解释:
并发方案:并发就是重新做应用结构设计,即将应用分解成多个在基本执行单元(图中这样的执行单元为操作系统线程)中执行的、可能有一定关联关系的代码片段(图中的模块1~模块N)。我们看到与并行方案中应用自身结构无须调整有所不同,并发方案中应用自身结构做出了较大调整,应用内部拆分为多个可独立运行的模块。这样虽然应用仍然以单实例的方式运行,但其中的每个内部模块都运行于一个单独的操作系统线程中,多核资源得以充分利用。基于多线程模型的应用设计就是一种典型的并发程序设计。
并行方案:在处理器核数充足的情况下启动多个单线程应用的实例,这样每个实例“运行”在一个核上(如图中的CPU核1~CPU核N),尽可能多地利用多核计算资源。理论上在这种方案下应用的整体处理能力是与实例数量(小于或等于处理器核数)成正比的。但这种方案是有约束的,对于那些不支持在同一环境下部署多实例或同一用户仅能部署一个实例的应用,用传统的部署方式使之并行运行是有难度的甚至是无法实现的。不过近些年兴起的轻量级容器技术(如Docker)可以在一定程度上促成此方案
并发不是并行,并发关乎结构,并行关乎执行。
协程
为了方便理解,这里解释一下进程、线程、协程
进程:应用程序的启动实例,每个进程有独立的内存空间,不同进程通过进程间的通信方式来通信
线程:属于进程,每个进程至少一个线程,线程是CPU调度的基本单位,可以共享进程的资源,多线程之间通过共享内存等线程间的通信方式进行交流
协程:可以理解为轻量级线程,但不受操作系统调度,由协程调度器负责,协程调度器按照调度策略把协程调度到线程中运行
线程与协程的对比
线程(Thread)和协程(Coroutine)都是用于并发执行的编程概念,它们有各自的优势和劣势,适用于不同的场景。以下是线程和协程的一些比较:
线程(Thread)
优势:
- 多核利用: 线程通常由操作系统的调度器进行管理,可以在多个核上并行执行。这使得线程适用于需要充分利用多核 CPU 的计算密集型任务。
- 底层硬件支持: 线程通常由操作系统内核直接支持,因此它们可以直接与底层硬件进行交互,执行底层系统调用。
- 广泛的支持: 多数编程语言和操作系统都提供了线程支持,这使得线程在编写跨平台的并发代码时更为方便。
劣势:
- 资源消耗: 每个线程都需要一定的内存和操作系统资源,创建大量线程可能导致过多的资源消耗。
- 上下文切换开销: 在线程之间进行切换需要保存和恢复线程的上下文,这可能引起一定的开销。
- 共享状态问题: 多线程之间共享内存,可能引发并发访问共享数据的问题,需要使用锁等同步机制来避免竞态条件。
协程(Coroutine)
优势:
- 轻量级: 协程是用户级别的线程,相比于操作系统级别的线程,协程更加轻量级。每个goroutine的初始栈大小仅为2KB。它们由用户空间的调度器管理,由Go运行时而不是操作系统调度,goroutine上下文切换代价较小。
- 高并发: 协程可以非常高效地进行大量的并发操作,因为它们不受底层系统资源的限制。
- 简化并发编程: 由于协程之间可以通过通道(Channel)进行通信,避免了共享内存的并发问题,使得并发编程变得更加简单和安全。
- 避免锁: 协程的通信方式通常是通过消息传递,而不是共享内存,避免了锁的使用。
劣势:
- 单线程执行: 协程通常在单一线程中执行,这意味着在执行 CPU 密集型任务时性能可能不如多线程。
- 操作系统支持不足: 一些传统的操作系统和编程语言可能不直接支持协程,需要使用额外的库或语言特性来实现。
- 不充分利用多核: 由于协程通常在单线程中运行,不能充分利用多核 CPU。
总的来说,线程适用于需要充分利用多核 CPU 的计算密集型任务,而协程更适合于需要高并发、I/O 密集型任务,以及对简化并发编程和避免共享状态问题有需求的情境。在实际应用中,它们经常结合使用,以发挥各自的优势。例如,在使用语言如 Go 时,协程(goroutines)和线程可以一起使用,利用 Go 的并发模型来充分发挥多核和高并发的优势。
运作
协程调度器把可运行的协程逐个调度到线程中执行,同时及时把阻塞的协程调度出协程,有效避免了线程的频繁切换,多个协程分享操作系统分给线程的时间片,从而达到了充分利用CPU算力的目的,协程调度器决定了协程运行的顺序。每一时刻一个线程只能运行一个协程
线程模型
线程可以分为用户线程和内核线程,GO主要采用的是M : N模型,M个用户线程(协程)对应N个内核线程,优点就是既充分利用CPU算力且协程上下文切换快,缺点就是调度算法较为复杂
goroutine调度模型与演进过程
首先先明确三个关键实体
machine(M):工作线程,由操作系统调度;M代表着真正的执行计算资源。在绑定有效的P后,进入一个调度循环;而调度循环的机制大致是从各种队列、P的本地运行队列中获取G,切换到G的执行栈上并执行G的函数,调用goexit做清理工作并回到M。如此反复。M并不保留G状态,这是G可以跨M调度的基础。
M的个数通常稍大于P,因为除了运行代码,runtime 包还有其他内置任务需要处理
processor(P):代表逻辑processor,P的数量决定了系统内最大可并行的G的数量(前提:系统的物理CPU核数>=P的数量)。P中最有用的是其拥有的各种G对象队列、链表、一些缓存和状态。
goroutine(G):代表goroutine,存储了goroutine的执行栈信息、goroutine状态及goroutine的任务函数等。另外G对象是可以重用的。
G-M模型
在这个调度器中,每个goroutine对应于运行时中的一个抽象结构——G(goroutine),而被视作“物理CPU”的操作系统线程则被抽象为另一个结构——M(machine)
G-M模型的重要不足:限制了Go并发程序的伸缩性,尤其是对那些有高吞吐或并行计算需求的服务程序,主要存在以下问题:
- 单一全局互斥锁(Sched.Lock)和集中状态存储的存在导致所有goroutine相关操作(如创建、重新调度等)都要上锁。
- goroutine传递问题:经常在M之间传递“可运行”的goroutine会导致调度延迟增大,带来额外的性能损耗。
- 每个M都做内存缓存,导致内存占用过高,数据局部性较差。
- 因系统调用(syscall)而形成的频繁的工作线程阻塞和解除阻塞会带来额外的性能损耗。
G-P-M模型
在多CPU环境下,多个处理器往往需要争夺锁来调度全局队列中的协程,严重影响并发执行效率,后来引入局部runqueues,每个处理器访问自己的局部队列时不需要加锁
发现了G-M模型的不足后,Dmitry Vyukov亲自操刀改进了goroutine调度器,在Go 1.1版本中实现了G-P-M调度模型和工作窃取算法,这个模型沿用至今:
“计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决。”
向G-M模型中增加了一个P,使得goroutine调度器具有很好的伸缩性。
P是一个“逻辑处理器”,每个G要想真正运行起来,首先需要被分配一个P,即进入P的本地运行队列(local runq)中,全局队列则由多个处理器共享。对于G来说,P就是运行它的“CPU”,可以说在G的眼里只有P。
但从goroutine调度器的视角来看,真正的“CPU”是M,只有将P和M绑定才能让P的本地运行队列中的G真正运行起来。这样的P与M的关系就好比Linux操作系统调度层面用户线程(user thread)与内核线程(kernel thread)的对应关系:多对多(N:M)
一般来说,处理器P中的协程G额外再创建的协程会加人本地的runqueues中,但如果本地的队列已满,或者阻塞的协程被唤醒,则协程会被放入全局的runqueues中,处理器P除了调度本地的runqueues中的协程,还会周期性地从全局runqueues 中摘取协程来调度。
抢占式调度器
之后由于调度器不支持抢占式调度,Dmitry Vyukov又提出了“Go抢占式调度器设计”(Go Preemptive Scheduler Design),并在Go 1.2版本中实现了抢占式调度。
这个抢占式调度的原理是在每个函数或方法的入口加上一段额外的代码,让运行时有机会检查是否需要执行抢占调度。这种协作式抢占调度的解决方案只是局部解决了“饿死”问题,对于没有函数调用而是纯算法循环计算的G,goroutine调度器依然无法抢占
其他优化
在Go 1.2以后,Go将重点放在了对GC低延迟的优化上
Go运行时已经实现了netpoller,这使得即便G发起网络I/O操作也不会导致M被阻塞(仅阻塞G),因而不会导致大量线程(M)被创建出来。但是对于常规文件的I/O操作一旦阻塞,那么线程(M)将进入挂起状态,等待I/O返回后被唤醒。这种情况下P将与挂起的M分离,再选择一个处于空闲状态(idle)的M。如果此时没有空闲的M,则会新创建一个M(线程),这就是大量文件I/O操作会导致大量线程被创建的原因。
增加了一个针对文件I/O的Poller,它可以像netpoller那样,在G操作那些支持监听的(pollable)文件描述符时,仅阻塞G,而不会阻塞M。不过该功能依然对常规文件无效,常规文件是不支持监听的。但对于goroutine调度器而言,这也算是一个不小的进步了
调度策略
队列轮转
每个处理器p维护着一个协程G的队列,处理器P依次将协程 G调度到M中执行。协程G执行结束后,处理器P会再次调度G到M中执行。
同时,每个P会周期性地看全局队列中是否有G待运行并将其调度到 M 中执行,全局队列中的G,主要来自从系统调用中恢复的G。之所以P会周期性地查看全局队列,也是为了防止全局队列中的G长时间得不到调度机会而被“饿死”。
系统调用
我们知道,当线程在执行系统调用时,可能会被阻塞,对应到调度器模型,如果一个协程发起系统调用,那么对应的工作线程会被阻塞,这样一来,处理器的rungueues 队列中的程将得不到调度,相当于队列中的所有协程都被阻塞。 前面提到p的个数默认等于 CPU 的核数,每个 M必须持有一个P才可以执行 G。一般情况下 M的个数会略大于P的个数,多出来的M将会在 G产生系统调用时发挥作用。与线程池类似,Go 也提供一个M的池子,需要时从池子中获取,用完放回池子,不够用时就再创建一个,当M运行的某个 G产生系统调用时,过程如下图所示。
当G0即将进入系统调用时,M0将释放P,进而某个冗余的M1获取P,继续执行P队列中剩下的G。M0由于陷入系统调用而被阻塞,M1接替M0的工作,只要P不空闲,就可以保证充分地利用CPU。
冗余的M的来源有可能是缓存池,也可能是新建的。当G0结束系统调用后,根据M0是否能获取到P,对G0进行不同的处理:
如果有空闲的P,则获取一个P,继续执行G0。
如果没有空闲的P,则将G0放入全局队列,等待被其他的P调度。然后M0将进入缓存池睡眠。
工作量窃取
通过 go 关键字创建的协程通常会优先放到当前协程对应的处理器队列中,可能有些协程自身不断地派生新的协程,而有些协程不派生协程。如此一来,多个处理器P中维护的G队列有可能是不均衡的,如果不加以控制,则有可能出现部分处理器P非常繁忙,而部分处理器 P怠工的情况。
为此,Go调度器提供了工作量窃取策略,即当某个处理器P没有需要调度的协程时,将从其他处理器中偷取协程,如下图所示。
发生窃取前(图中左半部分),右侧的处理器在没有协程需要调度时会查询全局队列,如果全局队列中也没有协程需要调度,则会从另一个正在运行的处理器中偷取协程,每次偷 取一半,偷取完的效果如图中右半部分所示。
抢占式调度
所谓抢占式调度,是指避免某个协程长时间执行,而阻碍其他协程被调度的机制。 调度器会监控每个协程的执行时间,一旦执行时间过长且有其他协程在等待时,会把协程暂停,转而调度等待的协程,以达到类似于时间片轮转的效果。 在Go1.14之前,Go协程调度器抢占式调度机制是有一定局限性的,在该设计中,在函数调用间隙检查协程是否可被抢占,如果协程没有函数调用,则会无限期地占用执行权。
直到在Go1.14中,调度器引入了了基于信号的抢占机制,这个问题才得到了解决。
GOMAXPROCS 对性能的影响
一般来讲,程序运行时就将GOMAXPR OCS 的大小设置为CPU 的核数,可让 Go程序充分利用 CPU。在某些I/O密集型的应用中,这个值可能并不意味着性能最好。理论上当某个goroutine进入系统调用时,会有一个新的M被启用或创建,继续占满CPU。但由于Go调度器检测到M被阻塞是有一定延迟的,即旧的M被阻塞和新的M得到运行之间是有一定间隔的,所以在I/O密集型应用中不妨把GOMAXPROCS 的值设置得大一些,或许会有好的效果。