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

Goroutine背后的系统知识

时间:2023-03-14 00:51:51 科技观察

Go语言诞生三年,流行三年。大多数先行者都是Web开发出身,也有一些畅销书。感觉比较模糊,网上流传比较广的文章有一些,但是或多或少有一些与事实不符的技术描述。希望这篇文章能够向缺乏系统编程背景的Web开发者介绍goroutine背后的系统知识。1.操作系统与运行时2.并发与并行(ConcurrencyandParallelism)3.线程调度4.并发编程框架5.Goroutine1.操作系统和运行时操作系统上就够了,但是对于开发者来说,我们还需要了解我们写的程序是如何运行在操作系统上的,操作系统是如何为应用程序提供服务的,这样才能区分哪些服务是操作系统提供,哪些服务是由我们使用的语言的运行时库提供的。除了内存管理、文件管理、进程管理、外设管理等内部模块外,操作系统还提供了许多外部接口供应用程序使用。这些接口就是所谓的“系统调用”。从DOS时代开始,以软中断的形式提供系统调用,就是著名的INT21。程序将要调用的函数号放入AH寄存器,将参数放入其他指定的寄存器,然后调用INT21中断返回后,程序从指定的寄存器(通常是AL)中获取返回值。直到Pentium2,也就是P6的发布,这种做法才有所改变。比如Windows通过INT2E提供系统调用,Linux使用INT80,但是后来的寄存器比以前大,可能多了一层跳表。查询。后来Intel和AMD分别提供了更高效的SYSENTER/SYSEXIT和SYSCALL/SYSRET指令来替代之前的中断方式,跳过了耗时的特权级检查和寄存器push和pop的操作,直接从RING3代码完成操作段到RING0代码段的转换。系统调用提供了哪些功能?使用操作系统的名称加上相应的中断号在谷歌上搜索得到完整的列表(Windows、Linux)。该列表是操作系统和应用程序之间通信的协议。如果你需要超出这个协议的功能,我们只能在自己的代码中实现。比如内存管理,操作系统只提供进程级的内存段管理,比如Windows的virtualmemory系列,或者Linux的brk,操作系统并不关心应用程序。如何为新对象分配内存,或者如何进行垃圾回收,这些都需要应用程序自己来实现。如果超出这个约定的功能我们自己无法实现,那么我们就说操作系统不支持这个功能。比如linux在2.6之前是不支持多线程的。无论我们在程序中如何模拟,都无法制作多线程。一个可以并发运行并符合POSIX1003.1c语义标准的调度单元。但是,我们在编写程序时并不需要调用中断或SYSCALL指令。这是因为操作系统提供了一层封装。在Windows上,它是NTDLL.DLL,通常称为NativeAPI。我们不仅不需要直接调用INT2E或SYSCALL。准确的说,我们不能直接调用INT2E或SYSCALL,因为Windows没有公开其调用规范,直接使用INT2E或SYSCALL不能保证以后的兼容性。在Linux上,没有这个问题,系统调用列表是公开的,而且Linus非常重视兼容性,不会做任何改动。glibc甚至提供了syscall(2)来方便用户直接按号码调用,但是,为了解决glibc和内核版本不兼容带来的麻烦,以及提高一些调用的效率(比如__NR_gettimeofday),一些系统调用在Linux上仍然被封装,就是VDSO(早期称为linux-gate.so)。但是我们在写程序的时候很少直接调用NTDLL或者VDSO,而是通过更高层次的封装。该层处理参数准备和返回值格式转换,以及错误处理和错误代码转换。这就是我们使用的。语言的运行时库,对于C语言,Linux上的glibc,Windows上的kernel32(或者叫msvcrt),其他语言的JRE,比如Java,这些“其他语言”的运行时库通常最后都是调用glibc或者kernel32.所谓运行库,其实不仅包括用来链接编译好的目标执行程序的库文件,还包括脚本语言或字节码解释语言的运行环境,如Python、C#的CLR、Java的JRE。对系统调用的封装只是运行时库功能的一小部分。运行时库通常还提供不需要操作系统支持的功能,如字符串处理、数学计算、通用数据结构容器等。同时运行时库也支持运维。系统支持的功能提供了更简单和更高级的封装,如带缓存和格式的IO和线程池。因此,当我们说“某种语言增加了某种功能”时,通常有几种可能:1、支持新的语义或句法,方便我们描述和解决问题。比如Java的泛型、Annotation、lambda表达式。2.提供新的工具或类库,减少我们开发的代码量。例如Python2.7的argparse3对系统调用进行了更好更全面的封装,让我们可以做以前在这种语言环境下不可能或很难做的事情。比如JavaNIO,但是任何语言,包括它的运行时库和运行时环境,都不可能创造出操作系统不支持的功能。Go语言也是如此。不管它的功能描述看起来多么眼花缭乱,那肯定是其他语言的。也可以做到,但是Go提供了更方便更清晰的语义和支持,提高了开发效率。2.并发与并行(ConcurrencyandParallelism)并发是指程序的逻辑结构。非并发程序只是一根竹竿。只有一种逻辑控制流,即顺序程序。任何时候,程序都只会处于这个逻辑控制流的某个位置。而如果一个程序有多个独立的逻辑控制流,也就是可以同时处理多个事情,我们就说这个程序是并发的。这里的“同时”不一定非要在时钟的某个时刻(即运行状态而不是逻辑结构),而是指:如果把这些逻辑控制流画成时序流程图,它们是在时间线可以重叠。并行度是指程序的运行状态。如果一个程序同时被多个CPU流水线处理,那么我们就说这个程序是并行运行的。(严格来说,我们不能说一个程序是“并行的”,因为“并行”描述的不是程序本身,而是描述程序的运行状态,不过这篇小文就不那么啰嗦了,下面是关于“并行”,指的是“并行运行”)显然,并行必须需要硬件支持。而且也不难理解:1.并发是并行的必要条件。如果一个程序本身不是并发的,即只有一个逻辑控制流,那我们就不能让它并行处理。2.并发不是并行的充分条件。如果一个并发程序只被一个CPU流水线处理(通过分时),那么它就不是并行的。3.并发只是一种更符合实际问题本质的表述。并发的初衷是为了简化代码逻辑,而不是让程序跑得更快;这段话有点抽象,我们可以用最简单的例子来说明这些概念改造:用C语言写一个最简单的HelloWorld,它是非并发的,如果我们创建多个线程,每个线程打印一个HelloWorld,那么这个程序就是并发,如果这个程序运行在老式的单核CPU上面,那么这个并发程序就不是并行的,如果我们用支持多任??务的多核多CPU操作系统运行,那么这个并发程序就是平行线。还有一个稍微复杂一点的例子,更能说明并发不一定是并行,并发不是为了效率,就是sieve.go,Go语言例子中计算素数。我们为每个因素从小到大开始一个代码片段。如果当前验证的数可以除以当前因数,则该数不是质数。如果不是,则将数字发送到下一个因子的代码片段,直到最后一个因子不能整除,则该数字是质数,我们开始另一个代码片段来验证更大的数字。这符合我们计算素数的逻辑,而且每个因子的代码处理片段都是一样的,所以程序很简洁,但是不能并行化,因为每个片段都依赖于前一个的处理结果和输出片段。并发可以通过以下方式实现:1.显式定义并触发多个代码片段,即逻辑控制流,由应用程序或操作系统调度。它们可以是独立的、互不相关的,也可以是相互依存的、需要相互作用的。比如上面提到的素数计算,其实就是一个经典的生产者和消费者问题:两个逻辑控制流A和B,A产生输出,当有输出时,B获取A的输出进行处理。线程只是实现并发的手段之一。此外,在运行时库或应用程序本身也有很多实现并发的方法,这是下一节的主要内容。2.隐式放置多个代码片段,当系统事件发生时触发相应代码片段的执行,即事件驱动的方法,如端口或管道接收数据(多通道IO情况下),另一个例子Aprocesshasreceivedasignal.并行可以在四个层面上完成:1.多台机器。自然地,我们有多个CPU管道,例如Hadoop集群中的MapReduce任务。2.多CPU。不管是真的多CPU还是多核或者超线程,总之我们有多个CPU流水线。3.ILP(Instruction-levelparallelism)在单CPU内核中,指令级并行。现在的CPU通过复杂的制造过程和指令分析、分支预测和乱序执行,可以在一个时钟周期内执行多条指令,这样即使是非并发的程序也可以并行执行。4、单指令多数据(Singleinstruction,multipledata.SIMD),对于多媒体数据的处理,目前CPU的指令集支持单指令操作多条数据。其中,1涉及分布式处理,包括数据分发和任务同步等,是基于网络的。3和4通常是编译器和CPU的开发人员需要考虑的。我们这里所说的并行主要针对第二种:单机多核CPU并行。关于并发和并行的问题,Go语言的作者RobPike就此写过幻灯片:http://talks.golang.org/2012/waza.slideCMU名著中的这张图《Computer Systems: A Programmer’s Perspective》也很直观清晰:3.线程调度上一节主要讲了并发和并行的概念,而线程是并发最直观的实现。这一节我们主要讲操作系统是如何让多个线程并发执行的。当然,当有多个CPU时,也就是并行执行。我们不讨论进程,这意味着“隔离的执行环境”而不是“单独的执行序列”。我们首先需要了解IA-32CPU的指令控制方式,这样才能理解如何在多个指令序列之间进行切换(即逻辑控制流程)。CPU通过CS:EIP寄存器的值来决定下一条指令的位置,但是CPU不允许直接使用MOV指令改变EIP的值,必须通过JMP系列指令跳转代码,CALL/RET指令,或INT中断指令转;在切换指令序列时,除了改变EIP外,我们还需要保证代码可能用到的各种寄存器的值,尤其是栈指针SS:ESP,以及EFLAGS标志位等,能够恢复到目标指令序列上次执行到该位置时的状态。线程是操作系统提供的一种服务。应用程序可以通过系统调用使操作系统启动线程,并负责后续的线程调度和切换。我们先考虑单个单核CPU。操作系统内核和应用程序实际上共享同一个CPU。当EIP在应用程序代码段时,内核没有控制权。内核不是进程或线程。运行在实模式下,内存中代码段权限为RING0的程序,只有在产生中断或应用程序调用系统调用时,才将控制权交给内核。在内核中,所有代码都在同一个地址。空间,为了给不同的线程提供服务,内核会为每个线程创建一个内核栈,这是线程切换的关键。通常,内核会在时钟中断或系统调用返回前(考虑性能,通常在不频繁的系统调用返回前)调度整个系统的线程,计算当前线程的剩余时间片,并进行切换必要时在“runnable”线程队列中计算优先级,选择目标线程后,保存当前线程的运行环境,恢复目标线程的运行环境。最重要的是切换堆栈指针ESP,然后将EIP指向目标线程最后一次移出CPU时的指令。Linux内核在实现线程切换的时候,玩了一个把戏。它并没有直接JMP,而是先将ESP切换到目标线程的内核栈,将目标线程的代码地址压入栈中,然后JMP到__switch_to(),这就相当伪造了一个CALL__switch_to()指令,然后在__switch_to()结束时使用RET指令返回,这样栈中目标线程的代码地址就被放入EIP中,然后CPU开始执行目标线程的代码其实,停在了上次switch_to宏展开的地方。这里需要补充几点:(1)虽然IA-32提供了TSS(TaskStateSegment),试图简化操作系统的线程调度过程,但由于其效率较低,不是一个通用的标准,不利于移植,所以主流的No操作系统利用了TSS。严格来说其实是用到了TSS,因为只有通过TSS才能把栈切换到内核栈指针SS0:ESP0,而其他的TSS函数根本用不到。(2)当一个线程从用户态进入内核时,相关的寄存器和用户态代码段的EIP都保存了一次,所以上面说的内核态线程切换时,需要的内容并不多保存和恢复。(3)以上描述的都是抢占式调度方式。内核及其硬件驱动程序在等待外部资源可用时也会主动调用schedule()。用户态代码也可以通过调用sched_yield()系统主动发起调度让出CPU。现在我们在一台普通的PC或服务中通常有多个CPU(物理包),每个CPU有多个核心(processorcore),每个核心可以支持超线程(每个核心两个逻辑处理器),即逻辑处理器。每个逻辑处理器都有自己的一套完整的寄存器,包括CS:EIP和SS:ESP,这样,从操作系统和应用程序的角度来看,每个逻辑处理器都是一个独立的流水线。在多处理器的情况下,线程切换的原理和过程与单处理器基本相同。内核代码只有一份。当某个CPU发生时钟中断或系统调用时,该CPU的CS:EIP和控制权交还给内核,内核根据调度策略的结果进行线程切换。但是这时候,如果我们的程序使用线程来实现并发,那么操作系统就可以让我们的程序在多个CPU上并行。这里需要补充两点:(1)在多核场景下,核不是完全相等的。例如,同一个核心上的两个超线程共享L1/L2缓存;场景中,每个核心访问内存不同区域的延迟是不同的;因此,多核场景下的线程调度引入了“调度域”的概念,但这并不影响我们对线程切换机制的理解。(2)在多核场景下,中断发送给哪个CPU?软中断(包括被0除、缺页异常、INT指令)是在触发中断的CPU上自然产生的,而硬中断则分为两种情况。一种是每个CPU自己产生的中断,比如时钟,这个是每个CPU自己处理的。还有就是外部中断,比如IO,可以通过APIC指定哪个CPU;因为调度器只能控制当前的CPU,如果IO中断没有均匀分布的话,那么IO相关的线程就只能在部分CPU上运行,造成CPU负载不均,进而影响整个系统的效率.4.并发编程框架以上大致介绍了一个使用多线程实现并发的程序是如何被操作系统并行调度执行的(当有多个逻辑处理器时)。同时你也可以看出,代码片段或者逻辑控制流的调度和切换其实并不神秘。理论上,我们也可以不依赖于操作系统及其提供的线程,在自己程序的代码段中定义多个段,然后在自己的程序中进行调度和切换。转变。为了描述方便,以下我们将“代码片段”简称为“任务”。类似于内核的实现,只是我们不需要考虑中断和系统调用,那么我们的程序本质上就是一个循环,它本身就是调度器schedule(),我们需要维护一个任务列表,根据我们定义的策略,先进先出或者优先级等,每次从列表中选择一个任务,然后恢复各个寄存器的值,JMP到上次任务被挂起的地方,所有需要保存的信息可以作为任务的属性存储在任务列表中。看起来很简单,但是我们还需要解决几个问题:(1)我们运行在用户态,没有中断或者系统调用等机制来打断代码的执行,那么,一旦我们的schedule()代码把Control传递给任务的代码,我们的下一次调度什么时候发生?答案是它不会发生。只有任务主动调用schedule(),我们才有机会调度。因此,这里的任务不能像线程一样依赖内核调度来毫无顾忌地执行。我们的任务必须显式调用schedule(),也就是所谓的协同(cooperative)调度。(虽然我们可以在内核中模拟一个时钟中断,通过注册一个信号处理函数来获得控制权,但问题是信号处理函数是被内核调用的,到最后,内核重新获得控制权,然后返回到用户态并沿着信号发生时被中断的代码路径继续执行,所以我们无法在信号处理程序内部进行任务切换)(2)堆栈。与内核调度线程的原理类似,我们也需要为每个任务单独分配一个栈,并在任务属性中保存其栈信息,同时在任务切换时保存或恢复当前的SS:ESP。任务栈的空间可以分配在当前线程的栈上,也可以分配在堆上,但通常分配在堆上更好:任务的大小或总数几乎没有限制,而且栈size可以动态扩展(gcc有splitstack,但是太复杂),容易切换任务到其他线程。至此,我们大概知道如何构建并发编程框架了,但是任务如何在多个逻辑处理器上并行执行呢?只有内核才有调度CPU的权限,所以我们还是要通过系统调用来创建线程来实现并行。在多线程多任务的时候,我们还需要考虑几个问题:(1)如果一个任务发起系统调用,比如长时间等待IO,那么当前线程会被放入队列等待调度内核,岂不是让其他任务没有机会执行?在单线程的情况下,我们只有一种解决方案,就是使用非阻塞IO系统调用,让出CPU,然后在schedule()中进行统一轮询,切换回对应的任务有数据时fd;效率稍微低一点的做法是不进行统一轮询,让每个任务轮到自己执行时以非阻塞的方式再次进行IO,直到有数据可用。如果我们使用多线程来构造我们整个程序,那么我们就可以封装系统调用的接口。当一个任务进入系统调用时,我们将当前线程(暂时)独占留给它,另开一个线程去处理其他任务。(2)任务同步。比如上一节我们提到的生产者和消费者的例子,如何在数据还没有产生的时候让消费者等待,有数据的时候触发消费者继续执行呢?在单线程的情况下,我们可以定义一个带有变量的结构,用于存储交互数据本身、数据的当前可用状态以及负责读写这些数据的两个任务的编号。然后我们的并发编程框架提供了任务调用的读写方法。在read方法中,我们循环检查数据是否可用。如果没有数据,我们调用schedule()让CPU等待;在write方法中,我们将数据写入结构体,改变数据可用性状态,然后返回;在schedule()中,我们检查数据可用性状态,如果可用,则激活需要读取数据的任务,任务继续循环检查数据是否可用,查找可用,读取,状态变为不可用,返回.代码的简单逻辑如下:strucchan{boolready,intdata};intread(strucchan*c){while(1){if(c->ready){c->ready=false;返回->数据;}else{时间表();}}}voidwrite(strucchan*c,inti){while(1){if(c->ready){schedule();}else{c->data=i;c->就绪=真;schedule();//可选返回;}}}显然,如果是多线程,我们需要通过线程库或者系统调用提供的同步机制来保护对这个结构体中数据的访问。以上是最简化的并发框架的设计考虑。我们在实际开发工作中遇到的并发框架可能会因为语言和运行时库的不同而有所不同,在功能和易用性上可能会有所取舍,但底层原理都是殊途同归路线。例如glic中的getcontext/setcontext/swapcontext系列库函数可以方便的用来保存和恢复任务执行状态;Windows提供Fiber系列SDKAPI;它们都不是系统调用,虽然getcontext和setcontext的手册页在第2节,但那只是SVR4遗留下来的历史问题。实现代码在glibc而不是kernel中;kernel32中提供了CreateFiber,NTDLL中没有对应的NtCreateFiber。在其他语言中,我们所说的“任务”更多时候被称为“协程”,即Coroutines。例如,Boost.Coroutine是C++中最常用的;Java有一层字节码解释,比较麻烦,但是也有支持协程的JVM补丁,或者动态修改字节码支持协程的项目;PHP和Python的Generator和yield其实就是对协程的支持,可以在其之上封装更通用的协程接口和调度;还有Erlang,原生支持协程等,我看不懂,就不说了。详情参见维基百科页面:http://en.wikipedia.org/wiki/Coroutine由于保存和恢复任务执行状态需要访问CPU寄存器,相关的运行时库也会列出支持的CPU。好像只有OSX和iOS的GrandCentralDispatch提供了操作系统层面的协程及其并行调度,其大部分功能也是在运行时库中实现的。5.goroutineGo语言通过goroutine提供了迄今为止(据我所知)所有语言中对并发编程最清晰最直接的支持。Go语言的文档也对它的特性描述的非常全面,甚至超过了它。这里,基于我们上面的系统知识介绍,我们来罗列一下goroutine的特点,可以算是一个总结:(1)goroutine是Go语言运行时库的一个函数,不是操作系统提供的函数,goroutine不是由线程实现的。具体请参考Go语言源码中的pkg/runtime/proc.c(2)。一个goroutine就是一段代码,一个函数入口,以及在堆上为其分配的栈。所以它非常便宜,我们可以轻松创建数以万计的goroutine,但它们不会被操作系统调度执行(3)除了系统调用阻塞的线程外,Go运行时最多会启动$GOMAXPROCS线程来runGoroutine(4)Goroutine是协同调度的。如果goroutine会执行很长时间,并没有通过等待数据读取或写入通道进行同步,则需要主动调用Gosched()放弃CPU(5)和所有其他并发框架中的协程一样,so-goroutine中所谓的“无锁”优势只在单线程下有效。如果$GOMAXPROCS>1并且需要协程之间的通信,Go运行时将负责锁定和保护数据,这也是像sieve.go这样的例子在多CPU多线程时速度较慢的原因(6)请求由web和其他服务器程序处理本质上是并行处理问题。每个请求基本上是独立的,相互独立的。几乎没有数据交互。这不是并发编程模型。并发编程框架只是解决了其语义表达的复杂性,并没有从根本上提高处理效率。可能并发连接和并发编程的英文是concurrent吧,很容易误解“并发编程框架和协程可以高效处理大量并发连接”。(7)Go语言运行时包封装了异步IO,这样你就可以写一个看似并发很多的服务器,但即使我们通过调整$GOMAXPROCS来充分利用多核CPU并行处理,其效率也并不高不如我们采用IO事件驱动设计,根据事务类型划分适当比例的线程池。在响应时间方面,协同调度是有缺陷的。(8)goroutine最大的价值在于实现了并发协程与实际并行执行线程的映射和动态扩展。随着它的运行时库的不断发展和完善,它的性能肯定会越来越好,尤其是在CPU核越来越多的未来,总有一天我们会为了代码的简洁而放弃那一点性能上的差异可维护性。原文链接:http://www.sizeofvoid.net/goroutine-under-the-hood/