教程集 www.jiaochengji.com
教程集 >  Golang编程  >  golang教程  >  正文 golang并发原理剖析

golang并发原理剖析

发布时间:2021-12-22   编辑:jiaochengji.com
教程集为您提供golang并发原理剖析等资源,欢迎您收藏本站,我们将为您提供最新的golang并发原理剖析资源
<h1>概述</h1>

    Go语言是为并发而生的语言,Go语言是为数不多的在语言层面实现并发的语言;也正是Go语言的并发特性,吸引了全球无数的开发者。

<h1>并发并行</h1> <h2>    并发</h2>

       两个或两个以上的任务在一段时间内呗执行,我们不必care这些任务在某一个时间点是否同时执行,可能同时执行,也可能不是,我们只关心在一段时间内,哪怕是很短的时间(1秒或者两秒)是否执行解决了两个或两个以上的任务。

<h2>    并行 </h2>

       两个或两个以上的任务在同一时刻被同时执行。

      并发说的是逻辑上的概念,而并行,强调的是物理运行状态。并发包含并行。

<h1>线程模型</h1>

    线程的实现模型主要有3种:内核级线程模型、用户级线程模型和两级线程模型(也称混合型线程模型),它们之间最大的差异就在于用户线程与内核调度实体(KSE,Kernel Scheduling Entity)之间的对应关系上。而所谓的内核调度实体 KSE 就是指可以被操作系统内核调度器调度的对象实体。简单来说 KSE 就是内核级线程,是操作系统内核的最小调度单元,也就是我们写代码的时候通俗理解上的线程了。

<h2>   用户级线程模型</h2>

    用户线程与内核线程KSE是多对一(N : 1)的映射模型,多个用户线程的一般从属于单个进程并且多线程的调度是由用户自己的线程库来完成,线程的创建、销毁以及多线程之间的协调等操作都是由用户自己的线程库来负责而无须借助系统调用来实现。一个进程中所有创建的线程都只和同一个KSE在运行时动态绑定,也就是说,操作系统只知道用户进程而对其中的线程是无感知的,内核的所有调度都是基于用户进程。许多语言实现的 协程库 基本上都属于这种方式(比如python的gevent)。由于线程调度是在用户层面完成的,也就是相较于内核调度不需要让CPU在用户态和内核态之间切换,这种实现方式相比内核级线程可以做的很轻量级,对系统资源的消耗会小很多,因此可以创建的线程数量与上下文切换所花费的代价也会小得多。但该模型有个原罪:并不能做到真正意义上的并发,假设在某个用户进程上的某个用户线程因为一个阻塞调用(比如I/O阻塞)而被CPU给中断(抢占式调度)了,那么该进程内的所有线程都被阻塞(因为单个用户进程内的线程自调度是没有CPU时钟中断的,从而没有轮转调度),整个进程被挂起。即便是多CPU的机器,也无济于事,因为在用户级线程模型下,一个CPU关联运行的是整个用户进程,进程内的子线程绑定到CPU执行是由用户进程调度的,内部线程对CPU是不可见的,此时可以理解为CPU的调度单位是用户进程。所以很多的协程库会把自己一些阻塞的操作重新封装为完全的非阻塞形式,然后在以前要阻塞的点上,主动让出自己,并通过某种方式通知或唤醒其他待执行的用户线程在该KSE上运行,从而避免了内核调度器由于KSE阻塞而做上下文切换,这样整个进程也不会被阻塞了。

<h2>   内核级线程模型</h2>

    用户线程与内核线程KSE是一对一(1 : 1)的映射模型,也就是每一个用户线程绑定一个实际的内核线程,而线程的调度则完全交付给操作系统内核去做,应用程序对线程的创建、终止以及同步都基于内核提供的系统调用来完成,大部分编程语言的线程库(比如Java的java.lang.Thread、C 11的std::thread等等)都是对操作系统的线程(内核级线程)的一层封装,创建出来的每个线程与一个独立的KSE静态绑定,因此其调度完全由操作系统内核调度器去做,也就是说,一个进程里创建出来的多个线程每一个都绑定一个KSE。这种模型的优势和劣势同样明显:优势是实现简单,直接借助操作系统内核的线程以及调度器,所以CPU可以快速切换调度线程,于是多个线程可以同时运行,因此相较于用户级线程模型它真正做到了并行处理;但它的劣势是,由于直接借助了操作系统内核来创建、销毁和以及多个线程之间的上下文切换和调度,因此资源成本大幅上涨,且对性能影响很大。

<h2>  两级线程模型</h2>

   两级线程模型是博采众长之后的产物,充分吸收前两种线程模型的优点且尽量规避它们的缺点。在此模型下,用户线程与内核KSE是多对多(N : M)的映射模型:首先,区别于用户级线程模型,两级线程模型中的一个进程可以与多个内核线程KSE关联,也就是说一个进程内的多个线程可以分别绑定一个自己的KSE,这点和内核级线程模型相似;其次,又区别于内核级线程模型,它的进程里的线程并不与KSE唯一绑定,而是可以多个用户线程映射到同一个KSE,当某个KSE因为其绑定的线程的阻塞操作被内核调度出CPU时,其关联的进程中其余用户线程可以重新与其他KSE绑定运行。所以,两级线程模型既不是用户级线程模型那种完全靠自己调度的也不是内核级线程模型完全靠操作系统调度的,而是中间态(自身调度与系统调度协同工作),也就是 — 『薛定谔的模型』(误),因为这种模型的高度复杂性,操作系统内核开发者一般不会使用,所以更多时候是作为第三方库的形式出现,而Go语言中的runtime调度器就是采用的这种实现方案,实现了Goroutine与KSE之间的动态关联,不过Go语言的实现更加高级和优雅;该模型为何被称为两级?即用户调度器实现用户线程到KSE的调度,内核调度器实现KSE到CPU上的调度。

<h1>goroutine简介</h1>

    Goroutine,Go语言基于并发(并行)编程给出的自家的解决方案。goroutine是什么?通常goroutine会被当做coroutine(协程)的 golang实现,从比较粗浅的层面来看,这种认知也算是合理,但实际上,goroutine并非传统意义上的协程,现在主流的线程模型分三种:内核级线程模型、用户级线程模型和两级线程模型(也称混合型线程模型),传统的协程库属于用户级线程模型,而goroutine和它的Go Scheduler在底层实现上其实是属于两级线程模型,因此,有时候为了方便理解可以简单把goroutine类比成协程,但心里一定要有个清晰的认知 — goroutine并不等同于协程。

<h2>GO的CSP并发模型</h2>

Go提供了两种并发形式。第一种是大家普遍认知的:多线程共享内存。其实就是Java或C 等语言中的多线程开发。另外一种是Go语言特有的,也是Go语言推荐的:CSP(communicating sequential process)并发模型。

CSP并发模型是1970年左右提出的概念,属于比较新的概念,不同于传统的多线程通过共享内存来通信,CSP讲究的是“以通信的方式来共享内存”。不要以共享内存的方式来通信,相反,要通过通信来共享内存。

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

Go的CSP并发模型,是通过goroutine和channel来实现的。

<ul><li>goroutine是Go语言中并发的执行单位。有点抽象,其实就是和传统概念上的线程类似,可以理解为线程。</li><li>channel是Go语言中各个并发结构体之间的通信机制,就是各个goroutine之间通信的“管道”,有点类似Linux中的管道。</li></ul>

生成一个goroutine的方式非常的简单:Go一下,就生成了。通信机制channel也很方便,传数据channel<-data,取数据<-channel。

在通信过程中,传数据channel<-data和取数据<-channel必然会成对出现,因为这边传,那边取,两个goroutine之间才会实现通信。而且不管传还是取,必阻塞,直到另外的goroutine传或者取为止。这便是Golang CSP并发模型最基本的形式。

对于goroutine,每一个OS线程都有一个固定大小的内存块(一般会是2MB)来做栈,这个栈会用来存储当前正在被调用或挂起(指在调用其它函数时)的函数的内部变量。这个固定大小的栈同时很大又很小。因为2MB的栈对于一个小小的goroutine来说是很大的内存浪费,而对于一些复杂的任务(如深度嵌套的递归)来说又显得太小。因此,Go语言做了它自己的线程。在Go语言中,每一个goroutine是一个独立的执行单元,相较于每个OS线程固定分配2M内存的模式,goroutine的栈采取了动态扩容方式, 初始时仅为2KB,随着任务执行按需增长,最大可达1GB(64位机器最大是1G,32位机器最大是256M),且完全由golang自己的调度器 Go Scheduler 来调度。此外,GC还会周期性地将不再使用的内存回收,收缩栈空间。 因此,Go程序可以同时并发成千上万个goroutine是得益于它强劲的调度器和高效的内存模型。Go的创造者大概对goroutine的定位就是屠龙刀,因为他们不仅让goroutine作为golang并发编程的最核心组件(开发者的程序都是基于goroutine运行的)而且golang中的许多标准库的实现也到处能见到goroutine的身影,比如net/http这个包,甚至语言本身的组件runtime运行时和GC垃圾回收器都是运行在goroutine上的,作者对goroutine的厚望可见一斑。任何用户线程最终肯定都是要交由OS线程来执行的,goroutine(称为G)也不例外,但是G并不直接绑定OS线程运行,而是由Goroutine Scheduler中的 P - Logical Processor (逻辑处理器)来作为两者的中介,P可以看作是一个抽象的资源或者一个上下文,一个P绑定一个OS线程,在golang的实现里把OS线程抽象成一个数据结构:M,G实际上是由M通过P来进行调度运行的,但是在G的层面来看,P提供了G运行所需的一切资源和环境,因此在G看来P就是运行它的 “CPU”,由 G、P、M 这三种由Go抽象出来的实现,最终形成了Go调度器的基本结构:

 

<ul><li>G: 表示Goroutine,每个Goroutine对应一个G结构体,G存储Goroutine的运行堆栈、状态以及任务函数,可重用。G并非执行体,每个G需要绑定到P才能被调度执行。</li><li>P: Processor,表示逻辑处理器, 对G来说,P相当于CPU核,G只有绑定到P(在P的local runq中)才能被调度。对M来说,P提供了相关的执行环境(Context),如内存分配状态(mcache),任务队列(G)等,P的数量决定了系统内最大可并行的G的数量(前提:物理CPU核数 >= P的数量),P的数量由用户设置的GOMAXPROCS决定,但是不论GOMAXPROCS设置为多大,P的数量最大为256。</li><li>M: Machine,OS线程抽象,代表着真正执行计算的资源,在绑定有效的P后,进入schedule循环;而schedule循环的机制大致是从Global队列、P的Local队列以及wait队列中获取G,切换到G的执行栈上并执行G的函数,调用goexit做清理工作并回到M,如此反复。M并不保留G状态,这是G可以跨M调度的基础,M的数量是不定的,由Go Runtime调整,为了防止创建过多OS线程导致系统调度不过来,目前默认最大限制为10000个。</li></ul><h2>  实现原理</h2>

    

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

 

 

 

 

 

以上这个图讲的是两个线程(内核线程)的情况。一个M会对应一个内核线程,一个M也会连接一个上下文P,一个上下文P相当于一个处理器,一个上下文连接一个或者多个Goroutine。P的数量是在启动时被设置为环境变量GOMAXPROCS的值,或者通过运行时调用runtime.GOMAXPROCS()进行设置。Processor数量固定意味着任意时刻只有固定数量的线程在运行go代码。

Goroutine中就是我们要执行并发的代码。你可能会想,为什么一定需要一个上下文,我们能不能直接除去上下文,让Goroutine的runqueues挂到M上呢?答案是不行,需要上下文的目的,是让我们可以直接放开其它线程,当遇到内核线程阻塞的时候。

 

 

 

 

 

一个很简单的例子就是系统调用sysall,一个线程肯定不能同时执行代码和系统调用被阻塞,这个时候,此线程M需要放弃当前的上下文环境P,以便可以让其他的Goroutine被调度执行。如上图所示,M0中的GO执行了syscall,然后就创建了一个M1(也可能本身就存在,没创建),转向右图,然后M0丢弃了P,等待syscall的返回值,M1接收了P,将继续执行Goroutine队列中的其他Goroutine。

当系统调用syscall结束后,M0会“偷”一个上下文,如果不成功,M0就把它的Goroutine GO放到一个全局的runqueue中,然后自己放到线程池或者转入休眠状态。全局runqueue是各个P在运行完自己的本地的Goroutine runqueue后用来拉取新goroutine的地方。P也会周期性的检查这个全局runqueue上的goroutinr,否则,全局runqueue上的goroutines可能得不到执行而饿死。

 

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

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

该如何解决呢?

Go的做法倒也直接,从其他P中偷一半。

 

有两个支持并发的模型:CSP和Actor。golang选择了CSP,并把channel作为通信机制。为了避免多线程编程的不太友好,go为了提供更容易使用的并发方法,使用了goroutine和channel。goroutine来自协程的概念,让一组可复用的函数运行在一组线程之上,即使有协程阻塞,该县城成的其他协程也可以被runtime调度,转移到其他可运行的线程上。最关键的是,程序员看不到这些程序的底层细节这就降低了编程的难度,提供了更容易的并发。Go中,协程被称为goroutine,它非常轻量,一个goroutine只占几KB,并且这几KB就足够goroutine运行完,这就能在有限的内存空间内支持大量goroutine,支持了更多的并发,虽然goroutine的栈只占几KB,但实际是可伸缩的,如果需要更多内容,runtime会自动为goroutine分配。

P:Processor,它包含了运行goroutine的资源,如果线程想运行goroutine,必须 先获取P,P中还包含了可运行的G队列。

work straling:当M绑定的P没有可运行的G时,它可以从其他的M哪里偷取G。

现在,调度器中3个重要的缩写你都接触到了,并且所有文章都是用这3个缩写,请一定记住:

G:goroutine

M:工作线程

P:处理器,它包含了运行go代码的资源,M必须和一个P关联才能运行G

调度器的设计思想:

1.复用线程:协程本身就是运行在一组线程之上,不需要频繁的创建、销毁线程,而是对线程的复用。在调度器中复用线程还有 2个体现:

<ul><li>work stealing,当本线程无可运行的G时,尝试从其他线程绑定的P偷取G,而不是销毁线程。</li><li>hand off:当本线程因为G进行系统调用阻塞时,线程释放绑定的P,把P转移给其他空闲的线程执行。</li></ul>

2.利用并行:GOMAXPROCS设置P的数量,当GOMAXPROCS大于1时,就最多有GOMAXPROCS个线程处于运行状态,这些线程可能分布在多个CPU核上同时运行,使得并发利用并行。另外,GOMAXPROCS也限制了并发的程度,比如GOMAXPROCS=核数/2,则最多利用了一半的CPU核数进行并行。

 

调度器的两个小策略:

抢占:在goroutine中要等待一个协程主动让出CPU才执行下一个协程,在Go中,一个goroutine最多占用CPU 10ms,防止其他goroutine被饿死,这就是goroutine不同于coroutine的一个地方。

全局G队列:在新的调度器中依然有全局G队列,但功能已经被弱化了,当M执行work stealing从其他P偷不到G时,它可以从全局G队列获取G。

上面提到并行了,关于并发核并行再说一下,Go创始人Rob Pike一直在强调Go是并发,不是并行,因为Go做的是在一段时间内完成几十万、甚至几百万的工作,而不是同一时间同时在做大量的工作。并发可以利用并行提高效率,调度器是有并行设计的。

并行依赖多核技术,每个核上在某个时间只能执行一个线程。当我们的CPU有8个核时,我们能同时执行8个线程,这就是并行。

 

Scheduler的宏观组成:

1.全局队列(Global Queue):存放等待运行的G。

2.P的本地队列:同全局队列类似,存放的也是等待运行的G,存的数量有限,不超过256个。新建G时,G优先加入到P的本地队列,如果队列满了,则会把本地队列中一半的G移动到全局队列。

3.P列表:所有的P都在程序启动时创建,并保存了在数组中,最多有GOMAXPROCS个。

4.M:线程想运行任务就得获取P,从P的本地队列获取G,P队列为空,M也会尝试从全局队列拿一批G放到P的本地队列,或从其他P的本地队列偷一半放到自己P的本地队列。M运行G,G执行之后,M会从P获取下一个G,不断重复下去。

Goroutine调度器和OS调度器时通过M结合起来的,每个M都代表了一个内核线程,OS调度器负责把内核线程分配到CPU的核上执行。

 

调度器的生命周期:

接下来我们从另外一个宏观角度---生命周期、认识调度器。

所有的Go程序运行都会经过一个完整的调度器生命周期:从创建到结束。

1.创建最初线程M0

2.创建最初的goroutine g0

3.关联m0和g0

4.调度初始化

5.创建main函数的goroutine

6.启动m0

 

 

1.runtime创建最初的的线程m0和goroutine g0,并把2者关联。

2.调度器初始化:初始化m0、栈、垃圾回收,以及创建和初始化GOMAXPROCS个P构成的P列表。

3.示例代码中main函数是main.main,runtime中也有一个main函数,runtime.main,代码经过编译后,runtime.main会调用main.main,程序启动时会为runtine.main创建goroutine,称它为main goroutine吧,然后把main goroutine加入到P的本地队列。

4.启动m0,m0已经绑定了P,会从P的本地队列获取G,获取到main goroutine。

5.G拥有栈,M根据G中的栈信息和调度信息设置运行环境

6.M运行G

7.G退出,再次回到M获取可运行的G,这样重复下去,直到main.main退出,runtime.main执行defer和panic处理或调用runtine.exit退出程序。

调度器的生命周期几乎占满了一个Go程序的一生,runtime.main的goroutine执行之前都是为调度器做准备工作,runtime.main的goroutine运行,才是调度器的真正开始,直到runtime.main 结束而结束。

 

<h2>源码剖析</h2>

特点:

1.一个Goroutine在进行阻塞操作(比如系统调用)时,会把当前线程中的其他Goroutine移交到其他线程中继续执行,从而避免了整个程序的阻塞。

 

2.由于Golang引入了垃圾回收(gc),在执行gc时就要求Goroutine是停止的。通过自己实现调度器,就可以方便的实现该功能。 通过多个Goroutine来实现并发程序,既有异步IO的优势,又具有多线程、多进程编写程序的便利性。

 

3.引入Goroutine,也意味着引入了极大的复杂性。一个Goroutine既要包含要执行的代码,又要包含用于执行该代码的栈和PC、SP指针。

既然每个Goroutine都有自己的栈,那么在创建Goroutine时,就要同时创建对应的栈。Goroutine在执行时,栈空间会不停增长。栈通常是连续增长的,由于每个进程中的各个线程共享虚拟内存空间,当有多个线程时,就需要为每个线程分配不同起始地址的栈。这就需要在分配栈之前先预估每个线程栈的大小。如果线程数量非常多,就很容易栈溢出。

为了解决这个问题,就有了Split Stacks 技术:创建栈时,只分配一块比较小的内存,如果进行某次函数调用导致栈空间不足时,就会在其他地方分配一块新的栈空间。新的空间不需要和老的栈空间连续。函数调用的参数会拷贝到新的栈空间中,接下来的函数执行都在新栈空间中进行。

Golang的栈管理方式与此类似,但是为了更高的效率,使用了连续栈( Golang连续栈) 实现方式也是先分配一块固定大小的栈,在栈空间不足时,分配一块更大的栈,并把旧的栈全部拷贝到新栈中。这样避免了Split Stacks方法可能导致的频繁内存分配和释放。

Goroutine的执行是可以被抢占的。如果一个Goroutine一直占用CPU,长时间没有被调度过,就会被runtime抢占掉,把CPU时间交给其他Goroutine。

源码:

1.schedule函数

schedule函数在runtime需要进行调度时执行,为当前P寻找一个可以运行的G并执行它,寻找顺序如下:

1)调用runqget函数来从P自己的runnable G队列中得到一个可以执行的G;

2)如果1失败,则调用findrunnable函数去寻找一个可以执行的G

 

2.findrunnable

findrunnable函数负责给P寻找可以执行的G,它的寻找顺序如下:

1)调用runqget函数来从P自己的runnable G队列中得到一个可以执行的G;

2)如果1)失败,调用globrunqget函数从全局runnable G队列中得到一个可以执行的G;

3)如果2)失败,调用netpoll(非阻塞)函数取一个异步回调的G;

4)如果3)失败,尝试从其他P那里偷取一半数量的G过来;

5) 如果4)失败,再次调用globrungget函数从全局runnable G队列中得到一个可以执行的G;

6)如果5)失败,调用netpoll(阻塞)函数取一个异步回调的G;

7)如果6仍然没有取到G,纳秒调用stopm函数停止这个M

 

3.newproc函数

newproc函数负责创建一个可以运行的G并将其放在当前的P的runnable G队列,它是类似“go func(){...}”语句真正被编译器翻译后的调用,核心代码在newproc1函数:执行顺序:

1)获得当前G所在的P,然后从free G队列中取出一个G;

2)如果1)取到则对这个G进行参数配置,否则新建一个G;

3)将G加入P的runnable G队列。

 

4.goexist()函数

goexit函数是当G退出调用的。这个函数对G进行一些设置后,将它放入free G列表中,供以后复用,之后调用schedule函数调度。

 

5.handoffp函数

handoffp函数将P从系统调用或阻塞的M中传递出去,如果P还有runnable G队列,那么新开一个M,调用startm函数,新开的M不空旋。

 

6.startm函数

startm函数调度一个M或者必要时创建一个M来运行指定的P。

 

7.entersyscall_handoff函数

entersyscall_handoff函数用来在goroutine进入系统调用(可能会阻塞)时将P传递出去。

 

8.sysmon函数

sysmon函数是 Go runtime启动时创建的,负责监控所有goroutine的状态,判断是否需要GC,进行netpoll等操作。sysmon函数中会调用retake函数进行抢占式调度。

 

9.retake函数

retake函数是实现抢占式调度的关键,它的实现步骤如下:

1)遍历所有P,如果该P处于系统调用中且阻塞,则调用handoffp将其移交其他M;

2)如果该P处于运行状态,且上次调度的时间超过了一定的阈值,那么就调用preemptone函数这将导致该P中正在执行的G进行下一次函数调用时,导致栈空间检查是失败。进而触发morestack()(汇编代码,位于asm_XXX.s中)然后进行一连串的函数调用, 主要的调用过程如下:morestack()(汇编代码)-> newstack() -> gopreempt_m() -> goschedImpl() ->schedule()在goschedImpl()函数中,会通过调用dropg()将 G 与 M 解除绑定; 

再调用globrunqput()将 G 加入全局runnable队列中。最后调用schedule() 来为当前 P 设置新的可执行的 G 。

参考:golang并发编程

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

到此这篇关于“golang并发原理剖析”的文章就介绍到这了,更多文章或继续浏览下面的相关文章,希望大家以后多多支持JQ教程网!

您可能感兴趣的文章:
Go Ticker实现原理剖析(轻松掌握Ticker实现原理)
golang runtime 简析
理解 Golang 中函数调用的原理
golang垃圾回收
jQuery源码分析系列
深入解析HTML5 内联框架--iFrame
剖析使Go语言高效的5个特性(1/5): 变量的处理和存储
Go语言微服务开发框架实践-go chassis(中篇)
Go range实现原理及性能优化剖析
内存都是由半导体器件构成的_开启5G新时代——XPS成像技术在半导体器件中的应用...

[关闭]
~ ~