這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
參考文章:Python 中的進程、線程、協程、同步、非同步、回調
進程和線程究竟是什麼東西?傳統網路服務模型是如何工作的?協程和線程的關係和區別有哪些?IO過程在什麼時間發生?
一、環境切換
上下文,指的是程式執行的某一個狀態,通常我們會通過調用棧來表示這個狀態,棧記載了每個調用層級執行到了哪裡,以及執行時的環境情況等所有有關的資訊
環境切換,表達的是從一個環境切換到另一個內容相關的技術。“調度”指的是哪個上下文可以獲得接下來CPU時間的方法。
進程
進程是一種古老而經典的上下文系統,每個進程有獨立的地址空間,資源控制代碼,他們之間互不發生幹擾。
每個進程在核心中會有一個資料結構進行描述,我們稱其為進程描述符。這些描述符包含了系統管理進程所需的資訊,並且放在一個叫做任務隊列的隊列裡面。
很顯然,當建立進程時,我們需要分配新的進程描述符,並且分配新的地址空間(和父地址空間的映射保持一致,但是兩者同時進入COW狀態)。這些過程需要一定的開銷。
進程狀態
忽略去linux核心複雜的狀態跳躍表,我們實際上可以把進程狀態歸結為三個最主要的狀態:就緒態,運行態,睡眠態。這就是任何一本系統書上都有的三態轉換圖。
就緒和執行可以互相轉換,基本這就是調度的過程。而當執行態程式需要等待某些條件(最典型就是IO)時,就會陷入睡眠態。而條件達成後,一般會自動進入就緒。
阻塞
當進程需要在某個檔案控制代碼上做IO,這個fd又沒有資料給他的時候,就會發生阻塞。具體來說,就是記錄XX進程阻塞在了XX fd上,然後將進程標記為睡眠態,並調度出去。當fd上有資料時(例如對端發送的資料到達),就會喚醒阻塞在fd上的進程。進程會隨後進入就緒隊列,等待合適的時間被調度。
阻塞後的喚醒也是一個很有意思的話題。當多個上下文阻塞在一個fd上(雖然不多見,但是後面可以看到一個例子),而且fd就緒時,應該喚醒多少個上下文呢?傳統上應當喚醒所有上下文,因為如果僅喚醒一個,而這個上下文又不能消費所有資料時,就會使得其他上下文處於無謂的死結中。
線程
線程是一種輕量進程,實際上在linux核心中,兩者幾乎沒有差別,除了一點——線程並不產生新的地址空間和資源描述符表,而是複用父進程的。 但是無論如何,線程的調度和進程一樣,必須陷入核心態。
二、傳統的網路模型
進程模型
為每個客戶分配一個進程。優點是業務隔離,在一個進程中出現的錯誤不至於影響整個系統,甚至其他進程。Oracle傳統上就是進程模型。缺點是進程的分配和釋放有非常高的成本。因此Oracle需要串連池來保持串連減少建立和釋放,同時盡量複用串連而不是隨意的建立串連。
執行緒模式
為每客戶分配一個線程。優點是更輕量,建立和釋放速度更快,而且多個上下文間的通訊速度非常快。缺點是一個線程出現問題容易將整個系統搞崩潰。
三、C10K問題
進程模型的問題
在C10K的時候,啟動和關閉這麼多進程是不可接受的開銷。事實上單純的進程fork模型在C1K時就應當拋棄了。
Apache的prefork模型,是使用預先分配(pre)的進程池。這些進程是被複用的。但即便是複用,本文所描述的很多問題仍不可避免。
執行緒模式的問題
從任何測試都可以表明,線程模式比進程模式更耐久一些,效能更好。但是在面對C10K還是力不從心的。執行緒模式的問題在於切換成本高。
熟悉linux核心的應該知道,近代linux調度器經過幾個階段的發展。
實際上直到O(1),調度器的調度複雜度才和隊列長度無關。在此之前,過多的線程會使得開銷隨著線程數增長(不保證線性)。
O(1)調度器看起來似乎是完全不隨著線程的影響。但是這個調度器有顯著的缺點——難於理解和維護,並且在一些情況下會導致互動式程式響應緩慢。 CFS使用紅/黑樹狀結構管理就緒隊列。每次調度,上下文狀態轉換,都會查詢或者變更紅/黑樹狀結構。紅/黑樹狀結構的開銷大約是O(logm),其中m大約為活躍上下文數(準確的說是同優先順序上下文數),大約和活躍的客戶數相當。
因此,每當線程試圖讀寫網路,並遇到阻塞時,都會發生O(logm)層級的開銷。而且每次收到報文,喚醒阻塞在fd上的上下文時,同樣要付出O(logm)層級的開銷。
分析
O(logm)的開銷看似並不大,但是卻是一個無法接受的開銷。因為IO阻塞是一個經常發生的事情。每次IO阻塞,都會發生開銷。而且決定活躍線程數的是使用者,這不是我們可控制的。更糟糕的是,當效能下降,響應速度下降時。同樣的使用者數下,活躍上下文會上升(因為響應變慢了)。這會進一步拉低效能。
問題的關鍵在於,http服務並不需要對每個使用者完全公平,偶爾某個使用者的回應時間大大的延長了是可以接受的。在這種情況下,使用紅/黑樹狀結構去組織待處理fd列表(其實是上下文列表),並且反覆計算和調度,是無謂的開銷
四、多工
簡述
要突破C10K問題,必須減少系統內活躍上下文數(其實未必,例如換一個調度器,例如使用RT的SCHED_RR),因此就要求一個上下文同時處理多個連結。而要做到這點,就必須在每次系統調用讀取或寫入資料時立刻返回。否則上下文持續阻塞在調用上,如何能夠複用?這要求fd處於非阻塞狀態,或者資料就緒。
上文所說的所有IO操作,其實都特指了他的阻塞版本。所謂阻塞,就是上下文在IO調用上等待直到有合適的資料為止。這種模式給人一種“只要讀取資料就必定能讀到”的感覺。而非阻塞調用,就是上下文立刻返回。如果有資料,帶回資料。如果沒有資料,帶回錯誤(EAGAIN)。因此,“雖然發生錯誤,但是不代表出錯”。
但是即使有了非阻塞模式,依然繞不過就緒通知問題。如果沒有合適的就緒通知技術,我們只能在多個fd中盲目的重試,直到碰巧讀到一個就緒的fd為止。這個效率之差可想而知。
在就緒通知技術上,有兩種大的模式——就緒事件通知和非同步IO。其差別簡要來說有兩點:
- 就緒通知維護一個狀態,由使用者讀取,而非同步IO由系統調用使用者的回呼函數
- 就緒通知在資料就緒時就生效,而非同步IO直到資料IO完成才發生回調
linux下的主流方案一直是就緒通知,其核心態非同步IO方案甚至沒有被封裝到glibc裡去。圍繞就緒通知,linux總共提出過三種解決方案(select、poll、epoll)。我們繞過select和poll方案,看看epoll方案的特性。
另外提一點。有趣的是,當使用了epoll後(更準確說只有在LT模式下),fd是否為非阻塞其實已經不重要了。因為epoll保證每次去讀取的時候都能讀到資料,因此不會阻塞在調用上。
epoll
使用者可以建立一個epoll檔案控制代碼,並且將其他fd和這個"epoll fd"關聯。此後可以通過epoll fd讀取到所有就緒的檔案控制代碼。
epoll有兩大模式,ET和LT。LT模式下,每次讀取就緒控制代碼都會讀取出完整的就緒控制代碼。而ET模式下,只給出上次到這次調用間新就緒的控制代碼。換個說法,如果ET模式下某次讀取出了一個控制代碼,這個控制代碼從未被讀取完過——也就是從沒有從就緒變為未就緒。那麼這個控制代碼就永遠不會被新的調用返回,哪怕上面其實充滿了資料——因為控制代碼無法經曆從非就緒變為就緒的過程。
類似CFS,epoll也使用了紅/黑樹狀結構——不過是用於組織加入epoll的所有fd。epoll的就緒列表使用的是雙向隊列。這方便系統將某個fd排入佇列中,或者從隊列中解除。
要進一步瞭解epoll的具體實現,可以參考這篇linux下poll和epoll核心源碼剖析。
效能
如果使用非阻塞函數,就不存在阻塞IO導致環境切換了,而是變為時間片耗盡被搶佔(大部分情況下如此),因此讀寫的額外開銷被消除。而epoll的常規操作,都是O(1)量級的。而epoll wait的複製動作,則和當前需要返回的fd數有關(在LT模式下幾乎就等同於上面的m,而ET模式下則會大大減少)。
但是epoll存在一點細節問題。epoll fd的管理使用紅/黑樹狀結構,因此在加入和刪除時需要O(logn)複雜度(n為總串連數),而且關聯操作還必須每個fd調用一次。因此在大串連量下頻繁建立和關閉串連仍然有一定效能問題(超短串連)。不過關聯操作調用畢竟比較少。如果確實是超短串連,tcp串連和釋放開銷就很難接受了,所以對總體效能影響不大。
固有缺陷
原理上說,epoll實現了一個wait_queue的回呼函數,因此原理上可以監聽任何能夠啟用wait_queue的對象。但是epoll的最大問題是無法用於普通檔案,因為普通檔案始終是就緒的——雖然在讀取的時候不是這樣。
這導致基於epoll的各種方案,一旦讀到普通檔案上下文仍然會阻塞。golang為瞭解決這個問題,在每次調用syscall的時候,會獨立的啟動一個線程,在獨立的線程中進行調用。因此golang在IO普通檔案的時候網路不會阻塞。
五、事件通知機制下的幾種程式設計模型
簡述
使用通知機制的一大缺憾就是,使用者進行IO操作後會陷入茫然——IO沒有完成,所以當前上下文不能繼續執行。但是由於複用線程的要求,當前線程還需要接著執行。所以,在如何進行非同步編程上,又分化出數種方案。
使用者態調度
首先需要知道的一點就是,非同步編程大多數情況下都伴隨著使用者態調度問題——即使不使用上下文技術。
因為系統不會自動根據fd的阻塞狀況來喚醒合適的上下文了,所以這個工作必須由其他人——一般就是某種架構——來完成。
你可以想像一個fd映射到對象的大map表,當我們從epoll中得知某個fd就緒後,需要喚醒某種對象,讓他處理fd對應的資料。
當然,實際情況會更加複雜一些。原則上所有不佔用CPU時間的等待都需要中斷執行,陷入睡眠,並且交由某種機構管理,等待合適的機會被喚醒。例如sleep,或是檔案IO,還有lock。更精確的說,所有在核心裡面涉及到wait_queue的,在架構裡面都需要做這種機制——也就是把核心的調度和等待搬到使用者態來。
當然,其實也有反過來的方案——就是把程式扔到核心裡面去。其中最著名的執行個體大概是微軟的http伺服器了。
這個所謂的“可喚醒可中斷對象”,用的最多的就是協程。
協程
協程是一種編程組件,可以在不陷入核心的情況進行環境切換。如此一來,我們就可以把協程內容物件關聯到fd,讓fd就緒後協程恢複執行。 當然,由於當前地址空間和資源描述符的切換無論如何需要核心完成,因此協程所能調度的,只有在同一進程中的不同上下文而已。
如何做到
這是如何做到的呢?
我們在核心裡實行環境切換的時候,其實是將當前所有寄存器儲存到記憶體中,然後從另一塊記憶體中載入另一組已經被儲存的寄存器。對於圖靈機來說,目前狀態寄存器意味著機器狀態——也就是整個上下文。其餘內容,包括棧上記憶體,堆上對象,都是直接或者間接的通過寄存器來訪問的。
但是請仔細想想,寄存器更換這種事情,似乎不需要進入核心態麼。事實上我們在使用者態切換的時候,就是用了類似方案。
C coroutine的實現,基本大多是儲存現場和恢複之類的過程。python則是儲存當前thread的top frame(greenlet)。
但是非常悲劇的,純使用者態方案(setjmp/longjmp)在多數系統上執行的效率很高,但是並不是為了協程而設計的。setjmp並沒有拷貝整個棧(大多數的coroutine方案也不應該這麼做),而是只儲存了寄存器狀態。這導致新的寄存器狀態和老寄存器狀態共用了同一個棧,從而在執行時互相破壞。而完整的coroutine方案應當在特定時刻建立一個棧。
而比較好的方案(makecontext/swapcontext)則需要進入核心(sigprocmask),這導致整個調用的效能非常低。
協程與線程的關係
首先我們可以明確,協程不能調度其他進程中的上下文。而後,每個協程要獲得CPU,都必須線上程中執行。因此,協程所能利用的CPU數量,和用於處理協程的線程數量直接相關。
作為推論,在單個線程中執行的協程,可以視為單線程應用。這些協程,在未執行到特定位置(基本就是阻塞操作)前,是不會被搶佔,也不會和其他CPU上的上下文發生同步問題的。因此,一段協程代碼,中間沒有可能導致阻塞的調用,執行在單個線程中。那麼這段內容可以被視為同步的。
我們經常可以看到某些協程應用,一啟動就是數個進程。這並不是跨進程調度協程。一般來說,這是將一大群fd分給多個進程,每個進程自己再做fd-協程對應調度。
基於就緒通知的協程架構
首先需要封裝read/write,在發生read的時候檢查返回。如果是EAGAIN,那麼將當前協程標記為阻塞在對應fd上,然後執行調度函數。
調度函數需要執行epoll(或者從上次的返回結果緩衝中取資料,減少核心陷入次數),從中讀取一個就緒的fd。如果沒有,上下文應當被阻塞到至少有一個fd就緒。
尋找這個fd對應的協程內容物件,並調度過去。
當某個協程被調度到時,他多半應當在調度器返回的路上——也就是read/write讀不到資料的時候。因此應當再重試讀取,失敗的話返回1。
如果讀取到資料了,直接返回。
這樣,非同步資料讀寫動作,在我們的想像中就可以變為同步的。而我們知道同步模型會極大降低我們的編程負擔。