字節一面:go的協程相比線程,輕量在哪?


  1. 傳統cpu調度背景 #操作系統原理#
  2. 線程切換的時機和代價
  3. go是怎樣的思路:
  • 將調度維持在用戶態
  • 推出用戶態runtime代碼實現的輕量級線程
  1. go 調度策略
  • 常規: 本地隊列- 其他隊列、全局隊列
  • 協作式調度: 基於用戶態事件
    -- 異步io: 網絡: 基於netpoller
    -- 同步io: 產生M
  1. goroutine的生命周期的實現

只想關注golang goroutine背景、調度方式、生命周期的請關注3,4,5部分。

1.傳統cpu調度背景: 應用不能既當運動員,又當裁判員

操作系統分為用戶態和內核態(或者叫用戶空間和內核空間), 那內核態究竟是什么呢?

計算機是多進程操作系統,多個用戶進程同時都在利用顯性的物理資源:cpu、內存、io, 不能讓用戶進程既當運動員,又當裁判員。

於是操作系統抽象出了內核對象,用於實現進程同步、進程通信、內存管理等系統資源的分配和管理。操作系統維護着內核對象表,每個內核對象都在這張表上記錄了狀態和屬性。

內核對象由內核態代碼創建,用戶可以系統調用、api函數來操作和管理內核對象,與此同時用戶進程墜入內核態。

eg: C# Thread類表示一個線程(是托管代碼),而線程是內核對象,故C#使用Thread創建線程,實際是由操作系統創建了一個內核對象來實現線程。

畫外音:
os作為裁判員,內核態是一種特殊的調度程序,統籌計算機的硬件資源,例如協調CPU資源、分配內存資源、並且提供穩定的環境供應用程序運行`。

2. 線程切換的時機和代價?

  • 線程是cpu調度的基本單位,進程是資源占有的基本單位。
  • 線程中的代碼是在用戶態運行,而線程的調度是在內核態
① 自發性上下文切換 線程受用戶代碼指令導致的切出
Thread.sleep() 線程主動休眠
object.wait() 線程等待鎖
Thread.yield() 當前線程主動讓出CPU,如果有其他就緒線程就執行其他線程,如果沒有則繼續當前線程
oThread.join() 阻塞發起調用的線程,直到oThread執行完畢

② 非自發性上下文切換: 來自內核線程調度器管控
線程的時間片用完 ,cpu時間片雨露均沾
高優先級線程搶占
虛擬機的垃圾回收動作

線程上下文切換的代價是高昂的:上下文切換的延遲取決於不同的因素,大概是50到100ns左右,考慮到硬件平均在每個核心上每ns執行12條指令,那么一次上下文切換可能會花費600到1200條指令的延遲時間

① 直接開銷
保存/恢復上下文所需的開銷
線程調度器調度線程的開銷
② 間接開銷
重新加載高速緩存
上下文切換可能導致 一級緩存被沖刷,寫入下一級緩存或內存

3. go的協程輕量級體現在哪?

如上面所述,常規線程切換會導致用戶態程序和內核態調度程序的切換

大佬們思考了另外一個思路:將調度維持在用戶態

go推出了用戶態runtime實現的輕量級線程goroutine, go將goroutine的調度維持在用戶態, 這是由GPM中的P Process來完成的, 功能類比於常規的操作系統線程調度器

(1) 上下文切換代價小: P 是G、M之間的橋梁,調度器對於goroutine的調度,很明顯也會有切換,這個切換是很輕量的:
只涉及

  • PC (程序計數器,標記當前執行的代碼的位置)
  • SP (當前執行的函數堆棧棧頂指針)
  • BP 三個寄存器的值的修改;

而對比線程的上下文切換則需要陷入內核模式、以及16個寄存器的刷新。

(2) 內存占用小: 線程棧空間通常是2M, Goroutine棧空間最小是2k, golang可以輕松支持1w+的goroutine運行,而線程數量到達1k(此時基本就達到單機瓶頸了), 內存占用就到2G。


4. GO 協程調度時機

通常情況下:

go關鍵字產生的一個常規執行邏輯的goroutine,由P調度進隊列,等到被M執行完之后,調度器P繼續從本地隊列調出G給到M執行,若沒有則從其他隊列/全局隊列偷取G。


存在P的本地隊列、全局隊列、parked goroutines(阻塞的協程)

Go scheduler is not a preemptive scheduler but a cooperating scheduler. Being a cooperating scheduler means the scheduler needs well-defined user space events that happen at safe points in the code to make scheduling decisions. The followings are the opportunities for scheduling:

GO調度器是協作式,非搶占式,這意味着調度器是基於用戶空間的事件來做出 調度決策。下面是調度的時機。

① The use of the keyword go

This is how we create a new goroutine, scheduler gain an opportunity when a new goroutine was created.

② Synchronization and Orchestration

If an mutex, or channel operation call will cause the Goroutine to block, the scheduler can context-switch a new Goroutine to run. Once the Goroutine can run again, it will be re-queued automatically.

③ System calls

Including async and sync system calls, go has different way to deal with them. With async type like network request, a network poller would be used, goroutine that might block is moved to net poller, let the proccesor can execute the next one.
With sync type like file I/O, the current pair of G and M will be seperated from G, P, M model. Meawhile, a new machine would be created in order to keep the original G, P, M model working, and the block goroutine would be take back while system call finished.

④ Garbage collection

Since the GC runs using its own set of Goroutines, those Goroutines need time on an M to run, scheduler needs a opportunitt to handle that

系統調用(system call)又分為兩種,同步和異步系統調用。
同步和異步系統調用是指在系統調用過程中,用戶程序和操作系統之間的交互方式。
同步系統調用(Synchronous System Call)是指用戶程序在進行系統調用時,必須等待系統完成操作並返回結果后才能繼續執行。在進行同步系統調用時,用戶程序會阻塞,直到系統調用完成。同步系統調用的優點是操作簡單,易於實現,但缺點是會造成用戶程序的阻塞,影響程序的響應性能。
異步系統調用(Asynchronous System Call)是指用戶程序在進行系統調用時,可以在系統調用的同時繼續執行其他操作,無需等待系統調用的完成。在進行異步系統調用時,用戶程序不會阻塞,而是會通過回調函數等機制在系統調用完成后再進行處理。異步系統調用的優點是可以提高程序的並發性和響應性能,但缺點是實現較為復雜。
在實際的系統編程中,通常需要根據具體的需求和場景選擇使用同步或異步系統調用。例如,在需要進行文件IO等較為簡單的操作時,可以使用同步系統調用;在需要進行網絡通信等較為復雜的操作時,可以使用異步系統調用,以提高程序的並發性和響應性能。


5. goroutine生命周期

Go 必須對每個運行着的線程上的 Goroutine 進行調度和管理。
這個調度的功能被委托給了一個叫做 g0 的特殊的 goroutine, g0 是每個 OS 線程創建的第一個goroutine。

g0為新創建的goroutine

  • 設置PC/SP寄存器
  • 更新goroutine內部的 ID和status


Go 需要一種方法來了解 goroutine的結束。

這個控制是在 goroutine 的創建過程中,在創建 goroutine 時,Go在開啟實際go執行片段之前,通過PC寄存器設置了SP寄存器的首個函數棧幀(名為goexit的函數),這個技巧強制goroutine在結束工作后調用函數goexit

newg.sched.pc = funcPC(goexit) + sys.PCQuantum
newg.sched.g = guintptr(unsafe.Pointer(newg))
gostartcallfn(&newg.sched, fn)

//------------- ......

// adjust Gobuf as if it executed a call to fn with context ctxt
// and then did an immediate gosave.
func gostartcall(buf *gobuf, fn, ctxt unsafe.Pointer) {
	sp := buf.sp
	...
	sp -= sys.PtrSize
	*(*uintptr)(unsafe.Pointer(sp)) = buf.pc
	buf.sp = sp
	buf.pc = uintptr(fn)
	buf.ctxt = ctxt
}

https://www.sobyte.net/post/2022-02/where-is-goexit-from/

5. goroutine的常規實踐


  • 在一個函數前放置go即可開啟一個go的 協程,如其他函數一樣,可以有形參,不過函數返回值會被忽略。

  • 在golang中, 大家習慣使用一個封裝了業務邏輯的閉包來啟動一個goroutine, 該閉包負責管理並發的數據和狀態,例如閉包從信道中讀取數據並傳遞給業務邏輯, 業務邏輯完全不知道它是在一個goroutine中,
    然后函數的結果被寫回另外一個信道,這種職責分離使代碼模塊化、可測試,並使得api調用簡單,無需關注並發問題。

func process(val int) int {

}

func runningConcurrently(in <-chan int, out  chan <- int) {
   go func() {                        // 業務邏輯協程
       for val := range in {
           result := process(val)
           out<- result               // 利用信道來在協程間通信
       }
   }
}

ref


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM