Goroutine被動排程之一(18)

愛寫程式的阿波張發表於2019-05-22

本文是《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<+0>: mov   %fs:0xfffffffffffffff8,%rcx
0x44f4d9<+9>: cmp   0x10(%rcx),%rsp
0x44f4dd<+13>: jbe   0x44f549 <main.main+121>
0x44f4df<+15>: sub   $0x28,%rsp
0x44f4e3<+19>: mov   %rbp,0x20(%rsp)
0x44f4e8<+24>: lea   0x20(%rsp),%rbp
0x44f4ed<+29>: lea   0xb36c(%rip),%rax       
0x44f4f4<+36>: mov   %rax,(%rsp)
0x44f4f8<+40>: movq   $0x0,0x8(%rsp)
0x44f501<+49>: callq    0x404330 <runtime.makechan>  #建立channel
0x44f506<+54>: mov   0x10(%rsp),%rax
0x44f50b<+59>: mov   %rax,0x18(%rsp)
0x44f510<+64>: movl   $0x8,(%rsp)
0x44f517<+71>: lea   0x240f2(%rip),%rcx       
0x44f51e<+78>: mov   %rcx,0x8(%rsp)
0x44f523<+83>: callq   0x42c1b0 <runtime.newproc> #建立goroutine
0x44f528<+88>: mov   0x18(%rsp),%rax
0x44f52d<+93>: mov   %rax,(%rsp)
0x44f531<+97>: movq   $0x0,0x8(%rsp)
0x44f53a<+106>: callq   0x405080 <runtime.chanrecv1> #從channel讀取資料
0x44f53f<+111>: mov   0x20(%rsp),%rbp
0x44f544<+116>: add   $0x28,%rsp
0x44f548<+120>: retq   
0x44f549<+121>: callq 0x447390 <runtime.morestack_noctxt>
0x44f54e<+126>: 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<+0>:mov   %fs:0xfffffffffffffff8,%rcx
0x44f489<+9>:cmp   0x10(%rcx),%rsp
0x44f48d<+13>:jbe   0x44f4c1 <main.start+65>
0x44f48f<+15>:sub   $0x18,%rsp
0x44f493<+19>:mov   %rbp,0x10(%rsp)
0x44f498<+24>:lea   0x10(%rsp),%rbp
0x44f49d<+29>:mov   0x20(%rsp),%rax
0x44f4a2<+34>:mov   %rax,(%rsp)
0x44f4a6<+38>:lea   0x2d71b(%rip),%rax       
0x44f4ad<+45>:mov   %rax,0x8(%rsp)
0x44f4b2<+50>:callq   0x404560 <runtime.chansend1> #寫channel
0x44f4b7<+55>:mov   0x10(%rsp),%rbp
0x44f4bc<+60>:add   $0x18,%rsp
0x44f4c0<+64>:retq   
0x44f4c1<+65>:callq    0x447390 <runtime.morestack_noctxt>
0x44f4c6<+70>: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如何去喚醒和建立新的工作執行緒。

相關文章