7

go进阶(1) -深入浅出goroutine并发运行调度机制

 1 year ago
source link: https://guisu.blog.csdn.net/article/details/129107148
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

并发指的是同时进行多个任务的程序,Web处理请求,读写处理操作,I/O操作都可以充分利用并发增长处理速度,随着网络的普及,并发操作逐渐不可或缺 

一、goroutine简述


     在Golang中一个goroutines就是一个执行单元,而每个程序都应该有一个主函数main也就是主Goroutines。

        协程也叫轻量级线程,为什么说是一个轻量级的线程呢?协程可以轻松创建上百万个而不会导致系统资源衰竭,而线程和进程通常不能超过1万个。在Go语言提供所有系统调用操作,都会出让CPU给其他goroutine,让轻量级线程的切换管理不依赖于系统的线程和进程,也不依赖CPU的核心数量。

线程和协程的概念

线程(Thread):有时被称为轻量级进程(Lightweight Process,LWP),是程序执行流的最小单元。一个标准的线程由线程ID,当前指令指针(PC),寄存器集合和堆栈组成。另外,线程是进程中的一个实体,是被系统独立调度和分派的基本单位,线程自己不拥有系统资源,只拥有一点儿在运行中必不可少的资源,但它可与同属一个进程的其它线程共享进程所拥有的全部资源。

线程拥有自己独立的栈和共享的堆,共享堆,不共享栈,线程的切换一般也由操作系统调度。

协程(coroutine):又称微线程与子例程(或者称为函数)一样,协程(coroutine)也是一种程序组件。相对子例程而言,协程更为一般和灵活,但在实践中使用没有子例程那样广泛。

d6d007ce2333454294d4b78f8f18aefe.png

和线程类似,共享堆,不共享栈,协程的切换一般由程序员在代码中显式控制。它避免了上下文切换的额外耗费,兼顾了多线程的优点,简化了高并发程序的复杂。

两者的区别:

相同点:

1、一个程序可以包含多个协程,可以对比于一个进程包含多个线程

2、和线程类似,共享堆,不共享栈

不同点:线程由操作系统调度和切换。协程切换由自己控制,避免了上下文切换额外消耗。

我们知道多个线程相对独立,有自己的上下文,切换受系统控制;而协程也相对独立,有自己的上下文,但是其切换由自己控制,由当前协程切换到其他协程,由当前协程来控制。

Goroutine和其他语言的协程(coroutine)在使用方式上类似,但从字面意义上来看不同(一个是Goroutine,一个是coroutine),再就是协程是一种协作任务控制机制,在最简单的意义上,协程不是并发的,而Goroutine支持并发的。因此Goroutine可以理解为一种Go语言的协程。同时它可以运行在一个或多个线程上。

goroutine是Go语言轻量级线程实现,由Go运行时(runtime)管理的goroutine 是Go语言中并发的执行单位。有点抽象,其实就是和传统概念上的”线程“类似,可以理解为”线程“。

Go语言中使用 goroutine 非常简单,只需要在函数或方法调用前加上go关键字就可以创建一个 goroutine,从而让该函数或方法在新创建的 goroutine 中执行。
go func

让这个函数并发执行非常简单:

go sum(1,1)

在一个函数调哦那个前面加上go关键字,这次调用就会在一个新的goroutine中并发执行。如果调用返回时,那么goroutine也自动结束了。(如该函数有返回值,那么这个返回值会被丢弃

 2、goroutine协程执行机制

goroutine协程执行机制:在主Goroutine结束之后其他的所有Goroutine都会直接退出。

Go程序从初始化main package并执行main()函数开始,当main()函数返回时,程序退出, 且程序并不等待其他goroutine(非主goroutine)结束。

,我们使用for循环中调用10次sum()函数,它们是并发执行,但是发现运行后,控制台啥也没有输出。

让主goroutine等待其它 goroutine:

主要特点:

  • 是一种轻量级“线程”
  • 最重要的是非抢占式多任务处理,有协程主动交出控制权。(非抢占就是正在执行的不允许中断)
  • 编译器/解析器/虚拟机层面的多任务
  • 多个协程肯在一个多个线程上运行

goroutine可能的切换点

  • I/O,select
  • channel
  • 函数调用(有时)
  • runtime.Gosched()
  • 只是参考,不能保证切换,不能保证在其他地方不切换。

3、WaitGroup:多个goroutine并发执行

WaitGroup让goroutine执行完后马上执行下一个goroutine

WaitGroup翻译为等待组,其实就是计数器,只要计数器中有内容将一直阻塞。

Go语言标准库中WaitGroup只有三个方法

    Add(delta int)表示向内部计数器添加增量(delta)其中参数delta可以是负数
    Done()表示减少WaitGroup计数器的值,应当在程序最后执行,相当于Add(-1)
    Wait()表示阻塞直到WaitGroup计数器为



newCodeMoreWhite.png

WaitGroup类似java的CountDownLatch工具,保证线程1、线程2....执行完之后再接着执行。

4、读写操作的重新排序

Go 可能会重排一些操作的执行顺序,可以保证在一个 goroutine 中操作是顺序执行的,但不保证多 goroutine 的执行顺序:



newCodeMoreWhite.png

如果你想保持多 goroutine 像代码中的那样顺序执行,可以使用 channel 或 sync 包中的锁机制等。

二、GO并发的实现原理


Go实现了两种并发形式:多线程共享内存和CSP(communicating sequential processes)并发模型。

第一种是大家普遍认知的:多线程共享内存。其实就是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并发模型,是通过goroutinechannel来实现的。

1、传统的线程并发模型:多线程共享内存

共享内存:线程不安全

//执行结果会小于5000, 出现了线程安全的问题,因为在并发的过程有可能会出现多个go同时执行counter++,这样只会有一个是有效的。

当多个协程操作一个变量时可能会出现冲突问题,也许会导致程序出异常也许不会,我们可以使用go run -race查看是否有竞争。

和大多数语言同样go也支持加锁保证线程的安全。

使用sync.Mutex对内容加锁:

当一个 goroutine 获取互斥锁之后,其他的 goroutine 将等待直至解锁。 

 1、互斥锁Mutex使用



newCodeMoreWhite.png

2、读写锁(RWMutex)

先看Go语言标准库中的API

var tex sync.RWMutex
tex.Lock()
tex.Unlock()

    读写锁分为两种:读锁和写锁。
    当一个 goroutine 获取到读锁之后,其他的 goroutine如果是获取读锁会继续获得锁,如果是获取写锁就会等待;

互斥锁的锁事同一时间只能一个goroutine运行,而读写锁表示在锁范围内数据的读写操作   

 共享内存:线程安全:回到上面的例子,通过加锁方式实现共享内存线程安全:



newCodeMoreWhite.png

读写锁使用场景

如果并发过程中绝大部分都是读操作,那么使用读写锁将会优于互斥锁,如果读操作并不是特别多那将不会有太大差别


读写锁使用原因

多个 goroutine 同时操作一个资源(临界区)的情况,这种情况下就会发生竞态问题(数据竞态)

2、Go的CSP并发模型:channel

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

    上面已经提到,Go并发的核心哲学是不要通过共享内存进行通信; 相反,通过沟通分享记忆。

    channel是Go提供goroutine间的通信方式,使用channel可以使多个goroutine之间通信。channel是进程内的通信方式,通过channel传递对象的过程和调用函数时的参数传递行为比较一致,比如也可以传递指针等。

如需跨进程通信,Go建议用分布式系统的方法来解决,如使用Socket或者HTTP等通信协议,Go语言在网络方面也有非常完善的支持。

channel是类型相关的,一个channel只能传递一种类型的值,这个类型需要在声 明channel时指定。

channel是Go语言中各个并发结构体(goroutine)之前的通信机制。 通俗的讲,就是各个goroutine之间通信的”管道“,有点类似于Linux中的管道。

1)、channel基本语法:

var channame chan ElementType
var channame chan <- ElementType //只写
var channame <- chan ElementType //只读
chanName := make(chan int) //无缓存channel
chanName := make(chan in,0) //无缓存channel
chanName := make(chan int,100) //有缓存channel

channel跟map类似的在使用之前都需要使用make进行初始化
ch1 := make(chan int, 5) 

未初始化的channel零值默认为nil

2)通信机制:

传数据用channel <- data,取数据用<-channel。例子:

  • 成对出现:在通信过程中,传数据channel <- data和取数据<-channel必然会成对出现,因为这边传,那边取,两个goroutine之间才会实现通信。
  • 阻塞:不管传还是取,必阻塞,直到另外的goroutine传或者取为止。
  • channel仅允许被一个goroutine读写。

 3)阻塞的通道,主协程和子协程之间通信:

newCodeMoreWhite.png

4)、两个子协程的通信

使用channel实现两个goroutine之间通信。

newCodeMoreWhite.png

5)、channel仅允许被一个goroutine读写。

newCodeMoreWhite.png

结果为:goRoutineA received the data 3

上面只是个简单的例子,只输出goRoutineA ,没有执行goRoutineB,说明channel仅允许被一个goroutine读写。

6)、一直阻塞的情况

  • 如果当前协程正在从一个没有任何值的通道中读取数据,那么当前协程会阻塞并且等待其他协程往此通道写入值。
  • 因此,读操作将被阻塞。类似的,如果你发送数据到一个通道,它将阻塞当前协程直到有其他协程从通道中读取数据。此时写操作将阻塞 。

主线程在进行通道操作的时候造成死锁:



newCodeMoreWhite.png

三、GO并发调度器模型的实现原理


 1、线程模型的实现

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

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

线程模型的实现,可以分为以下几种方式:

用户空间线程和内核空间线程之间的映射关系有:N:1,1:1和M:N

1)用户级线程模型N:1,多线程程序在一个核的cpu上运行

多个(N)用户线程始终在一个内核线程上跑,context上下文切换确实很快,但是无法真正的利用多核。

64e9a3ecea964d4fba79a587e10df17e.png

如图所示,多个用户态的线程对应着一个内核线程,程序线程的创建、终止、切换或者同步等线程工作必须自身来完成。它可以做快速的上下文切换。缺点是不能有效利用多核CPU。

2)内核级线程模型1:1,用户线程就只在一个内核线程

1:1是说,一个用户线程就只在一个内核线程上跑,这时可以利用多核,但是上下文switch很慢。

8b5dd3c8af294f65b24088a44e503658.png

这种模型直接调用操作系统的内核线程,所有线程的创建、终止、切换、同步等操作,都由内核来完成。一个用户态的线程对应一个系统线程,它可以利用多核机制,但上下文切换需要消耗额外的资源。C++就是这种。

3)两级线程模型M:N

M:N是说多个协程在多个内核线程上跑:

cf08df6f20d840b9bc26917d9cb3d4a6.png

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

M个用户线程对应N个系统线程,缺点增加了调度器的实现难度。

Go语言的线程模型就是一种特殊的两级线程模型(GPM调度模型)。

2、Goroutine的调度器演变过程

单线程调度器 – Go 0.X:程序中只能存在一个活跃线程。由G - M模型组成

多线程调度器 – Go 1.0:全局锁导致竞争严重

任务窃取调度器 – Go 1.1

改进点:引入了处理器P,构成了目前的G-M-P模型,在处理器P的基础上实现了基于工作窃取的调度器
缺陷点:在某些情况下Goroutine不会让出线程,进而导致饥饿问题垃圾收集机制时间过长
Go 1.1最大的特色之一就是这个新的调度器,2012 年由 Google 的工程师 Dmitry Vyukov提出。新调度器让并行的Go程序获得了一个动态的性能增长

抢占式调度 – Go 1.2至今

基于协作的抢占式调度器 – Go 1.2 ~ Go 1.13   

        改进:通过编译器在函数调用时插入抢占检查指令,在函数调用时检查当前Goroutine是否发起了抢占请求,实现基于协作的抢占式调度
        缺陷:Goroutine可能会因为垃圾收集和循环长时间占用资源导致程序暂停

基于信号的抢占式调度器 – Go 1.14 ~ 至今

       改进:实现了基于信号的真抢占式调度
       缺陷:垃圾收集在扫描线程时会触发抢占式调度,抢占的时间点不够多,不能覆盖所有边缘情况

3、Go线程实现模型MPG

Go的调度器内部有三个重要的结构:M,P,S

896dfaaa696d4f13a4bd8d650d44df27.png

M指的是Machine一个M直接关联了一个内核线程,是真正的内核OS线程。由操作系统管理。

P指的是”processor”,代表了M所需的上下文环境context,也是处理用户级代码逻辑的处理器。它负责衔接M和G的调度上下文,将等待执行的G与M对接。可以把它看做一个局部的调度器,使go代码在一个线程上跑。

G指的是Goroutine其实本质上也是一种轻量级的线程。它有自己的调用栈,instruction pointer(指令指针寄存器)重要的调度信息和其他信息例如channel等。

P的数量由环境变量中的GOMAXPROCS决定,通常来说它是和核心数对应,它其实也就代表了真正的并发度,即有多少个goroutine可以同时运行。例如在4Core的服务器上回启动4个线程。G会有很多个,每个P会将Goroutine从一个就绪的队列中做Pop操作,为了减小锁的竞争,通常情况下每个P会负责一个队列。

三者关系如下图所示:

6de22241447741ecb549917cef063e68.png

有2个物理线程M,每一个M都拥有一个context(P),每一个也都有一个正在运行的goroutine。

以上这个图讲的是

1)、两个物理线程(内核线程)M(一个M会对应一个内核线程)。

2)、每一个M都拥有一个context(P)一个上下文P相当于一个“处理器”

3)一个上下文context(P)连接一个或者多个Goroutine。为了运行goroutine,线程必须保存上下文。

上下文P(Processor)的数量在启动时设置为GOMAXPROCS环境变量的值或通过运行时函数GOMAXPROCS()。通常情况下,在程序执行期间不会更改。上下文数量固定意味着只有固定数量的线程在任何时候运行Go代码。我们可以使用它来调整Go进程到个人计算机的调用,例如4核PC在4个线程上运行Go代码。

图中为蓝色为P正在执行的Goroutine;灰色是处于ready(待执行状态)的Goroutine正在等待被调度,P维护着这个队列(称之为runqueue)。

Go语言里,启动一个goroutine很容易:go function 就行,所以每有一个go语句被执行,runqueue队列就在其末尾加入一个goroutine,一旦上下文运行goroutine直到调度点,它会从其runqueue中弹出goroutine,设置堆栈和指令指针并开始运行goroutine。

cf171593f50942b8a2f34e84dc28fdc6.png

为何不能抛弃上下文P(Processor)

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

答案是不行,需要上下文的目的,是让我们可以直接放开其他线程,当遇到内核线程阻塞的时候。当一个OS线程被阻塞时,P可以转而投奔另一个OS线程!

一个很简单的例子就是系统调用sysall,一个线程肯定不能同时执行代码和系统调用被阻塞,这个时候,此线程M需要放弃当前的上下文环境P,以便可以让其他的Goroutine被调度执行。

9222f2d1b31047e2ac6a04d403f1233b.png

如上图左图所示

1)线程M调度转换:当一个OS线程M0的G0执行了syscall就陷入阻塞时,然后M0丢弃了P,P转而在OS线程M1上运行,M1接受了P,将继续执行Goroutine队列中的其他Goroutine。调度器保证有足够的线程来运行所以的context P。

2)M0偷其他P:当系统调用syscall结束后,它必须尝试取得一个context P来运行goroutine,一般情况是M0会从其他OS线程steal“偷”一个上下文context,如果没有偷到,M0就把它的Gouroutine G0放到一个global runqueue中,将自己置于线程缓存中并进入休眠状态。

3)全局runqueue执行机制:全局runqueue是各个P在运行完自己的本地的Goroutine runqueue后用来拉取新goroutine的地方。P Contexts们也会周期性的检查这个全局runqueue上的goroutine,否则全局runqueue上的goroutines永远无法执行。

如何确保G得到均衡的分配工作

按照以上的说法,上下文P会定期的检查全局的goroutine 队列中的goroutine,以便自己在消费掉自身Goroutine队列的时候有事可做。假如全局goroutine队列中的goroutine也没了呢?就从其他运行的中的P的runqueue里偷。

每个P中的Goroutine不同导致他们运行的效率和时间也不同,在一个有很多P和M的环境中,不能让一个P跑完自身的Goroutine就没事可做了,因为或许其他的P有很长的goroutine队列要跑,得需要均衡。 该如何解决呢?

Go的做法倒也直接,从其他P中偷一半!这就确保了每个OS线程都能充分的使用。

cea7e01af6824dd9be4f19b75233ef90.png

四、Goroutine 小结


1、开销小

POSIX的thread API虽然能够提供丰富的API,例如配置自己的CPU亲和性,申请资源等等,线程在得到了很多与进程相同的控制权的同时,开销也非常的大,在Goroutine中则不需这些额外的开销,所以一个Golang的程序中可以支持10w级别的Goroutine。

每个 goroutine (协程) 默认占用内存远比 Java 、C 的线程少(*goroutine:*2KB ,线程:8MB)

2、调度性能好

在Golang的程序中,操作系统级别的线程调度,通常不会做出合适的调度决策。例如在GC时,内存必须要达到一个一致的状态。在Goroutine机制里,Golang可以控制Goroutine的调度,从而在一个合适的时间进行GC。

在应用层模拟的线程,它避免了上下文切换的额外耗费,兼顾了多线程的优点。简化了高并发程序的复杂度。

 大部分参考:golang_development_notes/9.5.md at master · guyan0319/golang_development_notes · GitHub


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK