add: golang gpm module

This commit is contained in:
vilet.yy 2021-05-28 18:19:03 +08:00
parent dc7f0d8a6e
commit ed7cb307f9
9 changed files with 139 additions and 0 deletions

View File

@ -0,0 +1,139 @@
---
layout: post
title: "Golang并发模型"
date: 2020-05-27 10:01:08
author: "Viletyy"
header-style: text
tags:
- Golang
---
Go语言相比Java等一个很大的优势就是可以方便地编写并发程序。Go语言内置了goroutine机制使用goroutine可用快速的开发并发程序更好的利用多核处理器资源。
### 线程模型
在现代操作系统中,线程数处理器调度和分配的基本单位,进程则作为资源拥有的基本单位。每个进程是由私用的虚拟地址空间、代码、数据和其他各种系统资源组成。线程是进程内部的一个执行单元。每一个进程至少有一个主执行线程,它无需由用户去主动创建,是由系统自动创建的。用户根据需要在应用程序中创建其他线程,多个线程并发地运行于同一个进程中。
无论语言层面何种并发模型到了操作系统层面一定是以线程的形态存在的。而操作系统根据资源访问权限的不同体系架构可分为用户空间和内核空间内核空间主要操作访问CPU资源、I/O资源、内存资源等硬件资源为上层应用程序提供最基本的基础资源用户空间呢就是上层应用程序的固定活动空间用户空间不可以直接访问资源必须通过“系统调用”“库函数”或“shell脚本”来调用内核空间提供的资源。
我们现在的计算机语言可以狭义的认为是一种“软件”它们中所谓的“线程”往往是用户态的线程和操作系统本身内核态的线程简称KSE还是有区别的。
线程可以视为进程中的控制流。一个进程至少会包含一个线程因为其中至少会有一个控制流持续运行。因而一个进程的第一个线程会随着这个进程的启动而创建这个线程称为该进程的主线程。当然一个进程也可以包含多个线程。这些线程都是由当前进程中已存在的线程创建出来的创建的方法就是调用系统调用更确切的说是调用phtread create函数。拥有多个线程的进程可以并发执行多个任务并且即使某个或某些任务被阻塞也不会影响其他任务正常执行这可以大大改善程序的响应时间和吞吐量。另一方面线程不可能独立于进程存在。它的生命周期不可能逾越其所属进程的生命周期。
线程的实现模型主要有3个分别是用户级线程模型内核级线程模型和两级线程模型。它们之间最大的差异就在于线程与内核调度实体Kernel Scheduling Entity简称KSE之间的对应关系上。顾名思义内核调度实体就是可以被内核的调度器调度的对象。在很多文献和书中它也成为内核级线程是操作系统内核的最小调度单元。
#### 内核级线程模型
用户线程与KSE是1对1关系1:1。大部分编程语言的线程库比如linux的pthread Java的java.lang.Thread, C++11的std::thread等等都是对操作系统的线程内核级线程的一层封装创建出来的每个线程与一个不同的KSE静态关联因此其调度完全由OS调度器来做。这种实现简单直接借助OS提供的线程能力并且不同用户线程直接一般也不会相互影响。但其创建销毁以及多个线程之间的上下文切换等操作都是直接由OS层面亲自来做在需要使用大量线程的场景下对OS的性能影响会很大。
![](/img/in-post/2020-05-27-golang-g-p-m-01.jpeg)
每个线程由内核调度器独立的调度,所以如果一个线程阻塞则不影响其他的线程。
优点:在多核处理器的硬件的支持下,内核空间线程模型支持了真正的并行,当一个线程被阻塞后,允许另一个线程继续执行,所以并发能力较强
缺点:每创建一个用户级线程都需要创建一个内核级线程与其对应,这样创建线程的开销比较大,会影响到应用程序的性能。
#### 用户级线程模型
用户线程与KSE是多对1关系M:1),这种线程的创建销毁以及多个线程之间的协调等操作都是用户自己实现的线程库来负责对OS内核透明一个进程中所有创建的线程都与同一个KSE在运行时动态关联。现在有许多语言实现的协程基本都属于这种方式。这种实现方式相比内核级线程可以做的很轻量级对系统资源的消耗会小很多因此可以创建的数量与上下文切换所花费的代价也会小得多。但该模型有个致命的缺点如果我们在某个用户线程上调用阻塞式系统调用如用阻塞方式read网络IO那么一旦KSE因阻塞被内核调度出CPU的话剩下的所有对应的用户线程全部都会变成阻塞状态整个进程挂起。所以这些语言的**协程库**会把自己的一些阻塞的操作重新封装为完全的非阻塞形式然后在以前要阻塞的点上主动让出自己并通过某种方式通知或唤醒其他待执行的用户线程在该KSE上运行从而避免了内核调度器由于KSE阻塞而做上下文切换这样整个进程也不会阻塞了。
![](/img/in-post/2020-05-27-golang-g-p-m-02.jpeg)
优点这种模型的好处是线程上下文切换都发生在用户空间避免的模态切换mode switch从而对于性能有积极的影响。
缺点所有的线程基于一个内核调度实体即内核线程这意味着只有一个处理器可以被利用在多处理器环境下这是不能够被接受的本质上用户线程只解决了并发问题但是没有解决并行问题。如果线程因为I/O操作陷入了内核态。内核态线程阻塞等待I/O数据则所有的用户级线程都将会被阻塞用户空间也可以使用非阻塞的I/O但是不能避免性能及复杂度问题。
#### 两级线程模型
用户线程与KSE是多对多关系MN这种实现综合了前两种模型的优点为一个进程中创建多个KSE并且线程可以与不同的KSE在运行时进行动态关联当某个KSE由于其上工作的线程的阻塞操作被内核调度出CPU时当前与其关联的其余用户线程可以重新与其他KSE建立关联关系。当然这种动态关联机制的实现很复杂也需要用户自己去实现这算是它的一个缺点。Go语言中的并发就是使用的这种实现方式Go为了实现该模型自己实现了一个运行时调度器来负责Go中的“线程”与KSE的动态关联。此模型有时也被称为**混合型线程模型**,即**用户调度器实现用户线程到KSE的“调度”内核调度器实现KSE到CPU上的调度**
![](/img/in-post/2020-05-27-golang-g-p-m-03.jpeg)
### Go并发调度G-P-M模型
在操作系统提供的内核线程上Go搭建了一个特用的两级线程模型。Goroutine机制实现了M:N的线程模型Goroutine机制是协程Coroutine的一种实现golang内置的调度器可以让多核CPU中每个CPU执行一个协程。
#### 调度器是如何工作的
开始真正的介绍Go的并发机制了先用一段代码展示一下在Go语言中新建一个“线程”Go语言中称为Goroutine的样子
```go
// 用go关键字加上一个函数这里使用匿名函数
// 调用就做到了在一个新的“线程”并发执行任务
go func() {
// do something
}()
```
功能上等价于Java8的代码
```java
new java.lang.Thread(() -> {
// do something
}).start();
```
理解Goroutine机制的原理关键是理解Go语言scheduler的实现
Go语言中职称整个scheduler实现的主要有4个重要结构分别是M、G、P、Sched前三个定义在runtime.h中Sched定义在proc.c中
- Sched结构就是调度器它维护有存储M和G的队列以及调度器的一些状态信息等。
- M结构是Machine系统线程它由操作系统管理的goroutine就是跑在M之上的M是一个很大的结构里面维护小对象内存cachemcache、当前执行的goroutine、随机数发生器等等非常多的信息。
- P机构是Processor处理器它的主要用途就是用来执行goroutine的它维护了一个goroutine队列即runquene。Processor是让我们从N:1调度到M:N调度到重要部分。
- G是Goroutine实现的核心结构它包含了栈指令指针以及其他对调度Goroutine很重要的信息例如其阻塞的channel
> Processor的数量是在启动时被设置为环境变量GOMAXPROCS的值或者通过运行时调用GOMAXPROCS()进行设置。Processor数量固定以为着任意时刻只有GOMAXPROCS个线程在运行go代码
我们分别用三角形矩形和圆形表示Machine、Processor和Goroutine
![](/img/in-post/2020-05-27-golang-g-p-m-04.jpeg)
在单核处理器的场景下所有Goroutine运行在同一个M系统线程中一个M系统线程维护一个Processor任何时刻一个Processor中只有一个Goroutine其他goroutine在runqueue中等待。一个goroutine运行完自己的时间片后让出上下文回到runqueue中。多核处理器的场景下为了运行goroutine每个M系统线程会持有一个Processor。
![](/img/in-post/2020-05-27-golang-g-p-m-05.jpeg)
#### 线程阻塞
当正在运行的goroutine阻塞的时候例如进行系统调用会再创建一个系统线程M1当前的M线程放弃了它的ProcessorP转到新的线程中去运行。
![](/img/in-post/2020-05-27-golang-g-p-m-06.jpeg)
#### runqueue执行完成
当其中一个Processor的runqueue为空没有goroutine可以调度。它会从另一个上下文偷取一半的goroutine
![](/img/in-post/2020-05-27-golang-g-p-m-07.jpeg)
> 图中的G、P和M都是Go语言运行时系统其中包括内存分配器并发调度器垃圾收集器等组件可以想象为Java中的JVM抽象出来概念和数据结构对象GGoroutine的简称上面用go关键字加函数调用的代码就是创建了一个G对象是对一个要并发执行的任务的封装也可以称作用户态线程。属于用户级资源对OS透明具备轻量级可以大量创建上下文切换成本低等特点。MMachine的简称在linux平台上是用clone系统调用创建的其与用linux pthread库创建出来的线程本质上是一样的都是利用系统调用创建出来OS线程实体。M的作用就是执行G中包装的并发任务。**Go运行时系统中的调度器的主要职责就是将G公平合理的安排到多个M上去执行**。其属于OS资源可创建的数量上也受限于OS。通常情况下G的数量都多于活跃的M。P: Processor的简称逻辑处理器主要作用是管理G对象每个P都有一个G队列并为G在M上的运行提供本地化资源。
从两级线程模型来看似乎不需要P的参与有G和M就可以了。为什么要加入P
---
其实Go语言运行时系统早期(Go1.0)的实现中并没有P的概念Go中的调度器直接将G分配到合适的M上运行。
但这样带来了很多问题。例如不同的G在不同的M上并发运行时可能都需向系统申请资源如堆内存由于资源是全局的将会由于资源竞争造成很多系统性能损耗为了解决类似的问题后面的GoGo1.1运行时系统加入了P让P去管理G对象M要想运行G必须先与一个P绑定然后才能运行该P管理的G。
---
这样带来的好处是我们可以在P对象中预先申请一些系统资源本地资源G需要的时候先向自己的本地P申请无需锁保护如果不够用或没有再向全局申请而且从全局拿的时候会多拿一部分以供后面高效的使用。就像现在我们去政府办事情一样先去本地政府看能搞定不如果搞不定再去中央从而提供办事效率。 而且由于P解耦了G和M对象这样即使M由于被其上正在运行的G阻塞住其余与该M关联的G也可以随着P一起迁移到别的活跃的M上继续运行从而让G总能及时找到M并运行自己从而提高系统的并发能力。
Go运行时系统通过构造G-P-M对象模型实现了一套用户态的并发调度系统可以自己管理和调度自己的并发任务所以可以说Go语言原生支持并发。自己实现的调度器负责将并发任务分配到不同的内核线程上运行然后内核调度器接管内核线程在CPU上的执行与调度。
可以看到Go的并发用起来非常简单用了一个语法糖将内部复杂的实现结结实实的包装了起来。其内部可以用下面这张图来概述
![](/img/in-post/2020-05-27-golang-g-p-m-08.png)
#### 调度示例
```go
func task1() {
go task2()
go task3()
}
```
假如我们有一个G(Goroutine1)以及通过P被安排到了一个M上正在执行在Goroutine1执行的过程中我们又创建了两个G这两个G会被马上放入与Goroutine1相同的本地G任务队列中排队等待与该P绑定的M执行这是最基本的结构。
关键问题是:
1. 如何在一个多核心系统上尽量合理分配G到多个M上运行充分利用多核提高并发能力
> 如果我们在一个Goroutine中通过go关键字创建了大量G这些G虽然暂时会被放在同一个队列但如果这时还有空闲P系统内P的数量默认等于系统cpu核心数Go运行时系统始终能保证至少有一个通常也只有一个活跃的M与空闲P绑定去各种G队列去寻找可运行的G任务该种M称为**自旋的M**。一般寻找顺序为自己绑定的P的队列全局队列然后其他P的队列。如果自己P队列找到就拿出来开始运行否则去全局队列看看由于全局队列需要锁保护如果里面有很多任务会转移一批到本地P队列中避免每次都去竞争锁。如果全局队列还是没有就要开始玩狠的了直接从其他P队列偷任务了偷一半任务回来。这样就保证了在还有可运行的G任务的情况下总有与CPU核心数相等的M+P组合 在执行G任务或在执行G的路上(寻找G任务)
2. 如果某个M在执行G的过程中被G中的系统调用阻塞了怎么办
> 在这种情况下这个M将会被内核调度器调度出CPU并处于阻塞状态与该M关联的其他G就没有办法继续执行了但Go运行时系统的一个监控线程(sysmon线程)能探测到这样的M并把与该M绑定的P剥离寻找其他空闲或新建M接管该P然后继续运行其中的G大致过程如下图所示。然后等到该M从阻塞状态恢复需要重新找一个空闲P来继续执行原来的G如果这时系统正好没有空闲的P就把原来的G放到全局队列当中等待其他M+P组合发掘并执行。
3. 如果某一个G在M运行时间过长有没有办法做抢占式调度让该M上的其他G获得一定的运行时间以保证调度系统的公平性
> 我们知道linux的内核调度器主要是基于时间片和优先级做调度的。对于相同优先级的线程内核调度器会尽量保证每个线程都能获得一定的执行时间。为了防止有些线程"饿死"的情况内核调度器会发起抢占式调度将长期运行的线程中断并让出CPU资源让其他线程获得执行机会。当然在Go的运行时调度器中也有类似的抢占机制但并不能保证抢占能成功因为Go运行时系统并没有内核调度器的中断能力它只能通过向运行时间过长的G中设置抢占flag的方法温柔的让运行的G自己主动让出M的执行权。 说到这里就不得不提一下Goroutine在运行过程中可以动态扩展自己线程栈的能力可以从初始的2KB大小扩展到最大1G64bit系统上因此在每次调用函数之前需要先计算该函数调用需要的栈空间大小然后按需扩展超过最大值将导致运行时异常。Go抢占式调度的机制就是利用在判断要不要扩栈的时候顺便查看以下自己的抢占flag决定是否继续执行还是让出自己。 运行时系统的监控线程会计时并设置抢占flag到运行时间过长的G然后G在有函数调用的时候会检查该抢占flag如果已设置就将自己放入全局队列这样该M上关联的其他G就有机会执行了。但如果正在执行的G是个很耗时的操作且没有任何函数调用(如只是for循环中的计算操作)即使抢占flag已经被设置该G还是将一直霸占着当前M直到执行完自己的任务。

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB