01.02 linux進程-線程-協程上下文環境的切換與實現

一:進程-線程-協程簡介

進程和線程的主要區別是:進程獨享地址空間和資源,線程則共享地址空間和資源,多線程就是多棧。

以下進程 線程 協程等等視頻資料需要的朋友可以後臺私信【架構】免費獲取


linux進程-線程-協程上下文環境的切換與實現

linux進程-線程-協程上下文環境的切換與實現

1、進程

進程是具有一定獨立功能的程序關於某個數據集合上的一次運行活動,進程是系統進行資源分配和調度的一個獨立單位。每個進程都有自己的獨立內存空間,不同進程通過進程間通信來通信。由於進程比較重量,佔據獨立的內存,所以上下文進程間的切換開銷(棧、寄存器、虛擬內存、文件句柄等)比較大,但相對比較穩定安全。

  2、線程

線程是進程的一個實體,是CPU調度和分派的基本單位,它是比進程更小的能獨立運行的基本單位.線程自己基本上不擁有系統資源,只擁有一點在運行中必不可少的資源(如程序計數器,一組寄存器和棧),但是它可與同屬一個進程的其他的線程共享進程所擁有的全部資源。線程間通信主要通過共享內存,上下文切換很快,資源開銷較少,但相比進程不夠穩定容易丟失數據。

  3、協程

協程是一種用戶態的輕量級線程,協程的調度完全由用戶控制。協程擁有自己的寄存器上下文和棧。協程調度切換時,將寄存器上下文和棧保存到其他地方,在切回來的時候,恢復先前保存的寄存器上下文和棧,直接操作棧則基本沒有內核切換的開銷,可以不加鎖的訪問全局變量,所以上下文的切換非常快。

調度

進程調度,切換進程上下文,包括分配的內存,包括數據段,附加段,堆棧段,代碼段,以及一些表格。
線程調度,切換線程上下文,主要切換堆棧,以及各寄存器,因為同一個進程裡的線程除了堆棧不同。

協程又稱為輕量級線程,每個協程都自帶了一個棧,可以認為一個協程就是一個函數和這個存放這個函數運行時數據的棧,這個棧非常小,一般只有幾十kb。

linux進程-線程-協程上下文環境的切換與實現

什麼是協程

wikipedia 的定義:協程是一個無優先級的子程序調度組件,允許子程序在特點的地方掛起恢復。

線程包含於進程,協程包含於線程。只要內存足夠,一個線程中可以有任意多個協程,但某一時刻只能有一個協程在運行,多個協程分享該線程分配到的計算機資源。

為什麼需要協程

簡單引入

就實際使用理解來講,協程允許我們寫同步代碼的邏輯,卻做著異步的事,避免了回調嵌套,使得代碼邏輯清晰。code like this:

<code>   co(function*(next){     let [err,data]=yield fs.readFile("./test.txt",next);//異步讀文件     [err]=yield fs.appendFile("./test2.txt",data,next);//異步寫文件     //....   })()/<code>

異步 指令執行之後,結果並不立即顯現的操作稱為異步操作。及其指令執行完成並不代表操作完成。

協程是追求極限性能和優美的代碼結構的產物。

一點歷史

起初人們喜歡同步編程,然後發現有一堆線程因為I/O卡在那裡,併發上不去,資源嚴重浪費。

然後出了異步(select,epoll,kqueue,etc),將I/O操作交給內核線程,自己註冊一個回調函數處理最終結果。

然而項目大了之後代碼結構變得不清晰,下面是個小例子。

<code>  async_func1("hello world",func(){     async_func2("what's up?",func(){       async_func2("oh ,friend!",func(){          //todo something       })     })  })/<code>

於是發明了協程,寫同步的代碼,享受著異步帶來的性能優勢。

程序運行是需要的資源:

  • cpu
  • 內存
  • I/O (文件、網絡,磁盤(內存訪問不在一個層級,忽略不計))

協程的實現原理(異步實現)

libco 一個C++協程庫實現

libco 是騰訊開源的一個C++協程庫,作為微信後臺的基礎庫,經受住了實際的檢驗。項目地址:https://github.com/Tencent/libco

個人源碼閱讀項目:https://github.com/yyrdl/libco-code-study (未完結)

libco源代碼文件一共11個,其中一個是彙編代碼,其餘是C++,閱讀起來相對較容易。

在C++裡面實現協程要解決的問題有如下幾個:

  • 何時掛起協程?何時喚醒協程?
  • 如何掛起、喚醒協程,如何保護協程運行時的上下文?
  • 如何封裝異步操作?

前期知識準備

  1. 現代操作系統是分時操作系統,資源分配的基本單位是進程,CPU調度的基本單位是線程。
  2. C++程序運行時會有一個運行時棧,一次函數調用就會在棧上生成一個record
  3. 運行時內存空間分為全局變量區(存放函數,全局變量),棧區,堆區。棧區內存分配從高地址往低地址分配,堆區從低地址往高地址分配。
  4. 下一條指令地址存在於指令寄存器IP,ESP寄存值指向當前棧頂地址,EBP指向當前活動棧幀的基地址。
  5. 發生函數調用時操作為:將參數從右往左依次壓棧,將返回地址壓棧,將當前EBP寄存器的值壓棧,在棧區分配當前函數局部變量所需的空間,表現為修改ESP寄存器的值。
  6. 協程的上下文包含屬於他的棧區和寄存器裡面存放的值。

何時掛起,喚醒協程?

如開始介紹時所說,協程是為了使用異步的優勢,異步操作是為了避免IO操作阻塞線程。那麼協程掛起的時刻應該是當前協程發起異步操作的時候,而喚醒應該在其他協程退出,並且他的異步操作完成時。

如何掛起、喚醒協程,如何保護協程運行時的上下文?

協程發起異步操作的時刻是該掛起協程的時刻,為了保證喚醒時能正常運行,需要正確保存並恢復其運行時的上下文。

所以這裡的操作步驟為:

  • 保存當前協程的上下文(運行棧,返回地址,寄存器狀態)
  • 設置將要喚醒的協程的入口指令地址到IP寄存器
  • 恢復將要喚醒的協程的上下文

二:什麼是上下文切換

即使是單核CPU也支持多線程執行代碼,CPU通過給每個線程分配CPU時間片來實現這個機制。時間片是CPU分配給各個線程的時間,因為時間片非常短,所以CPU通過不停地切換線程執行,讓我們感覺多個線程時同時執行的,時間片一般是幾十毫秒(ms)。

CPU通過時間片分配算法來循環執行任務,當前任務執行一個時間片後會切換到下一個任務。但是,在切換前會保存上一個任務的狀態,以便下次切換回這個任務時,可以再次加載這個任務的狀態,從任務保存到再加載的過程就是一次上下文切換。

三:線程上下文切換和進程上下文切換的區別

進程切換分兩步
1.切換頁目錄以使用新的地址空間
2.切換內核棧和硬件上下文。
對於linux來說,線程和進程的最大區別就在於地址空間。
對於線程切換,第1步是不需要做的,第2是進程和線程切換都要做的。所以明顯是進程切換代價大

線程上下文切換和進程上下問切換一個最主要的區別是線程的切換虛擬內存空間依然是相同的,但是進程切換是不同的。這兩種上下文切換的處理都是通過操作系統內核來完成的。內核的這種切換過程伴隨的最顯著的性能損耗是將寄存器中的內容切換出。

另外一個隱藏的損耗是上下文的切換會擾亂處理器的緩存機制。簡單的說,一旦去切換上下文,處理器中所有已經緩存的內存地址一瞬間都作廢了。還有一個顯著的區別是當你改變虛擬內存空間的時候,處理的頁表緩衝(processor’s Translation Lookaside Buffer (TLB))或者相當的神馬東西會被全部刷新,這將導致內存的訪問在一段時間內相當的低效。但是在線程的切換中,不會出現這個問題。

四:上線文切換的實質

對於線程或者協程來說切換的本質都是堆棧地址的保存和恢復。

大概的切換流程如下:

<code>X86 32 BistsSS  --> 選擇子--->段描述表-->(段限,段基址)CR3 --->頁目錄,頁表 ESP--> EBP-->   /<code>

這其實和參數傳遞有點相似,只需要傳遞地址就夠了。
ESP,EBP 正式 堆棧指針寄存器。


變量通過ESP,EBP 兩個指針 加偏移量訪問。

五:比較

1、進程多與線程比較

線程是指進程內的一個執行單元,也是進程內的可調度實體。線程與進程的區別:

1) 地址空間:線程是進程內的一個執行單元,進程內至少有一個線程,它們共享進程的地址空間,而進程有自己獨立的地址空間

2) 資源擁有:進程是資源分配和擁有的單位,同一個進程內的線程共享進程的資源

3) 線程是處理器調度的基本單位,但進程不是

4) 二者均可併發執行

5) 每個獨立的線程有一個程序運行的入口、順序執行序列和程序的出口,但是線程不能夠獨立執行,必須依存在應用程序中,由應用程序提供多個線程執行控制

  2、協程多與線程進行比較

1) 一個線程可以多個協程,一個進程也可以單獨擁有多個協程,這樣python中則能使用多核CPU。

2) 線程進程都是同步機制,而協程則是異步

3) 協程能保留上一次調用時的狀態,每次過程重入時,就相當於進入上一次調用的狀態

4) 協程是用戶級的任務調度,線程是內核級的任務調度。

5) 線程是被動調度的,協程是主動調度的。

補充協程上下文環境的切換

協程

協程是一種編程組件,可以在不陷入內核的情況進行上下文切換。如此一來,我們就可以把協程上下文對象關聯到fd,讓fd就緒後協程恢復執行。

當然,由於當前地址空間和資源描述符的切換無論如何需要內核完成,因此協程所能調度的,只有在同一進程(線程)中的不同上下文而已。

我們在內核裡實行上下文切換的時候,其實是將當前所有寄存器保存到內存中,然後從另一塊內存中載入另一組已經被保存的寄存器。對於圖靈機來說,當前狀態寄存器意味著機器狀態——也就是整個上下文。其餘內容,包括棧上內存,堆上對象,都是直接或者間接的通過寄存器來訪問的。 但是請仔細想想,寄存器更換這種事情,似乎不需要進入內核態麼。事實上我們在用戶態切換的時候,就是用了類似方案。也就是說協程是在用戶態保存寄存器狀態的!

作為推論:在單個線程中執行的協程,可以視為單線程應用。這些協程,在未執行到特定位置(基本就是阻塞操作)前,是不會被搶佔,也不會和其他CPU上的上下文發生同步問題的。因此,一段協程代碼,中間沒有可能導致阻塞的調用,執行在單個線程中。那麼這段內容可以被視為同步的。

我們經常可以看到某些協程應用,一啟動就是數個進程。這並不是跨進程調度協程。一般來說,這是將一大群fd分給多個進程,每個進程自己再做fd-協程對應調度。

基於就緒通知的協程框架(epool本身是同步的)

  1. 協程
  2. 首先需要包裝read/write,在發生read的時候檢查返回。如果是EAGAIN,那麼將當前協程標記為阻塞在對應fd上,然後執行調度函數。
  3. 調度函數需要執行epoll(或者從上次的返回結果緩存中取數據,減少內核陷入次數),從中讀取一個就緒的fd。如果沒有,上下文應當被阻塞到至少有一個fd就緒。
  4. 查找這個fd對應的協程上下文對象,並調度過去。
  5. 當某個協程被調度到時,他多半應當在調度器返回的路上——也就是read/write讀不到數據的時候。因此應當再重試讀取,失敗的話返回1。
  6. 如果讀取到數據了,直接返回。

這樣,異步的數據讀寫動作,在我們的想像中就可以變為同步的。而我們知道同步模型會極大降低我們的編程負擔。

我們經常可以看到某些協程應用,一啟動就是數個進程。這並不是跨進程調度協程。一般來說,這是將一大群fd分給多個進程,每個進程自己再做fd-協程對應調度。

基於就緒通知的協程框架

  1. 首先需要包裝read/write,在發生read的時候檢查返回。如果是EAGAIN,那麼將當前協程標記為阻塞在對應fd上,然後執行調度函數。
  2. 調度函數需要執行epoll(或者從上次的返回結果緩存中取數據,減少內核陷入次數),從中讀取一個就緒的fd。如果沒有,上下文應當被阻塞到至少有一個fd就緒。
  3. 查找這個fd對應的協程上下文對象,並調度過去。
  4. 當某個協程被調度到時,他多半應當在調度器返回的路上——也就是read/write讀不到數據的時候。因此應當再重試讀取,失敗的話返回1。
  5. 如果讀取到數據了,直接返回。

這樣,異步的數據讀寫動作,在我們的想像中就可以變為同步的。而我們知道同步模型會極大降低我們的編程負擔。

C/C++怎麼實現協程

作為一個C++後臺開發,我知道像go, lua之類的語言在語言層面上提供了協程的api,但是我比較關心C++下要怎麼實現這一點,下面的討論都是從C/C++程序員的角度來看協程的問題的。

boost和騰訊都推出了相關的庫,語言層面沒有提供這個東西。我近期閱讀了微信開源的libco協程庫,協程核心要解決幾個問題:

1. 協程怎麼切換? 這個是最核心的問題,有很多trick可以做到這點,libco的做法是利用glibc中ucontext相關調用保存線程上下文,然後用swapcontext來切換協程上下文,libco的實現中對swapcontext的彙編實現做了一些刪減和改動,所以在性能上會比C庫的swapcontext提升1個數量級。

2. IO阻塞了怎麼辦?試想在一個多協程的線程裡,一個阻塞IO由一個協程發起,那麼整個線程都阻塞了,別的協程也拿不到CPU資源,多個協程在一起等著IO的完成。libco中的做法是利用同名函數+dlsym來hook socket族的阻塞IO,比如read/write等,劫持了系統調用之後把這些IO註冊到一個epoll的事件循環中,註冊完之後把協程yield掉讓出cpu資源,在IO完成的時候resume這個協程,這樣其實把網絡IO的阻塞點放在了epoll上,如果epoll沒有就緒fd,那其實在超時時間內epoll還是阻塞的,只是把阻塞的粒度縮小了,本質上其實還是用epoll異步回調來解決網絡IO問題的。那麼問題來了,對於一些沒有fd的一些重IO(比如大規模數據庫操作)要怎麼處理呢?答案是:libco並沒有解決這個問題,而且也很難解決這個問題,首先要明確的一點是我們的目的是讓用戶只是僅僅調用了一個同步IO而已,不希望用戶感知到調用IO的時候其實協程讓出了cpu資源,按libco的思路一種可能的方法是,給所有重IO的api都hook掉,然後往某個異步事件庫裡丟這個IO事件,在異步事件返回的時候再resume協程。這裡的難點是可能存在的重IO這麼多,很難寫成一個通用的庫,只能根據業務需求來hook掉需要的調用,然後協程的編寫中依然可以以同步的方式調用這些IO。從以上可能的做法來看協程很難去把所有阻塞調用都hook掉,所以libco很聰明的只把socket族的相關調用給hook掉,這樣可以libco就成為一個通用的網絡層面的協程庫,可以很容易移植到現有的代碼中進行改造,但是也讓libco適用場景侷限於如rpc風格的proxy/logic的場景中。在我的理解裡,阻塞IO讓出cpu是協程要解決的問題,但是不是協程本身的性質,從實現上我們可以看出我們還是在用異步回調的方式在解決這個問題,和協程本身無關。

3. 如果一個協程沒有發起IO,但是一直佔用CPU資源不讓出資源怎麼辦?無解,所以協程的編寫對使用場景很重要,程序員對協程的理解也很重要,協程不適合於處理重cpu密集計算(耗時),只要某個協程即一直佔用著線程的資源就是不合理的,因為這樣做不到一個合理的併發,多線程同步模型由OS來調度併發,不存在說一個併發點需要讓出資源給另一個,而協程在編寫的時候cpu資源的讓出是由程序員來完成的,所以協程代碼的編寫需要程序員對協程有比較深刻的理解。最極端的例子是程序員在協程裡寫個死循環,好,這個線程的所有協程都可以歇歇了。

協程有什麼好處

說了這麼多協程,協程的好處到底是啥?為什麼要使用協程?

1. 協程極大的優化了程序員的編程體驗,同步編程風格能快速構建模塊,並易於複用,而且有異步的性能(這個看具體庫的實現),也不用陷入callback hell的深坑。

2. 第二點也是我最近一直在糾結的一點,協程到底有沒有性能提升?

1)從多線程同步模型切到協程來看,首先很明確的性能提升點在於同步到異步的切換,libco中把阻塞的點全部放到了epoll線程中,而協程線程並不會發生阻塞。其次是協程的成本比線程小,線程受棧空間限制,而協程的棧空間由用戶控制,而且實現協程需要的輔助數據結構很少,佔用的內存少,那麼就會有更大的容量,比如可以輕鬆開10w個協程,但是很難說開10w個線程。另外一個問題是很多人拿線程上下文切換比協程上下文切換開銷大來推出協程模型比多線程併發模型性能優這點,這個問題我糾結了很久。對於這個問題,我先做一個簡單的具體抽象:在不考慮阻塞的情況下,假設8核的cpu,不考慮搶佔中斷優先級等因素,100個任務併發執行,100個線程併發和10個線程每個線程10個協程併發對比兩者都可以把cpu資源利用起來,對OS來說,前者100個線程參與cpu調度,後者10個線程參與cpu調度,後者還有額外的協程切換調度,先考慮線程切換的上下文,根據Linux內核調度器CFS的算法,每個線程拿到的時間片是動態的,進程數在分配的時間片在可變區間的情況下會直接影響到線程時間片的長短,所以100個線程每個線程的時間片在一定條件下會要比10個線程情況下的要短,也就意味著在相同時間裡,前者的上下文切換次數是比後者要多的,所以可以得出一個結論:協程併發模型比多線程同步模型在一定條件下會減少線程切換次數(時間片有固定的範圍,如果超出這個範圍的邊界則線程的時間片無差異),增加了協程切換次數,由於協程的切換是由程序員自己調度的,所以很難說協程切換的代價比省去的線程切換代價小,合理的方式應該是通過測試工具在具體的業務場景得出一個最好的平衡點。

2)從異步回調模型切到協程模型來看,從一些已有協程庫的實現來看,協程的同步寫法卻有異步性能其實還是異步回調在支撐這個事情,所以我認為協程模型是在異步模型之上的東西,考慮到本身協程上下文切換的開銷(其實很小)和數據結構調用的一些開銷,理論上協程是比異步回調的性能要稍微差一點,但是可以處於幾乎持平的性能,因為協程實現的代價非常小。

3)從一些異步驅動庫的角度來看協程的話,因為異步框架把代碼封裝到很多個小類裡面,然後串起來,這中間會涉及相當多的內存分配,而數據大都在離散的堆內存裡面,而協程風格的代碼,可以簡單理解為一個簡潔的連續空間的棧內存池,輔助數據結構也很少,所以協程可能會比厚重的封裝性能會更好一些,但是這裡的前提是,協程庫能實現異步驅動庫所需要的功能,並把它封裝到同步調用裡。

分享視頻有C/C++,Linux,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒體,CDN,P2P,K8S,Docker,TCP/IP,協程,DPDK等等。

有關於c++ Linux後臺服務器開發的相關視頻


分享到:


相關文章: