golang笔记—— Go调度

一、Runtime

1. 为什么需要runtime

  • goroutines调度
    goroutines是go的执行单元,goroutines如果直接对应操作系统的线程,go在调度goroutines时,势必将像操作系统调度线程一样,需要设置信号掩码、CPU亲和性及cgroup的资源管理等,这些额外的线程操作不是go运行goroutines所需要的,这些操作将消耗大量资源、影响性能。所以go需要runtime来执行goroutines的调度,而不是让操作系统调度线程。
  • 垃圾回收
    go是需要支持垃圾回收的,执行垃圾回收时,我们需要保证goroutines处于暂停的状态,go的内存才会处于一种一致的状态。
    当没有调度器时,线程是由操作系统来调度,但这对于go而言是不可控的,go无法很好的控制线程状态及内存。所以为了GC,go需要自己的调度器。goroutines有go自身调度器控制才能确保内存一致,才能正确地执行GC。

所以要支持协程\线程调度就要有runtime。要支持垃圾回收就要有runtime。

2. 什么是runtime

上面可以分析出runtime所担任的职责:goroutines调度,垃圾回收,当然还提供goroutines的执行环境。
所以这也相当于简要解释了什么是runtime。

go的可执行程序可以分成两个层:用户代码和运行时:

  • 运行时提供接口函数供用户代码调用,用来管理goroutines,channels和其他一些内置抽象结构。
  • 用户代码对操作系统API的任何调用都会被运行时层截取,以方便调度和垃圾回收。


二、GMP调度模型

  • global queue(全局队列):存放等待运行的G。为保证数据竞争问题,需要加锁处理。
  • local queue(本地队列):本地队列时无锁的,可以可以提升处理速度。同全局队列类似,存放的也是等待运行的G,存的数量有限,不超过256个。新建G'时,G'优先加入到P的本地队列,如果队列满了,则会把本地队列中一半的G移动到全局队列。
  • P列表:所有的P都在程序启动时创建,并保存在数组中,最多有GOMAXPROCS(可配置)个。在任何时候,每个P只能有一个M运行。
  • M:线程想运行任务就得获取P,从P的本地队列获取G,P队列为空时,M也会尝试从全局队列拿一批G放到P的本地队列,或从其他P的本地队列偷一半放到自己P的本地队列。M运行G,G执行之后,M会从P获取下一个G,不断重复下去。

GMP主要包含4个基本单元:g、m、p、schedt。
- g是协程任务信息单元
- m是实际执行体
- p是本地资源池和本地g任务池
- schedt是全局资源池和全局g任务池
下面我们详细看下这4个基本单元的主要结构:

1. G

G是Goroutine的缩写,是对Goroutine的抽象。其中包括执行的函数指令及参数;G保存着任务对象,线程上下文切换,现场保护和现场恢复需要的寄存器(SP、IP)等信息。
Goroutine的主要结构(详见runtime/runtime2.go)

type g struct {
  stack       stack        // 描述了当前 Goroutine 的栈内存范围 [stack.lo, stack.hi)
  stackguard0 uintptr // 是对比 Go 栈增长的 prologue 的栈指针, 可以用于调度器抢占式调度
  stackguard1 uintptr // 是对比 C 栈增长的 prologue 的栈指针
  ...
  _panic       *_panic // 最内侧的 panic 结构体
  _defer       *_defer // 最内侧的延迟函数结构体
  m              *m     // 当前的m
  sched          gobuf   // goroutine切换时,用于保存g的上下文      
  ...
  param          unsafe.Pointer // 用于传递参数,睡眠时其他goroutine可以设置param,唤醒时该goroutine可以获取
  atomicstatus   uint32 // Goroutine 的状态
  stackLock      uint32 
  goid           int64  // goroutine的ID
  ...
  waitsince      int64 // g被阻塞的大体时间
  preempt       bool // 抢占信号
  preemptStop   bool // 抢占时将状态修改成 `_Gpreempted`
  preemptShrink bool // 在同步安全点收缩栈
  ...
  lockedm        *m     // G被锁定只在这个m上运行
  ...
}

g中最主要的当然是sched了,保存了goroutine的上下文。goroutine切换的时候,不同于线程有OS来负责这部分数据,而是由一个gobuf对象来保存,这样能够更加轻量级,再来看看gobuf的结构(详见runtime/runtime2.go):

type gobuf struct {
    // The offsets of sp, pc, and g are known to (hard-coded in) libmach.
    //
    // ctxt is unusual with respect to GC: it may be a
    // heap-allocated funcval, so GC needs to track it, but it
    // needs to be set and cleared from assembly, where it's
    // difficult to have write barriers. However, ctxt is really a
    // saved, live register, and we only ever exchange it between
    // the real register and the gobuf. Hence, we treat it as a
    // root during stack scanning, which means assembly that saves
    // and restores it doesn't need write barriers. It's still
    // typed as a pointer so that any other writes from Go get
    // write barriers.
    sp   uintptr // 栈指针
    pc   uintptr // 程序计数器
    g    guintptr // 当前gobuf所属的g
    ctxt unsafe.Pointer
    ret  uintptr 系统调用的返回值
    lr   uintptr
    bp   uintptr // for framepointer-enabled architectures
}

2. M

M是一个线程或称为Machine,M是有线程栈的。如果不对该线程栈提供内存的话,系统会给该线程栈提供内存(不同操作系统提供的线程栈大小不同)。当指定了线程栈,则M.stack→G.stack,M的PC寄存器指向G提供的函数,然后去执行。

type m struct {
    // g0是带有调度栈的goroutine。
    // 普通的Goroutine栈是在Heap分配的可增长的stack,而g0的stack是M对应的线程栈。
    // 所有调度相关代码,会先切换到该Goroutine的栈再执行。
    g0      *g    
    ......
    gsignal       *g         // 处理信号的goroutine
    ......
    tls           [6]uintptr // thread-local storage
    mstartfn      func() //m入口函数
    curg          *g       // 当前运行的goroutine
    caughtsig     guintptr 
    p             puintptr // 关联p和执行的go代码
    nextp         puintptr
    oldp          puintptr // 在执行系统调用之前所附加的p
    id            int32
    mallocing     int32 // 状态
    ......
    locks         int32 //m的锁
    ......
    spinning      bool // m不在执行g,但在积极寻找可执行的g
    blocked       bool // m是否被阻塞
    newSigstack   bool
    printlock     int8
    incgo         bool // m是否在执行cgo
    freeWait      uint32 // 如果为0,将安全释放g0并删除m(原子性)。
    fastrand      uint32
    ......
    ncgocall      uint64      // cgo调用的总数
    ncgo          int32       // 当前cgo调用的数目
    ......
    park          note
    alllink       *m // 用于链接allm
    schedlink     muintptr
    lockedg       *g // 锁定g在当前m上执行,而不会切换到其他m
    createstack   [32]uintptr // thread创建的栈
    ......
    nextwaitm     muintptr    // 下一个等待的m
    ......
}

而g0的栈是M对应的线程的栈。所有调度相关的代码,会先切换到该goroutine的栈中再执行。也就是说线程的栈也是用的g实现,而不是使用的OS的。

3. P

P代表一个处理器,每一个运行的M都必须绑定一个P,就像线程必须在么一个CPU核上执行一样,由P来调度G在M上的运行,P的个数就是GOMAXPROCS(最大256),启动时固定的,一般不修改;M的个数和P的个数不一定一样多(会有休眠的M或者不需要太多的M)(最大10000);每一个P保存着本地G任务队列,也有一个全局G任务队列。P的数据结构:

type p struct {
    id          int32
    status      uint32 // 状态,可以为pidle/prunning/...
    link        puintptr
    schedtick   uint32     // 每调度一次加1
    syscalltick uint32     // 每一次系统调用加1
    sysmontick  sysmontick 
    m           muintptr   // 回链到关联的m
    mcache      *mcache //当前m的内存缓存,意味着不必为每一个M都配备一块内存,避免了过多的内存消耗。
    pcache      pageCache
    raceprocctx uintptr
    ......
    // goroutine ids的缓存,摊销对runtime-sched.goidgen的访问。
    goidcache    uint64
    goidcacheend uint64
    
    // 可运行的goroutine的队列. 不需要锁即可访问
    runqhead uint32
    runqtail uint32
    runq     [256]guintptr

    runnext guintptr // 下一个运行的g,以高优先级执行 unblock G,提高了一些包的性能。
    // 可用的G (status == Gdead, Gdead 表示这个goroutine目前未被使用)
    gFree struct {
        gList
        n int32
    }
    // sudog 代表等待列表中的一个G,例如在向通道执行发送/接收的G。
    sudogcache []*sudog
    sudogbuf   [128]*sudog
    // 堆中mspan对象的缓存
    mspancache struct {
        // len 被用于不允许写障碍的调用代码路径中    
        len int
        buf [128]*mspan
    }
    ......
    palloc persistentAlloc // per-P to avoid mutex
    ......
}

其中P的状态有Pidle, Prunning, Psyscall, Pgcstop, Pdead;在其内部队列runqhead里面有可运行的goroutine,P优先从内部获取执行的g,这样能够提高效率。

4. schedt

除此之外,还有一个数据结构需要在这里提及,就是schedt,可以看做是一个全局的调度者:

type schedt struct {
    // 原子操作访问
    goidgen   uint64
    lastpoll  uint64 // 最后一次网络轮询的时间,如果正在轮询则为0
    pollUntil uint64 // 当前轮询的睡眠时间

    lock mutex

    // When increasing nmidle, nmidlelocked, nmsys, or nmfreed, be
    // sure to call checkdead().

    midle        muintptr // idle状态的m
    nmidle       int32    // idle状态的m个数
    nmidlelocked int32    // lockde状态的m个数
    mnext        int64    // 已经创建的m的数量和下一个m的ID
    maxmcount    int32    // m允许的最大个数
    nmsys        int32    // 非locked状态的系统M的数量
    nmfreed      int64    // 累计可用的M的数量

    ngsys uint32 // 系统中goroutine的数目,会自动更新

    pidle      puintptr // idle的p
    npidle     uint32
    nmspinning uint32 // 寻找可执行gotoutine的m数量

    // goroutine全局可运行队列.
    runq     gQueue
    runqsize int32
    ......
    // _Gdead状态G的全局缓存
    gFree struct {
        lock    mutex
        stack   gList // 有stack的Gs
        noStack gList // 没有stack的Gs
        n       int32
    }

    // sudog的Central 缓存
    sudoglock  mutex
    sudogcache *sudog
    ......
    // 等待被释放的m的列表
    freem *m
    ......
}

二、GMP调度细节分析

1. schedule

proc.go:3291 findrunnable()


  1. m是否阻塞?阻塞则sleep此m,其他m在从本地队列尾部获取g执行,执行完成后,继续执行schedule()
  2. 是否需要执行gc?需要gc则停止spining、释放p、开始sleep,gc完成后会被唤醒,继续执行schedule()
  3. 若存在gcBgMarkWorker则获取此g,否则偶尔(1/61概率)会从全局队列获取g(避免全局对列饥饿),没有则从本地队列获取g。
  4. 若仍未获取到g,则会进入findrunnable流程,循环地去找g
  5. 获取到g后,停止spining
  6. 获取到锁定的g,则将把对应锁定的m调度给当前的p并唤醒m。否则该m将移交p给其他等待中的m并唤醒,该m将sleep
  7. 再次进入schedule()循环

2. findrunnable

proc.go:2705 findrunnable():


  1. 是否需要执行gc?需要gc则停止spining、释放p、开始sleep,gc完成后会被唤醒,继续执行findrunnable()
  2. 从本地队列寻找g,没有则从全局队列寻找g。获取g后,将继续schedule()
  3. 本地队列、全局队列都没有g,则查看是否存在net或file需要执行。存在则获取g,并继续schedule()
  4. 没有可执行的g,开始spining,从其他p的本地队列尾部窃取一半的g,并继续scheudle()
  5. 其他p的本地队列没有g,将会在sleep之前,再次尝试查看全局队列中是否有g
  6. 仍没有g,则释放p,停止spining并准备sleep
  7. 检查是否有idle的p,则sleep,直到再次被唤醒

3. spining

线程自旋(spining)是相对于线程阻塞而言的,表象就是循环执行一个指定逻辑(调度逻辑,目的是不停地寻找 G)
缺点: 始终获取不到G时,自旋属于空转,浪费CPU
优点: 降低了 M 的上下文切换成本,提高了性能
GMP中有两个地方会引入自旋:

  • 类型1:没有P的M找P挂载,保证一有 P 释放就结合
  • 类型2:没有G的M找G运行,保证一有runnable的G就运行

由于P最多只有GOMAXPROCS,所以自旋的M最多只允许GOMAXPROCS个,多了就没有意义了。
同时当有类型1的自旋M存在时,类型2的自旋M就不阻塞,阻塞会释放P,一释放P就马上被类型1的自旋M抢走了,没必要。

有空闲的 P时,在以下3种场景,go调度器会确保至少有一个自旋 M 存在(唤醒或者创建一个 M):

  • 新G创建之前
    如果有空闲的 P,就意味着新 G 可以被立即执行,即便不在同一个 P 也无妨,所以我们保留一个自旋的 M,就可以保证新 G 很快被运行。
    为了执行G,不需要关注在哪个P上运行,这时应该不存在类型 1 的自旋只有类型 2 的自旋
  • M进入系统调用(syscall)之前
    当 M 进入系统调用,意味着 M 不知道何时可以醒来,那么 M 对应的 P 中剩下的 G 就得有新的 M 来执行,所以我们保留一个自旋的 M 来执行剩下的 G。
    为了执行P本地队列中的G,需要和P绑定,这时应该不存在类型 2 的自旋只有类型1的自旋
  • M从空闲变成活跃之前
    如果M从空闲变成活跃,意味着可能一个处于自旋状态的M进入工作状态了,这时要检查并确保还有一个自旋M存在,以防还有G或者还有P空着的。

4. GMP模式优点

  1. G分布在全局队列和P本地队列,全局队列依旧是全局锁,但是使用场景明显很少;P 本地队列使用无锁队列,使用原子操作来面对可能的并发场景。(解决了GM模式单一全局互斥锁的问题)
  2. G 创建时就在 P 的本地队列,可以避免在 P 之间传递(窃取除外),G 对 P 的数据局部性好; 当 G 开始执行了,系统调用返回后 M 会尝试获取可用 P,获取到了的话可以避免在 M 之间传递 。而且优先获取调用阻塞前的 P,所以 G 对 M 数据局部性好,G 对 P 的数据局部性也好。(解决了GM模式中G传递带来的开销问题,以及数据局部性问题)
  3. 内存 mcache 只存在 P 结构中,就不必为每一个M都配备一块内存了,避免过多的内存消耗。P 最多只有 GOMAXPROCS 个,远小于 M 的个数,所以也不会出现过多的内存消耗。(解决了GM模式中内存消耗大的问题)
  4. 通过引入自旋,保证任何时候都有处于等待状态的自旋M,避免在等待可用的P和G时频繁的阻塞和唤醒。(解决了GM模式中严重的线程阻塞/解锁问题)

5. syscall阻塞情况下的调度

当M1执行某一个G时候如果发生了syscall或者其他阻塞操作后,M1会阻塞。如果当前P中仍有一些G待执行,runtime会将 Goroutine 的状态更新至 _Gsyscall,将 Goroutine 的P和M暂时分离并更新P的状态到 _Psyscall,表明这个 P 的 G 正在 syscall 中。

当系统调用结束后,会调用退出系统调用的函数 runtime.exitsyscall 为当前 Goroutine 重新分配资源,该函数有两个不同的执行路径:

  1. 调用 runtime.exitsyscallfast
  2. 切换至调度器的 Goroutine 并调用 runtime.exitsyscall0

采用较快的路径runtime.exitsyscallfast优先来重新获取原来的P,能获取到就继续绑回去,这样有利于数据的局部性。runtime.exitsyscallfast中包含两个不同的分支:

  1. 如果 Goroutine 的原P处于 _Psyscall 状态,会直接调用 wirep将 Goroutine 与原P进行关联
  2. 如果原P不处于 _Psyscall 状态,且调度器中存在闲置的P,会调用 runtime.acquirep 使用闲置的P处理当前 Goroutine;

如果通过runtime.exitsyscallfast获取不到P,runtime就会采用另一个相对较慢的路径 runtime.exitsyscall0 ,将当前 Goroutine 切换至 _Grunnable 状态,并移除线程 M 和当前 Goroutine 的关联,然后执行以下逻辑分支:

  1. 当我们通过 runtime.pidleget 获取到闲置的处理器时就会在该处理器上执行 Goroutine;
  2. 否则找不到空闲的P,runtime就会把 G 放回 global queue,M 放回到 idle list,等待调度器调度


6. sysmon抢占调度

sysmon 也叫监控线程,它在一个单独的 M 上执行,无需 P 也可以运行,它是一个死循环,每 20us~10ms 循环一次,循环完一次就 sleep 一会。为什么会是一个变动的周期呢,主要是避免空转,如果每次循环都没什么需要做的事,那么 sleep 的时间就会加大。

1. sysmon的主要作用:

  • 释放闲置超过 5 分钟的 span 物理内存
  • 如果超过 2 分钟没有垃圾回收,强制执行
  • 将长时间未处理的 netpoll 添加到全局队列
  • 向长时间运行的 G 任务发出抢占调度
  • 收回因 syscall 长时间阻塞的 P

2. 满足什么条件会触发抢占调度呢?

go 1.13 抢占调度

sysmon发现一个 P 一直处于running状态超过了10ms,将调用preemptone 将 G 的stackguard0=stackPreempt,同时设置sched.gcwaiting=1。被标记后,在该G调用新函数时,通过g.stackguard0判断是否需要栈增长,需要栈增长就会通过morestack()检查执行schedule(),检查到sched.gcwaiting==1时,就会让当前G让出。

G设置了标记位后,也不一定会被抢占。如果G调用的新函数是一个简单的死循环,将无法被抢占。如果G调用的新函数所需栈空间很少也不会被抢占,只有当新函数触发栈空间检查(morestack()), 所需栈大于128字节,才会被抢占。那么这里就带来一些问题,我们看下下面的代码示例:

package main
import "fmt"

func main(n int) {
    go func(n int){
        for{
            n++
            fmt.Println(n)
        }
    }(0)

    for{}
}

在go 1.13版本之前,执行上述代码,会阻塞在go func()。原因是当go的GC触发时,会执行STW。而STW会抢占所有的P,让GC来运行。而go func()中是1个简单的死循环,这类操作无法进行newstack、morestack、syscall,所以无法检测stackguard0 == stackpreempt,也就不会执行后续的schedule()让当前G让出,导致阻塞。这种依赖栈增长的方式,不算是真正的抢占式调度。

go 1.14 抢占调度

在go 1.14版本实现了基于信号的抢占式调度

  1. sysmon发现一个 P 一直处于running状态超过了10ms,调用preemptone()方法时,会通过系统调用,向m发送sigPreempt信号。
  2. m收到信号后,会将信号交给sighandler处理
  3. sighandler确定信号为sigPreempt以后,调用doSigPreempt函数
  4. doSigPreempt函数在确认P和G允许抢占,并可以安全地执行抢占后,会向G的执行上下文中注入异步抢占函数asyncPreempt。
  5. asyncPreempt汇编函数调用后,就会保存G的上下文,并调用schedule()让当前G让出。

我们可以看到基于信号的抢占式调度,不再依赖于栈增长,即使空的for{}没有执行栈增长检测代码,也依然没有阻塞,可以成功实现抢占式调度。

7. netpoller

1. 什么是netpoller
在Go的实现中,期望在用户层面(程序员层面)所有IO都是阻塞调用的,Go的设计思想是程序员使用阻塞式的接口来编写程序,然后通过goroutine+channel来处理并发。因此所有的IO逻辑都是直来直去的,先xx,再xx, 你不再需要回调,不再需要future,要的仅仅是step by step。这对于代码的可读性是很有帮助的。

但是如果在Runtime内部也采用阻塞 I/O 调用,那么物理线程将也处于阻塞状态,导致大量资源的浪费。所以Runtime内部实际使用的是OS提供的非阻塞IO访问模式。那么如何将OS的异步I/O与Golang接口的阻塞I/O互相转换呢?golang内部就通过OS提供的非阻塞IO访问模式、并配合epll/kqueue等IO事件监控机制,通过runtime上做的一层封装,实现将OS的异步I/O与Goroutine的阻塞 I/O互相转换 。这一部分被称之为netpoller

1. goroutine同步调用转OS异步调用
当一个goroutine进行I/O操作时,并且文件描述符数据还没有准备好,经过一系列的调用,最后会进入gopark函数,gopark将当前正在执行的goroutine状态保存起来,然后切换到新的堆栈上执行新的goroutine。由于当前goroutine状态是被保存起来的,因此后面可以被恢复。这样进行I/O操作的goroutine以为一直同步阻塞到现在,其实内部是异步完成的。


2. goroutine什么时候调度回来

在schedule()执行时,findrunnable()中的netpoll()方法被调用后,处于就绪状态的 fd 对应的 G 就会被调度回来。

8. scheduler affinity

goroutine之间使用channel来回通信时,会导致goroutine频繁阻塞,导致其在本地队列会进行频繁地重新排队,导致goroutine存在被重排后有可能会被窃取的风险。


Go 1.5 在 P 中引入了runnext 特殊的一个字段,当一组goroutines在communicate-and-wait模式中被阻塞,但很快就runnable了,便会将runnext分别指向这组goroutines。在当前G运行结束,之后将立即执行runnext对应的G,而不是本地队列中的G。这允许 goroutine 在再次被阻塞之前能够快速运行,提高了一部分性能。

六、goroutine的生命周期

- 流程步骤:

  1. runtime创建最初的线程m0和goroutine g0,并把2者关联。
  2. 调度器初始化:初始化m0、栈、垃圾回收,以及创建和初始化P列表,P的数目优先取环境变量GOMAXPROCS,否则默认是cpu核数。随后把第一个P(便于理解可以叫它p0)与m0进行绑定,这样m0就有他自己的p了,就有条件执行后续的任务g了。
  3. m0的g0会执行调度任务(runtime.newproc),创建一个g,g指向runtime.main()(还不是我们main包中的main),并放到p的本地队列。这样m0就已经同时具有了任务g和p,什么条件都具备了。

runtime.main(): 启动 sysmon 线程;启动 GC 协程;执行 init,即代码中的各种 init 函数;执行 main.main 函数。

  1. 启动m0,m0已经绑定了P,会从P的本地队列获取g。
  2. g拥有栈,m根据g中的栈信息和调度信息设置运行环境
  3. M运行g
  4. g退出,再次回到M获取可运行的G,这样重复下去,直到main.main退出,runtime.main执行Defer和Panic处理,或调用runtime.exit退出程序。
  • M0
    M0是启动程序后的编号为0的主线程,这个M对应的实例会在全局变量runtime.m0中,不需要在heap上分配,M0负责执行初始化操作和启动第一个G, 在之后M0就和其他的M一样了
  • G0
    G0是每次启动一个M都会第一个创建的gourtine,G0仅负责调度,即shedule() 函数, 每个M都会有一个自己的G0。在调度或系统调用时会使用G0的栈空间, 全局变量的G0是M0的G0。
  • G0的调度
    以下3种场景,G0会执行调度:
  1. 当前 G 执行完成,G0会执行调度获取下一个G
  2. 当前 G 阻塞时:系统调用、互斥锁或 chan,G0会执行调度
  3. 在函数调用期间,如果当前 G 必须扩展其堆栈,G0会执行调度

与常规 G 相反,G0 有一个固定和更大的栈。G0除了负责G的调度,还有以下功能:

  • Defer 函数的分配
  • GC 收集,比如 STW、扫描 G 的堆栈和标记、清除操作
  • 栈扩容,当需要的时候,由 g0 进行扩栈操作

从 g 到 g0 或从 g0 到 g 的切换是相当迅速的,它们只包含少量固定的指令。相反,对于schedule(),执行schedule()需要检查许多资源以便确定下一个要运行的 G。

g0调度具体流程:

  • 当前 g 阻塞在 chan 上并切换到 g0:
    1、g的PC (程序计数器)和堆栈指针一起保存在内部结构中;
    2、将 g0 设置为正在运行的 goroutine;
    3、g0 的堆栈替换当前堆栈;
  • g0 执行schedule(),寻找runnable g
  • g0 使用所选的 G 进行切换:
    1、PC 和堆栈指针是从G内部结构中获取的;
    2、程序跳转到对应的 PC 地址;

References:
http://www.cs.columbia.edu/~aho/cs6998/reports/12-12-11_DeshpandeSponslerWeiss_GO.pdf
https://www.yuque.com/aceld/golang/srxd6d
https://zhuanlan.zhihu.com/p/68299348
https://rakyll.org/scheduler/
https://zhuanlan.zhihu.com/p/27056944
https://www.cnblogs.com/sunsky303/p/11058728.html
https://yizhi.ren/2019/06/03/goscheduler
https://yizhi.ren/2019/06/08/gonetpoller
https://cloud.tencent.com/developer/article/1234360
https://draveness.me/golang/docs/part3-runtime/ch06-concurrency/golang-goroutine

你可能感兴趣的:(golang笔记—— Go调度)