Go并发原理
一、GO并发原理
1、并发(CONCURRENCY)和并行(PARALLELLISM)
并发(CONCURRENCY):两个或两个以上的任务在一段时间内被执行。我们不必care这些任务在某一个时间点是否是同时执行,可能同时执行,也可能不是,我们只关心在一段时间内,哪怕是很短的时间(一秒或者两秒)是否执行解决了两个或两个以上任务。
并行(PARALLELLISM):两个或两个以上的任务在同一时刻被同时执行。
并发说的是逻辑上的概念,而并行,强调的是物理运行状态。并发“包含”并行。
2、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并发模型,是通过goroutine和channel来实现的。
- goroutine 是Go语言中并发的执行单位。有点抽象,其实就是和传统概念上的”线程“类似,可以理解为”线程“。
- channel是Go语言中各个并发结构体(goroutine)之前的通信机制。 通俗的讲,就是各个goroutine之间通信的”管道“,有点类似于Linux中的管道。
生成一个goroutine的方式非常的简单:Go一下,就生成了。
go f();
通信机制channel也很方便,传数据用channel <- data,取数据用 <-channel
在通信过程中,传数据 channel <- data 和取数据 <-channel 必然会成对出现,因为这边传,那边取,两个goroutine之间才会实现通信。
而且不管传还是取,必阻塞,直到另外的goroutine传或者取为止。
有两个goroutine,其中一个发起了向channel中发起了传值操作。(goroutine为矩形,channel为箭头)
左边的goroutine开始阻塞,等待有人接收。
这时候,右边的goroutine发起了接收操作。
右边的goroutine也开始阻塞,等待别人传送。
这时候,两边goroutine都发现了对方,于是两个goroutine开始一传,一收。
这便是Golang CSP并发模型最基本的形式。
3、GO并发模型的实现原理
我们先从线程讲起,无论语言层面何种并发模型,到了操作系统层面,一定是以线程的形态存在的。而操作系统根据资源访问权限的不同,体系架构可分为用户空间和内核空间;内核空间主要操作访问CPU资源、I/O资源、内存资源等硬件资源,为上层应用程序提供最基本的基础资源,用户空间呢就是上层应用程序的固定活动空间,用户空间不可以直接访问资源,必须通过“系统调用”、“库函数”或“Shell脚本”来调用内核空间提供的资源。
我们现在的计算机语言,可以狭义的认为是一种“软件”,它们中所谓的“线程”,往往是用户态的线程,和操作系统本身内核态的线程(简称KSE),还是有区别的。
线程模型的实现,可以分为以下几种方式:
- 用户级线程模型
如图所示,多个用户态的线程对应着一个内核线程,程序线程的创建、终止、切换或者同步等线程工作必须自身来完成。 - 内核级线程模型
这种模型直接调用操作系统的内核线程,所有线程的创建、终止、切换、同步等操作,都由内核来完成。C++就是这种。 - 两级线程模型
这种模型是介于用户级线程模型和内核级线程模型之间的一种线程模型。这种模型的实现非常复杂,和内核级线程模型类似,一个进程中可以对应多个内核级线程,但是进程中的线程不和内核线程一一对应;这种线程模型会先创建多个内核级线程,然后用自身的用户级线程去对应创建的多个内核级线程,自身的用户级线程需要本身程序去调度,内核级的线程交给操作系统内核去调度。
Go语言的线程模型就是一种特殊的两级线程模型。暂且叫它“MPG”模型吧。
二、Go线程实现模型MPG
为了完成调度任务,Go Scheduler使用3个主要实体:
M指的是Machine,代表OS线程。它是由OS管理的执行线程,其工作方式与标准POSIX线程非常相似。在运行时代码中,它被称为M for machine。
P指的是”processor”,表示调度的上下文。您可以将其视为调度程序的本地化版本,该调度程序在单个线程上运行Go代码。这是让我们从N:1调度程序转到M:N调度程序的重要部分。在运行时代码中,它被称为P for processor。
G指的是Goroutine,代表一个goroutine。它包括堆栈,指令指针和其他对调度goroutine很重要的信息,就像它可能被阻塞的任何通道一样。其实本质上也是一种轻量级的线程。在运行时代码,它被称为G。
三者关系如下图所示:
这里我们看到2个线程(M),每个线程都有一个上下文(P),每个线程都运行一个goroutine(G)。为了运行goroutine,线程必须保存上下文。
上下文的数量在启动时设置为GOMAXPROCS环境变量的值或通过运行时函数GOMAXPROCS()。通常情况下,在程序执行期间不会更改。上下文数量固定的事实意味着只GOMAXPROCS在任何时候运行Go代码。我们可以使用它来调整Go进程到个人计算机的调用,例如4核PC在4个线程上运行Go代码。
灰色的goroutines没有运行,但已准备好安排。它们被安排在名为runqueues的列表中。只要goroutine执行go语句,Goroutines就会被添加到runqueue的末尾。一旦上下文运行goroutine直到调度点,它会从其runqueue中弹出goroutine,设置堆栈和指令指针并开始运行goroutine。
为了降低互斥争用,每个上下文都有自己的本地runqueue。以前版本的Go调度程序只有一个带有互斥锁保护它的全局runqueue。线程经常被阻塞,等待互斥锁解锁。当你有32台核心机器想要尽可能多地挤出性能时,这真的很糟糕。
只要所有上下文都有运行goroutine,调度程序就会在此稳定状态下继续进行调度。但是,有几种情况可以改变这种情况。
三者关系的宏观的图为:
抛弃P(Processor)?
你可能会想,为什么一定需要一个上下文,我们能不能直接除去上下文,让Goroutine的runqueues挂到M上呢?答案是不行,需要上下文的目的,是让我们可以直接放开其他线程,当遇到内核线程阻塞的时候。
一个很简单的例子就是系统调用sysall,一个线程肯定不能同时执行代码和系统调用被阻塞,这个时候,此线程M需要放弃当前的上下文环境P,以便可以让其他的Goroutine被调度执行。
在这里,我们看到一个线程放弃了它的上下文,以便另一个线程可以运行它。调度程序确保有足够的线程来运行所有上下文。 上图中的M1可能仅为处理此系统调用而创建,或者它可能来自线程缓存。系统调用线程将保持创建系统调用的goroutine,因为它在技术上仍在执行,尽管在操作系统中被阻止。
当系统调用返回时,线程必须尝试获取上下文才能运行返回的goroutine。正常的操作模式是从其他线程之一窃取上下文。如果它不能窃取一个,它会将goroutine放在全局runqueue上,将自己置于线程缓存中并进入休眠状态。
全局runqueue是一个runxue,当它们用完本地runqueue时,它们会从中获取。上下文还定期检查goroutines的全局runqueue。否则,由于饥饿,全局运行队列中的goroutine最终可能永远不会运行。
这种系统调用的处理是Go程序运行多个线程的原因,即使GOMAXPROCS是1.运行时使用调用系统调用的goroutine,留下线程。
均衡的分配工作
按照以上的说法,上下文P会定期的检查全局的goroutine 队列中的goroutine,以便自己在消费掉自身Goroutine队列的时候有事可做。假如全局goroutine队列中的goroutine也没了呢?就从其他运行的中的P的runqueue里偷。
每个P中的Goroutine不同导致他们运行的效率和时间也不同,在一个有很多P和M的环境中,不能让一个P跑完自身的Goroutine就没事可做了,因为或许其他的P有很长的goroutine队列要跑,得需要均衡。
该如何解决呢?
Go的做法倒也直接,从其他P中偷一半!