笔者最近使用Go语言重构了项目的部分代码,也算是对这门年轻的语言有了一定的认识,来谈谈在近期的开发中对Go语言的并发性能的认识。
并发编程中,线程,是我们再熟悉不过的了。高并发往往离不开多线程的支持。Go语言是一种高并发场景下表现良好的语言,然而在Go语言中,我们并不使用线程来作为高并发最小执行单元。Go语言提供了一种特殊的特性 Goroutine,亦叫做协程。
那么Go语言提供的Goroutine和我们通常所说的线程,又有什么区别呢?
我们先来说说我们平时常见的线程。
线程,是系统中最小的执行单元。一个进程中至少会有一个线程。线程由操作系统进行管理,当应用程序需要创建一个线程时,它需要向操作系统申请资源,当一个线程完成了它的任务后,需要将资源释放掉。线程之间的通信,往往是通过共享变量来实现的,在一个进程中,多个线程往往会同时对同一个变量进行修改。这个时候,我们就需要合理的使用锁来控制这个变量。如果不能合理的使用锁,容易出现死锁,这对于一个应用程序来说是致命的。一个线程,从创建到销毁,往往会有一定的开销。创建一个线程通常会占用1M的内存。在一个Java服务中,如果我们创建一千个线程,那么将会有一笔较大的内存开销。
那么,Goroutine比起线程,又有着什么样的优势呢?
通常,创建一个线程的时候,是应用程序直接向操作系统申请资源,线程的调度,也会由操作系统来执行。然而在Go语言中,创建一个Goroutine是通过调用Go Runtime来实现的。Go Runtime是Go语言的运行环境,就像是Java的JVM。创建一个Goroutine比起创建一个线程,开销要小得多。在时间方面,Goroutine的创建直接由Go Runtime完成,而创建一个线程,需要先向操作系统申请资源,系统分配了硬件资源之后才能够启动一个线程。因此,Goroutine能比线程启动得更快。在空间方面,一个Goroutine所占用的内存大小往往只有2KB,比起一个线程占用1MB的开销,Goroutine能够节省更多的空间。笔者近期使用Go语言重构时,最直观的感受就是,相同的硬件配置之下,Go语言的内存占用少了数十倍。因此,Goroutine可以说是much more cheaper than thread,我们可以在一个应用程序中,创建成千上万个Goroutine而不会造成大量的系统开销,因为它们很“便宜”。上文提到,线程间通信使用共享内存来实现,而在Goroutine中,除了共享内存之外,还提供了一种更加便捷高效的方式,那就是使用Channel(通道),我会在下文中阐述Go语言中的Channel。
上文中我提到Goroutine是由Go Runtime来进行调度的,那么,Goroutine是如何被调度的呢?
Go语言中,Goroutine的调度使用的GPM调度模型。G=Goroutine,P=Processor,M=Machine。Processor(处理器),
不是CPU那个处理器!!!是Go语言用于处理函数逻辑的处理器。Machine(机器),理解为物理线程。Figure1是Goroutine的调度模型。每一个物理线程对应着一个Processor,每个Processor维持着一个队列,队列中存放着的便是等待调度Goroutine。图中,G0是正在被执行的Goroutine,每当一个Goroutine被执行完成之后,便会执行下一个Goroutine。使用的内核线程数量,默认是当前机器的CPU核数量,当然,也可以通过GOMAXPROCS()函数来自定义将要使用多少物理线程。多少个物理线程代表最多有多少个Goroutine可以并行执行。
然而,当Go程序使用了系统调用,这个调度模型会如何处理呢?
众所周知,系统调用是应用程序通过操作系统提供的接口调用操作系统底层的功能,比如说,创建进程,文件读写等等。当Goroutine执行逻辑中调用了阻塞的系统接口时,线程执行逻辑便会阻塞住等待系统接口返回执行结果。此时假设Figure1中的G0执行系统调用阻塞了,那么,队列中的Goroutine岂不是需要等待着?Go Runtime当然不会这么做,因为浪费了不少时间。
Figure2中阐述了Go的调度器会如何安排。当出现阻塞式系统调用导致某段执行逻辑被阻塞时,Goroutine会专门安排一个物理线程来等待这个阻塞住的Goroutine所调用的系统接口返回系统调用的结果。如图Figure2,G0原本是由M0线程执行,当发生阻塞式系统调用时,调度器会将队列中的协程连同上下文,也就是P,转移到一个专门为此安排的空闲的物理线程M1,M1便会继续执行队列中排队等待执行的Goroutine,而M0则是继续等待G0调用的系统接口返回结果。
那么假设,一个Processor动作很快,执行完了队列中所有的任务,它会啥都不干躺尸吗?
当然不会。Go语言维护了一个全局队列(Global Queue),当一个Processor执行完了队列中的所有任务之后,它就会去全局队列获取排队中待执行的Goroutine,可不能让自己闲着工作不饱和。或者还有另一种case,其它某个Processor维持的队列中有大量的Goroutine待执行,那么它会去向那个Processor获取任务,帮人家分担点。调度器会定期检查全局队列中的Goroutine数量,以保证没事干的Processor能从全局队列中获取Goroutine。
如何使用Goroutine呢?
很简单,一个go关键字解决问题。
Figure3和Figure4是两种常见的写法。go关键字后面可以直接跟一个函数或者匿名函数,函数内部便是要处理的逻辑。go关键字创建了一个Goroutine之后,会继续执行后面的代码。如果后续代码的逻辑强依赖于Goroutine中的逻辑,需要使用WaitGroup。如Figure5所示。
WaitGroup有点类似于信号量。如Figure5,开启5个Goroutine来一起处理一个逻辑。每次开启一个Goroutine之前,会先将WaitGroup加1,随后开启一个协程。当协程执行完成之后,需要将WaitGroup减一,也就是wg.Done()。图中的defer关键字就是在程序即将退出的时候,执行其中的函数的逻辑,不管程序发生了什么引发退出,只要在退出之前声明了defer函数,它就会被执行。除了WaitGroup减一之外,还需要注意捕捉异常,也就是图中的recover()函数。通过go关键字开启的协程中,如果发生了异常且没有被捕捉到,那么,将会引发整个
进程退出。Wait()函数是用来等待被创建的Goroutine执行完成的,因为后续的代码会强依赖于Goroutine的执行结果。Go语言中的Channel是个什么样的存在呢?
Channel,通道,是两个Goroutine之间传递消息的媒介。它有点类似于进程间通信的那个管道。Channel分为有缓冲的和无缓冲的。先上图讲讲他是怎么用的。
如图Figure6所示,这段代码中,先创建了一个channel。此时,开启一个子协程,向里面写入一条消息,在主协程中,使用select关键字对这个channel进行监听,一旦监听到消息,便会对消息进行处理。这是channel的一个简单使用,两个Goroutine之间使用channel进行通信,比起普通的线程使用共享内存进行通信,要来的更加便捷高效。