3.3 原生并发,轻量高效
并发是有关结构的,而并行是有关执行的。
——Rob Pike(2012)
将时钟回拨到2007年,那时Go语言的三位设计者Rob Pike、Robert Griesemer和Ken Thompson都在Google使用C++语言编写服务端代码。当时C++标准委员会正在讨论下一个C++标准(C++0x,也就是后来的C++11标准),委员会在标准草案中继续增加大量语言特性的行为让Go的三位设计者十分不满,尤其是带有原子类型的新C++内存模型,给本已负担过重的C++类型系统又增加了额外负担。三位设计者认为C++标准委员会在思路上是短视的,因为硬件很可能在未来十年内发生重大变化,将语言与当时的硬件紧密耦合起来是十分不明智的,是没法给开发人员在编写大规模并发程序时带去太多帮助的。
多年来,处理器生产厂商一直遵循着摩尔定律,在提高时钟频率这条跑道上竞争,各行业对计算能力的需求推动了处理器处理能力的提高。CPU的功耗和节能问题成为人们越来越关注的焦点。CPU仅靠提高主频来改进性能的做法遇到了瓶颈。主频提高导致CPU的功耗和发热量剧增,反过来制约了CPU性能的进一步提高。依靠主频的提高已无法实现性能提升,人们开始把研究重点转向把多个执行内核放进一个处理器,让每个内核在较低的频率下工作来降低功耗同时提高性能。2007年处理器领域已开始进入一个全新的多核时代,处理器厂商的竞争焦点从主频转向了多核,多核设计也为摩尔定律带来新的生命力。与传统的单核CPU相比,多核CPU带来了更强的并行处理能力、更高的计算密度和更低的时钟频率,并大大减少了散热和功耗。Go的设计者敏锐地把握了CPU向多核方向发展的这一趋势,在决定不再使用C++而去创建一门新语言的时候,果断将面向多核、原生内置并发支持作为新语言的设计原则之一。
Go语言原生支持并发的设计哲学体现在以下几点。
(1)Go语言采用轻量级协程并发模型,使得Go应用在面向多核硬件时更具可扩展性
提到并发执行与调度,我们首先想到的就是操作系统对进程、线程的调度。操作系统调度器会将系统中的多个线程按照一定算法调度到物理CPU上运行。传统编程语言(如C、C++等)的并发实现实际上就是基于操作系统调度的,即程序负责创建线程(一般通过pthread等函数库调用实现),操作系统负责调度。这种传统支持并发的方式主要有两大不足:复杂和难于扩展。
复杂主要体现在以下方面。
- 创建容易,退出难:使用C语言的开发人员都知道,创建一个线程时(比如利用pthread库)虽然参数也不少,但还可以接受。而一旦涉及线程的退出,就要考虑线程是不是分离的(detached)?是否需要父线程去通知并等待子线程退出(join)?是否需要在线程中设置取消点(cancel point)以保证进行join操作时能顺利退出?
- 并发单元间通信困难,易错:多个线程之间的通信虽然有多种机制可选,但用起来相当复杂;并且一旦涉及共享内存(shared memory),就会用到各种锁(lock),死锁便成为家常便饭。
- 线程栈大小(thread stack size)的设定:是直接使用默认的,还是设置得大一些或小一些呢?
难于扩展主要体现在以下方面。
- 虽然线程的代价比进程小了很多,但我们依然不能大量创建线程,因为不仅每个线程占用的资源不小,操作系统调度切换线程的代价也不小。
- 对于很多网络服务程序,由于不能大量创建线程,就要在少量线程里做网络的多路复用,即使用epoll/kqueue/IoCompletionPort这套机制。即便有了libevent、libev这样的第三方库的帮忙,写起这样的程序也是很不容易的,存在大量回调(callback),会给程序员带来不小的心智负担。
为了解决这些问题,Go果断放弃了传统的基于操作系统线程的并发模型,而采用了用户层轻量级线程或者说是类协程(coroutine),Go将之称为goroutine。goroutine占用的资源非常少,Go运行时默认为每个goroutine分配的栈空间仅2KB。goroutine调度的切换也不用陷入(trap)操作系统内核层完成,代价很低。因此,在一个Go程序中可以创建成千上万个并发的goroutine。所有的Go代码都在goroutine中执行,哪怕是Go的运行时代码也不例外。
不过,一个Go程序对于操作系统来说只是一个用户层程序。操作系统的眼中只有线程,它甚至不知道goroutine的存在。goroutine的调度全靠Go自己完成,实现Go程序内goroutine之间公平地竞争CPU资源的任务就落到了Go运行时头上。而将这些goroutine按照一定算法放到CPU上执行的程序就称为goroutine调度器(goroutine scheduler)。关于goroutine调度的原理,我们将在后面详细说明,这里就不赘述了。
(2)Go语言为开发者提供的支持并发的语法元素和机制
我们先来看看那些设计并诞生于单核年代的编程语言(如C、C++、Java)在语法元素和机制层面是如何支持并发的。
- 执行单元:线程。
- 创建和销毁的方式:调用库函数或调用对象方法。
- 并发线程间的通信:多基于操作系统提供的IPC机制,比如共享内存、Socket、Pipe等,当然也会使用有并发保护的全局变量。
与上述传统语言相比,Go提供了语言层面内置的并发语法元素和机制。
- 执行单元:goroutine。
- 创建和销毁方式:go+函数调用;函数退出即goroutine退出。
- 并发goroutine的通信:通过语言内置的channel传递消息或实现同步,并通过select实现多路channel的并发控制。
对比来看,Go对并发的原生支持将大大降低开发人员在开发并发程序时的心智负担。
(3)并发原则对Go开发者在程序结构设计层面的影响
由于goroutine的开销很小(相对线程),Go官方鼓励大家使用goroutine来充分利用多核资源。但并不是有了goroutine就一定能充分利用多核资源,或者说即便使用Go也不一定能写出好的并发程序。
为此Rob Pike曾做过一次关于“并发不是并行”[2]的主题分享,图文并茂地讲解了并发(Concurrency)和并行(Parallelism)的区别。Rob Pike认为:
- 并发是有关结构的,它是一种将一个程序分解成多个小片段并且每个小片段都可以独立执行的程序设计方法;并发程序的小片段之间一般存在通信联系并且通过通信相互协作。
- 并行是有关执行的,它表示同时进行一些计算任务。
以上观点的重点是,并发是一种程序结构设计的方法,它使并行成为可能。不过这依然很抽象,这里借用Rob Pike分享中的那个“搬运书问题”来重新诠释并发的含义。搬运书问题要求设计一个方案,使gopher能更快地将一堆废弃的语言手册搬到垃圾回收场烧掉。
最简单的方案莫过于图3-3所示的初始方案(以下搬书问题涉及的图片均来自https://talks.golang.org/2012/waza.slide)。
图3-3 搬书问题初始方案
这个方案显然不是并发设计方案,它没有对问题进行任何分解,所有事情都是由一个gopher从头到尾按顺序完成的。但即便是这样一个并非并发的方案,我们也可以将其放到多核硬件上并行执行,只是需要多建立几个gopher例程(procedure)的实例,见图3-4。
图3-4 搬书问题初始方案的并行化
但和并发方案相比,这种方案是缺乏自动扩展为并行的能力的。Rob Pike在分享中给出了两种并发方案(分别见图3-5和图3-6),也就是该问题的两种分解方案,两种方案都是正确的,只是分解的粒度大小有所不同。
图3-5 搬书问题并发方案1
图3-6 搬书问题并发方案2
并发方案1将原来单一的gopher例程执行拆分为4个执行不同任务的gopher例程,每个例程仅承担一项单一的简单任务,这些任务分别是:
- 将书搬运到车上(loadBooksToCart);
- 推车到垃圾焚化地点(moveCartToIncinerator);
- 将书从车上搬下送入焚化炉(unloadBookIntoIncinerator);
- 将空车送返(returnEmptyCart)。
理论上并发方案1的处理性能能达到初始方案的4倍,并且不同gopher例程可以在不同的处理器核上并行执行,而不是像最初方案那样需要通过建立新实例才能实现并行。
和并发方案1相比,并发方案2增加了“暂存区域”,分解的粒度更细,每个部分的gopher例程各司其职。
采用并发方案设计的程序在单核处理器上也是可以正常运行的(在单核上的处理性能可能不如非并发方案),并且随着处理器核数的增多,并发方案可以自然地提高处理性能,提升吞吐量。而非并发方案在处理器核数提升后,也仅能使用其中的一个核,无法自然扩展,这一切都是程序的结构所决定的。这告诉我们:并发程序的结构设计不要局限于在单核情况下处理能力的高低,而要以在多核情况下充分提升多核利用率、获得性能的自然提升为最终目的。
除此之外,并发与组合的哲学是一脉相承的,并发是一个更大的组合的概念,它在程序设计层面对程序进行拆解组合,再映射到程序执行层面:goroutine各自执行特定的工作,通过channel+select将goroutine组合连接起来。并发的存在鼓励程序员在程序设计时进行独立计算的分解,而对并发的原生支持让Go语言更适应现代计算环境。