嗯,你覺得 Go 在什麼時候會搶佔 P?

大家好,我是煎魚。

昨天剛從長沙浪完回來,準備 ” 瀟瀟灑灑 “ 寫一篇遊記,分享一波美食 + 遊記,有五一準備去長沙玩的小夥伴嗎?

前幾天我們有聊到《單核 CPU,開兩個 Goroutine,其中一個死循環,會怎麼樣?》的問題,我們在一個細節部分有提到:

有新的小夥伴會產生更多的疑問,那就是在 Go 語言中,是如何搶佔 P 的呢,這裏面是怎麼做的?

今天這篇文章我們就來解密搶佔 P。

調度器的發展史

在 Go 語言中,Goroutine 早期是沒有設計成搶佔式的,早期 Goroutine 只有讀寫、主動讓出、鎖等操作時纔會觸發調度切換。

這樣有一個嚴重的問題,就是垃圾回收器進行 STW 時,如果有一個 Goroutine 一直都在阻塞調用,垃圾回收器就會一直等待他,不知道等到什麼時候...

這種情況下就需要搶佔式調度來解決問題。如果一個 Goroutine 運行時間過久,就需要進行搶佔來解決。

這塊 Go 語言在 Go1.2 起開始實現搶佔式調度器,不斷完善直至今日:

調度器的新提案:非均勻存儲器訪問調度(Non-uniform memory access,NUMA), 但由於實現過於複雜,優先級也不夠高,因此遲遲未提上日程。

有興趣的小夥伴可以詳見 Dmitry Vyukov, dvyukov 所提出的 NUMA-aware scheduler for Go。

爲什麼要搶佔 P

爲什麼會要想去搶佔 P 呢,說白了就是不搶,就沒機會運行,會 hang 死。又或是資源分配不均了,

這在調度器設計中顯然是不合理的。

跟這個例子一樣:

// Main Goroutine 
func main() {
    // 模擬單核 CPU
    runtime.GOMAXPROCS(1)
    
    // 模擬 Goroutine 死循環
    go func() {
        for {
        }
    }()

    time.Sleep(time.Millisecond)
    fmt.Println("腦子進煎魚了")
}

這個例子在老版本的 Go 語言中,就會一直阻塞,沒法重見天日,是一個需要做搶佔的場景。

但可能會有小夥伴問,搶佔了,會不會有新問題。因爲原本正在使用 P 的 M 就涼涼了(M 會與 P 進行綁定),沒了 P 也就沒法繼續執行了。

這其實沒有問題,因爲該 Goroutine 已經阻塞在了系統調用上,暫時是不會有後續的執行新訴求。

但萬一代碼是在運行了好一段時間後又能夠運行了(業務上也允許長等待),也就是該 Goroutine 從阻塞狀態中恢復了,期望繼續運行,沒了 P 怎麼辦?

這時候該 Goroutine 可以和其他 Goroutine 一樣,先檢查自身所在的 M 是否仍然綁定着 P:

也就是搶佔 P,本身就是一個雙向行爲,你搶了我的 P,我也可以去搶別人的 P 來繼續運行。

怎麼搶佔 P

講解了爲什麼要搶佔 P 的原因後,我們進一步深挖,“他” 是怎麼搶佔到具體的 P 的呢?

這就涉及到前文所提到的 runtime.retake 方法了,其處理以下兩種場景:

在此主要針對搶佔 P 的場景,分析如下:

func retake(now int64) uint32 {
 n := 0
 // 防止發生變更,對所有 P 加鎖
 lock(&allpLock)
 // 走入主邏輯,對所有 P 開始循環處理
 for i := 0; i < len(allp); i++ {
  _p_ := allp[i]
  pd := &_p_.sysmontick
  s := _p_.status
  sysretake := false
  ...
  if s == _Psyscall {
   // 判斷是否超過 1 個 sysmon tick 週期
   t := int64(_p_.syscalltick)
   if !sysretake && int64(pd.syscalltick) != t {
    pd.syscalltick = uint32(t)
    pd.syscallwhen = now
    continue
   }
      
   ...
  }
 }
 unlock(&allpLock)
 return uint32(n)
}

該方法會先對 allpLock 上鎖,這個變量含義如其名,allpLock 可以防止該數組發生變化。

其會保護 allpidlepMasktimerpMask 屬性的無 P 讀取和大小變化,以及對 allp 的所有寫入操作,可以避免影響後續的操作。

場景一

前置處理完畢後,進入主邏輯,會使用萬能的 for 循環對所有的 P(allp)進行一個個處理。

   t := int64(_p_.syscalltick)
   if !sysretake && int64(pd.syscalltick) != t {
    pd.syscalltick = uint32(t)
    pd.syscallwhen = now
    continue
   }

第一個場景是:會對 syscalltick 進行判定,如果在系統調用(syscall)中存在超過 1 個 sysmon tick 週期(至少 20us)的任務,則會從系統調用中搶佔 P,否則跳過。

場景二

如果未滿足會繼續往下,走到如下邏輯:

func retake(now int64) uint32 {
 for i := 0; i < len(allp); i++ {
  ...
  if s == _Psyscall {
   // 從此處開始分析
   if runqempty(_p_) && 
      atomic.Load(&sched.nmspinning)+atomic.Load(&sched.npidle) > 0 && 
      pd.syscallwhen+10*1000*1000 > now {
    continue
   }
   ...
  }
 }
 unlock(&allpLock)
 return uint32(n)
}

第二個場景,聚焦到這一長串的判斷中:

這裏奇怪的是 runqempty 方法明明已經判斷了沒有其他任務,這就代表了沒有任務需要執行,是不需要搶奪 P 的。

但實際情況是,由於可能會阻止 sysmon 線程的深度睡眠,最終還是希望繼續佔有 P。

在完成上述判斷後,進入到搶奪 P 的階段:

func retake(now int64) uint32 {
 for i := 0; i < len(allp); i++ {
  ...
  if s == _Psyscall {
   // 承接上半部分
   unlock(&allpLock)
   incidlelocked(-1)
   if atomic.Cas(&_p_.status, s, _Pidle) {
    if trace.enabled {
     traceGoSysBlock(_p_)
     traceProcStop(_p_)
    }
    n++
    _p_.syscalltick++
    handoffp(_p_)
   }
   incidlelocked(1)
   lock(&allpLock)
  }
 }
 unlock(&allpLock)
 return uint32(n)
}

總結

至此完成了搶佔 P 的基本流程,我們可得出滿足以下條件:

  1. 如果存在系統調用超時:存在超過 1 個 sysmon tick 週期(至少 20us)的任務,則會從系統調用中搶佔 P。

  2. 如果沒有空閒的 P:所有的 P 都已經與 M 綁定。需要搶佔當前正處於系統調用之,而實際上系統調用並不需要的這個 P 的情況,會將其分配給其它 M 去調度其它 G。

  3. 如果 P 的運行隊列裏面有等待運行的 G,爲了保證 P 的本地隊列中的 G 得到及時調度。而自己本身的 P 又忙於系統調用,無暇管理。此時會尋找另外一個 M 來接管 P,從而實現繼續調度 G 的目的。

參考

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/WAPogwLJ2BZvrquoKTQXzg