详解 Go 语言实现高并发的基石:goroutine

楔子

这次我们说一说 Go 的并发编程,并发可以说是 Go 语言的一个最大的卖点,因为它在语言层面上就支持并发,而且使用方式非常简单。

在早期,CPU 都是以单核的形式顺序执行机器指令,Go 语言的祖先 C 语言正是这种顺序编程语言的代表。顺序编程语言中的顺序是指:所有的指令都以串行的方式执行,在相同的时刻有且仅有一个 CPU 在顺序执行程序的指令。

但随着处理器技术的发展,在单核时代通过提升处理器频率来提高运行效率的方式遇到了瓶颈,目前各种主流的 CPU 频率基本被锁定在了 3GHZ 附近。单核 CPU 发展的停滞,给多核 CPU 的发展带来了机遇。相应地,编程语言也开始逐步向并行化的方向发展,而 Go 语言正是在多核和网络化的时代背景下诞生的原生支持并发的编程语言。

并发与并行

关于并发和并行的区别:

  • 并行: 在一个时间点可以执行多个任务
  • 并发: 在一个时间段可以执行多个任务

而受限于 CPU 处理器的核心数,显然不可能对所有任务都执行并行操作,所以我们经常听到的是"高并发",而不是"高并行"。因为"高并行"只能通过硬件方面实现,而"高并发"是可以通过代码层面解决的。

常见的并行编程有多种模型,主要有多线程、消息传递等。从理论上来看,多线程和基于消息的并发编程是等价的。由于多线程并发模型可以自然对应到多核的处理器,主流的操作系统因此也就提供了系统级的多线程支持,同时从概念上讲多线程似乎也更直观,因此多线程编程模型逐步被吸纳到主流的编程语言特性或语言扩展库中。

而主流编程语言对基于消息的并发编程模型支持则相对较少,Erlang 语言是支持该模型的代表者,但它的并发体之间不共享内存。而 Go 语言是基于消息的并发模型的集大成者,它将基于 CSP 模型的并发编程内置到了语言中,通过一个 go 关键字就可以轻易地启动一个 goroutine,与 Erlang 不同的是 Go 语言的 goroutine 之间是共享内存的。

goroutine 和系统线程

goroutine 是 Go 语言特有的并发体,是一种轻量级的线程(或者理解为协程),由 go 关键字启动。在真实的 Go 语言的实现中,goroutine 和系统线程也不是等价的。尽管两者的区别实际上只是一个量的区别,但正是这个量变引发了 Go 语言并发编程质的飞跃。

首先,每个系统级线程都会有一个固定大小的栈(一般默认可能是 2MB),这个栈主要用来保存函数调用时的参数和局部变量。固定了栈的大小会导致两个问题:一是对于很多只需要很小的栈空间的线程来说是一个巨大的浪费,二是对于少数需要巨大栈空间的线程来说又面临栈溢出的风险。针对这两个问题的解决方案是:要么降低固定的栈大小,提升空间的利用率;要么增大栈的大小以允许更深的函数递归调用,但这两者是没法同时兼得的。

相反,一个 goroutine 会以一个很小的栈启动(可能是 2KB 或 4KB),当遇到深度递归导致当前栈空间不足时,goroutine 会根据需要动态地伸缩栈的大小(主流实现中栈的最大值可达到 1GB)。因为启动的代价很小,所以我们可以轻易地启动成千上万个 goroutine。

Go 的运行时还包含了其自己的调度器,这个调度器使用了一些技术手段,可以在 n 个操作系统线程上调度 m 个 goroutine。Go 调度器的工作原理和内核的调度是相似的,但是这个调度器只关注单独的 Go 程序中的 goroutine。goroutine 采用的是半抢占式的协作调度,只有在当前 goroutine 发生阻塞时才会导致调度;同时发生在用户态,调度器会根据具体函数只保存必要的寄存器,切换的代价要比系统线程低得多。运行时有一个 runtime.GOMAXPROCS 函数,用于控制当前运行正常非阻塞 goroutine 的系统线程数目。

在 Go 语言中启动一个 goroutine 不仅和调用函数一样简单,而且 goroutine 之间调度代价也很低,这些因素极大地促进了并发编程的流行和发展。

创建 goroutine

下面我们来看看如何在 Go 中启动 goroutine。

package main

import (
    "fmt"
)

func f(n int64) {
    fmt.Println("f ->", n)
}

func main() {
    // Go 语言中启动一个 goroutine 非常简单, 直接通过 go 关键字即可
    // 注意: go 关键字后面必须跟一个函数调用才可以
    go f(1)
    go f(2)
    go f(3)
    // 上面三个函数都会启动一个单独的 goroutine 去执行
}

但是当我们执行这段程序时却发现什么都没有输出,原因是程序默认有一个主 goroutine,在启动三个子 goroutine 之后,程序就结束了。因此我们看到主 goroutine 是不会等待子 goroutine 的,如果想象成多线程的话,那么子 goroutine 就类似于守护线程。

因此我们可以让主线程强制等待一下:

package main

import (
    "fmt"
    "time"
)

func f(n int64) {
    // 睡 n 秒
    time.Sleep(time.Second * time.Duration(n))
    fmt.Println("f ->", n)
}

func main() {
    go f(3)
    go f(1)
    go f(2)

    // 写一个死循环, 让主线程一直不结束
    // 我们看到 go 里面死循环是不是特别简单呢? 因为 go 里面经常要用到死循环
    for {}
    /*
    f -> 1
    f -> 2
    f -> 3
    */
}

通过程序输出结果,我们知道这几个函数在执行的时候都是并发执行的,谁先睡完,谁就先打印。三个 goroutine 执行完之后,总共花费了 3 秒钟,因为它们是由不同的 goroutine 执行的;如果不是通过 go 关键字启动,那么三个函数执行完之后,会总共花费六秒钟,而且是顺序打印的。

注意:在使用 goroutine 的时候,经常会不注意调到坑里面,举个例子。

package main

import (
    "fmt"
)

func main() {
    for i := 1; i <= 5; i++ {
        go func() {
            fmt.Printf("goroutine %d\n", i)
        }()
    }

    for {}
    /*
    goroutine 6
    goroutine 6
    goroutine 6
    goroutine 6
    goroutine 6
    */
}

我们看到打印的结果和我们想象中的不一样啊,因为 goroutine 还没执行时,循环就结束了,此时 i 已经变成了 6。因此解决办法就是,在启动 goroutine 的时候指定好参数。

func main() {
    for i := 1; i <= 5; i++ {
        go func(i int) {
            fmt.Printf("goroutine %d\n", i)
        }(i)
    }

    for {}
    /*
    goroutine 2
    goroutine 5
    goroutine 1
    goroutine 4
    goroutine 3
    */
}

因为不知道哪个 goroutine 先执行完毕,所以打印的顺序是不固定的。

主 goroutine 和子 goroutine 的同步

由于主 goroutine 不会等待子 goroutine,所以我们采用一个死循环,但这样做显然是不科学的。要是循环下面还有代码呢,我们希望某一段代码一定要等到当前子 goroutine 执行完之后才可以执行,这个时候使用死循环显然是不行的。如果采用 time.Sleep 的话显然也是不推荐的,因为不知道子 goroutine 什么时候执行完毕。

这个时候,我们就需要通过"等待组"的方式来实现:

package main

import (
    "fmt"
    "sync"
)

func f(wg *sync.WaitGroup, n int) {
    wg.Done()
    fmt.Printf("goroutine %d\n", n)
}

func main() {
    var wg sync.WaitGroup
    for i := 1; i <= 5; i++ {
        // "等待组" 内部有一个计数器, 调用 Add 方法让计数器增加, 增加的值等于传入的值
        // 每启动一个 goroutine, 就将计数器加 1
        wg.Add(1)
        // 因为 "等待组" 本质是一个结构体, 所以需要传递指针, 不然就会拷贝一份
        go f(&wg, i)
        // 在函数 f 的内部, 我们调用了 wg.Done(), 这个方法会使得 "等待组" 内部的计数器减一
    }
    // 如果 "等待组" 内部的计数器不为 0, 那么调用 Wait() 会一直阻塞在这里; 如果为 0, 那么会顺利通行
    wg.Wait()
    fmt.Println("程序结束......")
    
    /*
       goroutine 5
       goroutine 2
       goroutine 1
       goroutine 3
       goroutine 4
       程序结束......
    */
}

在使用 goroutine 的时候,一定要注意死锁的问题。

package main

import (
    "fmt"
    "sync"
)

func f(wg *sync.WaitGroup, n int) {
    fmt.Printf("goroutine %d\n", n)
}

func main() {
    var wg sync.WaitGroup
    for i := 1; i <= 5; i++ {
        wg.Add(1)
        go f(&wg, i)
    }
    wg.Wait()
    fmt.Println("程序结束......")
    /*
    goroutine 5
    goroutine 2
    goroutine 1
    goroutine 3
    goroutine 4
    fatal error: all goroutines are asleep - deadlock!
    */
}

我们看到在 goroutine 执行完之后,就造成了死锁,原因就是在函数 f 中我们将 wg.Done() 这行代码给去掉了。当子 goroutine 结束之后,只剩下一个主 goroutine,而且 "等待组" 的计数器不为 0。

因为 wg.Wait() 处于阻塞状态,所以就需要有其它的子 goroutine 将 "等待组" 的计数器减少到 0,所以但凡还有一个子 goroutine,都不会报错,而且程序也是等 5 个子 goroutine 都执行完之后才报的错。而子 goroutine 都执行完之后,只剩下主 goroutine 了,而且"等待组" 的计数器还不为 0,这个时候就出现死锁了。

关于并发编程中容易出现的错误问题,我们后续会用单独的文章进行解释。

goroutine 之间的调度

在 runtime 包中存在三个函数,可以对 goroutine 进行调度。

  • runtime.GOMAXPROCS(n):用来设置可以并行计算的 CPU 核数最大值,并返回之前的值。
  • runtime.Gosched():让出 CPU 时间片(当前 goroutine 的执行权限),调度器安排其它等待的任务运行,并在下次某个时候从该位置恢复执行。这就像跑接力赛,A 跑了一会碰到代码 runtime.Gosched() 就把接力棒交给 B 了,A 歇着了,B 继续跑。
  • runtime.Goexit(),调用此函数会立即中止当前的 goroutine,而其它的 goroutine 并不会受此影响。Goexit 在终止当前 goroutine 前会先执行此 goroutine 中还未执行的 defer 语句。另外注意千万别在主协程中调用 runtime.Goexit,因为会引发 panic。
package main

import (
    "fmt"
    "runtime"
)

func main() {
    // 默认采用 CPU 的所有核心, 我们可以查看 CPU 的核心数
    fmt.Println(runtime.NumCPU())  // 8
    
    // 设置使用的 CPU 核心数, 如果值小于 1, 则采用默认配置
    // 返回 8, 说明默认是跑满了所有的核
    fmt.Println(runtime.GOMAXPROCS(0))  // 8
}

然后我们看看在 Go 中,如何主动交出一个 goroutine 的执行权。

package main

import (
    "fmt"
    "runtime"
    "time"
)

func f1(){
    runtime.Gosched()  // 此处将执行权让出
    fmt.Println("f1 -> 1")
    // 将控制权让出
    runtime.Gosched()
    fmt.Println("f1 -> 2")
}

func f2(){
    // 这里会是第一个打印
    fmt.Println("f2 -> 1")
    // 将控制权让出, 显然会执行 f1 的第一个打印语句
    runtime.Gosched()
    fmt.Println("f2 -> 2")
}
func main() {
    go f1()
    go f2()
    time.Sleep(time.Second * 1)
    /*
       f2 -> 1
       f1 -> 1
       f2 -> 2
       f1 -> 2
    */
    // 打印的结果和我们想的是一样的, 首先打印: f2 -> 1, 然后交出执行权
    // 再打印 f1 -> 1, 然后交出执行权打印 f2 -> 2, 最后打印 f1 -> 2
}

最后看看 goroutine 的主动退出:

package main

import (
    "fmt"
    "runtime"
    "time"
)

func f1(){
    fmt.Println("f1")
    // 注意: 一旦函数内部出现了 Gosched 或者 Goexit
    // 那么这个函数一定要出现在 goroutine 中
    // 可以是在 main 函数中 go f1(), 也可以在其它函数中 go f1()
    // 如果是直接通过 f1() 调用的话, 那么外层函数一定要通过 goroutine 的方式启动
    runtime.Goexit()
}

func f2(){
    f1()
    fmt.Println("f2")
}

func f3(){
    go f1()
    fmt.Println("f3")
}
func main() {
    // f2 里面调用了 f1, 但 f2 是通过 goroutine 启动的, 所以是合法的
    // 并且会直接将整个 goroutine 停止掉, 因此 fmt.Println("f2") 没有执行
    go f2()
    time.Sleep(time.Second * 1)
    /*
       f1
    */
    
    // f3 里面的 f1 函数是通过 goroutine 启动的, 因此 goroutine 里面又启动了一个 goroutine
    // 而 Goexit 只会退出自身的 goroutine(最内层), 所以 fmt.Println("f3") 会被执行
    go f3()
    time.Sleep(time.Second * 1)
    /*
       f1
       f3
    */
    
    // 依旧退出自身的 goroutine, 跟外层无关
    f3()
    time.Sleep(time.Second * 1)
    /*
       f1
       f3
    */
}

以上就是 goroutine 之间的调度,可以体会一下。

goroutine 中的 defer

再来看看 defer 在 goroutine 中是如何体现的。

package main

import (
    "fmt"
)

func f1(){
    defer fmt.Println("f1 defer")
    fmt.Println("f1")
}

func main() {
    // 首先执行 f1(), 因为是 f1() 不是 go f1()
    // 所以 fmt.Println("func") 一定会在 f1() 执行完毕之后才会执行
    // 所以输出是什么很好分析
    go func() {
        defer fmt.Println("func defer")
        f1()
        fmt.Println("func")
    }()
    /*
       f1
       f1 defer
       func
       func defer
    */
    
    // 里面是 go f1(), 是 goroutine 里面启动一个 goroutine
    // 因此这是两个 goroutine, 它们之间是没有关系的
    go func() {
        defer fmt.Println("func defer")
        go f1()
        fmt.Println("func")
    }()
    /*
       f1
       func
       func defer
       f1 defer
    */
    for{}
}

如果里面出现了 return 呢?

package main

import (
    "fmt"
)

func f1(){
    defer fmt.Println("f1 defer")
    return
    fmt.Println("f1")
}

func main() {
    go func() {
        defer fmt.Println("func defer")
        f1()
        fmt.Println("func")
    }()
    /*
       f1 defer
       func
       func defer
    */
    
    go func() {
        defer fmt.Println("func defer")
        go f1()
        fmt.Println("func")
    }()
    /*
       func
       f1 defer
       func defer
    */
    for{}
}

结果很简单,那么重点来了,如果我们把 return 换成 Goexit 呢?

package main

import (
    "fmt"
    "runtime"
)

func f1(){
    defer fmt.Println("f1 defer")
    runtime.Goexit()  // goroutine 退出
    fmt.Println("f1")
}

func main() {
    // 在 f1 中将整个 goroutine 都给终止掉了, 因为是同一个 goroutine, 因此两个 fmt.Println 函数都不会执行
    // 但是: 两个 defer 都会调用
    go func() {
        defer fmt.Println("func defer")
        f1()
        fmt.Println("func")
    }()
    /*
       f1 defer
       func defer
    */
    
    // 这是两个独立的 goroutine, go f1() 对应的 goroutine 会被终止掉
    // 但是匿名函数对应的 goroutine 则不会
    go func() {
        defer fmt.Println("func defer")
        go f1()
        fmt.Println("func")
    }()
    /*
       func
       func defer
       f1 defer
    */
    for{}
}

关于协程的基础内容就介绍到这里,可能有人好奇返回值要怎么办?通过 goroutine 执行完毕之后函数的返回值要怎么获取呢?答案是通过 channel,关于 channel 后续再说,我们来剖析一下 goroutine 是怎么实现的。

Go 协程的底层结构

我们通过 go 关键字启动一个协程之后,编译器在底层会创建一个结构体 g,没错,协程在底层也是一个结构体。

type g struct {
    // goroutine 使用的栈
    stack       stack  
    // 用于栈的扩张和收缩检查,抢占标志
    stackguard0 uintptr 
    stackguard1 uintptr 
    
    _panic    *_panic 
    _defer    *_defer 
    // 当前与 g 绑定的 m,因为每个 goroutine 都对应一个工作线程
    m         *m   
    // goroutine 的运行现场
    sched     gobuf
    syscallsp uintptr 
    syscallpc uintptr 
    stktopsp  uintptr 
    // wakeup 时传入的参数
    param        unsafe.Pointer
    // 协程的状态
    atomicstatus uint32
    stackLock    uint32  
    // goroutine 的 id
    goid         int64
    // 指向全局队列里下一个 g
    schedlink    guintptr
    // g 被阻塞之后的近似时间
    waitsince    int64  
    // g 被阻塞的原因
    waitreason   waitReason  
    
    preempt       bool 
    preemptStop   bool 
    preemptShrink bool 
    asyncSafePoint bool
    
    paniconfault bool 
    gcscandone   bool 
    throwsplit   bool 
    activeStackChans bool
    parkingOnChan uint8
    raceignore     int8     
    sysblocktraced bool     
    tracking       bool     
    trackingSeq    uint8    
    runnableStamp  int64    
    runnableTime   int64    
    sysexitticks   int64    
    traceseq       uint64   
    tracelastp     puintptr 
    lockedm        muintptr
    sig            uint32
    writebuf       []byte
    sigcode0       uintptr
    sigcode1       uintptr
    sigpc          uintptr
    gopc           uintptr         
    ancestors      *[]ancestorInfo 
    startpc        uintptr         
    racectx        uintptr
    waiting        *sudog         
    cgoCtxt        []uintptr      
    labels         unsafe.Pointer 
    timer          *timer         
    selectDone     uint32         
    goroutineProfiled goroutineProfileStateHolder
    gcAssistBytes int64
}

里面的字段非常多,我们先看其中的两个:

  • stack:类型为 stack,表示 goroutine 运行时的栈;
  • sched:类型为 gobuf,goroutine 在运行时光有栈还不够,还必须包含 PC、SP 等寄存器;
type stack struct {
    // 栈的低地址
    lo uintptr
    // 栈的高地址
    hi uintptr
}

type gobuf struct {
    // 栈指针,指向程序正在执行的指令
    sp   uintptr
    // 程序计数器,标示程序执行到了哪一行
    pc   uintptr
    g    guintptr
    ctxt unsafe.Pointer
    ret  uintptr
    lr   uintptr
    bp   uintptr // for framepointer-enabled architectures
}

然后我们用一段代码,来解释一下这些底层结构:

func f3() {fmt.Println("Hello")}

func f2() {f3()}

func f1() {f2()}

func main() {
    go f1()
    for {}
}

通过 go f1() 开启一个协程,我们假定协程执行到了 f3() 函数里的打印语句。

因此从协程的底层结构,可以总结出来如下信息:

  • 在 runtime 中,协程本质就是一个 g 结构体
  • 里面的 stack 记录了协程的堆栈地址
  • gobuf 则记录了程序的运行现场
  • atomicstatus 则记录了协程状态

需要注意的是,协程的执行依赖线程,因为协程本质上就是一个结构体,真正负责执行的还是线程。所谓的多个协程并发执行,不过是线程在多个协程之间来回切换罢了。

线程的底层结构

然后是线程,它是由操作系统控制的,具体的执行过程 Go 编译器无权干涉,但 Go 编译器需要记录线程执行时的一些状态信息,这个状态信息在底层由 m 结构体表示。

// m 代表工作线程,保存了自身使用的栈信息
type m struct {
    // Go 启动的第一个协程(不是主协程)
    g0      *g     
    morebuf gobuf 
    divmod  uint32
    _       uint32
    
    procid        uint64            
    gsignal       *g                
    goSigStack    gsignalStack      
    sigmask       sigset   
    // 通过 tls 结构体实现 m 与工作线程的绑定,这里是线程本地存储
    tls           [tlsSlots]uintptr 
    mstartfn      func()
    // 指向正在运行的 gorutine 对象
    curg          *g       
    caughtsig     guintptr 
    // 当前工作线程绑定的 p
    p             puintptr 
    nextp         puintptr
    oldp          puintptr 
    // 线程 ID
    id            int64
    mallocing     int32
    throwing      throwType
    // 该字段不等于空字符串的话,要保持 curg 始终在这个 m 上运行
    preemptoff    string 
    locks         int32
    dying         int32
    profilehz     int32
    // 为 true 时表示当前 m 处于自旋状态,正在从其他线程偷工作
    spinning      bool 
    // m 是否正阻塞在 note 上
    blocked       bool 
    newSigstack   bool 
    printlock     int8
    // 是否正在执行 cgo 调用
    incgo         bool   
    freeWait      atomic.Uint32 /
    fastrand      uint64
    needextram    bool
    traceback     uint8
    ncgocall      uint64      
    ncgo          int32       
    cgoCallersUse uint32      
    cgoCallers    *cgoCallers 
    // 没有 goroutine 需要运行时,工作线程睡眠在这个 park 成员上,
    // 其它线程通过这个 park 唤醒该工作线程
    park          note
    // 记录所有工作线程的链表
    alllink       *m 
    schedlink     muintptr
    lockedg       guintptr
    createstack   [32]uintptr 
    lockedExt     uint32      
    lockedInt     uint32      
    // 正在等待锁的下一个 m
    nextwaitm     muintptr    
    waitunlockf   func(*g, unsafe.Pointer) bool
    waitlock      unsafe.Pointer
    waittraceev   byte
    waittraceskip int
    startingtrace bool
    syscalltick   uint32
    freelink      *m 
    libcall   libcall
    libcallpc uintptr  
    libcallsp uintptr
    libcallg  guintptr
    syscall   libcall  
    
    vdsoSP uintptr 
    vdsoPC uintptr 
    
    preemptGen uint32
    
    signalPending uint32
    
    dlogPerM
    // 记录线程的一些额外信息(针对操作系统而异)
    mOS
    
    locksHeldLen int
    locksHeld    [10]heldLockInfo
}

runtime 中将操作系统线程抽象为 m 结构体,里面的 g0 字段表示启动的第一个协程,主要用来操作调度器(一会解释);里面的 curg 字段表示当前正在运行的协程。

操作系统是感知不到 goroutine 的存在的,在早期的 Go 中,每个系统线程会执行一个调度循环,顺序执行 goroutine。整个过程就类似于线程池一样,每个线程不停地获取 goroutine,执行完了销毁协程栈,然后再执行下一个。

但是目前这种工作模式存在两个问题:

  • 无法实现高并发,因为一个线程同时只能执行一个 G;
  • 多线程并发时,会抢夺队列的全局锁;

所以为了解决这两个问题,Go 编译器又引入了 P。

GPM 模型

G、P、M 是 Go 调度器的三个核心组件,各司其职。在它们精密地配合下,Go 调度器得以高效运转,这也是 Go 天然支持高并发的内在动力。

  • G:取 goroutine 的首字母,主要保存 goroutine 的一些状态信息以及 CPU 的一些寄存器的值,例如 IP 寄存器,以便在轮到本 goroutine 执行时,CPU 知道要从哪一条指令处开始执行。当 goroutine 被调离 CPU 时,调度器负责把 CPU 寄存器的值保存在 g 对象的成员变量之中。当 goroutine 被调度起来运行时,调度器又负责把 g 对象的成员变量所保存的寄存器值恢复到 CPU 的寄存器。
  • M:取 machine 的首字母,它代表一个工作线程,或者说系统线程。G 需要调度到 M 上才能运行,M 是真正工作的人。结构体 m 就是我们常说的 M,它保存了 M 自身使用的栈信息、当前正在 M 上执行的 G 信息、与之绑定的 P 信息。当 M 没有工作可做的时候,在它休眠前,会“自旋”地来找工作:检查全局队列,查看 network poller,试图执行 gc 任务,或者“偷”工作。
  • P:取 processor 的首字母,为 M 的执行提供“上下文”,保存 M 执行 G 时的一些资源,例如本地可运行 G 队列,memeory cache 等。

所以通过引入 P,上面的两个问题都能得到解决,首先是第二个问题,多线程抢夺队列的全局锁问题。由于所有的 G 都放在一个全局队列里面,不同的线程每次获取 G 的时候,都要对队列加锁。那么问题来了,M 在获取 G 的时候能不能一次获取多个呢?这样就不用每执行完一个 G,就跑到队列获取一次了,所以就有了本地队列。

M 每次会从全局队列里面获取多个 G,存到本地队列里面,这样就不用每次都到全局队列里面取了。

而这个本地队列就被放在 P 中,一个 M 只有绑定 P 才能执行 goroutine,当 M 被阻塞时,整个 P 会被传递给其他 M ,或者说整个 P 被接管。

type p struct {
    id          int32
    status      uint32 
    link        puintptr
    // 每次调用 schedule 时会加一
    schedtick   uint32   
    // 每次系统调用时加一
    syscalltick uint32     
    // 用于 sysmon 线程记录被监控 p 的系统调用时间和运行时间
    sysmontick  sysmontick 
    // 指向绑定的 m,如果 p 是 idle 的话,那这个指针是 nil
    m           muintptr   
    mcache      *mcache
    pcache      pageCache
    raceprocctx uintptr
    deferpool    []*_defer 
    deferpoolbuf [32]*_defer
    goidcache    uint64
    goidcacheend uint64
    
    // 本地可运行的队列,不用通过锁即可访问
    runqhead uint32  // 队列头
    runqtail uint32  // 队列尾
    // 使用数组实现的循环队列,长度为 256,并且是无锁访问
    runq     [256]guintptr
    // runnext 非空时,代表的是一个 runnable 状态的 G
    // 这个 G 被当前 G 修改为 ready 状态,相比 runq 中的 G 有更高的优先级
    // 如果当前 G 还有剩余的可用时间,那么就应该运行这个 G
    // 运行之后,该 G 会继承当前 G 的剩余时间
    runnext guintptr
    
    // 空闲的 g
    gFree struct {
        gList
        n int32
    } 
    sudogcache []*sudog
    sudogbuf   [128]*sudog
    mspancache struct {
        len int
        buf [128]*mspan
    }
    
    tracebuf traceBufPtr
    traceSweep bool
    traceSwept, traceReclaimed uintptr
    
    palloc persistentAlloc
    
    _ uint32 
    timer0When uint64
    timerModifiedEarliest uint64
    gcAssistTime         int64 
    gcFractionalMarkTime int64 
    limiterEvent limiterEvent
    gcMarkWorkerMode gcMarkWorkerMode
    gcMarkWorkerStartTime int64

    gcw gcWork
    wbBuf wbBuf
    
    runSafePointFn uint32 
    statsSeq uint32
    timersLock mutex
    timers []*timer
    numTimers uint32
    deletedTimers uint32
    timerRaceCtx uintptr
    maxStackScanDelta int64
    scannedStackSize uint64 
    scannedStacks    uint64 
    preempt bool
}

GPM 三足鼎力,共同成就 Go scheduler。G 需要在 M 上才能运行,M 依赖 P 提供的资源,P 则持有待运行的 G。你中有我,我中有你。

所以相信你应该明白 P 的作用了,它相当于 G 和 M 之间的中介,P 持有相应的 G,使得 M 不需要每次获取 G 的时候都从全局队列中查找,从而大大减少了并发冲突的情况。

  • M 首先从 P 的 runnext 获取优先级最高的 G
  • runnext 如果没有待执行的 G, 那么从本地队列获取
  • 本地队列如果为空, 也就是里面的 G 都执行完了, 那么会从全局队列获取一个 batch 的 G 添加到本地队列
  • 如果全局队列也为空了, 那么会从其它的 P 的本地队列偷一些 G 过来, 不然绑定在该 P 上的 M 就会处于空闲状态

然后当我们新建一个协程时,编译器会认为它的优先级最高,于是会随机找一个 P,然后放在该 P 的 runnext 里面。

到目前为止我们解决了第二个问题,前面说了,在引入 P 之前,存在两个问题:

  • 无法实现高并发,因为一个线程同时只能执行一个 G;
  • 多线程并发时,会抢夺队列的全局锁;

第二个问题已经解决了,下面说一说第一个问题是怎么解决的。

在引入 P 之前,M 是顺序执行 G 的,从本地队列获取一个 G,执行完了再获取下一个。但这样容易造成饥饿问题,如果某一个 G 耗时特别长,那么其它的 G 永远得不到执行。另外当出现 IO 阻塞的时候,由于不耗费 CPU,那么这个时候干等也没有意义。所以当 G 出现了阻塞时,编译器会将它的状态保存起来放回本地队列,然后从本地队列获取别的 G 执行。

这么做解决了本地队列的协程饥饿以及 CPU 利用率的问题,但全局队列又可能会造成饥饿。前面说了,当本地队列为空时,会从全局队列获取一个 batch 的 G 添加到本地队列中,但如果本地队列的 G 耗时很长呢?因此会导致全局队列的 G 始终得不到执行。所以 Go 里面,每当本地队列的协程执行了 61 个,就从全局队列里面取一个 G 执行。

所以通过协程之间的切换,Go 实现了高并发。

小结

  • 协程相当于对线程精打细算,可以支撑超高并发,当然从 runtime 的角度看,它就是一个可以被调用的 g 结构体(里面包含了要运行的代码)。从线程的角度上看,协程就是一个对象,自带执行现场。
  • 光有 G 和 M 还不够,通过 P 达到了缓存部分 G 的目的。
  • P 是一个结构体,但里面存储了包含 G 的本地队列,避免全局并发等待。
  • 窃取式的工作分配机制,能够更加充分利用线程资源。
  • 如果协程顺序执行,会有饥饿问题。于是在协程执行中需要被挂起的时候,将协程挂起(可以是主动挂起、也可以是需要完成系统时挂起),执行其它协程。所以通过 GPM 模型,即使只用一个线程也能实现并发。
  • 为防止全局队列饥饿,会在本地队列完成 61 个 G 时,从全局队列抽取 1 个 G。
posted @ 2019-11-19 23:26  古明地盆  阅读(4385)  评论(0编辑  收藏  举报