go1.14 基於訊號的搶佔式排程實現原理

situ發表於2020-02-26

前言:

疫情期間里老老實實在家蹲著,這期間主要研究下 go 1.14 新增的部分。go 1.14 中比較大的更新有訊號的搶佔排程、defer 內聯優化,定時器優化等。前幾天剛寫完了 golang 1.14 timer 定時器的優化,有興趣的朋友可以看看go1.14 基於 netpoll 優化 timer 定時器實現原理

golang 在之前的版本中已經實現了搶佔排程,不管是陷入到大量計算還是系統呼叫,大多可被 sysmon 掃描到並進行搶佔。但有些場景是無法搶佔成功的。比如輪詢計算 for { i++ } 等,這類操作無法進行 newstack、morestack、syscall,所以無法檢測 stackguard0 = stackpreempt。

go team 已經意識到搶佔是個問題,所以在 1.14 中加入了基於訊號的協程排程搶佔。原理是這樣的,首先註冊繫結 SIGURG 訊號及處理方法 runtime.doSigPreempt,sysmon 會間隔性檢測超時的 p,然後傳送訊號,m 收到訊號後休眠執行的 goroutine 並且進行重新排程。

對比測試:

// xiaorui.cc

package main

import (
    "runtime"
)

func main() {
    runtime.GOMAXPROCS(1)

    go func() {
        panic("already call")
    }()

    for {
    }
}

上面的測試思路是先針對 GOMAXPROCS 的 p 配置為 1,這樣就可以規避併發而影響搶佔的測試,然後 go 關鍵字會把當前傳遞的函式封裝協程結構,扔到 runq 佇列裡等待 runtime 排程,由於是非同步執行,所以就執行到 for 死迴圈無法退出。

go1.14 是可以執行到 panic,而 1.13 版本一直掛在死迴圈上。那麼在 go1.13 是如何解決這個問題? 要麼併發加大,要麼執行一個 syscall,要麼執行復雜的函式產生 morestack 擴棧。對比 go1.13 版,通過 strace 可以看到 go1.14 多了一步傳送訊號中斷。這看似就是文章開頭講到的基於訊號的搶佔式排程了。

原碼分析:

以前寫過文章來分析 go sysmon() 的工作,在新版 go 1.14 裡其他功能跟以前一樣,只是加入了訊號搶佔。

怎麼註冊的 sigurg 訊號?

// xiaorui.cc

const sigPreempt = _SIGURG

func initsig(preinit bool) {
    for i := uint32(0); i < _NSIG; i++ {
        fwdSig[i] = getsig(i)
        ,,,
        setsig(i, funcPC(sighandler)) // 註冊訊號對應的回撥方法
    }
}

func sighandler(sig uint32, info *siginfo, ctxt unsafe.Pointer, gp *g) {
    ,,,
    if sig == sigPreempt {  // 如果是搶佔訊號
        // Might be a preemption signal.
        doSigPreempt(gp, c)
    }
    ,,,
}

// 執行搶佔
func doSigPreempt(gp *g, ctxt *sigctxt) {
    if wantAsyncPreempt(gp) && isAsyncSafePoint(gp, ctxt.sigpc(), ctxt.sigsp(), ctxt.siglr()) {
        // Inject a call to asyncPreempt.
        ctxt.pushCall(funcPC(asyncPreempt))  // 執行搶佔的關鍵方法
    }

    // Acknowledge the preemption.
    atomic.Xadd(&gp.m.preemptGen, 1)
}

go 在啟動時把所有的訊號都註冊了一遍,包括可靠的訊號。(截圖為部分)

由誰去發起檢測搶佔?

go1.14 之前的版本是是由 sysmon 檢測搶佔,到了 go1.14 當然也是由 sysmon 操作。runtime 在啟動時會建立一個執行緒來執行 sysmon,為什麼要獨立執行? sysmon 是 golang 的 runtime 系統檢測器,sysmon 可進行 forcegc、netpoll、retake 等操作。拿搶佔功能來說,如 sysmon 放到 pmg 排程模型裡,每個 p 上面的 goroutine 恰好阻塞了,那麼還怎麼執行搶佔?

所以 sysmon 才要獨立繫結執行,就上面的指令碼在測試執行的過程中,雖然看似阻塞狀態,但進行 strace 可看到 sysmon 在不斷休眠喚醒操作。sysmon 啟動後會間隔性的進行監控,最長間隔 10ms,最短間隔 20us。如果某協程獨佔 P 超過 10ms,那麼就會被搶佔!

sysmon 依賴 schedwhen 和 schedtick 來記錄上次的監控資訊,schedwhen 記錄上次的檢測時間,schedtick 來區分排程時效。比如 sysmon 在兩次監控檢測期間,已經發生了多次 runtime.schedule 協程排程切換,每次排程時都會更新 schedtick 值。所以 retake 發現 sysmontick.schedtick 值不同時重新記錄 schedtick。

runtime/proc.go

// xiaorui.cc

func main() {
    g := getg()
    ,,,
    if GOARCH != "wasm" {
        systemstack(func() {
            newm(sysmon, nil)
        })
    }
    ,,,
}

func schedule() {
    ,,,
    execute(gp, inheritTime)
}

func execute(gp *g, inheritTime bool) {
    if !inheritTime {
        _g_.m.p.ptr().schedtick++
    }
    ,,,
}

func sysmon(){
    ,,,
         // retake P's blocked in syscalls
         // and preempt long running G's
         if retake(now) != 0 {
             idle = 0
         } else {
             idle++
         }
    ,,,
}

// 記錄每次檢查的資訊
type sysmontick struct {
    schedtick   uint32
    schedwhen   int64
    syscalltick uint32
    syscallwhen int64
}

const forcePreemptNS = 10 * 1000 * 1000 // 搶佔的時間閾值 10ms

func retake(now int64) uint32 {
    n := 0
    lock(&allpLock)
    for i := 0; i < len(allp); i++ {
        _p_ := allp[i]
        if _p_ == nil {
            continue
        }
        pd := &_p_.sysmontick
        s := _p_.status
        if s == _Prunning || s == _Psyscall {
            // Preempt G if it's running for too long.
            t := int64(_p_.schedtick)
            if int64(pd.schedtick) != t {
                pd.schedtick = uint32(t)
                pd.schedwhen = now // 記錄當前檢測時間
            // 上次時間加10ms小於當前時間,那麼說明超過,需進行搶佔。
            } else if pd.schedwhen+forcePreemptNS <= now {
                preemptone(_p_)
            }
        }

        // 下面省略掉慢系統呼叫的搶佔描述。
        if s == _Psyscall {
            // 原子更為p狀態為空閒狀態
            if atomic.Cas(&_p_.status, s, _Pidle) {
                ,,,
                handoffp(_p_)  // 強制解除安裝P, 然後startm來關聯
            }
        ,,,
} 

func preemptone(_p_ *p) bool {
    mp := _p_.m.ptr()
    ,,,

    gp.preempt = true

    ,,,
    gp.stackguard0 = stackPreempt

    // Request an async preemption of this P.
    if preemptMSupported && debug.asyncpreemptoff == 0 {
        _p_.preempt = true
        preemptM(mp)
    }

    return true
}

傳送 SIGURG 訊號?

signal_unix.go

// xiaorui.cc

// 給m傳送sigurg訊號
func preemptM(mp *m) {
    if !pushCallSupported {
        // This architecture doesn't support ctxt.pushCall
        // yet, so doSigPreempt won't work.
        return
    }
    if GOOS == "darwin" && (GOARCH == "arm" || GOARCH == "arm64") && !iscgo {
        return
    }
    signalM(mp, sigPreempt)
}

收到 sigurg 訊號後如何處理 ?

preemptPark 方法會解綁 mg 的關係,封存當前協程,繼而重新排程 runtime.schedule() 獲取可執行的協程,至於被搶佔的協程後面會去重啟。

goschedImpl 操作就簡單的多,把當前協程的狀態從_Grunning 正在執行改成 _Grunnable 可執行,使用 globrunqput 方法把搶佔的協程放到全域性佇列裡,根據 pmg 的協程排程設計,globalrunq 要後於本地 runq 被排程。

runtime/preempt.go

// xiaorui.cc

//go:generate go run mkpreempt.go

// asyncPreempt saves all user registers and calls asyncPreempt2.
//
// When stack scanning encounters an asyncPreempt frame, it scans that
// frame and its parent frame conservatively.
func asyncPreempt()

//go:nosplit
func asyncPreempt2() {
    gp := getg()
    gp.asyncSafePoint = true
    if gp.preemptStop {
        mcall(preemptPark)
    } else {
        mcall(gopreempt_m)
    }
    gp.asyncSafePoint = false
}

runtime/proc.go

// xiaorui.cc

// preemptPark parks gp and puts it in _Gpreempted.
//
//go:systemstack
func preemptPark(gp *g) {
    ,,,
    status := readgstatus(gp)
    if status&^_Gscan != _Grunning {
        dumpgstatus(gp)
        throw("bad g status")
    }
    ,,,
    schedule()
}

func goschedImpl(gp *g) {
    status := readgstatus(gp)
    ,,,
    casgstatus(gp, _Grunning, _Grunnable)
    dropg()
    lock(&sched.lock)
    globrunqput(gp)
    unlock(&sched.lock)

    schedule()
}

原始碼解析粗略的分析完了,還有一些細節不好讀懂,但訊號搶佔實現的大方向摸的 89 不離 10 了。

搶佔是否影響效能 ?

搶佔分為_Prunning 和 Psyscall,Psyscall 搶佔通常是由於阻塞性系統呼叫引起的,比如磁碟 io、cgo。Prunning 搶佔通常是由於一些類似死迴圈的計算邏輯引起的。

過度的傳送訊號來中斷 m 進行搶佔多少會影響效能的,主要是軟中斷和上下文切換。在平常的業務邏輯下,很難發生協程阻塞排程的問題。?

慢系統排程的錯誤處理?

EINTR 錯誤通常是由於被訊號中斷引起的錯誤,比如在執行 epollwait、accept、read&write 等操作時,收到訊號,那麼該系統呼叫會被打斷中斷,然後去執行訊號註冊的回撥方法,完事後會返回 eintr 錯誤。

下面是 golang 的處理方法,由於 golang 的 netpoll 設計使多數的 io 相關的 syscall 操作非阻塞化,所以就只有 epollwait 有該問題。

// xiaorui.cc

func netpoll(delay int64) gList {
    ,,,
    var events [128]epollevent
retry:
    n := epollwait(epfd, &events[0], int32(len(events)), waitms)
    if n < 0 {
        if n != -_EINTR {
            println("runtime: epollwait on fd", epfd, "failed with", -n)
            throw("runtime: netpoll failed")
        }
        goto retry
    }
    ,,,
}

func netpollBreak() {
    for {
        var b byte
        n := write(netpollBreakWr, unsafe.Pointer(&b), 1)
        if n == 1 {
            break
        }
        if n == -_EINTR {
            continue
        }
        ,,,
    }
}

通常需要手動來解決 EINTR 的錯誤問題,雖然可通過 SA_RESTART 來重啟被中斷的系統呼叫,但不管是 syscall 相容和業務上有可能出現偏差。

// xiaorui.cc

// epoll_wait
if(  -1 == epoll_wait() )
{
    if(errno!=EINTR)
    {
          return -1;
    }
}

// read 
again:
          if ((n = read(fd buf BUFFSIZE)) < 0) {
             if (errno == EINTR)
                  goto again;
          }

配置 SA_RESTART 後,執行緒被中斷後還可繼續執行被中斷的系統呼叫。

// xiaorui.cc

--- SIGINT {si_signo=SIGINT, si_code=SI_KERNEL, si_value={int=0, ptr=0x100000000}} ---
rt_sigreturn()                          = -1 EINTR (Interrupted system call)
futex(0x1b97a30, FUTEX_WAIT_PRIVATE, 0, NULL) = ? ERESTARTSYS (To be restarted if SA_RESTART is set)
--- SIGINT {si_signo=SIGINT, si_code=SI_KERNEL, si_value={int=0, ptr=0x100000000}} ---
rt_sigreturn()                          = -1 EINTR (Interrupted system call)
...

訊號的原理?

我們對一個程式傳送訊號後,核心把訊號掛載到目標程式的訊號 pending 佇列上去,然後進行觸發軟中斷設定目標程式為 running 狀態。當程式被喚醒或者排程後獲取 CPU 後,才會從核心態轉到使用者態時檢測是否有 signal 等待處理,等程式處理完後會把相應的訊號從連結串列中去掉。

通過 kill -l 拿到當前系統支援的訊號列表,1-31 為不可靠訊號,也是非實時訊號,訊號有可能會丟失,比如傳送多次相同的訊號,程式只能收到一次。

// xiaorui.cc

// Print a list of signal names.  These are found in /usr/include/linux/signal.h

kill -l

1) SIGHUP     2) SIGINT     3) SIGQUIT     4) SIGILL     5) SIGTRAP
6) SIGABRT     7) SIGBUS     8) SIGFPE     9) SIGKILL    10) SIGUSR1
11) SIGSEGV    12) SIGUSR2    13) SIGPIPE    14) SIGALRM    15) SIGTERM
16) SIGSTKFLT    17) SIGCHLD    18) SIGCONT    19) SIGSTOP    20) SIGTSTP
21) SIGTTIN    22) SIGTTOU    23) SIGURG    24) SIGXCPU    25) SIGXFSZ
26) SIGVTALRM    27) SIGPROF    28) SIGWINCH    29) SIGIO    30) SIGPWR
31) SIGSYS  

在 Linux 中的 posix 執行緒模型中,執行緒擁有獨立的程式號,可以通過 getpid() 得到執行緒的程式號,而執行緒號儲存在 pthread_t 的值中。而主執行緒的程式號就是整個程式的程式號,因此向主程式傳送訊號只會將訊號傳送到主執行緒中去。如果主執行緒設定了訊號遮蔽,則訊號會投遞到一個可以處理的執行緒中去。

註冊的訊號處理函式都是執行緒共享的,一個訊號只對應一個處理函式,且最後一次為準。子執行緒也可更改訊號處理函式,且隨時都可改。

多執行緒下傳送及接收訊號的問題?

預設情況下只有主執行緒才可處理 signal,就算指定子執行緒傳送 signal,也是主執行緒接收處理訊號。

那麼 Golang 如何做到給指定子執行緒發 signal 且處理的?如何指定給某個執行緒傳送 signal? 在 glibc 下可以使用 pthread_kill 來給執行緒發 signal,它底層呼叫的是 SYS_tgkill 系統呼叫。

// xiaorui.cc

#include "pthread_impl.h"

int pthread_kill(pthread_t t, int sig)
{
    int r;
    __lock(t->killlock);
    r = t->dead ? ESRCH : -__syscall(SYS_tgkill, t->pid, t->tid, sig);
    __unlock(t->killlock);
    return r;
}

那麼在 go runtime/sys_linux_amd64.s 裡找到了 SYS_tgkill 的彙編實現。os_linux.go 中 signalM 呼叫的就是 tgkill 的實現。

// xiaorui.cc
#define SYS_tgkill      234

TEXT ·tgkill(SB),NOSPLIT,$0
    MOVQ    tgid+0(FP), DI
    MOVQ    tid+8(FP), SI
    MOVQ    sig+16(FP), DX
    MOVL    $SYS_tgkill, AX
    SYSCALL
    RET
// xiaorui.cc

func tgkill(tgid, tid, sig int)

// signalM sends a signal to mp.
func signalM(mp *m, sig int) {
    tgkill(getpid(), int(mp.procid), sig)
}

總結:

隨著 go 版本不斷更新,runtime 的功能越來越完善。現在看來基於訊號的搶佔式排程顯得很精妙。下一篇文章繼續寫 go1.4 defer 的優化,簡單說在多場景下編譯器消除了 deferproc 壓入和 deferreturn 插入呼叫,而是直接呼叫延遲方法。

大家覺得文章對你有些作用! 如果想賞錢,可以用微信掃描下面的二維碼,感謝!

部落格原地址 xiaorui.cc (http://xiaorui.cc/)

更多原創文章乾貨分享,請關注公眾號
  • go1.14 基於訊號的搶佔式排程實現原理
  • 加微信實戰群請加微信(註明:實戰群):gocnio

相關文章