8.1.2 并发原理
经过前面两节的介绍,读者应该已经知道了Go语言对数据竞态的解决方法,也了解了Go语言的并发理念:不要以共享内存的方式来实现通信,而应该以通信的方式来实现内存共享。
本节将继续深入探讨Go语言并发模式的设计以及大致的实现方式。
除Go语言以外,大多数其他的主流语言使用的都是内存共享式的并发模式。什么是内存共享式的并发模式呢?就是通过并发的方式使用相同的内存区域,比如多个线程并发使用同一个变量。内存共享式对于计算机科班出身的读者来说一定不陌生,在“操作系统”课程中有详细介绍。非科班出身的读者也不用担心,本书8.2节会详细介绍内存共享式并发的实现方式。
Go语言也支持内存共享的并发方式,但这不是Go语言所倡导的,在并发编程中应该优先使用通信顺序进程方式,也叫CSP(Communicating Sequential Process)方式。CSP既是一个技术名词,也是介绍该技术的论文的名字。该技术由Charles Antony Richard Hoare于1978年在ACM发表的同名论文中首次提出。
CSP的理念也是Go语言的座右铭:“不要以共享内存的方式实现通信,而应该以通信的方式实现共享内存”。8.1.1节解决数据竞态问题时用到的第二种方式就是CSP的并发方式。
这里不会对CSP做过多的理论介绍,主要是结合Go语言把CSP在使用层面的知识进行说明。读者可以结合8.4节工作池的示例进行阅读,后面会有越来越多的例子用到CSP并发模式。
Go语言中的CSP是通过goroutine和channel实现的。goroutine可以理解为协程,是Go语言中并发编程必须使用的基本单位。goroutine彼此之间可以通过channel进行数据传递,如图8-3所示。
图8-3是CSP实现并发的基本模式,channel可以是双向的也可以是单向的。当通道为空时,读取的goroutine会阻塞;而当channel容量变满时,写入的goroutine也会阻塞。当然,channel可以在两个以上的goroutine之间进行数据的传递,这种方式不用考虑互斥的问题,也不需要加锁,在进行数据传递的过程中就完成了内存的共享。
其实,仅有goroutine和channel还是不够灵活。当逻辑比较复杂,根据不同的需求执行不同的代码时,如果仅有goroutine和channel就会让代码非常啰嗦。鉴于此,Go语言提供了select关键字。
select语句是绑定channel的黏合剂,工程师往往使用select在程序中对channel进行组合,以实现多个代码块的拼装。select语句可以高效地等待事件,从几个满足条件的case中均匀、随机地选择一个,并在没有满足条件的情况下继续等待。
注意
从本质上来讲,channel比内存共享同步访问方式更具有可组合性。
Go语言的goroutine不是系统线程,也不是语言运行时管理的绿色线程,而是一种抽象层次更高的线程,一般称为协程。goroutine是一种非抢占式的简单并发运行的函数、闭包或方法,也就是说goroutine是不可以被中断的,但是可以暂停或重新进入。goroutine与Go语言的运行时包高度集成,goroutine没有对外暴露暂停或再运行点。
goroutine的运行机制是基于M:N的调度方式实现的,即有M个Go语言运行时绿色线程映射到N个操作系统线程,而goroutine运行在绿色线程之上。若goroutine数量超过绿色线程数,调度程序会行使调度作用,确保部分goroutine阻塞且让之前处于等待状态的程序运行,如此往复,充分利用。接下来会继续此话题,深入讨论MPG模型。
系统线程、运行时绿色线程、goroutine,这是前面提到的几个名词,也许前文的介绍不够形象,所以此处再通过M、P、G这几个对象进行深入的介绍。那么,M、P、G分别代表什么呢?
▪M:Machine,一个M关联一个内核线程。
▪P:Processor,用户代码的逻辑上的处理器,是M和G调度所需要的上下文,P的数量由GOMAXPROCS决定,通常来说是CPU的核芯数。
▪G:goroutine,是运行在运行时绿色线程之上的轻量级线程。
M、P、G三者的关系如图8-4所示。
图8-4展示的是两个系统线程,也就是两个M。每个M对应一个内核线程,同时每个M还会连接一个P作为上下文,这个P是逻辑上的处理器,P上面可以运行一个或多个goroutine。P就是前面说的运行时绿色线程,作为内核线程的M是不能直接运行goroutine的,要先有上下文。
P的数量由启动时环境变量GOMAXPROCS的值确定,通常情况下,此数量在程序运行期间不会改变。既然P的数量在运行时基本不变,那么也就意味着用来运行goroutine的运行时绿色线程是不变的,或者说运行Go代码的上下文是不变的,比如四核的处理器上就有四个运行时绿色线程运行Go代码。
在图8-4中,运行的goroutine是左侧的M、P、G,而右侧带底纹的G代表在等待状态的goroutine,可以看到,处于等待状态的goroutine排成了一个队列。
为什么一定需要P呢,让goroutine直接运行在M上不可以吗?当然不可以,因为如果goroutine直接挂载在M上,就会严重依赖于M,一旦某个M阻塞,很可能会导致很多goroutine阻塞。而当中有了P这一层,即便有某个M阻塞,只要还有M可以运行,那么P就可以带着上下文信息便利地在M上开始运行。
P在运行的过程中还起着负载均衡的作用,一旦自己的goroutine队列运行完毕,还会去找其他的P的队列,会从其他队列分一部分过来。