本文是《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. printf行缓冲区的分析总结

    最近在客户那调试串口的时候,read串口然后printf打印,单字符printf,发现没有输出,后来想起来printf这些标准输入输出函数也是属于标准C库glibc的, 这里就要区分一下标准库函数和系 ...

  2. Python基础语法07--面向对象+正则表达式

    Python 面向对象 Python从设计之初就已经是一门面向对象的语言,正因为如此,在Python中创建一个类和对象是很容易的.本章节我们将详细介绍Python的面向对象编程. 如果你以前没有接触过 ...

  3. vim列块操作

    一.可视模式 进入可视模式有三种方法:v,V,CTRL+V (1)按v启用可视模式,能够按单个字符选择内容,移动光标能够选择. 如: (2)按V启用可视模式,立马选中光标所在行.按单行符选择内容.移动 ...

  4. 九度OJ #1437 To Fill or Not to Fil

    题目描写叙述: With highways available, driving a car from Hangzhou to any other city is easy. But since th ...

  5. ``Accordian&#39;&#39; Patience

    ``Accordian'' Patience  You are to simulate the playing of games of ``Accordian'' patience, the rule ...

  6. DW格式与布局

  7. Distributed Management Task Force----分布式管理任务组

    http://baike.baidu.com/link?url=Y9HGLs8Qj6pXbbgY6xPdfiGDsQO8Eu1e80B4giQtQ_hAfGNF59byxnLoERYri4Duw7Gw ...

  8. 如何动态地给vSphere虚拟机模板注入信息

    在做vSphere自动化安装过程中,遇到这样一个需求:将vCenter Server做成模板,在给用户自动化装好vSphere后, 下载vCenter Server模板并启动虚拟机,然后将vCente ...

  9. [网页游戏开发]容器的使用及自定义Tab,RadioGroup,List,ViewStack

    Morn里面,容器和其他普通组件不同,无需皮肤,所以也不能从组件树种拖动创建(Tab,RadioGroup例外),只能转换而来 Morn的容器组件主要有Box,Container,Panel,Tab, ...

  10. 【c++】map 迭代器删除演示样例

    C++ STL中的map是很常见的.通常我们用例如以下方式来遍历,而且删除map中的一些entry: map<int, int> mp; mp.insert(make_pair(1,1)) ...