一起讀 Gevent 原始碼

發表於2016-10-27

這一篇主要想跟大家分享一下 Gevent 實現的基礎邏輯,也是有同學對這個很感興趣,所以貼出來跟大家一起分享一下。

Greenlet

我們知道 Gevent 是基於 Greenlet 實現的,greenlet 有的時候也被叫做微執行緒或者協程。其實 Greenlet 本身非常簡單,其自身實現的功能也非常直接。區別於常規的程式設計思路——順序執行、呼叫進棧、返回出棧—— Greenlet 提供了一種在不同的呼叫棧之間自由跳躍的功能。從一個簡單的例子來看一下吧(摘自官方文件):

這裡,每一個 greenlet 就是一個呼叫棧——您可以把他想象成一個執行緒,只不過真正的執行緒可以並行執行,而同一時刻只能有一個 greenlet 在執行(同一執行緒裡)。正如例子中最後三句話,我們建立了 gr1gr2 兩個不同的呼叫棧空間,入口函式分別是 test1test2;這最後一句 gr1.switch() 得多解釋一點。

因為除了 gr1gr2,我們還有一個棧空間,也就是所有 Python 程式都得有的預設的棧空間——我們暫且稱之為 main,而這一句 gr1.switch() 恰恰實現了從 maingr1 的跳躍,也就是從當前的棧跳到指定的棧。這時,就猶如常規呼叫 test1() 一樣,gr1.switch() 的呼叫暫時不會返回結果,程式會跳轉到 test1 繼續執行;只不過區別於普通函式呼叫時 test1() 會向當前棧壓棧,而 gr1.switch() 則會將當前棧存檔,替換成 gr1 的棧。如圖所示:

一起讀 Gevent 原始碼

對於這種棧的切換,我們有時也稱之為執行權的轉移,或者說 main 交出了執行權,同時 gr1 獲得了執行權。Greenlet 在底層是用匯編實現的這樣的切換:把當前的棧(main)相關的暫存器啊什麼的儲存到記憶體裡,然後把原本儲存在記憶體裡的 gr1 的相關資訊恢復到暫存器裡。這種操作速度非常快,比作業系統對多程式排程的上下文切換還要快。程式碼在這裡,有興趣的同學可以一起研究一下(其中 switch_x32_unix.h 是我寫的哈哈)。

回到前面的例子,最後一句 gr1.switch() 呼叫將執行點跳到了 gr1 的第一句,於是輸出了 12。隨後順序執行到 gr2.switch(),繼而跳轉到 gr2 的第一句,於是輸出了 56。接著又是 gr1.switch(),跳回到 gr1,從之前跳出的地方繼續——對 gr1 而言就是 gr2.switch() 的呼叫返回了結果 None,然後輸出 34

這個時候 test1 執行到頭了,gr1 的棧裡面空了。Greenlet 設計了 parent greenlet 的概念,就是說,當一個 greenlet 的入口函式執行完之後,會自動切換回其 parent。預設情況下,greenlet 的 parent 就是建立該 greenlet 時所在的那個棧,前面的例子中,gr1gr2 都是在 main 裡被建立的,所以他們倆的 parent 都是 main。所以當 gr1 結束的時候,會回到 main 的最後一句,接著 main 結束了,所以整個程式也就結束了——78 從來沒有被執行到過。另外,greenlet 的 parent 也可以手工設定。

簡單來看,greenlet 只是為 Python 語言增加了建立多條執行序列的功能,而且多條執行序列之間的切換還必須得手動顯式呼叫 switch() 才行;這些都跟非同步 I/O 沒有必然關係。

gevent.sleep

接著來看 Gevent。最簡單的一個 Gevent 示例就是這樣的了:

貌似非常簡單的一個 sleep,卻包含了 Gevent 的關鍵結構,讓我們仔細看一下 sleep 的實現吧。程式碼在 gevent/hub.py

這裡我把一些當前用不著的程式碼做了一些清理,只留下了三句關鍵的程式碼,其中就有 Gevent 的兩個關鍵的部件——hublooploop 是 Gevent 的核心部件,也就是主迴圈核心,預設是用 Cython 寫的 libev 的包裝(所以效能槓槓滴),稍後會在詳細提到它。hub 則是一個 greenlet,裡面跑著 loop

hub 是一個單例,從 get_hub() 的原始碼就可以看出來:

所以第一次執行 get_hub() 的時候,就會建立一個 hub 例項:

同樣這是一段精簡了的程式碼,反映了一個 hub 的關鍵屬性——looploop 例項隨著 hub 例項的建立而建立,預設的 loop 就是 gevent/core.ppyx 裡的 class loop,也可以通過環境變數 GEVENT_LOOP 來自定義。

值得注意的是,截止到 hub = get_hub()loop = hub.loop,我們都只是建立了 hubloop,並沒有真正開始跑我們的主迴圈。稍安勿躁,第三句就要開始了。

loop 有一堆介面,對應著底層 libev 的各個功能,詳見此處。我們這裡用到的是 timer(seconds),該函式返回的是一個 watcher 物件,對應著底層 libev 的 watcher 概念。我們大概能猜到,這個 watcher 物件會在幾秒鐘之後做一些什麼事情,但是具體怎麼做,讓我們一起看看 hub.wait() 的實現吧。

程式碼也不長,不過能看到 watcher 的介面 watcher.start(method),也就是說,當給定的幾秒鐘過了之後,會呼叫這裡給的函式,也就是 waiter.switch。讓我們再看一下這裡用到的 Waiter,都是在同一個檔案 hub.py 裡面:

這裡同樣刪掉了大量干擾因素。根據前面 wait() 的定義,我們會先建立一個 waiter,然後呼叫其 get(),隨後幾秒鐘之後 loop 會呼叫其 switch()。一個個看。

get() 一上來會保證自己不會被同時呼叫到(assert),接著就去獲取了當前的 greenlet,也就是呼叫 get() 時所處的棧,一直往前找,找到 sleep(1),所以 getcurrent() 的結果是 mainWaiter 隨後將 main 儲存在了 self.greenlet 引用中。

下面的一句話是重中之重了,self.hub.switch()!由不管任何上下文中,直接往 hub 裡跳。由於這是第一次跳進 hub 裡,所以此時 loop 就開始運轉了。

正巧,我們之前已經通過 loop.timer(1)watcher.start(waiter.switch),在 loop 裡註冊了說,1 秒鐘之後去呼叫 waiter.switchloop 一旦跑起來就會嚴格執行之前註冊的命令。所以呢,一秒鐘之後,我們在 hub 的棧中,呼叫到了 Waiter.switch()

switch() 裡,程式一上來就要驗證當前上下文必須得是 hub,翻閱一下前面的程式碼,這個是必然的。最後,跳到 self.greenlet!還記得它被設定成什麼了嗎?——main。於是乎,我們就回到了最初的程式碼裡,gevent.sleep(1) 在經過了 1 秒鐘的等待之後終於返回了。

回頭看一下這個過程,其實也很簡單的:當我們需要等待一個事件發生時——比如需要等待 1 秒鐘的計時器事件,我們就把當前的執行棧跟這個事件做一個繫結(watcher.start(waiter.switch)),然後把執行權交給 hubhub 則會在事件發生後,根據註冊的記錄儘快回到原來的斷點繼續執行。

非同步

hub 一旦拿到執行權,就可以做很多事情了,比如切換到別的 greenlet 去執行一些其他的任務,直到這些 greenlet 又主動把執行權交回給 hub。巨集觀的來看,就是這樣的:一個 hub,好多個其他的任務 greenlet(其中沒準就包括 main),hub 負責總排程,去依次呼叫各個任務 greenlet;任務 greenlet 則在執行至下一次斷點時,主動切換回 hub。這樣一來,許多個任務 greenlet 就可以看似並行地同步執行了,這種任務排程方式叫做協作式的任務排程(cooperative scheduling)。

舉個例子:

例子裡我們總共建立了 10greenlet,每一個都會按照不同頻率輸出“蜂鳴”;最後一句的 beep(20) 又讓 main greenlet 也不斷地蜂鳴。算上 hub,這個例子一共會有 12 個不同的 greenlet 在協作式地執行。

I/O

Gevent 最主要的功能當然是非同步 I/O 了。其實,I/O 跟前面 sleep 的例子沒什麼本質的區別,只不過 sleep 用的 watchertimer,而 I/O 用到的 watcherio。比如說 wait_read(fileno) 是這樣的:

沒什麼太大區別吧,原理其實都是一樣的。基於這個,我們就可以搞非同步 socket 了。socket 的介面較為複雜,這裡提取一些標誌性的程式碼一起讀一下吧:

libev

最後提一點關於 libev 的東西,因為有同學也問到 Gevent 底層的排程方式。簡單來說,libev 是依賴作業系統底層的非同步 I/O 介面實現的,Linux 用的是 epoll,FreeBSD 則是 kqueue。Python 程式碼裡,socket 會建立一堆 io watcher,對應底層則是將一堆檔案描述符新增到一個——比如—— epoll 的控制程式碼裡。當切換到 hub 之後,libev 會呼叫底層的 epoll_wait 來等待這些 socket 中可能出現的事件。一旦有事件產生(可能是一次出現好多個事件),libev 就會按照優先順序依次呼叫每個事件的回撥函式。注意,epoll_wait 是有超時的,所以一些無法以檔案描述符的形式存在的事件也可以有機會被觸發。關於 libev 網上還有很多資料,有興趣大家可以自行查閱。

Gevent 的效能調優

Gevent 不是銀彈,不能無限制地建立 greenlet。正如多執行緒程式設計一樣,用 gevent 寫伺服器也應該建立一個“微執行緒池”,超過池子大小的 spawn 應該被阻塞並且開始排隊。只有這樣,才能保證同時執行的 greenlet 數量不至於多到顯著增加非同步等待的恢復時間,從而保證每個任務的響應速度。其實,當池子的大小增加到一定程度之後,CPU 使用量的增速會放緩甚至變為 0,這時繼續增加池子大小隻能導致回撥函式開始排隊,不能真正增加吞吐量。正確的做法是增加硬體或者優化程式碼(提高演算法效率、減少無謂呼叫等)。

關於 pool 的大小,我覺得是可以算出來的:

1、在壓力較小、pool 資源充足的情況下,測得單個請求平均處理總時間,記作 Ta
2、根據系統需求,估計一下能接受的最慢的請求處理時間,記作 Tm
3、設 Ta 中有 Ts 的時間,執行權是不屬於當前處理中的 greenlet 的,比如正在進行非同步的資料庫訪問或是呼叫遠端 API 等後端訪問
4、在常規壓力下,通過測量後端訪問請求處理的平均時間,根據程式碼實際呼叫情況測算出 Ts
5、pool 的大小 = (Tm / (Ta - Ts)) * 150%,這裡的 150% 是個 buffer 值,拍腦門拍出來的

比如理想情況下平均每個請求處理需要 20ms,其中平均有 15ms 是花在資料庫訪問上(假設資料庫效能較為穩定,能夠線性 scale)。如果最大能容忍的請求處理時間是 500ms 的話,那池子大小應該設定成 (500 / (20 - 15)) * 150% = 150,也就意味著單程式最大併發量是 150

從這個演算法也可以看出,花在 Python 端的 CPU 時間越少,系統併發量就越高,而花在後端訪問上的時間長短對併發影響不是很大——當然了,依然得假設資料庫等後端可以線性 scale。

下面是我之前在 Amazon EC2 m1.small 機器上的部分測試結果,對比了同步多程式和 Gevent 在處理包含非同步 PostgreSQL 和 Redis 訪問的請求時的效能:

8 actors, 128 testers: 798 rps on client

1 async actor with 8 concurrency limit, 128 testers: 1031 rps on client

1 async actor with no concurrency limit, 128 testers: 987 rps on client

能看出來,同樣是 8 的併發限制,同步比非同步處理快兩三倍(但是 load balance 拉低了同步的優勢),吞吐量上雖比不上非同步,但也不差。在去掉併發限制之後,吞吐量變化不大,但處理時間翻了 10 倍(因為大量 callback 開始排隊,無法及時被呼叫到),且不穩定。

相關文章