工作執行緒的喚醒及建立(19)

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

本文是《Go語言排程器原始碼情景分析》系列的第19篇,也是第四章《Goroutine被動排程》的第2小節。


本文需要重點關注:

  • 如何喚醒睡眠中的工作執行緒

  • 如何建立新的工作執行緒

上一篇文章我們分析到了ready函式通過把需要喚醒的goroutine放入執行佇列來喚醒它,本文接著上文繼續分析。

喚醒空閒的P

為了充分利用CPU,ready函式在喚醒goroutine之後會去判斷是否需要啟動新工作執行緒出來工作,判斷規則是,如果當前有空閒的p而且沒有工作執行緒正在嘗試從各個工作執行緒的本地執行佇列偷取goroutine的話(沒有處於spinning狀態的工作執行緒),那麼就需要把空閒的p喚醒起來工作,詳見下面的ready函式:

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()
    }
    ......
}

而喚醒空閒的p是由wakep函式完成的。

runtime/proc.go : 2051

// Tries to add one more P to execute G's.
// Called when a G is made runnable (newproc, ready).
func wakep() {
    // be conservative about spinning threads
    if !atomic.Cas(&sched.nmspinning, 0, 1) {
        return
    }
    startm(nil, true)
}

wakep首先通過cas操作再次確認是否有其它工作執行緒正處於spinning狀態,這裡之所以需要使用cas操作再次進行確認,原因在於,在當前工作執行緒通過如下條件

atomic.Load(&sched.npidle) != 0 & &atomic.Load(&sched.nmspinning) == 0

判斷到需要啟動工作執行緒之後到真正啟動工作執行緒之前的這一段時間之內,如果已經有工作執行緒進入了spinning狀態而在四處尋找需要執行的goroutine,這樣的話我們就沒有必要再啟動一個多餘的工作執行緒出來了。

如果cas操作成功,則繼續呼叫startm建立一個新的或喚醒一個處於睡眠狀態的工作執行緒出來工作。

runtime/proc.go : 1947

// Schedules some M to run the p (creates an M if necessary).
// If p==nil, tries to get an idle P, if no idle P's does nothing.
// May run with m.p==nil, so write barriers are not allowed.
// If spinning is set, the caller has incremented nmspinning and startm will
// either decrement nmspinning or set m.spinning in the newly started M.
//go:nowritebarrierrec
func startm(_p_ *p, spinning bool) {
    lock(&sched.lock)
    if _p_ == nil { //沒有指定p的話需要從p的空閒佇列中獲取一個p
        _p_ = pidleget() //從p的空閒佇列中獲取空閒p
        if _p_ == nil {
            unlock(&sched.lock)
            if spinning {
                // The caller incremented nmspinning, but there are no idle Ps,
                // so it's okay to just undo the increment and give up.
                //spinning為true表示進入這個函式之前已經對sched.nmspinning加了1,需要還原
                if int32(atomic.Xadd(&sched.nmspinning, -1)) < 0 {
                    throw("startm: negative nmspinning")
                }
            }
            return //沒有空閒的p,直接返回
        }
    }
    mp := mget() //從m空閒佇列中獲取正處於睡眠之中的工作執行緒,所有處於睡眠狀態的m都在此佇列中
    unlock(&sched.lock)
    if mp == nil {
        //沒有處於睡眠狀態的工作執行緒
        var fn func()
        if spinning {
            // The caller incremented nmspinning, so set m.spinning in the new M.
            fn = mspinning
        }
        newm(fn, _p_) //建立新的工作執行緒
        return
    }
    if mp.spinning {
        throw("startm: m is spinning")
    }
    if mp.nextp != 0 {
        throw("startm: m has p")
    }
    if spinning && !runqempty(_p_) {
        throw("startm: p has runnable gs")
    }
    // The caller incremented nmspinning, so set m.spinning in the new M.
    mp.spinning = spinning
    mp.nextp.set(_p_)
   
    //喚醒處於休眠狀態的工作執行緒
    notewakeup(&mp.park)
}

startm函式首先判斷是否有空閒的p結構體物件,如果沒有則直接返回,如果有則需要建立或喚醒一個工作執行緒出來與之繫結,從這裡可以看出所謂的喚醒p,其實就是把空閒的p利用起來。

在確保有可以繫結的p物件之後,startm函式首先嚐試從m的空閒佇列中查詢正處於休眠狀態的工作執行緒,如果找到則通過notewakeup函式喚醒它,否則呼叫newm函式建立一個新的工作執行緒出來。

下面我們首先分析notewakeup函式是如何喚醒工作執行緒的,然後再討論newm函式建立工作執行緒的流程。

喚醒睡眠中的工作執行緒

在第三章我們討論過,當找不到需要執行的goroutine時,工作執行緒會通過notesleep函式睡眠在m.park成員上,所以這裡使用m.park成員作為引數呼叫notewakeup把睡眠在該成員之上的工作執行緒喚醒。

runtime/lock_futex.go : 130

func notewakeup(n *note) {
    //設定n.key = 1, 被喚醒的執行緒通過檢視該值是否等於1來確定是被其它執行緒喚醒還是意外從睡眠中甦醒
    old := atomic.Xchg(key32(&n.key), 1)  
    if old != 0 {
        print("notewakeup - double wakeup (", old, ")\n")
        throw("notewakeup - double wakeup")
    }
    //呼叫futexwakeup喚醒
    futexwakeup(key32(&n.key), 1)
}

notewakeup函式首先使用atomic.Xchg設定note.key值為1,這是為了使被喚醒的執行緒可以通過檢視該值是否等於1來確定是被其它執行緒喚醒還是意外從睡眠中甦醒了過來,如果該值為1則表示是被喚醒的,可以繼續工作了,但如果該值為0則表示是意外甦醒,需要再次進入睡眠,工作執行緒甦醒之後的處理邏輯我們已經在notesleep函式中見過,所以這裡略過。 

把note.key的值設定為1後,notewakeup函式繼續呼叫futexwakeup函式

runtime/os_linux.go : 66

// If any procs are sleeping on addr, wake up at most cnt.
//go:nosplit
func futexwakeup(addr *uint32, cnt uint32) {
    //呼叫futex函式喚醒工作執行緒
    ret := futex(unsafe.Pointer(addr), _FUTEX_WAKE_PRIVATE, cnt, nil, nil, 0)
    if ret >= 0 {
        return
    }

    // I don't know that futex wakeup can return
    // EAGAIN or EINTR, but if it does, it would be
    // safe to loop and call futex again.
    systemstack(func() {
        print("futexwakeup addr=", addr, " returned ", ret, "\n")
    })

    *(*int32)(unsafe.Pointer(uintptr(0x1006))) = 0x1006
}

對於Linux平臺來說,工作執行緒通過note睡眠其實是通過futex系統呼叫睡眠在核心之中,所以喚醒處於睡眠狀態的執行緒也需要通過futex系統呼叫進入核心來喚醒,所以這裡的futexwakeup又繼續呼叫包裝了futex系統呼叫的futex函式來實現喚醒睡眠在核心中的工作執行緒。

runtime/sys_linux_amd64.s : 525

// int64 futex(int32 *uaddr, int32 op, int32 val,
//struct timespec *timeout, int32 *uaddr2, int32 val2);
TEXT runtime·futex(SB),NOSPLIT,$0
    MOVQ  addr+0(FP), DI #這6條指令在為futex系統呼叫準備引數
    MOVL  op+8(FP), SI
    MOVL  val+12(FP), DX
    MOVQ  ts+16(FP), R10
    MOVQ  addr2+24(FP), R8
    MOVL  val3+32(FP), R9
    MOVL  $SYS_futex, AX  #futex系統呼叫編號放入AX暫存器
    SYSCALL  #系統呼叫,進入核心
    MOVL  AX, ret+40(FP) #系統呼叫通過AX暫存器返回返回值,這裡把返回值儲存到記憶體之中
    RET

futex函式由彙編程式碼寫成,前面的幾條指令都在為futex系統呼叫準備引數,引數準備完成之後則通過SYSCALL指令進入作業系統核心完成執行緒的喚醒功能,核心在完成喚醒工作之後當前工作執行緒則從核心返回到futex函式繼續執行SYSCALL指令之後的程式碼並按函式呼叫鏈原路返回,繼續執行其它程式碼,而被喚醒的工作執行緒則由核心負責在適當的時候排程到CPU上執行。

看完喚醒流程,下面我們來分析工作執行緒的建立。

建立工作執行緒

回到startm函式,如果沒有正處於休眠狀態的工作執行緒,則需要呼叫newm函式新建一個工作執行緒。

runtime/proc.go : 1807

// Create a new m. It will start off with a call to fn, or else the scheduler.
// fn needs to be static and not a heap allocated closure.
// May run with m.p==nil, so write barriers are not allowed.
//go:nowritebarrierrec
func newm(fn func(), _p_ *p) {
    mp := allocm(_p_, fn)
    mp.nextp.set(_p_)
    ......
    newm1(mp)
}

newm首先呼叫allocm函式從堆上分配一個m結構體物件,然後呼叫newm1函式。

runtime/proc.go : 1843

func newm1(mp *m) {
      //省略cgo相關程式碼.......
      execLock.rlock() // Prevent process clone.
      newosproc(mp)
      execLock.runlock()
}

newm1繼續呼叫newosproc函式,newosproc的主要任務是呼叫clone函式建立一個系統執行緒,而新建的這個系統執行緒將從mstart函式開始執行。

runtime/os_linux.go : 143

// May run with m.p==nil, so write barriers are not allowed.
//go:nowritebarrier
func newosproc(mp *m) {
    stk := unsafe.Pointer(mp.g0.stack.hi)                    
    ......
    ret := clone(cloneFlags, stk, unsafe.Pointer(mp), unsafe.Pointer(mp.g0),         unsafe.Pointer(funcPC(mstart)))
    ......
}
//clone系統呼叫的Flags選項
cloneFlags = _CLONE_VM | /* share memory */ //指定父子執行緒共享程式地址空間
  _CLONE_FS | /* share cwd, etc */
  _CLONE_FILES | /* share fd table */
  _CLONE_SIGHAND | /* share sig handler table */
  _CLONE_SYSVSEM | /* share SysV semaphore undo lists (see issue #20763) */
  _CLONE_THREAD /* revisit - okay for now */  //建立子執行緒而不是子程式

clone函式是由組合語言實現的,該函式使用clone系統呼叫完成建立系統執行緒的核心功能。我們分段來看

runtime/sys_linux_amd64.s : 539

// int32 clone(int32 flags, void *stk, M *mp, G *gp, void (*fn)(void));
TEXT runtime·clone(SB),NOSPLIT,$0
    MOVL  flags+0(FP), DI//系統呼叫的第一個引數
    MOVQ  stk+8(FP), SI  //系統呼叫的第二個引數
    MOVQ  $0, DX        //第三個引數
    MOVQ  $0, R10        //第四個引數

    // Copy mp, gp, fn off parent stack for use by child.
    // Careful:Linux system call clobbers CXand R11.
    MOVQ  mp+16(FP), R8
    MOVQ  gp+24(FP), R9
    MOVQ  fn+32(FP), R12

    MOVL  $SYS_clone, AX
    SYSCALL

clone函式首先用了4條指令為clone系統呼叫準備引數,該系統呼叫一共需要四個引數,根據Linux系統呼叫約定,這四個引數需要分別放入rdi, rsi,rdx和r10暫存器中,這裡最重要的是第一個引數和第二個引數,分別用來指定核心建立執行緒時需要的選項和新執行緒應該使用的棧。因為即將被建立的執行緒與當前執行緒共享同一個程式地址空間,所以這裡必須為子執行緒指定其使用的棧,否則父子執行緒會共享同一個棧從而造成混亂,從上面的newosproc函式可以看出,新執行緒使用的棧為m.g0.stack.lo~m.g0.stack.hi這段記憶體,而這段記憶體是newm函式在建立m結構體物件時從程式的堆上分配而來的。

準備好系統呼叫的引數之後,還有另外一件很重的事情需要做,那就是把clone函式的其它幾個引數(mp, gp和執行緒入口函式)儲存到暫存器中,之所以需要在系統呼叫之前儲存這幾個引數,原因在於這幾個引數目前還位於父執行緒的棧之中,而一旦通過系統呼叫把子執行緒建立出來之後,子執行緒將會使用我們在clone系統呼叫時給它指定的棧,所以這裡需要把這幾個引數先儲存到暫存器,等子執行緒從系統呼叫返回後直接在暫存器中獲取這幾個引數。這裡要注意的是雖然這個幾個引數值儲存在了父執行緒的暫存器之中,但建立子執行緒時,作業系統核心會把父執行緒的所有暫存器幫我們複製一份給子執行緒,所以當子執行緒開始執行時就能拿到父執行緒儲存在暫存器中的值,從而拿到這幾個引數。這些準備工作完成之後程式碼呼叫syscall指令進入核心,由核心幫助我們建立系統執行緒。

clone系統呼叫完成後實際上就多了一個作業系統執行緒,新建立的子執行緒和當前執行緒都得從系統呼叫返回然後繼續執行後面的程式碼,那麼從系統呼叫返回之後我們怎麼知道哪個是父執行緒哪個是子執行緒,從而來決定它們的執行流程?使用過fork系統呼叫的讀者應該知道,我們需要通過返回值來判斷父子執行緒,系統呼叫的返回值如果是0則表示這是子執行緒,不為0則表示這個是父執行緒。用c程式碼來描述大概就是這個樣子:

if (clone(...) == 0) { //子執行緒
    子執行緒程式碼
} else {//父執行緒
    父執行緒程式碼
}

雖然這裡只有一次clone呼叫,但它卻返回了2次,一次返回到父執行緒,一次返回到子執行緒,然後2個執行緒各自執行自己的程式碼流程。

回到clone函式,下面程式碼的第一條指令就在判斷系統呼叫的返回值,如果是子執行緒則跳轉到後面的程式碼繼續執行,如果是父執行緒,它建立子執行緒的任務已經完成,所以這裡把返回值儲存在棧上之後就直接執行ret指令返回到newosproc函式了。

runtime/sys_linux_amd64.s : 555   

// In parent, return.
    CMPQ  AX, $0  #判斷clone系統呼叫的返回值
    JEQ  3(PC) / #跳轉到子執行緒部分
    MOVL  AX, ret+40(FP) #父執行緒需要執行的指令
    RET #父執行緒需要執行的指令

而對於子執行緒來說,還有很多初始化工作要做,下面是子執行緒需要繼續執行的指令。

runtime/sys_linux_amd64.s : 561

# In child, on new stack.
    #子執行緒需要繼續執行的指令
    MOVQ  SI, SP #設定CPU棧頂暫存器指向子執行緒的棧頂,這條指令看起來是多餘的?核心應該已經把SP設定好了

    # If g or m are nil, skip Go-related setup.
    CMPQ  R8, $0    # m,新建立的m結構體物件的地址,由父執行緒儲存在R8暫存器中的值被複制到了子執行緒
    JEQ  nog
    CMPQ  R9, $0    # g,m.g0的地址,由父執行緒儲存在R9暫存器中的值被複制到了子執行緒
    JEQ  nog

    # Initialize m->procid to Linux tid
    MOVL  $SYS_gettid, AX #通過gettid系統呼叫獲取執行緒ID(tid)
    SYSCALL
    MOVQ  AX, m_procid(R8)  #m.procid = tid

    #Set FS to point at m->tls.
    #新執行緒剛剛建立出來,還未設定執行緒本地儲存,即m結構體物件還未與工作執行緒關聯起來,
    #下面的指令負責設定新執行緒的TLS,把m物件和工作執行緒關聯起來
    LEAQ  m_tls(R8), DI #取m.tls欄位的地址
    CALL  runtime·settls(SB)

    #In child, set up new stack
    get_tls(CX)
    MOVQ  R8, g_m(R9)  # g.m = m 
    MOVQ  R9, g(CX)      # tls.g = &m.g0
    CALL  runtime·stackcheck(SB)

nog:
    # Call fn
    CALL  R12 #這裡呼叫mstart函式
    ......

這段程式碼的第一條指令把CPU暫存器的棧頂指標設定為新執行緒的的棧頂,這條指令看起來是多餘的,因為我們在clone系統呼叫時已經把棧資訊告訴作業系統了,作業系統在把新執行緒排程起來執行時已經幫我們把CPU的rsp暫存器設定好了,這裡應該沒必要自己去設定。接下來的4條指令判斷m和g是否為nil,如果是則直接去執行fn函式,對於我們這個流程來說,因為現在正在建立工作執行緒,所以m和g(其實是m.g0)都不為空,因而需要繼續對m進行初始化。

對新建立出來的工作執行緒的初始化過程從上面程式碼片段的第6條指令開始,它首先通過系統呼叫獲取到子執行緒的執行緒id,並賦值給m.procid,然後呼叫settls設定執行緒本地儲存並通過把m.g0的地址放入執行緒本地儲存之中,從而實現了m結構體物件與工作執行緒之間的關聯,settls函式我們已經在第二章詳細分析過,所以這裡直接跳過。

新工作執行緒的初始化完成之後,便開始執行mstart函式,我們在第二章也見過該函式,主執行緒初始化完成之後也是呼叫的它。回憶一下,mstart函式首先會去設定m.g0的stackguard成員,然後呼叫mstart1()函式把當前工作執行緒的g0的排程資訊儲存在m.g0.sched成員之中,最後通過呼叫schedule函式進入排程迴圈。

總結

本章僅以讀寫channel為例分析了goroutine因操作被阻塞而發生的被動排程,其實發生被動排程的情況還比較多,比如因讀寫網路連線而阻塞、加鎖被阻塞或select操作阻塞等等都會發生被動排程,讀者可以自行閱讀相關原始碼。

本章還分析了睡眠中的工作執行緒是如何被喚起起來工作的以及新工作執行緒的建立和初始化流程。

相關文章