Go的并发机制:线程模型

目录

一、 Go的并发机制:线程模型
二、 Go的并发机制:goroutine和channel

背景

由于个人兴趣和学习的目的,在十月份自己开始着手开发一个公链项目,当然这属于一个练手的项目,其功能也有限,只能用于学习交流,并不能作为其他用途,在后面我也打算写这项目开发过程的一系列文章,项目的框架及介绍请看GitHub地址,如果你也有兴趣一起参与开发,欢迎fork:https://github.com/ReedWe/reed。

其实公链相关的技术,没有什么新的,但需要的知识面会比较广:共识机制、密码学、账号钱包、UTXO模型、P2P、虚拟机等等,由于目前是自己在开发,所以每一个知识点都需要较为深入的研究下。目前大多区块链项目开发语言大体都在C++、Go、Rust三种,而我自己选用的开发语言是Go。

虽然是从零开始使用Go,好在这类C家族的语言之间转换还是比较容易的,只是有一些它独有的特性需要翻阅资料。项目目前开发到节点相关部分,而这部分节点负责的工作是有点多的,自然要使用到并发实现,在之前也有听过Go的并发机制,貌似和其他语言很不一样,所以我决定花几天时间去深入了解里面的原理,反正也是看书籍和资料了,索性记录下来,当做是知识沉淀吧。

并发和并行

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


Go的并发机制:线程模型_第1张图片
并发与并行

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

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

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

线程模型

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

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

Go的线程模型

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

Go的并发机制:线程模型_第3张图片
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.allgsgo后面的函数会被放在字段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的空闲列表,这样可以提升调度效率。


Go的并发机制:线程模型_第4张图片
调度器一轮调用流程

调度器启动于runtime.schedule(),上图是调度器的一轮流程图,左边和右边的判断条件是当前M是否锁定了G,我们先来看左边的流程:M中有个lockedg字段,G中有个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\n")
        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关联的G
func 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(&mp.park)
    //因为唤醒了与gp锁定的M,所以这里停止当前M
    stopm()
}

接着继续看右边的M未锁定的流程,调度器会导出寻找可执行的G,实际上这也是调度器的日常工作。调度器首先会在P和全局列表获取,如果都没有,调用runtime.findrunnable()使M处于自旋的状态直到找到一个可执行的G,或者出现STW的情况导致调度器停止。
同一时刻只会出现一个M在自旋,而当找到一个可执行的G时,就会把这个G挂在自旋的M上。
调度器的工作远不止这些,要复杂的多,由于这里只是想写些初步的了解,就不全部记录了。

总结

在使用Go语言编写项目的时候,意识到后面需要用到并发相关的知识,听说在Go可以使用goroutine结合channel优雅的实现,这些是处于开发阶段如何合理地使用层面,如果想深入去了解它的机制原理,就需要读懂它的线程模型,而它的模型框架实现是类似两层(混合)线程模型,所以也需要读懂内核的线程模型。

你可能感兴趣的:(Go的并发机制:线程模型)