go 切换当前运行目录_Go的并发机制:线程模型

目录

一、 Go的并发机制:线程模型

二、 Go的并发机制:goroutine、channel(待续)

最近在使用Golang开发一个项目,也是第一次使用Go,属于边学边用,刚开始使用觉得Go非常简洁易学,项目开发到阶段,需要用涉及到并发相关的知识了,如果是其他语言感觉没什么,多线程就完了。但是Go主打的就是它的并发机制,goroutine的名字在未使用Go的时候已经知道了。

由于我的项目是完全处于个人兴趣,没有人会催进度,所以我觉得在使用goroutine之前,还是需要学习一下,读懂里面的实现原理。而我们的需求是并发/并行,所以一切从它开始。

并发和并行

在讨论并发(Concurrent)的时候,必定会引出另一个概念,并行(parallel)。先发一张大神的图解:

f404ab185d9cdfce803a2f693edd1449.png

并发与并行

并发,两队人交替的使用一台咖啡机,而并行有两台咖啡机。就像我们平时写了多个任务,使用了多个线程去执行,这时候可以说你的程序是并发的,但是不一定是并行的,这取决于CPU是但和还是多核。

要了解Go并发机制,先要了解它的线程模型,在开始之前还是要熟悉一下有关线程的知识。

对于一个CPU来说,每时每刻都在为调度线程(或者说切换上下文)而忙碌,CPU有两个存放线程的队列,一个是存放等待运行的线程队列,另一个是正在运行的队列。调度器会被时间划分为很小的时间片段分配给线程,这样每个线程都可以在CPU上面运行,但是同一时间只能运行一个,如果线程因为一些原因而导致他进入睡眠状态,调度器会把它在队列移除,等待它被唤醒,就会重新加入到队列,继续执行。

一般的用户程序是无法直接来操作线程的,能操作线程的一般叫做系统调用,系统调用属于内核的一部分,出于系统的安全考虑,这和普通的程序调用是不一样的,可以说操作系统划分了用户空间和内核空间,当运行用户程序是,CPU出于用户空间,这时候它的身份是用户,只能访问用户空间的资源。

线程模型

正因为划分了两个割裂的空间,所以就分别有了内核线程和用户线程。用户线程再内核线程之上,这两种线程并不都是一对一的,在实现中,需要将用户线程根据不同的策略映射到内核线程,根据他们的映射关系不同可以分为下面三种线程模:

  • 用户级线程模型
    在此模型下,调度器可调度的最小单元就是 进程 ,所以内核线程不知道用户程序线程(后面统一叫用户线程)的存在,所以这些用户线程的管理由用户程序来完成,因此它们的切换速度也极快,由于进程下所有用户线程都只能对应同一内核线程,所以这种此模型有一个用户线程被阻塞(例如执行I/O操作),这个进程就会完全停下来,因为内核无法调度里面的用户线程,即使存在多个CPU,用户线程也无法分配给其他CPU执行,完全无法发挥出多核的优势,这种模型在现代的操作系统都已不再使用,由于看起来像是多个用户线程对应一个内核线程,所以这种模型也叫做(M:1)线程实现。
  • 内核级线程模型
    在此模型下的用户线程与内核线程一一对应,也就是说完全接管了用户线程,它也属于内核的一部分,统一由调度器来创建、终止和切换。这样就能完全发挥出多核的优势,多个线程可以跑在不同的CPU上,实现真正的并行。但也正由于一切都由内核来调度,这样大大增加了工作量,线程的切换是非常耗时的,而且创建也很用到更多的资源,所以也大大减少能创建线程的数量。由于是一对一的关系所以也叫(1:1)线程实现。
  • 两层线程模型
    或者叫混合线程模型(M:N),这种模型结合了以上两种的优点。同一个 进程 可以关联多个内核线程,这和内核级线程模型类似,不同的是用户线程与内核线程并不是一一对应关系,实现了此模型的线程库会将多个用户线程关联到一个内核线程上,当一个内核线程阻塞,可以通过线程库将部分用户线程再关联到其他的内核线程,由于允许多对多或一对一操作,所以也叫两层模型。这种设计增加了复杂性,因为管理线程的工作需要内核和用户线程库协同完成,但是也因为这后只能怪灵活的设计,使内核资源的消耗可以大大减少,同时也使线程调度效率提高(类似用户级线程模型)。这种模型的实现是要体现在语言层面的,例如Java,是否支持此模型取决于JVM的实现,当然其他两种模型也是可以支持。
c332f4fb82ad544ddf8527462dcad10b.png

三种线程模型实现

Go的线程模型

Go在内核线程之上, 搭建了一个特有的两层线程模型,这个特有的两层线程模型架构主要由G(goroutine)、M(machine)、P(processer)再加一个调度器(schedt)组成。简单的介绍它们的关系:M和P的存在,都是为了执行G,而我们写的支持并发的代码块,通过G的包裹,挂到一个P上,获得了执行所需的上下文环境。等待调度器将P于M关联,而M是和核心线程对应的,这样G就能被调度到内核线程上执行。

357ecf78768633518aeda0d9a6889347.png

GMP关系图

上图实现表示关系是固定的,虚线表示不固定。M与核心线程是固定的,在它的生命周期内不会变更,而M与P,P与G关系都是可变。下面深入了解它们每一个独立个体,这部分内容时,我是同步阅读Go的源代码 https://github.com/golang/go.git

,这样更方便理解。

说到Go的运行时源代码,不得不提Go是实现了自举的语言, 它的运行时和编译器源代码也是用Go语言写的,我觉得自举的过程还是比较有趣的一起分享下:Go最早使用C语言编写它的编译器,然后使用Go编写了另一个程序,这个程序功能和前面的编译器一样,接着使用C语言写的编译器去编译这个程序,这样就得到了一个Go语言版本的编译器。当然实际没有说的这么简单,当中还要进行大量的测试和优化,这是个不错的想法,后面可以考虑写个demo试一试。

M(machine)

可以理解为一个M就对应一个内核线程。看到位于源码 runtime/runtime2.go 看下M的结构

type m struct {  g0  *g     // goroutine with scheduling stack  mstartfn func()  curg  *g       // current running goroutine  p  puintptr // attached p for executing go code (nil if not executing go code)  nextp  puintptr  ...  spinning  bool // m is out of work and is actively looking for work}

M结构里有几十个字段,这里挑选相关的几个,字段 g0 :每个M都有一个特殊的gorountine,用于执行一些运行时任务,g0在源代码中也大量出现,它并不是由Go程序创建的,而是由Go的运行时系统初始化M的时候分配的。字段 mstartfn : M的起始函数,起始就是编写go语句携带的函数。字段 curg :保存当前M正在执行的那个G,字段 p :保存正在与当前M关联的P,字段 nextp :保存M与潜在关联的P,在某些情况下,运行时重新启动了M,这时候M会把这个的P关联在一起。字段 spinning :布尔类型,如果为真,表示当前M正在不停的到处去寻找可运行的G,直到找到为止,所以字段的名字才叫自旋(这部分内容下面会详细介绍)。M在创建的时候会被加入到一个运行时的全局列表( runtime.allm ),这样做是为了方便管理。M的数量是有限制的,限制数量的初始值是10000个,正因为它与内核线程一一对应,新建一个M就等于需要一个内核线程对应,一个线程需要分配的内存以MB为单位,况且用户程序是能使用用户空间的虚拟内存,所以基本上是不会达到这个值。

P(processer)

Go运行时的调度器会根据不同的情况,让P与不同的M进行关联,尽量使那些等待运行的G都有就会在M上运行。看下P的结构:

type p struct {  ...  status  uint32 // one of pidle/prunning/...  m   muintptr   // back-link to associated m (nil if idle)  runq  [256]guintptr    // runnext, if non-nil, is a runnable G that was ready'd by    // the current G and should be run next instead of what's in    // runq if there's time remaining in the running G's time    // slice. It will inherit the time left in the current time    // slice. If a set of goroutines is locked in a    // communicate-and-wait pattern, this schedules that set as a    // unit and eliminates the (potentially large) scheduling    // latency that otherwise arises from adding the ready'd    // goroutines to the end of the run queue.  runnext  guintptr    // Available G's (status == Gdead)  gFree struct {      gList      n int32    }  ...}

P结构的字段也有很多,这里也挑出几个,字段 m :表示与当前P关联的M,如果当前P是空闲的,此值为零值nil。字段 runq :当前P维护一个用于存放可运行的G的队列。字段 runnext :当前准备运行的G。字段 gFree :用于存放空闲的G的队列。可以看到P中有两个队列:可运行G和空闲G队列。当一个G初始化完成变得可运行后便会加入到P的 runq 队列,当前一个G运行完成后,就会放到 runnext 空闲队列中。在结构中不难看出,同一时间一个P只能关联一个M,这个关联关系是灵活变化的,受控于运行时的调度器。例如一个P已关联了一个M,取出队列的最新G正在运行,这时候G正在进行一些I/O操作阻塞,调度器会考虑将当前P与M分离,与其他空闲的M关联(如果没有空闲M则创建一个)。这种调度发生在Go运行时。和M一样,Go运行时也拥有一个P的列表,里面存放所有的P。

另外,调度器分别存放了空闲的M列表(schedt.midle)、空闲的P列表(schedt.pidle)和空闲的G列表(schedt.gFree),当一个P与M不再关联(可能是G已全部执行完毕,也可能是发生STW),运行时会把它放入空闲列表,等到需要新的P与M关联,再拿出来。至于schedt.gFree,上面介绍了已执行完的G存放在当前P的 gFree 空闲列表,当G存放到一定数量,调度器会把它移到调度器里的空闲G列表。

当前用户使用go去启动一个函数,运行时会从P的空闲G列表获取一个,如果没有,再尝试从调度器的空闲列表获取,都没有才会创建一个新的G。可以看出,Go的线程模型中,把一切的资源尽可能的复用来提升效率。

P的最大数量默认是CPU的核数,上限是256个。因为P中已经存放了一个G的队列,我猜是Go的开发者觉得可以满足需要,设置更多的P反而在运行时增加复杂性。

P结构还有一个字段 status 没介绍,表示P是有状态的, runtime2.go 文件中:

const (    // P status    _Pidle = iota    _Prunning    _Psyscall    _Pgcstop    _Pdead)
  • _Pidle:当前P处于空闲状态,没有与任何M关联。
  • _Prunning:表示当前P与关联了一个M,需要注意的是关联意味着M拥有了当P,只有该M能修改P的状态。
  • _Psyscall:当前P中运行的G正在进行系统调用(syscall)。
  • _Pgcstop:表示进行STW(stop the world,运行时需要执行一些特殊的任务,需要停止调度器工作),需要停止调度。
  • _Pdead:表示当前P已经不再使用,一个处于pdead状态的P,只有面临被释放掉资源的命运。
    P的初始化函数源码 runtime/proc.go:func (pp *p) init(id int32) ,初始化时status值为 _Pgcstop ,初始化完成后,运行时会把它设置为 _Pidle ,放入到空闲列表,等待调用,与某个M关联后状态变为 _Prunning ,如果进入系统调用会转变为 _Psyscall ,等到调用完成状态又变为 _Pgcstop 。

G(goroutine)

Go编写一个段并发代码很简单:

go func() {fmt.Printf("go")}()

只要在普通的函数前面加一个 go 关键字,运行时就会按我们的要求,对函数进行并行执行(也许)。而运行时后面的底层逻辑从G、P、M、内核线程传递。除了G,其他的上面已经介绍。

goroutine与现有的线程、协程等概念并不一样,在这里我们可以理解为一个G,它是执行上面func函数的基础。

运行时遇到 go ,来到源代码 runtime/proc.go

func newproc(siz int32, fn *funcval) {    argp := add(unsafe.Pointer(&fn), sys.PtrSize)    gp := getg()    pc := getcallerpc()    systemstack(func() {        newg := newproc1(fn, argp, siz, gp, pc)        _p_ := getg().m.p.ptr()        runqput(_p_, newg, true)        if mainStarted {            wakep()        }    })}

第二个参数是go后面的函数,接着调用了 getg() 函数,获取当前的G。继续往下主要逻辑在 newproc1(...) 实现。此函数先检查入参合法性,尝试从P的空闲列表获取一个G,如果没有再尝试从调度器的空闲列表获取,都没有就新创建一个G。对G进行初始化,并加入到运行时的全局G列表 runtime.allgs 。 go 后面的函数会被放在字段 g.startpc 。G还有一个字段 m 用于记录当前关联的M的指针。

G也一样是有状态的,挑选其中涉及到的一部分介绍:

const (    // G status    _Gidle = iota // 0    _Grunnable // 1    _Grunning // 2    _Gsyscall // 3    _Gwaiting // 4    _Gmoribund_unused // 5    _Gdead // 6    ...)
  • _Gidle:刚的到的G(新建或从空闲列表获取),尚未初始化的G。
  • _Grunnable:一个已初始化并已加入P的等待运行队列,准备运行。
  • _Grunning:表示当前G正在运行。
  • _Gsyscall:表示当前G正在进行系统调用。
  • _Gwaiting:表示当前G正在阻塞。
    -_Gdead:表示G正处于闲置状态,例如G的函数执行完成。
    G的状态会根据开发者编写的go函数变化,例如函数有I/O操作、time.sleep会导致进入 _Gwaiting 状态。当G进入 _Gdead 状态后,其实并不会真正死亡,它可以重新初始化被复用。
    普通函数如果想要并发执行,就要穿上G这个个强大的外壳,加入到P的待执行队列,到达M,通过M由最终的内核来执行。

调度器(schedt)

调度器也是Go线程模型组成的重要部分,上面多次出现这个词,本篇如没特别说明,调度器是指Go运行时下的schedt,要和内核的调度器区分开来。不过两者在职责上基本相同。

首先看下调度器的结构体,位于源代码 runtime/runtime2.go :

type schedt struct {  midle  muintptr // idle m's waiting for work  pidle  puintptr // idle p's  // Global cache of dead G's.  gFree struct {    lock    mutex    stack   gList // Gs with stacks    noStack gList // Gs without stacks    n       int32  }

上面也有提到过,调度器本身也是拥有G、P、M的空闲列表,这样可以提升调度效率。

17134ea42e46610efe69b63c41f8e96b.png

调度器一轮调用流程

调度器启动于 runtime.schedule() ,上图是调度器的一轮流程图,左边和右边的判断条件是当前M是否锁定了G,我们先来看左边的流程:其实现是在M中有个 lockedm 的字段,该字段保存一个G的指针。为什么要锁定G?出于某些原因,有些时候G中的代码需要在同一核心线程执行,由于M和内核线程是一对一不变,所以M锁定G即可。当前调度器遇到锁定G的情况,会调用 runtime.stoplockedm() 函数来停止M,当需要唤醒时,导调用``runtime.startlockedm()函数来继续执行:

// Stops execution of the current m that is locked to a g until the g is runnable again.// Returns with acquired P.func stoplockedm() {    _g_ := getg()    //判断当前M是否与G锁定    if _g_.m.lockedg == 0 || _g_.m.lockedg.ptr().lockedm.ptr() != _g_.m {        throw("stoplockedm: inconsistent locking")    }    //接触当前M与P之间的关联    if _g_.m.p != 0 {        // Schedule another M to run this p.        _p_ := releasep()        handoffp(_p_)    }    incidlelocked(1)    // Wait until another thread schedules lockedg again.    mPark()    status := readgstatus(_g_.m.lockedg.ptr())    if status&^_Gscan != _Grunnable {        print("runtime:stoplockedm: lockedg (atomicstatus=", status, ") is not Grunnable or Gscanrunnable")        dumpgstatus(_g_.m.lockedg.ptr())        throw("stoplockedm: not runnable")    }    acquirep(_g_.m.nextp.ptr())    //停止当前M,等待被唤醒    _g_.m.nextp = 0}======= 唤醒M的代码`runtime.startlockedm()`=======// Schedules the locked m to run the locked gp.// May run during STW, so write barriers are not allowed.//go:nowritebarrierrec//参数gp表示被锁定的G,也就是与需要唤醒的M关联的Gfunc startlockedm(gp *g) {    //当前G    _g_ := getg()    mp := gp.lockedm.ptr()    if mp == _g_.m {        throw("startlockedm: locked to me")    }    if mp.nextp != 0 {        throw("startlockedm: m has p")    }    // directly handoff current P to the locked m    incidlelocked(-1)    //取消关联P,把P转给将要唤醒的M    _p_ := releasep()    mp.nextp.set(_p_)    notewakeup(∓.park)    //因为唤醒了与gp锁定的M,所以这里停止当前M    stopm()}

接着继续看右边的M未锁定的流程,调度器会导出寻找可执行的G,实际上这也是调度器的日常工作。调度器首先会在P和全局列表获取,如果都没有,调用 runtime.findrunnable() 使M处于自旋的状态直到找到一个可执行的G,或者出现STW的情况导致调度器停止。

同一时刻只会出现一个M在自旋,而当找到一个可执行的G时,就会把这个G挂在自旋的M上。

调度器的工作远不止这些,要复杂的多,由于这里只是想写些初步的了解,就不全部记录了。

总结

Go为方便使用并发编程,提供了gorountie、select、channel的组合,这些是处于编程使用层面的,如果想深入去了解它的机制原理,就需要读懂它的线程模型,而它的模型框架实现是类似两层(混合)线程模型,所以也需要读懂内核的线程模型。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值