前引
Go
语言是为并发
而生的语言,Go
语言是为数不多的在语言层面实现并发编程的语言;也正是Go
语言的并发特性,吸引了全球无数的开发者。
并发vs并行
并发(concurrency)
- 两个或两个以上的任务在一段时间内被执行。我们不必在意这些任务在某一个时间点是否是同时执行,我们只关心在一段时间内,哪怕是很短的时间段(一秒或者两秒)是否执行解决了两个或两个以上任务。典型单核
CPU
执行逻辑。多个任务同时推进,交替执行。
并行(parallellism)
- 两个或两个以上的任务在同一时刻被同时执行。典型的多核
CPU
执行逻辑,多个线程在不同的CPU
上同时执行。
Go的CSP并发模型
Go
实现了两种并发形式。
- 第一种是大家普遍认知的:多线程共享内存。其实就是
Java
或者C++
等语言中的多线程开发。 - 第二种是
Go
语言特有的,也是Go
语言推荐的:CSP(communicating sequential processes)
并发模型。
CSP
并发模型是在1970年左右提出的概念,属于比较新的概念,不同于传统的多线程通过共享内存来通信,CSP
讲究的是“以通信的方式来共享内存”。
Do not communicate by sharing memory; instead, share memory by communicating.
不要以共享内存的方式来通信,相反,要通过通信来共享内存。
普通的线程并发模型,就是像Java
、C++
、或者Python
,他们线程间通信都是通过共享内存的方式来进行的。非常典型的方式就是,在访问共享数据(例如数组、Map、或者某个结构体或对象)的时候,通过锁来访问,因此,在很多时候,衍生出一种方便操作的数据结构,叫做“线程安全的数据结构”。例如Java提供的包java.util.concurrent
中的数据结构。
Go
中也实现了传统的线程并发模型,通过共享内存,核心就是锁。
Go的CSP并发模型
Go
的CSP并发模型,是通过goroutine
和channel
来实现的。
-
goroutine
是Go
语言中并发的执行单位。有点抽象,其实就是和传统概念上的线程类似,可以理解为线程。 -
channel
是Go
语言中各个并发结构体(goroutine
)之间的通信机制。 通俗的讲,就是各个goroutine
之间通信的管道,有点类似于Linux
中的管道。
生成一个goroutine
的方式非常的简单:go
一下,就生成了。
go func(){
...
}()
通信机制
- 通信机制
channel
也很方便,传数据用channel <- data
,取数据用<-channel
。 - 在通信过程中,传数据
channel <- data
和取数据<-channel
必然会成对出现,因为这边传,那边取,两个goroutine
之间才会实现通信。 - 而且不管传还是取,必阻塞,直到另外的
goroutine
传或者取为止。
这里有点像进程的同步与信号量的机制,两个进程要合作,一个进程的执行必须得等到另一个进程发来的信号才能进行;不然就一直
sleep
在那里。这里可以简单理解为:“协程的同步”
通信过程
goroutine
为矩形,channel
为箭头
- 左边的
goroutine1
作为生产者,向channel
发送数据,goroutine1
开始阻塞,等待有人接收。 - 这时候,右边的
goroutine2
作为数据的消费者,发起了接收操作。goroutine2也开始阻塞,等待别人传送到达。 - 两边
goroutine
都发现了对方,于是两个goroutine开始一传,一收。 - 这便是
Golang
CSP
并发模型最基本的形式。
Go并发模型的实现原理
我们先从线程讲起,无论语言层面何种并发模型,到了操作系统层面,一定是以线程的形态存在的。而操作系统根据资源访问权限的不同,体系架构可分为用户空间和内核空间;内核空间主要操作访问CPU
资源、I/O
资源、内存资源等硬件资源,为上层应用程序提供最基本的基础资源,用户空间呢就是上层应用程序的固定活动空间,用户空间不可以直接访问资源,必须通过系统调用、库函数或Shell脚本来调用内核空间提供的资源。
我们现在的计算机语言,可以狭义的认为是一种软件,它们中所谓的线程,往往是用户态的线程,和操作系统本身内核态的线程(简称KSE
),还是有区别的。
用户级线程模型
如图所示,多个用户态的线程对应着一个内进程(核线)程,用户级线程的创建、终止、切换或者同步等线程工作必须自身来完成。
内核级线程模型
这种模型直接调用操作系统的内核线程,所有线程的创建、终止、切换、同步等操作,都由内核来完成。C++就是这种。
用户级+内核级线程模型
这种模型是介于用户级线程模型和内核级线程模型之间的一种线程模型。这种模型的实现相对复杂,和内核级线程模型类似,一个进程中可以对应多个内核级线程,但是进程中的用户级线程不和内核级线程1 1
对应;这种线程模型会先创建多个内核级线程,然后用自身的用户级线程去对应内核级线程,自身的用户级线程需要自身程序去调度,内核级的线程交给操作系统内核去调度。
Go
语言的线程模型就是一种特有的两级组合线程模型。但是,goroutine
背后的支撑体系远没有这么简单。暂且叫它MPG
模型吧。
Go线程实现模型MPG
说起Go的线程实现模型,有三个必知的核心元素,它们支撑起了这个模型的主框架。
M:machine
的缩写。一个M
对应一个内核线程(“工作线程”)。P:processor
的缩写。一个P
代表执行一个Go
代码片段所必需的资源(或称"上下文环境")。G:goroutine
的缩写。一个G
代表一个Go
代码片段执行的基本单位。其实本质上也是一种轻量级的线程(协程)详情请查看Golang协程。
简单来说,一个G
的执行需要P
和M
的支持。一个M
在与一个P
关联之后,就形成了一个有效的G
运行环境(内核线程+上下文环境)。每个P
都会包含一个可运行的G
的队列(runq
)。该队列中的G
会被依次传递给与本地P关联的M,并获得运行时机。
以上这个图讲的是两个线程(内核线程)的情况。
- 一个
M
会关联一个内核线程,一个M
也会连接一个上下文P
。 - 一个上下文
P
相当于一个G
执行的"上下文切换环境+调度"。 - 一个上下文
P
连接一个或多个G
(goroutine
)。
P
(Processor)
的数量是在启动时被设置为环境变量GOMAXPROCS
的值,或者通过运行时调用函数runtime.GOMAXPROCS()
进行设置。
Processor
数量固定意味着任意时刻只有固定数量的线程在运行go
代码。Goroutine
中就是我们要执行并发的代码。
图中P
正在执行的Goroutine
为蓝色的;处于待执行状态的Goroutine
为灰色的,灰色的Goroutine
形成了一个队列runqueues
三者关系的宏观的图为:
-
M
与KSE
之间总是一对一的关系,一个M
能且仅能代表一个内核线程。 -
Go
的运行时系统(runtime system
)用M
代表一个内核调度实体。 -
M
与KSE
之间的关联非常稳固,一个M
在其生命周期内,会且仅会与一个KSE
产生关联。 -
M
与P
、P
与G
之间的关联都是易变的,它们之间的关系会在实际调度的过程中改变。 -
M
与G
之间也会建立关联,因为一个G
终归会由一个M
来负责运行;它们之间的关联会由P
来牵线。
调度G
抛弃P(Processor)
Q:你可能会想,为什么一定需要一个上下文P
,我们能不能直接除去上下文P
,让Goroutine
的runqueues
挂到M
上呢?
答案是不行,需要上下文
P
的目的是,当遇到内核线程阻塞的时候,让我们可以直接调度到其他线程。
一个很简单的例子就是系统调用sysall
,一个线程因为系统调用被阻塞,肯定不能同时执行其它代码,这个时候,此线程M
需要放弃当前的上下文环境P
,以便可以让队列中其他的G
(goroutine
)被调度到其它的M
中去执行。
- 左图
M0
中的G0
执行了syscall
,然后就创建了一个M1
(也有可能本身就存在,没创建)。 - 右图 然后
M0
丢弃了P
,等待syscall
的返回值。 -
M1
接受了P
,将继续执行G
队列中的其他goroutine
。
当系统调用
syscall
结束后,M0
会“偷”一个上下文P
,如果不成功,M0
就把它的G0
(gouroutine
) 放到一个全局的runqueue
中,然后自己放到线程池或转入休眠状态。全局runqueue
是各个P
在运行完自己的本地的G runqueue
后用来拉取新G
(goroutine
)的地方。各个P
也会周期性的检查这个全局runqueue
上的G
(goroutine
),否则,全局runqueue
上的goroutines
可能得不到执行而饿死。
G负载均衡
Q:上下文P
会定期的检查全局的goroutine
队列中的goroutine
,以便自己在消费掉自身goroutine
队列的时候有事可做。假如全局goroutine
队列中的goroutine
也没了呢?
答案是,就从其他运行的中的
P
的runqueue
里偷
。
Q:每个P
中的G
(goroutine
)不同导致他们运行的效率和时间也不同,在一个有很多P
和M
的环境中,不能让一个P
跑完自身的G
(goroutine
)就没事可做了,因为或许其他的P
有很长的goroutine
队列要跑;那如何做负载均衡呢?
答案是,
go
的做法倒也直接,从其他P
中偷一半!