在進一步以前,讓咱們先回顧一下各類上下文切換技術。python
不過首先說明一點術語。當咱們說「上下文」的時候,指的是程序在執行中的一個狀態。一般咱們會用調用棧來表示這個狀態——棧記載了每一個調用層級執行到哪裏,還有執行時的環境狀況等全部有關的信息。linux
當咱們說「上下文切換」的時候,表達的是一種從一個上下文切換到另外一個上下文執行的技術。而「調度」指的是決定哪一個上下文能夠得到接下去的CPU時間的方法。程序員
進程是一種古老而典型的上下文系統,每一個進程有獨立的地址空間,資源句柄,他們互相之間不發生干擾。golang
每一個進程在內核中會有一個數據結構進行描述,咱們稱其爲進程描述符。這些描述符包含了系統管理進程所需的信息,而且放在一個叫作任務隊列的隊列裏面。編程
很顯然,當新建進程時,咱們須要分配新的進程描述符,而且分配新的地址空間(和父地址空間的映射保持一致,可是二者同時進入COW狀態)。這些過程須要必定的開銷。數組
忽略去linux內核複雜的狀態轉移表,咱們實際上能夠把進程狀態歸結爲三個最主要的狀態:就緒態,運行態,睡眠態。這就是任何一本系統書上都有的三態轉換圖。緩存
就緒和執行能夠互相轉換,基本這就是調度的過程。而當執行態程序須要等待某些條件(最典型就是IO)時,就會陷入睡眠態。而條件達成後,通常會自動進入就緒。服務器
當進程須要在某個文件句柄上作IO,這個fd又沒有數據給他的時候,就會發生阻塞。具體來講,就是記錄XX進程阻塞在了XX fd上,而後將進程標記爲睡眠態,並調度出去。當fd上有數據時(例如對端發送的數據到達),就會喚醒阻塞在fd上的進程。進程會隨後進入就緒隊列,等待合適的時間被調度。網絡
阻塞後的喚醒也是一個頗有意思的話題。當多個上下文阻塞在一個fd上(雖然很少見,可是後面能夠看到一個例子),並且fd就緒時,應該喚醒多少個上下文呢?傳統上應當喚醒全部上下文,由於若是僅喚醒一個,而這個上下文又不能消費全部數據時,就會使得其餘上下文處於無謂的死鎖中。數據結構
可是有個著名的例子——accept,也是使用讀就緒來表示收到的。若是試圖用多個線程來accept會發生什麼?當有新鏈接時,全部上下文都會就緒,可是隻有第一個能夠實際得到fd,其餘的被調度後又馬上阻塞。這就是驚羣問題thundering herd problem。
現代linux內核已經解決了這個問題,方法驚人的簡單——accept方法加鎖。
(inet_connection_sock.c:inet_csk_wait_for_connect)
線程是一種輕量進程,實際上在linux內核中,二者幾乎沒有差異,除了一點——線程並不產生新的地址空間和資源描述符表,而是複用父進程的。
可是不管如何,線程的調度和進程同樣,必須陷入內核態。
爲每一個客戶分配一個進程。優勢是業務隔離,在一個進程中出現的錯誤不至於影響整個系統,甚至其餘進程。Oracle傳統上就是進程模型。缺點是進程的分配和釋放有很是高的成本。所以Oracle須要鏈接池來保持鏈接減小新建和釋放,同時儘可能複用鏈接而不是隨意的新建鏈接。
爲每客戶分配一個線程。優勢是更輕量,創建和釋放速度更快,並且多個上下文間的通信速度很是快。缺點是一個線程出現問題容易將整個系統搞崩潰。
在這個例子中,線程模式和進程模式能夠輕易的互換。
如何工做的:
thread模式,虛擬機:
1: 909.27 2: 3778.38 3: 4815.37 4: 5000.04 10: 4998.16 50: 4881.93 100: 4603.24 200: 3445.12 500: 1778.26 (出現錯誤)
fork模式,虛擬機:
1: 384.14 2: 435.67 3: 435.17 4: 437.54 10: 383.11 50: 364.03 100: 320.51 (出現錯誤)
thread模式,物理機:
1: 6942.78 2: 6891.23 3: 6584.38 4: 6517.23 10: 6178.50 50: 4926.91 100: 2377.77
注意在python中,雖然有GIL,可是一個線程陷入到網絡IO的時候,GIL是解鎖的。所以從調用開始到調用結束,減去CPU切換到其餘上下文的時間,是能夠多線程的。現象是,在此種情況下能夠觀測到短暫的python CPU用量超過100%。
若是執行多個上下文,能夠充分利用這段時間。所觀測到的結果就是,只能單核的python,在小範圍內,其隨着併發數上升,性能竟然會跟着上升。若是將這個過程轉移到一臺物理機上執行,那麼基本不能得出這樣的結論。這主要是由於虛擬機上內核陷入的開銷更高。
當同時鏈接數在10K左右時,傳統模型就再也不適用。實際上在效率測試報告的線程切換開銷一節能夠看到,超過1K後性能就差的一塌糊塗了。
在C10K的時候,啓動和關閉這麼多進程是不可接受的開銷。事實上單純的進程fork模型在C1K時就應當拋棄了。
Apache的prefork模型,是使用預先分配(pre)的進程池。這些進程是被複用的。但即使是複用,本文所描述的不少問題仍不可避免。
從任何測試均可以代表,線程模式比進程模式更耐久一些,性能更好。可是在面對C10K仍是力不從心的。問題是,線程模式的問題出在哪裏呢?
有些人可能認爲線程模型的失敗首先在於內存。若是你這麼認爲,必定是由於你查閱了很是老的資料,而且沒仔細思考過。
你可能看到資料說,一個線程棧會消耗8M內存(linux默認值,ulimit能夠看到),512個線程棧就會消耗4G內存,而10K個線程就是80G。因此首先要考慮調整棧深度,並考慮爆棧問題。
聽起來頗有道理,問題是——linux的棧是經過缺頁來分配內存的(How does stack allocation work in Linux?),不是全部棧地址空間都分配了內存。所以,8M是最大消耗,實際的內存消耗只會略大於實際須要的內存(內部損耗,每一個在4k之內)。可是內存一旦被分配,就很難回收(除非線程結束),這是線程模式的缺陷。
這個問題提出的前提是,32位下地址空間有限。雖然10K個線程不必定會耗盡內存,可是512個線程必定會耗盡地址空間。然而這個問題對於目前已經成爲主流的64位系統來講根本不存在。
所謂內核陷入開銷,就是指CPU從非特權轉向特權,而且作輸入檢查的一些開銷。這些開銷在不一樣的系統上差別很大。
線程模型主要經過陷入切換上下文,所以陷入開銷大聽起來有點道理。實際上,這也是不成立的。線程在何時發生陷入切換?正常狀況下,應當是IO阻塞的時候。一樣的IO量,難道其餘模型就不須要陷入了麼?只是非阻塞模型有很大可能直接返回,並不發生上下文切換而已。
效率測試報告的基礎調用開銷一節,證明了當代操做系統上內核陷入開銷是很是驚人的小的(10個時鐘週期這個量級)。
熟悉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方案的特性。
另外提一點。有趣的是,當使用了epoll後(更準確說只有在LT模式下),fd是否爲非阻塞其實已經不重要了。由於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服務器了。
這個所謂的「可喚醒可中斷對象」,用的最多的就是協程。
當程序員還沉浸在解決C10K問題帶來的成就感時,一個新的問題被拋出了。異步嵌套回調太TM難寫了。尤爲是Node.js層層回調,縮進了幾十層,要把程序員逼瘋了。因而一個新的技術被提出來了,那就是協程(coroutine)。這個技術本質上也是異步非阻塞技術,它是將事件回調進行了包裝,讓程序員看不到裏面的事件循環。程序員就像寫阻塞代碼同樣簡單。好比調用 client->recv() 等待接收數據時,就像阻塞代碼同樣寫。其實是底層庫在執行recv時悄悄保存了一個狀態,好比代碼行數,局部變量的值。而後就跳回到EventLoop中了。何時真的數據到來時,它再把剛纔保存的代碼行數,局部變量值取出來,又開始繼續執行。
這個就像時間禁止的遊戲同樣,國王對巫師說「我必須立刻獲得寶物,否則就砍了你的腦殼」,巫師唸了一句時間中止的咒語,直到過了1年後勇士們才把寶物送來。這時候巫師解開咒語,把寶物交給國王。這裏國王就能夠理解成協程,他根本沒感受到時間中止,在他中止到醒來期間發生了什麼他不知道,也不關心。
這就是協程的本質。協程是異步非阻塞的另一種展示形式。Golang,Erlang,Lua協程都是這個模型。
其中Lua是非對稱式模型。它的基本思想很是簡單。經過顯式調用 coroutine.create 函數來建立一個協程,把一個函數做爲協程主體來執行。當咱們啓動 (resume) 協程時,它開始運行函數體而且直到結束或者讓出控制權 (yield) ;一個協程只有經過顯式調用 yield 函數纔會中斷。之後,咱們能夠 resume 它,它將會從它中止的地方繼續執行。它的基本思想很是相似於 Python 的生成器,但有一個關鍵區別:Lua協程能夠在嵌套調用中 yield,而在 Python 中,生成器只能從它的主函數中 yield。在實現上,這意味着每一個協程像線程同樣必須有獨立堆棧。和「平坦」的生成器相比,「帶堆棧」的協程發揮了難以想象的強大威力。例如,咱們能夠在它們的基礎上實現一次性延續點 (one-shot continuations)。
再回顧一下同步阻塞這個概念,不知道你們看完協程是否感受獲得,實際上協程和同步阻塞是同樣的。答案是的。因此協程也叫作用戶態進/用戶態線程。區別就在於進程/線程是操做系統充當了EventLoop調度,而協程是本身用Epoll進行調度。
協程的優勢是它比系統線程開銷小,缺點是若是其中一個協程中有密集計算,其餘的協程就不運行了。操做系統進程的缺點是開銷大,優勢是不管代碼怎麼寫,全部進程均可以併發運行。
Erlang解決了協程密集計算的問題,它基於自行開發VM,並不執行機器碼。即便存在密集計算的場景,VM發現某個協程執行時間過長,也能夠進行停止切換。Golang因爲是直接執行機器碼的,因此沒法解決此問題。因此Golang要求用戶必須在密集計算的代碼中,自行Yield。
實際上同步阻塞程序的性能並不差,它的效率很高,不會浪費資源。當進程發生阻塞後,操做系統會將它掛起,不會分配CPU。直到數據到達纔會分配CPU。多進程只是開多了以後反作用太大,由於進程多了互相切換有開銷。因此若是一個服務器程序只有1000左右的併發鏈接,同步阻塞模式是最好的。
協程是一種編程組件,能夠在不陷入內核的狀況進行上下文切換。如此一來,咱們就能夠把協程上下文對象關聯到fd,讓fd就緒後協程恢復執行。
固然,因爲當前地址空間和資源描述符的切換不管如何須要內核完成,所以協程所能調度的,必須是在同一進程中的不一樣上下文。
這是如何作到的呢?
咱們在內核裏實行上下文切換的時候,實際上是將當前全部寄存器保存到內存中,而後從另外一塊內存中載入另外一組已經被保存的寄存器。對於圖靈機來講,當前狀態寄存器意味着機器狀態——也就是整個上下文。其他內容,包括棧上內存,堆上對象,都是直接或者間接的經過寄存器來訪問的。
可是請仔細想一想,寄存器更換這種事情,彷佛不須要進入內核態麼。事實上咱們在用戶態切換的時候,就是用了相似方案。
C coroutine的實現,基本大可能是保存現場和恢復之類的過程。python則是保存當前thread的top frame(greenlet)。
可是很是悲劇的,純用戶態方案(setjmp/longjmp)在多數系統上執行的效率很高,可是並非爲了協程而設計的。setjmp並無拷貝整個棧(大多數的coroutine方案也不該該這麼作),而是隻保存了寄存器狀態。這致使新的寄存器狀態和老寄存器狀態共享了同一個棧,從而在執行時互相破壞。而完整的coroutine方案應當在特定時刻新建一個棧。
而比較好的方案(makecontext/swapcontext)則須要進入內核(sigprocmask),這致使整個調用的性能很是低。
首先咱們能夠明確,協程不能調度其餘進程中的上下文。然後,每一個協程要得到CPU,都必須在線程中執行。所以,協程所能利用的CPU數量,和用於處理協程的線程數量直接相關。
做爲推論,在單個線程中執行的協程,能夠視爲單線程應用。這些協程,在未執行到特定位置(基本就是阻塞操做)前,是不會被搶佔,也不會和其餘CPU上的上下文發生同步問題的。所以,一段協程代碼,中間沒有可能致使阻塞的調用,執行在單個線程中。那麼這段內容能夠被視爲同步的。
咱們常常能夠看到某些協程應用,一啓動就是數個進程。這並非跨進程調度協程。通常來講,這是將一大羣fd分給多個進程,每一個進程本身再作fd-協程對應調度。
基於就緒通知的協程框架
這樣,異步的數據讀寫動做,在咱們的想像中就能夠變爲同步的。而咱們知道同步模型會極大下降咱們的編程負擔。
其實這個模型有個更流行的名字——回調模型。之因此扯上CPS這麼高大上的玩意,主要是裏面涉及很多有趣的話題。
首先是回調模型的大體過程。在IO調用的時候,同時傳入一個函數,做爲返回函數。當IO結束時,調用傳入的函數來處理下面的流程。這個模型聽起來挺簡單的。
而後是CPS。用一句話來描述這個模型——他把一切操做都看成了IO,不管幹什麼,結果要經過回調函數來返回。從這個角度來講,IO回調模型只能被視做CPS的一個特例。
例如,咱們須要計算1+2*3,在cps裏面就須要這麼寫:
mul(lambda x: add(pprint.pprint, x, 1), 2, 3)
其中mul和add在python裏面以下定義:
add = lambda f, *nums: f(sum(nums)) mul = lambda f, *nums: f(reduce(lambda x,y: x*y, nums))
並且因爲python沒有TCO,因此這樣的寫法會產生很是多的frame。
可是要正確理解這個模型,你須要仔細思考一下如下幾個問題:
不知道你是否思考過爲何函數調用層級(上下文棧)會被表述爲一個棧——是否有什麼必要性,必須將函數調用的過程定義爲一個棧呢?
緣由就是返回值和同步順序。對於大部分函數,咱們須要獲得函數計算的返回值。而要獲得返回值,調用者就必須阻塞直到被調用者返回爲止。所以調用者的執行狀態就必須被保存,等到被調用者返回後繼續——從這點來講,調用實際上是最樸素的上下文切換手段。而對於少部分無需返回的函數,咱們又每每須要他的順序外部效應——例如干掉了某個進程,開了一個燈,或者僅僅是在環境變量裏面添加了一項內容。而順序外部效應一樣須要等待被調用者返回以代表這個外部效應已經發生。
那麼,若是咱們不須要返回值也不須要順序的外部效應呢?例如啓動一個背景程序將數據發送到對端,無需保證發送成功的狀況下。或者是開始一個數據抓取行爲,無需保證抓取的成功。
一般這種需求咱們就湊合着用一個同步調用混過去了——反正問題也不嚴重。可是對於阻塞至關嚴重的狀況而言,不少人仍是會考慮到將這個行爲作成異步過程。目前最流行的異步調用分解工具就是mq——不只異步,並且分佈。固然,還有一個更簡單的非分佈方案——開一個coroutine。
而CPS則是另外一個方向——函數的返回值能夠不返回調用者,而是返回給第三者。
其實這個問題的核心在於——整個回調模型是基於多路複用的仍是基於異步IO的?
原則上二者均可以。你能夠監聽fd就緒,也能夠監聽IO完成。固然,即便監聽IO完成,也不表明使用了內核態異步接口。極可能只是用epoll封裝的而已。
這個問題則須要和上面提到的「用戶態調度框架」結合起來講。IO回調註冊的實質是將回調函數綁定到某個fd上——就如同將coroutine綁定上去那樣。只是coroutine容許你順序的執行,而callback則會切碎函數。固然,大部分實現中,使用callback也有好處——coroutine的最小切換開銷也在50ns,而call自己則只有2ns。
狀態機模型是一個更難於理解和編程的模型,其本質是每次重入。
想像你是一個週期失憶的病人(就像「一週的朋友」那樣)。那麼你如何才能完成一項須要跨越週期的工做呢?例如刺繡,種植做物,或者——交一個男友。
固然,類比到失憶病人的例子上必須有一點限制。正常的生活技能,還有一些常識性的東西必須不能在週期失憶範圍內。例如從新學習認字什麼的可沒人受的了。
答案就是——作筆記。每次重複失憶後,你須要閱讀本身的筆記,觀察上次作到哪一個步驟,下一個步驟是什麼。這須要將一個工做分解爲不少步驟,在每一個步驟內「重入」直到步驟完成,轉移到下一個狀態。
同理,在狀態機模型解法裏,每次執行都須要推演合適的狀態,直到工做完成。這個模型已經不多用到了,由於相比回調函數來講,狀態機模型更難理解和使用,性能差別也不大。