GO语言的进阶之路-goroutine(并发)
作者:尹正杰
版权声明:原创作品,谢绝转载!否则将追究法律责任。
有人把Go比作21世纪的C 语言,第一是因为 Go语言设计简单,第二,21世纪最重要的就是并行程序设计,而GO 从语言层面就支持了并行。Go语言中最重要的一个特性,那就是 go 关键字。优雅的并发编程范式,完善的并发支持,出色的并发性能是Go语言区别于其他语言的一大特色。使用Go语言开发服务器程序时,就需要对它的并发机制有深入的了解。
一.并发基础
回到在Windows和Linux出现之前的古老年代,程序员在开发程序时并没有并发的概念,因为命令式程序设计语言是以串行为基础的,程序会顺序执行每一条指令,整个程序只有一个执行上下文,即一个调用栈,一个堆。并发则意味着程序在运行时有多个执行上下文,对应着多个调用栈。我们知道每一个进程在运行时,都有自己的调用栈和堆,有一个完整的上下文,而操作系统在调度进程的时候,会保存被调度进程的上下文环境,等该进程获得时间片后,再恢复该进程的上下文到系统中。
1>.从整个操作系统层面来说,多个进程是可以并发的,那么并发的价值何在?下面我们先看以下几种场景。
2>.一方面我们需要灵敏响应的图形用户界面,一方面程序还需要执行大量的运算或者IO密集操作,而我们需要让界面响应与运算同时执行。
3>.当我们的Web服务器面对大量用户请求时,需要有更多的“Web服务器工作单元”来分别响应用户。
4>.我们的事务处于分布式环境上,相同的工作单元在不同的计算机上处理着被分片的数据。
5>.计算机的CPU从单内核(core)向多内核发展,而我们的程序都是串行的,计算机硬件的能力没有得到发挥。
6>.我们的程序因为IO操作被阻塞,整个程序处于停滞状态,其他IO无关的任务无法执行。
从以上几个例子可以看到,串行程序在很多场景下无法满足我们的要求。下面我们归纳了并发程序的几条优点,让大家认识到并发势在必行:
a>.并发能更客观地表现问题模型;
b>.并发可以充分利用CPU核心的优势,提高程序的执行效率;
c>.并发能充分利用CPU与其他硬件设备固有的异步性。
现在我们已经意识到并发的好处了,那么到底有哪些方式可以实现并发执行呢?就目前而言,并发包含以下几种主流的实现模型。
1>.多进程。多进程是在操作系统层面进行并发的基本模式。同时也是开销最大的模式。在Linux平台上,很多工具链正是采用这种模式在工作。比如某个Web服务器,它会有专门的进程负责网络端口的监听和链接管理,还会有专门的进程负责事务和运算。这种方法的好处在于简单、进程间互不影响,坏处在于系统开销大,因为所有的进程都是由内核管理的。
2>.多线程。多线程在大部分操作系统上都属于系统层面的并发模式,也是我们使用最多的最有效的一种模式。目前,我们所见的几乎所有工具链都会使用这种模式。它比多进程 的开销小很多,但是其开销依旧比较大,且在高并发模式下,效率会有影响。
3>.基于回调的非阻塞/异步IO。这种架构的诞生实际上来源于多线程模式的危机。在很多高并发服务器开发实践中,使用多线程模式会很快耗尽服务器的内存和CPU资源。而这种模式通过事件驱动的方式使用异步IO,使服务器持续运转,且尽可能地少用线程,降低开销,它目前在Node.js中得到了很好的实践。但是使用这种模式,编程比多线程要复杂,因为它把流程做了分割,对于问题本身的反应不够自然。
4>.协程。协程(Coroutine)本质上是一种用户态线程,不需要操作系统来进行抢占式调度,且在真正的实现中寄存于线程中,因此,系统开销极小,可以有效提高线程的任务并发性,而避免多线程的缺点。使用协程的优点是编程简单,结构清晰;缺点是需要语言的支持,如果不支持,则需要用户在程序中自行实现调度器。目前,原生支持协程的语言还很少。
接下来我们先诠释一下传统并发模型的缺陷,之后再讲解goroutine并发模型是如何逐一解决这些缺陷的。
人的思维模式可以认为是串行的,而且串行的事务具有确定性。线程类并发模式在原先的确定性中引入了不确定性,这种不确定性给程序的行为带来了意外和危害,也让程序变得不可控。线程之间通信只能采用共享内存的方式。为了保证共享内存的有效性,我们采取了很多措施,比如加锁等,来避免死锁或资源竞争。实践证明,我们很难面面俱到,往往会在工程中遇到各种奇怪的故障和问题。
我们可以将之前的线程加共享内存的方式归纳为“共享内存系统”,虽然共享内存系统是一种有效的并发模式,但它也暴露了众多使用上的问题。计算机科学家们在近40年的研究中又产生了一种新的系统模型,称为“消息传递系统”。
对线程间共享状态的各种操作都被封装在线程之间传递的消息中,这通常要求:发送消息时对状态进行复制,并且在消息传递的边界上交出这个状态的所有权。从逻辑上来看,这个操作与共享内存系统中执行的原子更新操作相同,但从物理上来看则非常不同。由于需要执行复制操作,所以大多数消息传递的实现在性能上并不优越,但线程中的状态管理工作通常会变得更为简单。
最早被广泛应用的消息传递系统是由C. A. R. Hoare在他的Communicating Sequential Processes中提出的。在CSP系统中,所有的并发操作都是通过独立线程以异步运行的方式来实现的。这些线程必须通过在彼此之间发送消息,从而向另一个线程请求信息或者将信息提供给另一个线程。使用类似CSP的系统将提高编程的抽象级别。
随着时间的推移,一些语言开始完善消息传递系统,并以此为核心支持并发,比如Erlang。
二.协程
执行体是个抽象的概念,在操作系统层面有多个概念与之对应,比如操作系统自己掌管的进程(process)、进程内的线程(thread)以及进程内的协程(coroutine,也叫轻量级线程)。与传统的系统级线程和进程相比,协程的最大优势在于其“轻量级”,可以轻松创建上百万个而不会导致系统资源衰竭,而线程和进程通常最多也不能超过1万个。这也是协程也叫轻量级线程的原因。
多数语言在语法层面并不直接支持协程,而是通过库的方式支持,但用库的方式支持的功能也并不完整,比如仅仅提供轻量级线程的创建、销毁与切换等能力。如果在这样的轻量级线程中调用一个同步 IO 操作,比如网络通信、本地文件读写,都会阻塞其他的并发执行轻量级线程,从而无法真正达到轻量级线程本身期望达到的目标。
Go 语言在语言级别支持轻量级线程,叫goroutine。Go 语言标准库提供的所有系统调用操作(当然也包括所有同步 IO 操作),都会出让 CPU 给其他goroutine。这让事情变得非常简单,让轻量级线程的切换管理不依赖于系统的线程和进程,也不依赖于CPU的核心数量。
三.goroutine
goroutine不同于thread,threads是操作系统中的对于一个独立运行实例的描述,不同操作系统,对于thread的实现也不尽相同;但是,操作系统并不知道goroutine的存在,goroutine的调度是有Golang运行时进行管理的。启动thread虽然比process所需的资源要少,但是多个thread之间的上下文切换仍然是需要大量的工作的(寄存器/Program Count/Stack Pointer/...),Golang有自己的调度器,许多goroutine的数据都是共享的,因此goroutine之间的切换会快很多,启动goroutine所耗费的资源也很少,一个Golang程序同时存在几百个goroutine是很正常的。
有疑问加站长微信联系(非本文作者)