golang并发机制的实现和其他语言有什么区别?为什么能高效快速?本文对其进行了详细介绍。由于网上有很多介绍常用语法的资源,官方围??棋指南ATourofGo:https://tour.golang.org/(请自备梯子)就是一个很好的例子。详细说明。这次我们开门见山,先从Go最大的卖点——并发说起。funcHello(){fmt.Println("I'mB")//OutputA}goHello()fmt.Println("I'mA")//OutputB如果上面的Go代码编译运行在双核(和above)machine,我们可以观察到A/B输出的顺序随着运行次数的变化而变化,也就是说,仅用5行代码,我们就创建了一个两行并发的程序。相比于C/C++/Java/Python等语言为了创建并发执行环境而调用POSIX-API/定义继承类等繁琐的步骤,Golang简单的一句gofunc()确实大放异彩。当然,单纯的语法简单显然不足以成为一门编程语言吹牛的本钱。下面我们就在这几行语句下详细探讨一下Golang的并发机制和实现。一等公民——GoroutineGoroutine是Go并发机制的绝对主角。它代表指令流及其执行环境,也是被调度的基本单位。从宏观上看,goroutine类似于操作系统中线程的概念(注意这里的类比并不严格,下面会详细对比两者):不同的线程共享同一个内存空间,但是做不共享栈并发执行;类似地,goroutines也与不同的内存堆栈并发运行。如上图所示,上述代码片段第四行的goHello()会创建一个新的goroutine(绿线)并开始执行Hello()函数。需要注意的是,由于maingoroutine(蓝线)和新创建的goroutine是并发的,而且maingoroutine在执行goHello()时并没有等待被调用函数执行结束,所以“我是A"(主goroutine输出)和“I'mB”(新goroutine输出)可能以任何顺序交错出现。为什么不使用线程(pThread)?直到现在,我们在goroutine中也看不到线程有什么不同,这促使Golang编写者放弃传统的线程模型,自己造轮子。那么线程(pThread)在操作系统层面有什么问题呢?生命周期开销太高。线程的创建、销毁、切换都需要一系列的系统调用,而每一次系统调用都意味着触发软中断、进入内核态、将所有寄存器值存入内存、维护相关数据结构、恢复寄存器等。,返回用户状态等一系列组合拳。这一轮操作不仅耗时,而且会大大降低内存缓存的加速效果。因此,避免频繁创建和销毁线程成为高性能并发的必要条件已成为程序员的共识。以线程为并发模型的C/C++/Java采用线程池的方式来减少线程昂贵的生命周期开销。既然线程创建/死亡代价高昂,为什么我们不让创建的线程永不死亡呢?具体来说,对于每一个已经创建但已经完成工作的线程,我们将其休眠,并放入一个资源池中。下次需要新的线程时,我们直接唤醒线程池中休眠的线程。使用而不是创建一个新线程。这样,大部分的线程创建/销毁需求都被线程池成功吸收了。此外,通过指定线程池的最大容量,我们可以将线程创建和销毁的开销控制在一个固定值。例如,一个普通的JavaWeb应用程序会设置一个大小为30~50的线程池来处理HTTP请求,并且达到了很好的并发效果。不必要的线程切换即使线程池切断了线程生命周期的开销,线程在操作系统层面仍然存在不足:线程的语义在于并行。当线程数超过CPU内核数时,操作系统会给每个CPU内核在不同的线程之间切换,让它们“看起来”在同时运行。当然,这样的切换也需要多次中断、系统调用,以及当前线程的工作集被新线程从缓存中完全擦除的代价。乍一看,这样的价格似乎是必要的,但事实并非如此。由于我们的大多数应用都是I/O和计算混合的,即一段时间与硬盘/网络(I/O)交互,一段时间进行相对密集的内存访问和计算,同时等待I/O完成期间,线程处于休眠状态,CPU已经切换到其他线程。即使操作系统在计算密集期不强行中断和切换线程,应用在宏观上仍然表现出一定的并发性。并且通过去掉计算密集期的线程切换,有效提升了整体CPU效率——NodeJS就是在这样的哲学下诞生的:单线程、全异步I/O、事件驱动、非抢占式调度(当某个特定的时候)函数在单纯进行计算和访问内存时不会被打断),而在进行I/O密集型工作(如网站后台)时,通过强制单个CPU的使用率为100%,从而打败几乎所有其他可用资源的效率。多线程多核脚本语言。这简直就是本就特立独行的Javascript向整个编程语言行业的同仁竖起的又一根中指。当然,只能使用单核处理能力的NodeJS显然无法胜任对计算要求更高的工作,但它给我们的启示是值得注意的。高切换开销在锁竞争、协程同步等情况下,频繁进入内核态的线程模型会放大其在切换开销上的劣势。用户态调度器(比如goroutine调度器)可以在用户态处理这一切,省时省力。另外,由于编程语言可以更好地分析自己语言中的同步原语,所以编程语言本身的调度器可以根据语义更好地优化调度。Goroutine调度模型Go使用用户模式调度程序来控制goroutine的执行,从而避免了大部分内核开销。具体来说,Golang的调度模型由三部分组成:执行环境(Executor)、调度器(Scheduler)和goroutine。执行环境,顾名思义,就是用来执行代码的。虽然在抽象概念上应该对应一个CPU核,但是Go将其实现为一个线程,因为在用户态不能触及硬件资源。当线程数等于CPU核心数时,既最大化了CPU核心的利用率,又最小化了线程切换的开销,是最理想的情况(当然,在实际情况下,操作系统也会从其他进程运行和切换线程,但这超出了普通程序的控制范围)。因此,默认情况下,用于指定执行环境数的运行时变量GOMAXPROCS等于CPU核心数。当然,开发者可以根据自己的需要改变这个值。当GOMAXPROCS=1时,Go的执行模型几乎等同于NodeJS。调度器是调度模型的核心,它决定了每个执行环境(核心)什么时候执行什么goroutine。Go使用任务队列来调度goroutine:如上图所示,所有的goroutine都作为任务在任务队列中排队,调度器做的事情就是在执行者空闲的时候,从组长那里取下一个goroutine执行.每个任务(goroutine)都会被执行者执行完成或阻塞(如发起I/O请求、系统调用、请求锁被他人使用或自己让出计算资源等),在第二种情况,该goroutine既不在executor中也不在队列中,而是处于阻塞状态,被Scheduler监听,直到阻塞结束重新入队。值得注意的是,这与上面提到的“去除计算密集期的线程切换”有关:由于调度器对任务采用非抢占式调度,即执行器不会放弃当前任务正常计算和内存访问的情况。goroutine,因此可以去除冗余的goroutine切换成本。这样的任务队列模型还是有很多问题的:由于只有一个任务队列,为了保证入队和出队的原子性,需要在分配/加入任务时给整个队列加上一个互斥量。将新任务分配给执行程序会使单个队列成为并行性能瓶颈。为了解决这个问题,Go使用多任务队列进行任务调度:如上图所示,在多任务调度模型中,每个执行器都有自己对应的任务队列。在正常情况下,每个执行器从自己的队列中取出一个goroutine,并将其生成的新goroutine放在自己队列的末尾。分布式结构可能带来的问题很明显:如果任务在队列中分布不均,就会造成计算资源的浪费,比如上图中的executor3。如果没有其他措施,核心会空闲,因为相应的队列中没有任务。对于这个问题,Go的解决方案是引入了“偷任务”机制:当Scheduler发现一个队列没有任务可用时,它会从其他队列“偷”一些任务。由于窃取任务的成本很高(需要锁定两个队列),Scheduler会争取一次窃取足够多的任务,以减少以后窃取任务的频率。对于处于阻塞状态的goroutine,Scheduler需要监听其未阻塞状态并重新入队。Goroutines被阻塞有两个原因:阻塞I/O或系统调用。由于底层实现的限制,这种类型的阻塞需要线程显式执行相应的系统调用并等待调用返回。在这种情况下,Scheduler会创建一个新的线程来执行系统调用,并在返回后通知Scheduler。同样,该线程在线程池中维护以节省开销。值得注意的是,这类线程几乎整个生命周期都在等待被阻塞(阻塞结束后立即通知Scheduler,然后结束),被阻塞的线程不参与操作系统线程切换,所以不会带来太大的线程切换开销。当然,如果借鉴NodeJS,将同步版本的api尽量替换成异步版本的api,可以省去线程池的操作,进一步优化性能(Go是否采用这种优化还是值得怀疑的)。内部同步机制,Goroutine因为调用Go内部同步机制(channel、mutex、waitgroup、条件变量等)而被阻塞。对于这种阻塞,由于同步机制的语义是由Go定义的,因此对Scheduler是透明的,Scheduler可以分析阻塞依赖,将监控阻塞状态的任务交给它依赖的goroutine。例如,如果goroutineA请求一个mutex正在被goroutineB获取并阻塞,那么Scheduler可以唤醒goroutineA并在goroutineB释放锁时由相应的执行器加入队列。整个过程不需要引入新的线程。以上就是GolangScheduler的大致工作逻辑。通过各个组件的协作,构建了一个支持调度数千个goroutines的高性能并发环境。总结与启示从Golang的并发机制中,我们可以得到如下启示:系统调用和内核态代价高昂,用户态的调度器性能更好。由于频繁和不必要的切换,线程不是一个合适的并发执行单元;相反,最好使用线程作为执行资源(CPU)的抽象,并让每个CPU核心有一个线程作为执行者。单一的任务队列在任务短任务长的时候劣势很明显,分布式队列+任务窃取可以更好的解决问题。可以说Golang的并发机制是NodeJS的通用版,优点是可以更好的利用多核计算能力;与Python使用OS线程、阻塞I/O、GIL的并发模式相比,更是天差地别。正是更复杂的并发机制和简单的并发原语,让并发成为Go语言最大的卖点。需要指出的是,Go使用的所有技术都不是原创的——gofunc()的同步原语与Cilk非常相似,分布式任务队列也有些模仿Cilk/OpenMP。如果非要说区别的话,大概是因为Go是一门完整的编程语言,原生支持这个功能,而另外两个只是对C/C++的语法扩展。
