本文是《Go语言调度器源代码情景分析》系列的第18篇,也是第四章《Goroutine被动调度》的第1小节。


前一章我们详细分析了调度器的调度策略,即调度器如何选取下一个进入运行的goroutine,但我们还不清楚什么时候以及什么情况下会发生调度,从这一章开始我们就来讨论这个问题。

总体说来,go语言的调度器会在以下三种情况下对goroutine进行调度:

  1. goroutine执行某个操作因条件不满足需要等待而发生的调度;

  2. goroutine主动调用Gosched()函数让出CPU而发生的调度;

  3. goroutine运行时间太长或长时间处于系统调用之中而被调度器剥夺运行权而发生的调度。

本章主要分析我们称之为被动调度的第1种调度,剩下的两种调度将在后面两章分别进行讨论。

Demo例子

我们以一个demo程序为例来分析因阻塞而发生的被动调度。

package main

func start(c chan int) {
c<-100
} func main() {
c:=make(chan int) go start(c) <-c
}

该程序启动时,main goroutine首先会创建一个无缓存的channel,然后启动一个goroutine(为了方便讨论我们称它为g2)向channel发送数据,而main自己则去读取这个channel。

这两个goroutine读写channel时一定会发生一次阻塞,不是main goroutine读取channel时发生阻塞就是g2写入channel时发生阻塞。

创建g2 goroutine

首先用gdb反汇编一下main函数,看看汇编代码。

0x44f4d0<+>: mov   %fs:0xfffffffffffffff8,%rcx
0x44f4d9<+>: cmp 0x10(%rcx),%rsp
0x44f4dd<+>: jbe 0x44f549 <main.main+>
0x44f4df<+>: sub $0x28,%rsp
0x44f4e3<+>: mov %rbp,0x20(%rsp)
0x44f4e8<+>: lea 0x20(%rsp),%rbp
0x44f4ed<+>: lea 0xb36c(%rip),%rax
0x44f4f4<+>: mov %rax,(%rsp)
0x44f4f8<+>: movq $0x0,0x8(%rsp)
0x44f501<+>: callq 0x404330 <runtime.makechan> #创建channel
0x44f506<+>: mov 0x10(%rsp),%rax
0x44f50b<+>: mov %rax,0x18(%rsp)
0x44f510<+>: movl $0x8,(%rsp)
0x44f517<+>: lea 0x240f2(%rip),%rcx
0x44f51e<+>: mov %rcx,0x8(%rsp)
0x44f523<+>: callq 0x42c1b0 <runtime.newproc> #创建goroutine
0x44f528<+>: mov 0x18(%rsp),%rax
0x44f52d<+>: mov %rax,(%rsp)
0x44f531<+>: movq $0x0,0x8(%rsp)
0x44f53a<+>: callq 0x405080 <runtime.chanrecv1> #从channel读取数据
0x44f53f<+>: mov 0x20(%rsp),%rbp
0x44f544<+>: add $0x28,%rsp
0x44f548<+>: retq
0x44f549<+>: callq 0x447390 <runtime.morestack_noctxt>
0x44f54e<+>: jmp 0x44f4d0 <main.main>

从main函数的汇编代码我们可以看到,创建goroutine的go关键字被编译器翻译成了对runtime.newproc函数的调用,第二章我们对这个函数的主要流程做过详细分析,这里简单的回顾一下:

  1. 切换到g0栈;

  2. 分配g结构体对象;

  3. 初始化g对应的栈信息,并把参数拷贝到新g的栈上;

  4. 设置好g的sched成员,该成员包括调度g时所必须pc, sp, bp等调度信息;

  5. 调用runqput函数把g放入运行队列;

  6. 返回

因为当时我们的主要目标是调度器的初始化部分,所以并没有详细分析上述流程中的第5步,也就是runqput是如何把goroutine放入运行队列的,现在就回头分析一下这个过程,下面我们直接从runqput函数开始。

通过runqput函数把goroutine挂入运行队列

runtime/proc.go : 4746

// runqput tries to put g on the local runnable queue.
// If next is false, runqput adds g to the tail of the runnable queue.
// If next is true, runqput puts g in the _p_.runnext slot.
// If the run queue is full, runnext puts g on the global queue.
// Executed only by the owner P.
func runqput(_p_ *p, gp *g, next bool) {
if randomizeScheduler && next && fastrand() % 2 == 0 {
next = false
} if next {
//把gp放在_p_.runnext成员里,
//runnext成员中的goroutine会被优先调度起来运行
retryNext:
oldnext := _p_.runnext
if !_p_.runnext.cas(oldnext, guintptr(unsafe.Pointer(gp))) {
//有其它线程在操作runnext成员,需要重试
goto retryNext
}
if oldnext == 0 { //原本runnext为nil,所以没任何事情可做了,直接返回
return
}
// Kick the old runnext out to the regular run queue.
gp = oldnext.ptr() //原本存放在runnext的gp需要放入runq的尾部
} retry:
//可能有其它线程正在并发修改runqhead成员,所以需要跟其它线程同步
h := atomic.LoadAcq(&_p_.runqhead) // load-acquire, synchronize with consumers
t := _p_.runqtail
if t - h < uint32(len(_p_.runq)) { //判断队列是否满了
//队列还没有满,可以放入
_p_.runq[t % uint32(len(_p_.runq))].set(gp) // store-release, makes it available for consumption
//虽然没有其它线程并发修改这个runqtail,但其它线程会并发读取该值以及p的runq成员
//这里使用StoreRel是为了:
//1,原子写入runqtail
//2,防止编译器和CPU乱序,保证上一行代码对runq的修改发生在修改runqtail之前
//3,可见行屏障,保证当前线程对运行队列的修改对其它线程立马可见
atomic.StoreRel(&_p_.runqtail, t + 1)
return
}
//p的本地运行队列已满,需要放入全局运行队列
if runqputslow(_p_, gp, h, t) {
return
}
// the queue is not full, now the put above must succeed
goto retry
}

runqput函数流程很清晰,它首先尝试把gp放入_p_的本地运行队列,如果本地队列满了,则通过runqputslow函数把gp放入全局运行队列。

runtime/proc.go : 4784

// Put g and a batch of work from local runnable queue on global queue.
// Executed only by the owner P.
func runqputslow(_p_ *p, gp *g, h, t uint32) bool {
var batch [len(_p_.runq) / 2 + 1]*g //gp加上_p_本地队列的一半 // First, grab a batch from local queue.
n := t - h
n = n / 2
if n != uint32(len(_p_.runq) / 2) {
throw("runqputslow: queue is not full")
}
for i := uint32(0); i < n; i++ { //取出p本地队列的一半
batch[i] = _p_.runq[(h+i) % uint32(len(_p_.runq))].ptr()
}
if !atomic.CasRel(&_p_.runqhead, h, h + n) { // cas-release, commits consume
//如果cas操作失败,说明已经有其它工作线程从_p_的本地运行队列偷走了一些goroutine,所以直接返回
return false
}
batch[n] = gp if randomizeScheduler {
for i := uint32(1); i <= n; i++ {
j := fastrandn(i + 1)
batch[i], batch[j] = batch[j], batch[i]
}
} // Link the goroutines.
//全局运行队列是一个链表,这里首先把所有需要放入全局运行队列的g链接起来,
//减少后面对全局链表的锁住时间,从而降低锁冲突
for i := uint32(0); i < n; i++ {
batch[i].schedlink.set(batch[i+1])
}
var q gQueue
q.head.set(batch[0])
q.tail.set(batch[n]) // Now put the batch on global queue.
lock(&sched.lock)
globrunqputbatch(&q, int32(n+1))
unlock(&sched.lock)
return true
}

runqputslow函数首先使用链表把从_p_的本地队列中取出的一半连同gp一起串联起来,然后在加锁成功之后通过globrunqputbatch函数把该链表链入全局运行队列(全局运行队列是使用链表实现的)。值的一提的是runqputslow函数并没有一开始就把全局运行队列锁住,而是等所有的准备工作做完之后才锁住全局运行队列,这是并发编程加锁的基本原则,需要尽量减小锁的粒度,降低锁冲突的概率。

分析完runqput函数是如何把goroutine放入运行队列之后,接下来我们继续分析main goroutine因读取channel而发生的阻塞流程。

因读取channel阻塞而发生的被动调度

从代码逻辑的角度来说,我们不能确定main goroutine和新创建出来的g2谁先运行,但对于我们分析来说我们可以假定某个goroutine先运行,因为不管谁先运行,都会阻塞在channel的读或则写上,所以这里我们假设main创建好g2后首先阻塞在了对channel的读操作上。下面我们看看读取channel的过程。

从前面的反汇编代码我们知道读取channel是通过调用runtime.chanrecv1函数来完成的,我们就从它开始分析,不过在分析过程中我们不会把精力放在对channel的操作上,而是分析这个过程中跟调度有关的细节。

runtime/chan.go : 403

// entry points for <- c from compiled code
//go:nosplit
func chanrecv1(c *hchan, elem unsafe.Pointer) {
chanrecv(c, elem, true)
} // runtime/chan.go : 415
func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) {
......
//省略部分的代码逻辑主要在判断读取操作是否可以立即完成,如果不能立即完成
//就需要把g挂在channel c的读取队列上,然后调用goparkunlock函数阻塞此goroutine
goparkunlock(&c.lock, waitReasonChanReceive, traceEvGoBlockRecv, 3)
......
}

chanrecv1直接调用chanrecv函数实现读取操作,chanrecv首先会判断channel是否有数据可读,如果有数据则直接读取并返回,但如果没有数据,则需要把当前goroutine挂入channel的读取队列之中并调用goparkunlock函数阻塞该goroutine.

runtime/proc.go : 304

// Puts the current goroutine into a waiting state and unlocks the lock.
// The goroutine can be made runnable again by calling goready(gp).
func goparkunlock(lock*mutex, reasonwaitReason, traceEvbyte, traceskipint) {
gopark(parkunlock_c, unsafe.Pointer(lock), reason, traceEv, traceskip)
} // runtime/proc.go : 276
// Puts the current goroutine into a waiting state and calls unlockf.
// If unlockf returns false, the goroutine is resumed.
// unlockf must not access this G's stack, as it may be moved between
// the call to gopark and the call to unlockf.
// Reason explains why the goroutine has been parked.
// It is displayed in stack traces and heap dumps.
// Reasons should be unique and descriptive.
// Do not re-use reasons, add new ones.
func gopark(unlockffunc(*g, unsafe.Pointer) bool, lockunsafe.Pointer, reason waitReason, traceEvbyte, traceskipint) {
......
// can't do anything that might move the G between Ms here.
mcall(park_m) //切换到g0栈执行park_m函数
}

goparkunlock函数直接调用gopark函数,gopark则调用mcall从当前main goroutine切换到g0去执行park_m函数(mcall前面我们分析过,其主要作用就是保存当前goroutine的现场,然后切换到g0栈去调用作为参数传递给它的函数)

runtime/proc.go : 2581

// park continuation on g0.
func park_m(gp*g) {
_g_ := getg() if trace.enabled {
traceGoPark(_g_.m.waittraceev, _g_.m.waittraceskip)
} casgstatus(gp, _Grunning, _Gwaiting)
dropg() //解除g和m之间的关系 ...... schedule()
}

park_m首先把当前goroutine的状态设置为_Gwaiting(因为它正在等待其它goroutine往channel里面写数据),然后调用dropg函数解除g和m之间的关系,最后通过调用schedule函数进入调度循环,schedule函数我们也详细分析过,它首先会从运行队列中挑选出一个goroutine,然后调用gogo函数切换到被挑选出来的goroutine去运行。因为main goroutine在读取channel被阻塞之前已经把创建好的g2放入了运行队列,所以在这里schedule会把g2调度起来运行,这里完成了一次从main goroutine到g2调度(我们假设只有一个工作线程在进行调度)。

唤醒阻塞在channel上的goroutine

g2 goroutine的入口是start函数,下面我们就从该函数开始分析g2写channel的流程,看它如何唤醒正在等待着读取channel的main goroutine。还是先来反汇编一下start函数的代码:

0x44f480<+>:mov   %fs:0xfffffffffffffff8,%rcx
0x44f489<+>:cmp 0x10(%rcx),%rsp
0x44f48d<+>:jbe 0x44f4c1 <main.start+>
0x44f48f<+>:sub $0x18,%rsp
0x44f493<+>:mov %rbp,0x10(%rsp)
0x44f498<+>:lea 0x10(%rsp),%rbp
0x44f49d<+>:mov 0x20(%rsp),%rax
0x44f4a2<+>:mov %rax,(%rsp)
0x44f4a6<+>:lea 0x2d71b(%rip),%rax
0x44f4ad<+>:mov %rax,0x8(%rsp)
0x44f4b2<+>:callq 0x404560 <runtime.chansend1> #写channel
0x44f4b7<+>:mov 0x10(%rsp),%rbp
0x44f4bc<+>:add $0x18,%rsp
0x44f4c0<+>:retq
0x44f4c1<+>:callq 0x447390 <runtime.morestack_noctxt>
0x44f4c6<+>:jmp 0x44f480 <main.start>

可以看到,编译器把对channel的发送操作翻译成了对runtime.chansend1函数的调用

runtime/chan.go : 124

/ entry point for c <- x from compiled code
//go:nosplit
func chansend1(c *hchan, elem unsafe.Pointer) {
chansend(c, elem, true, getcallerpc())
} // runtime/chan.go : 142
func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {
......
if sg := c.recvq.dequeue(); sg != nil {
// Found a waiting receiver. We pass the value we want to send
// directly to the receiver, bypassing the channel buffer (if any).
//可以直接发送数据给sg
send(c, sg, ep, func() { unlock(&c.lock) }, 3)
return true
}
......
} // runtime/chan.go : 269
func send(c *hchan, sg *sudog, ep unsafe.Pointer, unlockf func(), skip int) {
......
goready(gp, skip+1)
} // runtime/proc.go : 310
func goready(gp *g, traceskip int) {
systemstack(func() {
ready(gp, traceskip, true)
})
}

channel发送和读取的流程类似,如果能够立即发送则立即发送并返回,如果不能立即发送则需要阻塞,在我们这个场景中,因为main goroutine此时此刻正挂在channel的读取队列上等待数据,所以这里直接调用send函数发送给main goroutine,send函数则调用goready函数切换到g0栈并调用ready函数来唤醒sg对应的goroutine,即正在等待读channel的main goroutine。

runtime/proc.go : 639

// Mark gp ready to run.
func ready(gp *g, traceskip int, next bool) {
......
// Mark runnable.
_g_ := getg()
......
// status is Gwaiting or Gscanwaiting, make Grunnable and put on runq
casgstatus(gp, _Gwaiting, _Grunnable)
runqput(_g_.m.p.ptr(), gp, next) //放入运行队列
if atomic.Load(&sched.npidle) != 0 && atomic.Load(&sched.nmspinning) == 0 {
//有空闲的p而且没有正在偷取goroutine的工作线程,则需要唤醒p出来工作
wakep()
}
......
}

ready函数首先把需要唤醒的goroutine的状态设置为_Grunnable,然后把其放入运行队列之中等待调度器的调度。

对于本章我们分析的场景,执行到这里main goroutine已经被放入了运行队列,但还未被调度起来运行,而g2 goroutine在向channel写完数据之后就从这里的ready函数返回并退出了,从第二章我们对goroutine的退出流程的分析可以得知,在g2的退出过程中将会在goexit0函数中调用schedule函数进入下一轮调度,从而把刚刚放入运行队列的main goroutine调度起来运行。

在上面分析ready函数时我们略过了一种情况:如果当前有空闲的p而且没有工作线程正在尝试从各个工作线程的本地运行队列偷取goroutine的话(没有处于spinning状态的工作线程),那么就需要通过wakep函数把空闲的p唤醒起来工作。为了不让篇幅过长,下一节我们再来分析wakep如何去唤醒和创建新的工作线程。

Goroutine被动调度之一(18)的更多相关文章

  1. golang goroutine的调度

    golang goroutine的调度 1.什么是协程? 协程是一种用户态的轻量级线程. 2.进程.线程.协程的关系和区别: * 进程拥有自己独立的堆和栈,既不共享堆,亦不共享栈,进程由操作系统调度. ...

  2. goroutine与调度器

    29 November 2013 by skoo 我们都知道Go语言是原生支持语言级并发的,这个并发的最小逻辑单元就是goroutine.goroutine就是Go语言提供的一种用户态线程,当然这种用 ...

  3. Goroutine并发调度模型深度解析之手撸一个协程池

    golanggoroutine协程池Groutine Pool高并发 并发(并行),一直以来都是一个编程语言里的核心主题之一,也是被开发者关注最多的话题:Go语言作为一个出道以来就自带 『高并发』光环 ...

  4. Golang/Go goroutine调度器原理/实现【原】

    Go语言在2016年再次拿下TIBOE年度编程语言称号,这充分证明了Go语言这几年在全世界范围内的受欢迎程度.如果要对世界范围内的gopher发起一次“你究竟喜欢Go的哪一点”的调查,我相信很多Gop ...

  5. goroutine调度

    0.1.索引 https://blog.waterflow.link/articles/1662974432717 1.进程 一个进程包含可以由任何进程分配的公共资源.这些资源包括但不限于内存地址空间 ...

  6. [转]golang的goroutine调度机制

    golang的goroutine调度机制 版权声明:本文为博主原创文章,未经博主允许不得转载.   目录(?)[-] 一直对goroutine的调度机制很好奇最近在看雨痕的golang源码分析基于go ...

  7. go语言之行--golang核武器goroutine调度原理、channel详解

    一.goroutine简介 goroutine是go语言中最为NB的设计,也是其魅力所在,goroutine的本质是协程,是实现并行计算的核心.goroutine使用方式非常的简单,只需使用go关键字 ...

  8. [GO语言的并发之道] Goroutine调度原理&Channel详解

    并发(并行),一直以来都是一个编程语言里的核心主题之一,也是被开发者关注最多的话题:Go语言作为一个出道以来就自带 『高并发』光环的富二代编程语言,它的并发(并行)编程肯定是值得开发者去探究的,而Go ...

  9. 非main goroutine的退出及调度循环(15)

    本文是<Go语言调度器源代码情景分析>系列的第15篇,也是第二章的第5小节. 上一节我们说过main goroutine退出时会直接执行exit系统调用退出整个进程,而非main goro ...

随机推荐

  1. 【转】 使用 Python 获取 Linux 系统信息

    在本文中,我们将会探索使用Python编程语言工具来检索Linux系统各种信息.走你. 哪个Python版本? 当我提及Python,所指的就是CPython 2(准确的是2.7).我会显式提醒那些相 ...

  2. 怎样使用1M的内存排序100万个8位数

    今天看到这篇文章.颇为震撼.感叹算法之"神通". 借助于合适的算法能够完毕看似不可能的事情. 最早这个问题是在Stack Overflow站点上面给出的(Sorting numbe ...

  3. gulp - sass 插件一直安装不好?

    1.没有sass,只存在与scss 任务 gulp.task('scss', function() { return gulp.src(app.srcPath + '/sass/*.scss') .p ...

  4. HTML制作练习

  5. 浅谈JavaScript的事件(事件流)

     事件流描述的是从页面中接收事件的顺序.IE的事件流失事件冒泡,而Netspace的事件流失事件捕获. 事件冒泡 IE的事件流叫事件冒泡,即事件开始时,由具体的元素(文档中嵌套层次最深的节点)接收,然 ...

  6. hibernate 的POJO状态

    瞬时状态 刚new出来的对象,还没和session发生联系,或者delete之后的对象 持久化状态 用save,get等方法保存或获取到session中的对象,和数据保持一一对应的关系 脱管状态 对象 ...

  7. 小程序 单页应用的 tab切换 实现

    小程序 单页应用的  tab切换 实现

  8. MongoDB and Redis

    简介 MongoDB更类似MySQL,支持字段索引.游标操作,其优势在于查询功能比较强大,擅长查询JSON数据,能存储海量数据,但是不支持事务. Mysql在大数据量时效率显著下降,MongoDB更多 ...

  9. 使用iconv的包装类CharsetConverter进行编码转换的示例

    GitHub地址https://github.com/BuYishi/charset_converter_test charset_converter_test.cpp #include <io ...

  10. Form Template Method

    <重构>中此方法叫做塑造模板函数,在设计模式中,对应的模式就是模板模式.重构中的很多变动比较大的方法都会导致重构,但重构中有非常多的小重构手法.就好像建筑一个房子,设计模式教你厨房客厅怎么 ...