go并发机制学习

Go 原生支持高并发场景,其原因就是提供了goroutine(协程)以及底层提供的GMP调度器。

goroutine协程

协程与线程有什么区别?

(1)goroutine是非常轻量级的,它就是一段代码,一个函数入口,以及在堆上为其分配的一个堆栈(初始大小为2K,会随着程序的执行自动增长删除)。所以它非常廉价,我们可以很轻松的创建上万个goroutine。

(2)线程切换需要陷入内核,然后进行上下文切换,而协程在用户态由协程调度器完成,不需要陷入内核,这代价就小很多。协程的切换时间点是由协程调度器决定的,而不是系统内核决定的。


Go中提供了一个关键字 go 来让我们创建一个 Go 协程,当我们在函数或方法的调用之前添加一个关键字 go,这样我们就开启了一个 Go 协程,该函数或者方法就会在这个 Go 协程中运行。在默认情况下,每个独立的 Go 主程序运行时就创建了一个 Go 协程,其 main 函数就在这个Go 协程中运行,这个 Go 协程就被称为 go 主协程(main Goroutine,下面简称主协程)。

而使用go + 函数名是异步的,即它不会阻塞原有的执行流。即go出来的协程与原协程是并发执行的。

比如下面的例子:在main函数中go一个新协程,打印一句”do something“,但是实际运行中我们发现do something并没有打印,这是因为main函数在go 出新协程后并不会阻塞,而是继续运行。而当main结束后,他所go出来的协程还没来及做任何操作,就也被迫退出了。

在这里插入图片描述

倘若人为的阻塞main函数或者使用信道channel,就能看见协程的效果

time.Sleep(time.second)

知道协程自身是并发执行了以后,我们在来看一下go内部的线程模型

线程的实现模型主要有3种:内核级线程模型、用户级线程模型和混合型线程模型。它们之间最大的区别在于线程与内核调度实体KSE(Kernel Scheduling Entity)之间的对应关系上。内核调度实体KSE 就是指可以被操作系统内核调度器调度的对象实体,有些地方也称其为内核级线程,是操作系统内核的最小调度单元。

内核级线程模型

用户线程与KSE是1对1关系(1:1)。大部分编程语言的线程库(如linux的pthread,Java的java.lang.Thread,C++11的std::thread等等)都是对操作系统的线程(内核级线程)的一层封装,创建出来的每个线程与一个不同的KSE静态关联,因此其调度完全由OS调度器来做。这种方式实现简单,直接借助OS提供的线程能力,并且不同用户线程之间一般也不会相互影响。但其创建,销毁以及多个线程之间的上下文切换等操作都是直接由OS层面亲自来做,在需要使用大量线程的场景下对OS的性能影响会很大。

用户级线程模型

用户线程与KSE是多对1关系(M:1),这种线程的创建,销毁以及多个线程之间的协调等操作都是由用户自己实现的线程库来负责,对OS内核透明,一个进程中所有创建的线程都与同一个KSE在运行时动态关联。现在有许多语言实现的 协程 基本上都属于这种方式。这种实现方式相比内核级线程可以做的很轻量级,对系统资源的消耗会小很多,因此可以创建的数量与上下文切换所花费的代价也会小得多。但该模型有个致命的缺点,如果我们在某个用户线程上调用阻塞式系统调用(如用阻塞方式read网络IO),那么一旦KSE因阻塞被内核调度出CPU的话,剩下的所有对应的用户线程全都会变为阻塞状态(整个进程挂起)。

混合型线程模型

用户线程与KSE是多对多关系(M:N), 这种实现综合了前两种模型的优点,为一个进程中创建多个KSE,并且线程可以与不同的KSE在运行时进行动态关联,当某个KSE由于其上工作的线程的阻塞操作被内核调度出CPU时,当前与其关联的其余用户线程可以重新与其他KSE建立关联关系。Go语言中的并发就是使用的这种实现方式,Go为了实现该模型自己实现了一个运行时调度器来负责Go中的"线程"与KSE的动态关联。此模型有时也被称为 两级线程模型,即用户调度器实现用户线程到KSE的“调度”,内核调度器实现KSE到CPU上的调度。

GMP调度器

在这里插入图片描述
go中的二层线程模型中,有M,P,G等元素:

M:Machine的缩写,一个M代表了一个内核线程

P :Processor的缩写,一个P代表了M运行G所需要的资源,他与一个M结合,并能使一个协程在该M上运行。其个数是GOMAXPROCS个, 默认是与CPU核心数目相同。一个P通常管理着多个G形成的队列。

G:Goroutine的缩写,一个G代表了对一段需要被执行的Go语言代码的封装,使用go创建新协程来执行这段代码,创建出后挂在当前协程所在的G队列中,由队列管理员P决定谁进入对应的M执行。

在这里插入图片描述

G的运行需要M+P的支持,由M+P构成了G的运行时所需环境 (内核线程+资源)。同一时间 P仅能使一个G在M中执行,当该G运行结束后,由P决定下一个执行的G。

来看一些调度是可能遇到的问题:

某个G因为系统调用而阻塞了当前的M,如何保证同个队列中的其他G也能得到执行?

该种情况下,G在M中运行,而M被G中的syscall阻塞。此时,go运行时会有一个监控线程(sysmon线程)将该 P 与 M分离,让这个M继续处理当前G,而P去与其他M结合处理后面的G。当syscall结束后,该G会被添加至全局队列中,等待别的M+P组合来挖掘运行。

在这里插入图片描述

多核心系统中,怎么保证G合理分散到多个M上执行?是否会出现空闲的M无G可跑?(不会)

要是在某一个goroutine中调用go+函数名创建了许多新的协程,他们按照规定都会先挂在对应的P的队列下。但是如果系统中有空闲的P,go会创建一个新的M(又称自旋M),去寻觅空闲的P并结合,结合完毕后去找可以执行的G,找G的顺序是 结合P的队列,全局队列,别的P的队列。如果结合的P中无G可跑,则去全局队列中找G,如果全局中也没有,这个M+P组合就会从别的P中取一半的G来运行。

故可以认为 go会保证有足够的M与P结合并寻找可执行的G,不会让CPU闲着。同一时间能保证至少有与CPU核心数相等的 M + P组合在运行G或者在找G运行的路上。

如果当前G运行时间过长怎么办,是否会导致同一个队列中的其他G出现“饥饿问题”?(不会)

go中也是使用的基于时间片和优先级做调度手段,当某个G运行时间到达阈值时,go运行时体统会给该G标记一个抢占flag,当一个G在发生扩栈操作的时候,会顺道检查自己是否被标记了抢占flag,如果是,则让出M,让其他的G进入执行。

在文章开头我们提到过,一个G创建时仅2kb,开销非常低廉,而协程运行时的栈是按需分配的,故当扩栈+flag == true时就会让出,确保其他的G有机会运行。

那要是没有扩栈操作呢? = =这个就尴尬了,这个G会一直占据着M直至运行完成。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章