寫了一年golang,來聊聊進程、線程與協程


本文已收錄 https://github.com/lkxiaolou/lkxiaolou 歡迎star。

進程

在早期的單任務計算機中,用戶一次只能提交一個作業,獨享系統的全部資源,同時也只能干一件事情。進行計算時不能進行 IO 讀寫,但 CPU 與 IO 的速度存在巨大差異,一個作業在 CPU 上所花費的時間非常少,大部分時間在等待 IO。

為了更合理的利用 CPU 資源,把內存划分為多塊,不同程序使用各自的內存空間互不干擾,這里單獨的程序就是一個進程,CPU 可以在多個進程之間切換執行,讓 CPU 的利用率變高。

為了實現 CPU 在多個進程之間切換,需要保存進程的上下文(如程序計數器、棧、內核數據結構等等),以便下次切換回來可以恢復執行。還需要一種調度算法,Linux 中采用了基於時間片和優先級的完全公平調度算法。

線程

多進程的出現是為了解決 CPU 利用率的問題,那為什么還需要線程?答案是為了減少上下文切換時的開銷

進程在如下兩個時間點可能會讓出 CPU,進行 CPU 切換:

  • 進程阻塞,如網絡阻塞、代碼層面的阻塞(鎖、sleep等)、系統調用等
  • 進程時間片用完,讓出 CPU

而進程切換 CPU 時需要進行這兩步:

  • 切換頁目錄以使用新的地址空間
  • 切換內核棧和硬件上下文

進程和線程在 Linux 中沒有本質區別,他們最大的不同就是進程有自己獨立的內存空間,而線程(同進程中)是共享內存空間。

在進程切換時需要轉換內存地址空間,而線程切換沒有這個動作,所以線程切換比進程切換代價更小。

為什么內存地址空間轉換這么慢?Linux 實現中,每個進程的地址空間都是虛擬的,虛擬地址空間轉換到物理地址空間需要查頁表,這個查詢是很慢的過程,因此會用一種叫做 TLB 的 cache 來加速,當進程切換后,TLB 也隨之失效了,所以會變慢。

綜上,線程是為了降低進程切換過程中的開銷。

協程

當我們的程序是 IO 密集型時(如 web 服務器、網關等),為了追求高吞吐,有兩種思路:

  1. 為每個請求開一個線程處理,為了降低線程的創建開銷,可以使用線程池技術,理論上線程池越大,則吞吐越高,但線程池越大,CPU 花在切換上的開銷也越大

線程的創建、銷毀都需要調用系統調用,每次請求都創建,高並發下開銷就顯得很大,而且線程占用內存是 MB 級別,數量不能太多

為什么線程越多 cpu 切換越多?准確來說是可執行的線程越多,cpu 切換越多,因為操作系統的調度要保證絕對公平,有可執行線程時,一定是要雨露均沾,所以切換次數變多

  1. 使用異步非阻塞的開發模型,用一個進程或線程接收請求,然后通過 IO 多路復用讓進程或線程不阻塞,省去上下文切換的開銷

這兩個方案,優缺點都很明顯,方案1實現簡單,但性能不高;方案2性能非常好,但實現起來復雜。有沒有介於這兩者之間的方案?既要簡單,又要性能高,協程就解決了這個問題。

協程是用戶視角的一種抽象,操作系統並沒有這個概念,其主要思想是在用戶態實現調度算法,用少量線程完成大量任務的調度。

協程需要解決線程遇到的幾個問題:

  • 內存占用要小,且創建開銷要小
  • 減少上下文切換的開銷

第一點好實現,用戶態的協程,只是一個數據結構,無需系統調用,而且可以設計的很小,達到 KB 級別。

第二點只能減少上下文切換次數來解決,因為協程的本質還是線程,其切換開銷在用戶態是無法降低的,只能通過降低切換次數來達到總體上開銷的減少,可以有如下手段:

  1. 讓可執行的線程盡量少,這樣切換次數必然會少
  2. 讓線程盡可能的處於運行狀態,而不是阻塞讓出時間片

Goroutine

goroutine 是 golang 實現的協程,其特點是在語言層面就支持,使用起來非常方便,它的核心是MPG調度模型:

  • M:內核線程
  • P:處理器,用來執行 goroutine,它維護了本地可運行隊列
  • G:goroutine,代碼和數據結構
  • S:調度器,維護M和P的信息

除此之外還有一個全局可運行隊列。

image

  1. 在 golang 中使用 go 關鍵字啟動一個 goroutine,它將會被掛到 P 的 runqueue 中,等待被調度

image
2. 當 M0 中正在運行的 G0 阻塞時(如執行了一個系統調用),此時 M0 會休眠,它將放棄掛載的 P0,以便被其他 M 調度到

image
3. 當 M0 系統調用結束后,會嘗試“偷”一個 P,如果不成功,M0 將 G0 放到全局的 runqueue 中

  1. P 會定期檢查全局 runqueue,保證自己消化完 G 后有事可做,同時也會從其他 P 里“偷” G

從上述看來,MPG 模型似乎只限制了同時運行的線程數,但上下文切換只發生在可運行的線程上,應該是有一定的作用,當然這只是一部分。

golang 在 runtime 層面攔截了可能導致線程阻塞的情況,並針對性優化,他們可分為兩類:

  • 網絡 IO、channel 操作、鎖:只阻塞 G,M、P 可用,即線程不會讓出時間片
  • 系統調用:阻塞 M,P 需要切換,線程會讓出時間片

所以綜合來看,goroutine 會比線程切換開銷少。

總結

從單進程到多進程提高了 CPU 利用率;從進程到線程,降低了上下文切換的開銷;從線程到協程,進一步降低了上下文切換的開銷,使得高並發的服務可以使用簡單的代碼寫出來,技術的每一步發展都是為了解決實際問題。


搜索關注微信公眾號"捉蟲大師",后端技術分享,架構設計、性能優化、源碼閱讀、問題排查、踩坑實踐。


免責聲明!

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



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