當前位置:網站首頁>go中的協程原理詳解

go中的協程原理詳解

2022-07-23 18:10:00大家好,我是好同學


為什麼要有協程?

我們使用工廠來錶示計算機,而內存空間就相當於工廠的地皮,那麼我們可以認為進程就是工廠中的廠房,它占據了工廠的地皮。(進程時分配資源的最小單比特)
在這裏插入圖片描述

線程是什麼呢?我們繼續使用這個場景來理解,線程此時就相當於工廠中的流水工作線(線程時資源調度的最小單比特),每個廠房可以有多個流水線(進程可以有多個線程),流水線的存在占據了廠房的空間(線程使用系統分配給進程的內存,且同一個線程之間共享內存)。

在這裏插入圖片描述

我們運行兩段代碼來模擬線程並發的工作狀態,如下圖所示:讓CPU在多線程之間互相切換,如下圖所示,CPU先執行線程一,獲得中間結果,然後系統對該CPU進行切換;

![在這裏插入圖片描述](https://img-blog.csdnimg.cn/821e17a859fc446ca0f5643dbc549824.pn
切換後來到線程二,如下圖所示,獲得線程二的中間結果,循環往複,這樣一個單核CPU就實現了多線程的並發工作。

在這裏插入圖片描述
通過以上的過程,我們可以知道線程會占用CPU時間,且線程的調度需要由系統來進行,開銷會比較大,如果還是使用如上的場景來理解,線程相當於工廠的生產線(線程裏跑的程序就是生產流程),會占用工人的工時。所以線程的缺點如下:

  • 線程本身占用資源大
  • 線程的操作開銷大
  • 線程切換開銷大

那麼協程是如何工作的呢?首先協程包括執行代碼和臨時執行的狀態(如變量中間值),此時的CPU將固定在一個線程上執行,所以不存在線程的切換開銷,通過將協程的相關數據放在線程上,讓CPU來執行,執行結束後將中間結果存放至協程,然後清空,轉而執行協程二。

在這裏插入圖片描述

協程二的執行和協程一相似,也是先將其放在線程上進行執行,然後保存相關中間變量。

在這裏插入圖片描述

這樣的方式就是讓同一個線程執行了多個協程。協程的本質是將一段數據的運行狀態進行打包,可以在線程之間調度,所以協程並不取代線程,協程也在線程上運行(線程是協程的資源,協程使用線程這個資源去運行)。這樣的好處,也就是說協程的好處:

  • 資源利用:協程可以利用任何的線程去運行,不需要等待CPU的調度;
  • 快速調度:協程可以快速地調度(避開了系統調用和切換),快速的切換;
  • 超高並發:有限的線程就可以並發很多的協程;

協程的本質

首先可以使用go 函數名()來啟動一個協程。在go語言中,協程的本質是一個名為g的結構體,由於該結構體內部成員非常多,我們抓取幾個重要的變量來進行說明,具體如下:

在這裏插入圖片描述

首先最左側的就是協程的結構體,本文主要關注其中4個結構體變量,第一個變量是一個stack結構體,該結構體中有兩個指針,分別指目前棧中數據的高比特指針hi和低比特指針lo;

第二個變量是sched結構體,其中有一個gobuf結構體,gobuf中存有該協程的目前的運行狀態,如sp即是棧指針,指向壓棧的某一條數據,其實就是目前運行中的某個函數,初次以外pc即是程序計數器,其中存放的是目前運行到了哪一行代碼。

第三個變量atomicstatus,存放的是協程的狀態;第四個變量goid,存放的是改協程的id。

協程如何在線程中執行

我們知道協程是用線程去執行的,所以我們觀察一下線程的底層,了解線程和協程的關聯,在go中線程本質上是一個名為m的結構體,我們同樣也只是關注其中幾個相關的變量。
在這裏插入圖片描述

變量含義
g0g0協程,操作調度器
curg目前程序正在運行的協程
mOS針對某種操作系統分別實現的,是操作系統的線程信息

在go中每個線程都是循環執行一系列工作,又稱作單線程循環如下圖所示:左側為棧,右側比特線程執行的函數順序,其中的業務方法就是協程方法。

在這裏插入圖片描述
普通協程棧只能記錄業務方法的業務信息,且當線程沒有獲得協程之前是沒有普通協程棧的。所以在內存中開辟了一個g0棧,專門用於記錄函數調用跳轉的信息。下錶是執行環境

函數名工作內容
schedule()獲取一個可以運行的協程,並以拿到的協程為參數調用execute
execute()為該協程初始化相關結構體,以sched結構體為參數調用gogo
gogo()匯編實現的方法,獲取gobuf結構體,向普通協程棧中壓入goexit函數,獲取當前程序計數器裏記錄的代碼行數,並進行跳轉執行業務方法
業務方法業務方法就是協程中需要執行的相關函數
goexit()執行完協程棧中的業務方法之後,就會退到goexit方法中,調用到goexit1使用mcall(mcall還有一個工作就是切換棧)調用goexit0,對協程相關參數重新進行初始化,然後調用schedule函數

以上就成功地在線程上執行了協程,但目前在實際使用中,其實是一種多線程循環,如下圖所示:
在這裏插入圖片描述
但這種多線程獲取一個協程的過程中將會存在並發問題,所以在該過程中需要鎖的存在。這種線程循環非常像線程池,操作系統並不知道協程的存在,二是執行一個調度循環來順序執行協程。

但是這裏我們學習的線程循環使得協程只能順序執行,意思就是在系統中的線程數目確定的情况下,使用這種線程循環只能同時執行與系統中線程數目相等的協程,在某種意義上這其實還是一種順序執行。且在多線程循環中,線程為了執行協程任務需要從隊列中獲取協程信息,在這個過程中需要搶鎖,這同樣也會導致一些問題。

G-M-P調度模型

這部分我們主要解决上文中我們提及多線程循環存在的問題,當多個線程來全局獲取協程任務時,往往需要搶鎖,這就使得可能會出現鎖沖突,如下圖所示:

在這裏插入圖片描述
解决方法就其本質就是减少線程在全局環境中盡量减少搶鎖的操作,轉而在本地無鎖的執行協程任務。這種思想的專業術語稱為本地隊列,就是讓線程在搶鎖之後一次性抓取多個協程執行,將這些抓取到的協程鏈接為本地隊列,當抓取的所有協程全部執行結束後,才會去全局搶鎖,這樣就避免了一部分的搶鎖操作。

在這裏插入圖片描述

接下來介紹的G-M-P調度模型就是go中用於解决鎖沖突的具體調度模型,其中的中的G指的是協程結構體g,M指的是線程結構體m,P指的是也是一個結構體,其實就是一個本地隊列。這個結構體的成員非常複雜,我們主要看和調度模型相關的一部分成員。

在這裏插入圖片描述

成員變量含義
M相關線程
runqhead隊列的頭指針
runqtail隊列的尾指針
runnext下一個可用的協程指針

接下來我們總結一下P的作用:

  • M和G之間的中介,我們可以理解為送料器
  • P持有一些G,使得每次獲取G的時候不用從全局找
  • 大大减少了並發沖突的情况

注意:

  1. 如果某個線程的本地隊列和全局隊列中的都沒有協程可以執行的情况下,此時該線程就會去其他線程“竊取”協程,從而增大線程的利用率。
  2. 如果新建協程,系統將會隨機尋找一個本地隊列,將新建協程置於P的runnext進行插隊(在go中認為新建協程的優先級高),如果本地隊列都滿了,就會將這個新建協程放在全局隊列中。

協程並發

我們在前文中將線程循環的搶鎖問題使用調度模型解决了,剩下一個關於如何讓協程並發的問題,這個問題乍一看我們很容易覺得沒有什麼問題,但實際上這將會造成協程饑餓問題。這個問題指的是在線程正在執行的某一個協程所需時間過多,使得在隊列中的某些時間敏感的協程執行失敗。

基本的解决思路是當協程執行一段時間後將當前任務暫定,執行後續協程任務,防止時間敏感攜程執行失敗。如下圖所示:

在這裏插入圖片描述
當目前線程中執行的協程是一個超長時間的任務,此時先保存該協程的運行狀態也就是保護現場,若是後續還需繼續執行就將其放入本地隊列中去,所示不需要執行就將其處於休眠狀態,然後直接跳轉到schedule函數中。

這樣就讓本地隊列成了一個小循環,但是如果目前系統中的線程的本地隊列中都擁有一個超大的協程任務,那麼所有的線程都將在一段時間內處於忙碌狀態,全局隊列中的任務將會長期無法運行,這個問題又稱為全局隊列饑餓問題,解决方式就是在本地隊列循環時,以一定的概率從全局隊列中取出某個任務,讓它也參與到本地循環當中去。

這樣似乎就很完美了,但是實際上當協程正在運行狀態時,我們很難將協程的任務打斷,解决方案如下:

  1. 主動掛取:gopark方法,當業務調用這個方法線程就會直接回到schedule函數並切換協程棧,當前運行的協程將會處於等待狀態,等待狀態的協程是無法立即進入任務隊列中的。程序員無法主動調用gopark函數,但是我們可以通過Sleep等具有gopark的函數來進行主動掛取,Sleep五秒之後系統將會把任務的等待狀態更改為運行狀態放入隊列中。
  2. 系統調用完成時:go程序在運行狀態中進行了系統調用,那麼當系統的底層調用完成後就會調用exitsyscall函數,線程就會停止執行當前協程,將當前協程放入隊列中去。
  3. 標記搶占morestack():當函數跳轉時都會調用這個方法,它的本意在於檢查當前協程棧空間是否有足够內存,如果不够就要擴大該棧空間。當系統監控到協程運行超過10ms,就將g.stackguard0置為0xfffffade(該值是一個搶占標志),讓程序在只執行morestack函數時順便判斷一下是否將g中的stackguard置為搶占,如果的確被標記搶占,就回到schedule方法,並將當前協程放回隊列中。

基於信號的搶占式調度

當程序在執行過程中既無法主動掛起,越不能進行系統調用,且無法進行函數調用時,也就是說以上關於協程並發的解决方法都行不通時,我們該怎麼辦?所以提出了基於信號的搶占式調度。這裏的信號其實就是線程信號,在操作系統中有很多基於信號的底層通信方式,而我們的線程可以注册對應信號的處理函數。

基本的思路:

  • 注册SIGURG信號(該信號其他地方用的很少)的處理函數
  • GC工作時(GC工作意味著某些線程停了),向目標線程發送信號
  • 線程收到信號,觸發調度。

在這裏插入圖片描述

當GC放信號之後,當前正在處理協程任務的線程將會執行doSigPreempt函數,將當前協程放回隊列,重新調用schedule函數。

版權聲明
本文為[大家好,我是好同學]所創,轉載請帶上原文鏈接,感謝
https://cht.chowdera.com/2022/204/202207231539284393.html

隨機推薦