Python 中的程式、執行緒、協程、同步、非同步、回撥

發表於2015-07-08

程式和執行緒究竟是什麼東西?傳統網路服務模型是如何工作的?協程和執行緒的關係和區別有哪些?IO過程在什麼時間發生?

在剛剛結束的 PyCon2014 上海站,來自七牛雲端儲存的 Python 高階工程師許智翔帶來了關於 Python 的分享《Python中的程式、執行緒、協程、同步、非同步、回撥》。

 

一、上下文切換技術

簡述

在進一步之前,讓我們先回顧一下各種上下文切換技術。

不過首先說明一點術語。當我們說“上下文”的時候,指的是程式在執行中的一個狀態。通常我們會用呼叫棧來表示這個狀態——棧記載了每個呼叫層級執行到哪裡,還有執行時的環境情況等所有有關的資訊。

當我們說“上下文切換”的時候,表達的是一種從一個上下文切換到另一個上下文執行的技術。而“排程”指的是決定哪個上下文可以獲得接下去的CPU時間的方法。

程式

程式是一種古老而典型的上下文系統,每個程式有獨立的地址空間,資源控制程式碼,他們互相之間不發生干擾。

每個程式在核心中會有一個資料結構進行描述,我們稱其為程式描述符。這些描述符包含了系統管理程式所需的資訊,並且放在一個叫做任務佇列的佇列裡面。

很顯然,當新建程式時,我們需要分配新的程式描述符,並且分配新的地址空間(和父地址空間的對映保持一致,但是兩者同時進入COW狀態)。這些過程需要一定的開銷。

程式狀態

忽略去linux核心複雜的狀態轉移表,我們實際上可以把程式狀態歸結為三個最主要的狀態:就緒態,執行態,睡眠態。這就是任何一本系統書上都有的三態轉換圖。

就緒和執行可以互相轉換,基本這就是排程的過程。而當執行態程式需要等待某些條件(最典型就是IO)時,就會陷入睡眠態。而條件達成後,一般會自動進入就緒。

阻塞

當程式需要在某個檔案控制程式碼上做IO,這個fd又沒有資料給他的時候,就會發生阻塞。具體來說,就是記錄XX程式阻塞在了XX fd上,然後將程式標記為睡眠態,並排程出去。當fd上有資料時(例如對端傳送的資料到達),就會喚醒阻塞在fd上的程式。程式會隨後進入就緒佇列,等待合適的時間被排程。

阻塞後的喚醒也是一個很有意思的話題。當多個上下文阻塞在一個fd上(雖然不多見,但是後面可以看到一個例子),而且fd就緒時,應該喚醒多少個上下文呢?傳統上應當喚醒所有上下文,因為如果僅喚醒一個,而這個上下文又不能消費所有資料時,就會使得其他上下文處於無謂的死鎖中。

但是有個著名的例子——accept,也是使用讀就緒來表示收到的。如果試圖用多個執行緒來accept會發生什麼?當有新連線時,所有上下文都會就緒,但是隻有第一個可以實際獲得fd,其他的被排程後又立刻阻塞。這就是驚群問題thundering herd problem。

現代linux核心已經解決了這個問題,方法驚人的簡單——accept方法加鎖。

執行緒

執行緒是一種輕量程式,實際上在linux核心中,兩者幾乎沒有差別,除了一點——執行緒並不產生新的地址空間和資源描述符表,而是複用父程式的。

但是無論如何,執行緒的排程和程式一樣,必須陷入核心態。

 

二、傳統網路服務模型

程式模型

為每個客戶分配一個程式。優點是業務隔離,在一個程式中出現的錯誤不至於影響整個系統,甚至其他程式。Oracle傳統上就是程式模型。缺點是程式的分配和釋放有非常高的成本。因此Oracle需要連線池來保持連線減少新建和釋放,同時儘量複用連線而不是隨意的新建連線。

執行緒模型

為每客戶分配一個執行緒。優點是更輕量,建立和釋放速度更快,而且多個上下文間的通訊速度非常快。缺點是一個執行緒出現問題容易將整個系統搞崩潰。

一個例子

在這個例子中,執行緒模式和程式模式可以輕易的互換。

如何工作的:

  1. 父程式監聽服務埠
  2. 在有新連線建立的時候,父程式執行fork,產生一個子程式副本
  3. 如果子程式需要的話,可以exec(例如CGI)
  4. 父程式執行(理論上應當先執行子程式,因為exec執行的快可以避免COW)到accept後,發生阻塞
  5. 上下文排程,核心排程器選擇下一個上下文,如無意外,應當就是剛剛派生的子程式
  6. 子程式程式進入讀取處理狀態,阻塞在read呼叫上,所有上下文均進入睡眠態
  7. 隨著SYN或者資料包文到來,CPU會喚醒對應fd上阻塞的上下文(wait_queue),切換到就緒態,並加入排程佇列
  8. 上下文繼續執行到下一個阻塞呼叫,或者因為時間片耗盡被掛起

評價

  • 同步模型,編寫自然,每個上下文可以當作其他上下文不存在一樣的操作,每次讀取資料可以當作必然能讀取到。
  • 程式模型自然的隔離了連線。即使程式複雜且易崩潰,也隻影響一個連線而不是在整個系統。
  • 生成和釋放開銷很大(效率測試的程式fork和執行緒模式開銷測試),需要考慮複用。
  • 程式模式的多客戶通訊比較麻煩,尤其在共享大量資料的時候。

效能

thread模式,虛擬機器:

fork模式,虛擬機器:

thread模式,物理機:

注意在python中,雖然有GIL,但是一個執行緒陷入到網路IO的時候,GIL是解鎖的。因此從呼叫開始到呼叫結束,減去CPU切換到其他上下文的時間,是可以多執行緒的。現象是,在此種狀況下可以觀測到短暫的python CPU用量超過100%。

如果執行多個上下文,可以充分利用這段時間。所觀測到的結果就是,只能單核的python,在小範圍內,其隨著併發數上升,效能居然會跟著上升。如果將這個過程轉移到一臺物理機上執行,那麼基本不能得出這樣的結論。這主要是因為虛擬機器上核心陷入的開銷更高。

 

三、C10K 問題

描述

當同時連線數在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排程器經過幾個階段的發展。

  1. linux2.4的排程器
  2. O(1)排程器
  3. CFS

實際上直到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

使用者可以新建一個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-協程對應排程。

基於就緒通知的協程框架

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

這樣,非同步的資料讀寫動作,在我們的想像中就可以變為同步的。而我們知道同步模型會極大降低我們的程式設計負擔。

CPS模型

其實這個模型有個更流行的名字——回撥模型。之所以扯上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。

但是要正確理解這個模型,你需要仔細思考一下以下幾個問題:

函式的呼叫過程為什麼必須是一個棧?
IO過程在什麼時間發生?呼叫發生時,還是回撥時?
回撥函式從哪裡呼叫?如果當時利用工具去看上下文的話,呼叫棧是什麼樣子的?
函式元件和返回值

不知道你是否思考過為什麼函式呼叫層級(上下文棧)會被表述為一個棧——是否有什麼必要性,必須將函式呼叫的過程定義為一個棧呢?

原因就是返回值和同步順序。對於大部分函式,我們需要得到函式計算的返回值。而要得到返回值,呼叫者就必須阻塞直到被呼叫者返回為止。因此呼叫者的執行狀態就必須被儲存,等到被呼叫者返回後繼續——從這點來說,呼叫其實是最樸素的上下文切換手段。而對於少部分無需返回的函式,我們又往往需要他的順序外部效應——例如干掉了某個程式,開了一個燈,或者僅僅是在環境變數裡面新增了一項內容。而順序外部效應同樣需要等待被呼叫者返回以表明這個外部效應已經發生。

那麼,如果我們不需要返回值也不需要順序的外部效應呢?例如啟動一個背景程式將資料傳送到對端,無需保證傳送成功的情況下。或者是開始一個資料抓取行為,無需保證抓取的成功。

通常這種需求我們就湊合著用一個同步呼叫混過去了——反正問題也不嚴重。但是對於阻塞相當嚴重的情況而言,很多人還是會考慮到將這個行為做成非同步過程。目前最流行的非同步呼叫分解工具就是mq——不僅非同步,而且分佈。當然,還有一個更簡單的非分佈方案——開一個coroutine。

而CPS則是另一個方向——函式的返回值可以不返回撥用者,而是返回給第三者。

IO 過程在什麼時間發生

其實這個問題的核心在於——整個回撥模型是基於多路複用的還是基於非同步IO的?

原則上兩者都可以。你可以監聽fd就緒,也可以監聽IO完成。當然,即使監聽IO完成,也不代表使用了核心態非同步介面。很可能只是用epoll封裝的而已。

回撥函式的上下文環境

這個問題則需要和上面提到的“使用者態排程框架”結合起來說。IO回撥註冊的實質是將回撥函式繫結到某個fd上——就如同將coroutine繫結上去那樣。只是coroutine允許你順序的執行,而callback則會切碎函式。當然,大部分實現中,使用callback也有好處——coroutine的最小切換開銷也在50ns,而call本身則只有2ns。

狀態機模型

狀態機模型是一個更難於理解和程式設計的模型,其本質是每次重入。

想像你是一個週期失憶的病人(就像“一週的朋友”那樣)。那麼你如何才能完成一項需要跨越週期的工作呢?例如刺繡,種植作物,或者——交一個男朋友。

當然,類比到失憶病人的例子上必須有一點限制。正常的生活技能,還有一些常識性的東西必須不能在週期失憶範圍內。例如重新學習認字什麼的可沒人受的了。

答案就是——做筆記。每次重複失憶後,你需要閱讀自己的筆記,觀察上次做到哪個步驟,下一個步驟是什麼。這需要將一個工作分解為很多步驟,在每個步驟內“重入”直到步驟完成,轉移到下一個狀態。

同理,在狀態機模型解法裡,每次執行都需要推演合適的狀態,直到工作完成。這個模型已經很少用到了,因為相比回撥函式來說,狀態機模型更難理解和使用,效能差異也不大。

最後順帶一提,交一個男友的方案和其他幾個略有不同,主要靠顏好高冷反差萌,一般人就不要嘗試挑戰了。。。當然一般人也不會一週失憶一次,畢竟生活不是韓劇也不是日本動漫。。。

相關文章