前引

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.
不要以共享内存的方式来通信,相反,要通过通信来共享内存。

普通的线程并发模型,就是像JavaC++、或者Python,他们线程间通信都是通过共享内存的方式来进行的。非常典型的方式就是,在访问共享数据(例如数组、Map、或者某个结构体或对象)的时候,通过来访问,因此,在很多时候,衍生出一种方便操作的数据结构,叫做“线程安全的数据结构”。例如Java提供的包java.util.concurrent中的数据结构。

Go中也实现了传统的线程并发模型,通过共享内存,核心就是锁。

Go的CSP并发模型

Go的CSP并发模型,是通过goroutinechannel来实现的。

  • goroutineGo语言中并发的执行单位。有点抽象,其实就是和传统概念上的线程类似,可以理解为线程
  • channelGo语言中各个并发结构体(goroutine)之间的通信机制。 通俗的讲,就是各个goroutine之间通信的管道,有点类似于Linux中的管道

生成一个goroutine的方式非常的简单:go一下,就生成了。

go func(){
	...
	}()

通信机制

  • 通信机制channel也很方便,传数据用channel <- data,取数据用<-channel
  • 在通信过程中,传数据channel <- data和取数据<-channel必然会成对出现,因为这边,那边,两个goroutine之间才会实现通信。
  • 而且不管还是必阻塞,直到另外的goroutine传或者取为止。

这里有点像进程的同步与信号量的机制,两个进程要合作,一个进程的执行必须得等到另一个进程发来的信号才能进行;不然就一直sleep在那里。这里可以简单理解为:“协程的同步

通信过程

goroutine为矩形,channel为箭头

  1. 左边的goroutine1作为生产者,向channel发送数据,goroutine1开始阻塞,等待有人接收。
  2. go语言并发网络 go并发编程_go并发原理

  3. 这时候,右边的goroutine2作为数据的消费者,发起了接收操作。goroutine2也开始阻塞,等待别人传送到达。
  4. go语言并发网络 go并发编程_golang_02

  5. 两边goroutine都发现了对方,于是两个goroutine开始一传,一收。
  6. go语言并发网络 go并发编程_go语言并发网络_03

  7. 这便是Golang CSP并发模型最基本的形式

Go并发模型的实现原理

我们先从线程讲起,无论语言层面何种并发模型,到了操作系统层面,一定是以线程的形态存在的。而操作系统根据资源访问权限的不同,体系架构可分为用户空间内核空间;内核空间主要操作访问CPU资源、I/O资源、内存资源等硬件资源,为上层应用程序提供最基本的基础资源,用户空间呢就是上层应用程序的固定活动空间,用户空间不可以直接访问资源,必须通过系统调用库函数Shell脚本来调用内核空间提供的资源。

我们现在的计算机语言,可以狭义的认为是一种软件,它们中所谓的线程,往往是用户态的线程,和操作系统本身内核态的线程(简称KSE),还是有区别的。

用户级线程模型

go语言并发网络 go并发编程_go并发原理_04

如图所示,多个用户态的线程对应着一个内进程(核线)程,用户级线程的创建、终止、切换或者同步等线程工作必须自身来完成。

内核级线程模型

go语言并发网络 go并发编程_go_05

这种模型直接调用操作系统的内核线程,所有线程的创建、终止、切换、同步等操作,都由内核来完成。C++就是这种。

用户级+内核级线程模型

go语言并发网络 go并发编程_go语言并发网络_06


这种模型是介于用户级线程模型和内核级线程模型之间的一种线程模型。这种模型的实现相对复杂,和内核级线程模型类似,一个进程中可以对应多个内核级线程,但是进程中的用户级线程不和内核级线程1 1对应;这种线程模型会先创建多个内核级线程,然后用自身的用户级线程去对应内核级线程,自身的用户级线程需要自身程序调度内核级的线程交给操作系统内核去调度。

Go语言的线程模型就是一种特有两级组合线程模型。但是,goroutine背后的支撑体系远没有这么简单。暂且叫它MPG模型吧。

Go线程实现模型MPG

说起Go的线程实现模型,有三个必知的核心元素,它们支撑起了这个模型的主框架。

  • M:machine的缩写。一个M对应一个内核线程(“工作线程”)。
  • P:processor的缩写。一个P代表执行一个Go代码片段所必需的资源(或称"上下文环境")。
  • G:goroutine的缩写。一个G代表一个Go代码片段执行的基本单位。其实本质上也是一种轻量级的线程(协程)详情请查看Golang协程。

简单来说,一个G的执行需要PM的支持。一个M在与一个P关联之后,就形成了一个有效的G运行环境(内核线程+上下文环境)。每个P都会包含一个可运行的G的队列(runq)。该队列中的G会被依次传递给与本地P关联的M,并获得运行时机。

go语言并发网络 go并发编程_go语言并发网络_07


以上这个图讲的是两个线程(内核线程)的情况。

  • 一个M会关联一个内核线程,一个M也会连接一个上下文P
  • 一个上下文P相当于一个G执行的"上下文切换环境+调度"。
  • 一个上下文P连接一个多个G(goroutine)。

P(Processor)的数量是在启动时被设置为环境变量GOMAXPROCS的值,或者通过运行时调用函数runtime.GOMAXPROCS()进行设置。

Processor数量固定意味着任意时刻只有固定数量的线程在运行go代码Goroutine中就是我们要执行并发的代码。

图中P正在执行的Goroutine蓝色的;处于待执行状态的Goroutine灰色的,灰色的Goroutine形成了一个队列runqueues

三者关系的宏观的图为:

go语言并发网络 go并发编程_goroutine_08

  • MKSE之间总是一对一的关系,一个M能且仅能代表一个内核线程。
  • Go的运行时系统(runtime system)用M代表一个内核调度实体。
  • MKSE之间的关联非常稳固,一个M在其生命周期内,会且仅会与一个KSE产生关联。
  • MPPG之间的关联都是易变的,它们之间的关系会在实际调度的过程中改变。
  • MG之间也会建立关联,因为一个G终归会由一个M来负责运行;它们之间的关联会由P来牵线。

调度G

抛弃P(Processor)

Q:你可能会想,为什么一定需要一个上下文P,我们能不能直接除去上下文P,让Goroutinerunqueues挂到M上呢?

答案是不行,需要上下文P的目的是,当遇到内核线程阻塞的时候,让我们可以直接调度到其他线程。

一个很简单的例子就是系统调用sysall,一个线程因为系统调用被阻塞,肯定不能同时执行其它代码,这个时候,此线程M需要放弃当前的上下文环境P,以便可以让队列中其他的G(goroutine)被调度到其它M中去执行。

go语言并发网络 go并发编程_go_09

  1. 左图 M0中的G0执行了syscall,然后就创建了一个M1(也有可能本身就存在,没创建)。
  2. 右图 然后M0丢弃了P,等待syscall的返回值。
  3. 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也没了呢?

答案是,就从其他运行的中的Prunqueue

Q:每个P中的G(goroutine)不同导致他们运行的效率和时间也不同,在一个有很多PM的环境中,不能让一个P跑完自身的G(goroutine)就没事可做了,因为或许其他P有很长的goroutine队列要跑;那如何做负载均衡呢?

答案是,go的做法倒也直接,从其他P中偷一半!

go语言并发网络 go并发编程_go并发原理_10