当前位置: 首页 > 科技观察

Go为什么这么“快”

时间:2023-03-13 13:56:24 科技观察

本文主要介绍Go程序内部调度器(G-P-M模型)为了达到极高的并发性能的实现架构,以及Go调度器如何处理线程以最大化利用计算资源阻塞场景。如何让我们的系统更快随着信息技术的飞速发展,单台服务器的处理能力越来越强,迫使编程模型从以前的串行模型升级到并发模型。并发模型包括IO多路复用、多进程、多线程。这些模型各有优缺点。现代复杂的高并发架构,大多都是几种模型结合使用。不同的场景使用不同的模型,扬长避短。最大性能。而多线程,因为它的轻量级和易用性,已经成为并发编程中使用频率最高的并发模型,包括后派生协程等其他子产品也是基于它的。并发≠并行并发(concurrency)和并行(parallelism)是不同的。在单核CPU上,线程通过时间片切换任务或放弃控制,达到“同时”运行多个任务的目的,这称为并发。但实际上,任何时刻只执行一个任务,其他任务通过某种算法排队。多核CPU让同一个进程中的“多个线程”真正意义上同时运行,这就是并行。进程、线程、协程进程:进程是系统资源分配的基本单位,拥有独立的内存空间。线程:线程是CPU调度调度的基本单位。线程依附于进程而存在,每个线程都会共享父进程的资源。协程:协程是用户态的轻量级线程。协程的调度完全由用户控制。协程之间的切换只需要保存任务的上下文,不需要内核的开销。线程上下文切换会由于中断处理、多任务处理、用户状态切换等原因导致CPU从一个线程切换到另一个线程,切换过程需要保存当前进程的状态,恢复另一个进程的状态。上下文切换很昂贵,因为在核心上交换线程需要很多时间。上下文切换延迟取决于不同的因素,但它介于50到100纳秒之间。考虑到硬件每个内核平均每纳秒执行12条指令,上下文切换可能会花费600到1200条指令的延迟时间。事实上,上下文切换占用了程序执行指令的很大一部分时间。如果存在跨核上下文切换(Cross-CoreContextSwitch),可能会导致CPU缓存失效(CPU从缓存中访问数据的成本大约是3到40个时钟周期,而访问数据的成本从主存大约需要100到300个时钟周期),这种情况下的切换成本会比较昂贵。Golang为并发而生。自2009年正式发布以来,Golang凭借极高的运行速度和高效的开发效率迅速占领了市场份额。Golang在语言层面支持并发,通过轻量级协程Goroutines实现程序的并发运行。Goroutine非常轻量,主要体现在以下两个方面:上下文切换成本小:Goroutine上下文切换只涉及修改三个寄存器(PC/SP/DX)的值;而对比线程的上下文切换则需要模式切换(从用户态切换到内核态),刷新16个寄存器,PC,SP……等寄存器;占用内存少:线程栈空间一般为2M,Goroutine栈空间至少2K;Golang程序可以轻松支持10w级别的Goroutine运行,当线程数达到1k时,内存占用已经达到2G。Go调度器实现机制:Go程序使用调度器调度Goroutine在内核线程上执行,但Goroutine并不直接绑定到OS线程M-Machine上运行,而是由内核中的P-Processor(逻辑处理器)运行协程调度器。充当获取内核线程资源的“中介”。Go调度器模型通常称为G-P-M模型。它包括4个重要的结构体,分别是G、P、M、Sched:G:Goroutine,每个Goroutine对应一个G结构体,G存储了Goroutine的运行栈、状态和任务。功能,可重复使用。G不是执行体,每个G都需要和P绑定才能被调度执行。P:Processor,表示逻辑处理器。对于G,P相当于一个CPU核心,G只有绑定P才能被调度。对于M,P提供相关的执行环境(Context),如内存分配状态(mcache),任务队列(G)等。P的数量决定了系统中最大并行G的数量(前提:CPU物理核数>=P的数量)。P的个数由用户设置的GoMAXPROCS决定,但无论GoMAXPROCS设置多大,P的最大个数都是256。M:Machine,OS内核线程抽象,代表实际执行计算的资源。绑定一个有效的P后,进入schedule循环;scheduleloop的机制大致是从Global队列,P的Local队列,wait队列中得到的。M的数量是可变的,由GoRuntime调整。为了防止OS线程创建过多导致系统无法调度,目前默认最大数为10000。M不保留G的状态,这是G能够跨M调度的基础。Sched:Go调度器,维护队列,用于存储M和G以及调度器的一些状态信息。schedulercycle的机制大致是从各个队列和P的本地队列获取G,切换到G的执行栈执行G的函数,调用Goexit做清理工作返回给M,等等。要理解M、P、G之间的关系,我们可以通过地鼠车搬砖的经典模型来说明三者的关系:地鼠的工作任务是:工地上有一些砖块,并且地鼠用手推车将砖块运到火种上进行烧制。M可以看作是图中的地鼠,P是汽车,G是汽车中的积木。搞清楚了三者的关系,我们再来看看仓鼠是怎么搬砖的。Processor(P):根据用户设置的GoMAXPROCS值创建一批处理器(P)。Goroutine(G):Go关键字用于创建一个Goroutine,相当于制作了一块砖(G),然后将这块砖(G)放入当前的车(P)中。机器(M):地鼠(M)无法在外部创建,但是砖块(G)太多,地鼠(M)太少,实在是太忙了,只有一辆免费的车(P)如果你不'使用它,然后从别处借一些地鼠(M)直到你用完手推车(P)。因为地鼠(M)不够用,所以有一个从别处借地鼠(M)的过程。这个过程就是创建一个内核线程(M)。应该注意地鼠(M)不能在没有推车(P)的情况下运输砖块。手推车的数量(P)决定了可以工作的地鼠(M)的数量。在Go程序中,它对应于activity的线程数;在Go程序中,我们用下图来表示G-P-M模型:P代表可以“并行”运行的逻辑处理器,每个P分配给一个系统线程M,G代表Go协程。Go调度器中有两种不同的运行队列:全局运行队列(GRQ)和本地运行队列(LRQ)。每个P都有一个LRQ,它管理在P的上下文中执行的Goroutines的分配,而这些Goroutines依次由绑定到P的M进行上下文切换。GRQ用于尚未分配给P的Goroutines。从上图可以看出,G的数量可以远大于M的数量。换句话说,Go程序可以用少量的内核级线程来支持大量Goroutines的并发。多个Goroutines通过用户级上下文切换共享内核线程M的计算资源,但是对于操作系统来说,线程上下文切换不会带来性能损失。为了充分利用线程的计算资源,Go调度器采用了以下调度策略:问题是,忙了就忙死,闲了Go肯定不会让P钓鱼,势必会充分利用计算资源。为了提高Go的并行处理能力,提高整体处理效率,当各个P之间的G任务不平衡时,调度器允许G的执行从其他P的GRQ或LRQ中获取。减少阻塞如果正在执行的Goroutine阻塞了线程M怎么办?P上的LRQ中的Goroutine会不会获取不到调度?Go中阻塞主要分为以下四种场景:场景一:由于atomic、mutex或channel操作调用导致Goroutine阻塞,调度器会切换出当前阻塞的Goroutine,重新调度LRQ上的其他Goroutine;场景二:Goroutine因为网络请求和IO操作被阻塞。在这种情况下,我们的G和M会怎么做呢?Go程序提供了一个网络轮询器(NetPoller)来处理网络请求和IO操作,其后台通过kqueue(MacOS)、epoll(Linux)或iocp(Windows)使用来实现IO多路复用。通过使用NetPoller进行网络系统调用,调度程序可以防止Goroutines在进行这些系统调用时阻塞M。这允许M执行P的LRQ中的其他Goroutine而无需创建新的M。有助于减少操作系统的调度负载。下图展示了它是如何工作的:G1在M上执行,有3个Goroutines等待在LRQ上执行。网络轮询器处于空闲状态,什么也不做。接下来,G1想要进行网络系统调用,因此它被移至网络轮询器并处理异步网络系统调用。M然后可以从LRQ执行其他Goroutines。此时,G2上下文切换到M。最后,异步网络系统调用由网络轮询器完成,G1被移回P的LRQ。一旦G1可以在M上进行上下文切换,它负责的Go相关代码就可以再次执行。这里的一大优势是不需要额外的M来执行网络系统调用。网络轮询器使用系统线程,它始终处理活动事件循环。这种调用方式看似复杂,但幸运的是,Go语言将这种“复杂性”隐藏在了Runtime中:Go开发者不需要关注socket是否是非阻塞的,也不需要注册的回调文件描述符。它只需要在每个连接对应的Goroutine中把socket处理当做“blockI/O”,实现goroutine-per-connection这种简单的网络编程模式(但是大量的Goroutine也会带来额外的问题,对于例如,堆栈内存增加,调度程序负担增加)。用户层看到的Goroutine中的“blocksocket”实际上是Go运行时的netpoller通过Non-blocksocket+I/O复用机制“模拟”出来的。Go中的net库就是这样实现的。场景三:调用一些系统方法时,如果系统方法调用被阻塞,这种情况下,网络轮询器(NetPoller)就不能使用了,进行系统调用的Goroutine会阻塞当前M。我们来看案例其中同步系统调用(例如文件I/O)将导致M阻塞:G1将进行同步系统调用以阻塞M1。调度器介入后:认识到是G1导致了M1阻塞。这时,调度器将M1从P中分离出来,将G1带走。然后调度器引入一个新的M2来服务P。此时,可以从LRQ中选择G2,并在M2上进行上下文切换。阻塞系统调用完成后:G1可以移回LRQ并再次由P执行。如果再次发生这种情况,M1将被搁置以备将来重复使用。场景四:如果在Goroutine中进行sleep操作,则M被阻塞。Go程序后台有一个监控线程sysmon,监控那些长时间运行的G任务,然后设置一个可以被抢占的标识符,让其他Goroutine抢占执行。只要这个Goroutine下次有函数调用,就会被抢占,保护地盘,然后放回P的本地队列,等待下一次执行。小结本文主要从Go调度器架构的角度介绍G-P-M模型。通过这个模型,如何实现少量的内核线程来支持大量Goroutines的并发运行。并通过NetPoller、sysmon等帮助Go程序减少线程阻塞,充分利用已有的计算资源,从而最大化Go程序的运行效率。