深入理解 tornado 之底層 ioloop 實現

發表於2016-06-13

最近打算學習 tornado 的原始碼,所以就建立一個系列主題 “深入理解 tornado”。 在此記錄學習經歷及個人見解與大家分享。文中一定會出現理解不到位或理解錯誤的地方,還請大家多多指教

進入正題:

tornado 優秀的大併發處理能力得益於它的 web server 從底層開始就自己實現了一整套基於 epoll 的單執行緒非同步架構(其他 python web 框架的自帶 server 基本是基於 wsgi 寫的簡單伺服器,並沒有自己實現底層結構。 關於 wsgi 詳見之前的文章: 自己寫一個 wsgi 伺服器執行 Django 、Tornado 應用)。 那麼 tornado.ioloop 就是 tornado web server 最底層的實現。

看 ioloop 之前,我們需要了解一些預備知識,有助於我們理解 ioloop。

epoll

ioloop 的實現基於 epoll ,那麼什麼是 epoll? epoll 是Linux核心為處理大批量檔案描述符而作了改進的 poll 。
那麼什麼又是 poll ? 首先,我們回顧一下, socket 通訊時的服務端,當它接受( accept )一個連線並建立通訊後( connection )就進行通訊,而此時我們並不知道連線的客戶端有沒有資訊發完。 這時候我們有兩種選擇:

  1. 一直在這裡等著直到收發資料結束;
  2. 每隔一定時間來看看這裡有沒有資料;

第二種辦法要比第一種好一些,多個連線可以統一在一定時間內輪流看一遍裡面有沒有資料要讀寫,看上去我們可以處理多個連線了,這個方式就是 poll / select 的解決方案。 看起來似乎解決了問題,但實際上,隨著連線越來越多,輪詢所花費的時間將越來越長,而伺服器連線的 socket 大多不是活躍的,所以輪詢所花費的大部分時間將是無用的。為了解決這個問題, epoll 被創造出來,它的概念和 poll 類似,不過每次輪詢時,他只會把有資料活躍的 socket 挑出來輪詢,這樣在有大量連線時輪詢就節省了大量時間。

對於 epoll 的操作,其實也很簡單,只要 4 個 API 就可以完全操作它。

epoll_create

用來建立一個 epoll 描述符( 就是建立了一個 epoll )

epoll_ctl

操作 epoll 中的 event;可用引數有:

引數 含義
EPOLL_CTL_ADD 新增一個新的epoll事件
EPOLL_CTL_DEL 刪除一個epoll事件
EPOLL_CTL_MOD 改變一個事件的監聽方式

而事件的監聽方式有七種,而我們只需要關心其中的三種:

巨集定義 含義
EPOLLIN 緩衝區滿,有資料可讀
EPOLLOUT 緩衝區空,可寫資料
EPOLLERR 發生錯誤

epoll_wait

就是讓 epoll 開始工作,裡面有個引數 timeout,當設定為非 0 正整數時,會監聽(阻塞) timeout 秒;設定為 0 時立即返回,設定為 -1 時一直監聽。

在監聽時有資料活躍的連線時其返回活躍的檔案控制程式碼列表(此處為 socket 檔案控制程式碼)。

close

關閉 epoll

現在瞭解了 epoll 後,我們就可以來看 ioloop 了 (如果對 epoll 還有疑問可以看這兩篇資料: epoll 的原理是什麼百度百科:epoll

tornado.ioloop

很多初學者一定好奇 tornado 執行伺服器最後那一句 tornado.ioloop.IOLoop.current().start() 到底是幹什麼的。 我們先不解釋作用,來看看這一句程式碼背後到底都在幹什麼。

先貼 ioloop 程式碼:

IOLoop 類首先宣告瞭 epoll 監聽事件的巨集定義,當然,如前文所說,我們只要關心其中的 EPOLLIN 、 EPOLLOUT 、 EPOLLERR 就行。

類中的方法有很多,看起來有點暈,但其實我們只要關心 IOLoop 核心功能的方法即可,其他的方法在明白核心功能後也就不難理解了。所以接下來我們著重分析核心程式碼。

instanceinitializedinstallclear_instancecurrentmake_currentclear_current 這些方法不用在意細節,總之現在記住它們都是為了讓 IOLoop 類變成一個單例,保證從全域性上呼叫的都是同一個 IOLoop 就好。

你一定疑惑 IOLoop 為何沒有 __init__, 其實是因為要初始化成為單例,IOLoop 的 new 函式已經被改寫了,同時指定了 initialize 做為它的初始化方法,所以此處沒有 __init__ 。 說到這,ioloop 的程式碼裡好像沒有看到 new 方法,這又是什麼情況? 我們先暫時記住這裡。

接著我們來看這個初始化方法:

what? 裡面只是判斷了是否第一次初始化或者呼叫 self.make_current() 初始化,而 make_current() 裡也僅僅是把例項指定為自己,那麼初始化到底去哪了?

然後再看看 start()run()close() 這些關鍵的方法都成了返回 NotImplementedError 錯誤,全部未定義?!跟網上搜到的原始碼分析完全不一樣啊。 這時候看下 IOLoop 的繼承關係,原來問題出在這裡,之前的 tornado.ioloop 繼承自 object 所以所有的一切都自己實現,而現在版本的 tornado.ioloop 則繼承自 Configurable 看起來現在的 IOLoop 已經成為了一個基類,只定義了介面。 所以接著看 Configurable 程式碼:

tornado.util.Configurable

之前我們尋找的 __new__ 出現了! 注意其中這句: impl = cls.configured_class() impl 在這裡就是 epoll ,它的生成函式是 configured_class(), 而其方法裡又有 base.__impl_class = cls.configurable_default() ,呼叫了 configurable_default() 。而 Configurableconfigurable_default():

顯然也是個介面,那麼我們再回頭看 ioloop 的 configurable_default():

原來這是個工廠函式,根據不同的作業系統返回不同的事件池(linux 就是 epoll, mac 返回 kqueue,其他就返回普通的 select。 kqueue 基本等同於 epoll, 只是不同系統對其的不同實現)

現線上索轉移到了 tornado.platform.epoll.EPollIOLoop 上,我們再來看看 EPollIOLoop:

tornado.platform.epoll.EPollIOLoop

EPollIOLoop 完全繼承自 PollIOLoop注意這裡是 PollIOLoop 不是 IOLoop)並只是在初始化時指定了 impl 是 epoll,所以看起來我們用 IOLoop 初始化最後初始化的其實就是這個 PollIOLoop,所以接下來,我們真正需要理解和閱讀的內容應該都在這裡:

tornado.ioloop.PollIOLoop

果然, PollIOLoop 繼承自 IOLoop 並實現了它的所有介面,現在我們終於可以進入真正的正題了

ioloop 分析

首先要看的是關於 epoll 操作的方法,還記得前文說過的 epoll 只需要四個 api 就能完全操作嘛? 我們來看 PollIOLoop 的實現:

epoll 操作

epoll_ctl:這個三個方法分別對應 epoll_ctl 中的 add 、 modify 、 del 引數。 所以這三個方法實現了 epoll 的 epoll_ctl 。

epoll_create:然後 epoll 的生成在前文 EPollIOLoop 的初始化中就已經完成了:super(EPollIOLoop, self).initialize(impl=select.epoll(), **kwargs)。 這個相當於 epoll_create 。

epoll_wait:epoll_wait 操作則在 start() 中:event_pairs = self._impl.poll(poll_timeout)

epoll_close:而 epoll 的 close 則在 PollIOLoop 中的 close 方法內呼叫: self._impl.close() 完成。

initialize

接下來看 PollIOLoop 的初始化方法中作了什麼:

除了註釋中的解釋,還有幾點補充:

  1. close_exec 的作用: 子程式在fork出來的時候,使用了寫時複製(COW,Copy-On-Write)方式獲得父程式的資料空間、 堆和棧副本,這其中也包括檔案描述符。剛剛fork成功時,父子程式中相同的檔案描述符指向系統檔案表中的同一項,接著,一般我們會呼叫exec執行另一個程式,此時會用全新的程式替換子程式的正文,資料,堆和棧等。此時儲存檔案描述符的變數當然也不存在了,我們就無法關閉無用的檔案描述符了。所以通常我們會fork子程式後在子程式中直接執行close關掉無用的檔案描述符,然後再執行exec。 所以 close_exec 執行的其實就是 關閉 + 執行的作用。 詳情可以檢視: 關於linux程式間的close-on-exec機制
  2. Waker(): Waker 封裝了對於管道 pipe 的操作:

    可以看到 waker 把 pipe 分為讀、 寫兩個管道並都設定了非阻塞和 close_exec。 注意wake(self)方法中:self.writer.write(b"x") 直接向管道中寫入隨意字元從而釋放管道。

start

ioloop 最核心的部分:

stop

這個很簡單,設定判斷條件,然後呼叫 self._waker.wake() 向 pipe 寫入隨意字元喚醒 ioloop 事件迴圈。 over!

總結

噗,寫了這麼長,終於寫完了。 經過分析,我們可以看到, ioloop 實際上是對 epoll 的封裝,並加入了一些對上層事件的處理和 server 相關的底層處理。

最後,感謝大家不辭辛苦看到這,文中理解有誤的地方還請多多指教!

原文地址
作者:rapospectre

相關文章