本文章主要目的是介紹tornado的工作原理及延伸的關鍵技術,為了便於讀者理解,會透過幾個簡單易懂的例子,再配合原理圖進行講解。由於本文主要是分析tornado的工作原理,所以牽扯到一些作業系統的細節會簡單略過,希望讀者把握文章重點,不要迷失在理解各種作業系統名詞的“深淵”裡。當你真正瞭解了tornado的工作原理後,相應的應用場景也就能舉一反三了。
本文主要透過以下三點進行講解:
Tornado的背景及技術介紹
簡單程式碼後鮮為人知的秘密
Tornado的應用場景
一、Tornado的背景及技術介紹
與傳統框架的區別
首先談一下傳統的一些web伺服器框架吧,沒有對比就沒有“傷害”嘛,這裡主要和Django為代表的傳統框架進行比較,這一類的Python web應用部署的時候一般是採用WSGI協議與伺服器對接的,而這類伺服器通常是基於多執行緒/多程序的,也就是說每有一個網路請求,伺服器都會有一個執行緒/程序進行處理。
這裡要重點介紹一下WSGI協議。
WSGI協議的由來:
例子:
中國有三家有名的通訊運營商,分別是移動、聯通和電信,這三家通訊商的手機號是可以跨平臺撥打的,假設三家通訊商負責通訊的協議不同且無法互通,用移動的手機號就無法給聯通電信的手機打電話,為了方便通訊就需要一個統一的規範。
WSGI協議的角色就是這個統一的規範,是描述web server如何與web application通訊的規範,要實現WSGI協議,就必須同時實現web server和web application,目前常見的有Tornado、Flask和Django。
WSGI協議具體做了什麼:
1. 定義呼叫方式:讓Web伺服器知道如何呼叫python應用程式,並把使用者的請求告訴應用程式。
2. 定義接收方式:讓python應用程式知道使用者的請求是什麼,以及如何返回結果給web伺服器。
application物件形式: #application是定義的應用端的呼叫方式 defsimple_app(environ, start_response): # environ解釋了第一點,將客戶的需求告訴應用端 # start_response解釋了第二點,回撥函式 pass
3. 定義以上兩點後,WSGI還去充當了伺服器和應用程式間的中介軟體,即充當應用程式又充當伺服器,可以形象的用下圖表示。
其中大致流程是這樣的:
1. Server收到客戶端的HTTP請求後,生成了environ_s,並且已經定義了start_response_s。
2. Server呼叫Middleware的application物件,傳遞的引數是environ_s和start_response_s。
3. Middleware會根據environ執行業務邏輯,生成environ_m,並且已經定義了start_response_m。
4. Middleware決定呼叫Application的application物件,傳遞引數是environ_m和start_response_m。Application的application物件處理完成後,會呼叫start_response_m並且返回結果給Middleware,存放在result_m中。
5. Middleware處理result_m,然後生成result_s,接著呼叫start_response_s,並返回結果result_s給Server端。Server端獲取到result_s後就可以傳送結果給客戶端了。
WSGI是個同步模型,不支援非阻塞的請求方式,Tornado預設是不推薦使用WSGI的,如果在Tornado中使用WSGI,將無法使用Tornado的非同步非阻塞的處理方式,相應的非同步介面也就無法使用,效能方面也就大打折扣,這個也是Tornado效能如此優越的原因。
二、C10K問題的提出
考慮兩種高併發場景:
1. 使用者量大,高併發。如秒殺搶購,雙十一,618和春節搶票。
2. 大量的HTTP持久連線。
對於以上兩種熟悉的場景,通常基於多程序/執行緒的伺服器是很難應付的。
基於上述高併發場景,引出了C10k問題,是由一名叫DanKegel的軟體工程師提出的,即當同時的連線數以萬計的時候,伺服器效能會出現急劇下降甚至直接崩潰的情況,這就是著名的C10k問題。
值得一提的,騰訊QQ就遇到過C10k問題,當時採用了udp的方式避開了這個問題,當然過程是相當痛苦的,後來也就專用了tcp,主要是當時還沒有epoll技術。
簡要地分析下C10k的本質問題,其實無外乎是操作系的問題,連線多了,建立的程序執行緒就多了,資料複製也就變得頻繁(快取I/O、核心將資料複製到使用者程序空間、阻塞),程序/執行緒上下文切換消耗又大,直接就導致作業系統奔潰。
可見,解決C10k問題的關鍵就是減少這些CPU等核心計算資源消耗,從而榨乾單臺機器的效能,突破C10k描述的瓶頸;那麼常規的解決思路有哪些呢,其實無外乎下面兩種方法:
解決一、對於每個連線處理分配一個獨立的程序/執行緒。提升單臺機器的能力,儘可能多提供程序/執行緒,一臺機器不夠就增加多臺機器。
解決二、用一個程序/執行緒來同時處理若干個連線。
針對方法一,假設每臺機器都達到了一萬連線,同時有一億個請求,那麼就需要一萬臺機器,所以這種解決方法不太實際。
針對方法二,需要有新的技術支援這種方案,實際上是可行的,也是現在普遍採取的方法,針對這種方法,其實有過多種技術支援,接下來重點介紹下。
三、epoll技術的引入
接下來我們根據技術的迭代發展來引入epoll技術。
實現方式一:傳統的迴圈遍歷的方式處理多個連線
這種方式明顯的缺點就是,當其中任何一個socket的檔案資料不ready的時候,執行緒/程序會一直等待,進而導致後面要處理的連線都被阻塞,整個應用也就阻塞了。
實現方式二:select技術
首先解釋下select,它是個系統呼叫函式,格式如下:
int select(int nfds, fd_set *readfds, fd_set *writefds,fd_set *exceptfds, const struct timeval *timeout);
nfds:select監視的檔案控制代碼數
readfds:select監視的可讀檔案控制代碼集合,就是個long型別的陣列。
writefds: select監視的可寫檔案控制代碼集合。
exceptfds:select監視的異常檔案控制代碼集合。
timeout:本次select()的超時結束時間。
還有幾個對fds集合操作的宏:
FD_ZERO(fd_set *fdset):清空fdset與所有檔案控制代碼的聯絡。
FD_SET(int fd,fd_set *fdset):建立檔案控制代碼fd與fdset的聯絡。
FD_CLR(int fd,fd_set *fdset):清除檔案控制代碼fd與fdset的聯絡。FD_ISSET(int fd,fd_set *fdset):檢查fdset聯絡的檔案控制代碼fd是否
可讀寫,當>0表示可讀寫。
實現過程:
1. 首先fdset集合裡需要監控的檔案控制代碼由程式設計師來新增,當前連線需要監控哪些檔案控制代碼,那麼透過FD_SET宏來進行新增。
2. 然後呼叫select()函式將fd_set從使用者空間複製到核心空間。
3. 註冊一個回撥函式。
4. 核心對檔案控制代碼進行監控。
5. 當有滿足可讀寫等條件時/超時呼叫回撥函式並將檔案控制代碼集合複製回使用者空間。
6. 應用透過輪詢的方式查詢所有檔案控制代碼,用FD_ISSET宏來判讀具體是哪個檔案控制代碼可操作。
7. 當再次有新連線處理需要監控,再次重複以上步驟往核心複製fdset。
用圖表示整個流程如下:
缺點分析:
2. 重複初始化:每次監控都重複將fdset從使用者空間複製到核心空間,然後又從核心空間複製到使用者空間,這個過程重複比較耗費系統資源。
3. 逐個排查檔案效率不高: 檢測哪些檔案控制代碼可操作時,採用的是輪詢遍歷所有的檔案控制代碼,用FD_ISSET宏來判斷檔案控制代碼是否可操作,然而實際情況,大部分檔案控制代碼是不可操作的,這種逐個排查的方式效率太低。
實現方式三、poll技術
實現方式四、epoll技術
epoll技術整個流程其實和select、poll技術大體上是一樣的,主要是針對造成效率低下的點進行最佳化,可以說是將select和poll技術的缺點一一解決才達到現在的高效率,接下來我們一一道來:
1. 控制代碼上限
控制代碼上限的問題poll技術已經解決,就不用多說了。
2. 重複初始化
這個問題就像中學時候讀書書包帶課本一樣(中學的課程數量和書本數量之多大家應該都懂的),每天上學把所有幾十本課本從家裡背到學校,放學了再從學校將所有書揹回家,但你今天家庭作業實際需要帶的書可能就個別課程的個別幾本書而已。
所以為了減輕我們身體的負擔,是不是放學的時候只帶幾本今天需要做家庭作業的幾本書就很輕鬆了,同樣的為了減少重複初始化過程中使用者空間和核心空間發生不必要的複製帶來的資源浪費,epoll技術提供了epoll_ctl函式,在用epoll_ctl函式進行事件註冊的時候,會將檔案控制代碼都複製到核心中,所以不用每次都複製一遍,當有新的檔案控制代碼時採用的也是增量往核心複製,確保了每個檔案控制代碼只會被複製一次。
3. 逐個排查檔案效率不高
epoll會用epoll_ctl為每個檔案控制代碼註冊一個回撥函式,同時會在核心中透過epoll_create建立一個專用連結串列(還有包含儲存fd的專用記憶體空間),當有檔案控制代碼狀態發生變更,透過回撥函式會將狀態發生變更的檔案控制代碼加入該連結串列,epoll技術還提供了epoll_wait函式,來檢視連結串列中有沒有就緒的檔案控制代碼,然後只將該連結串列中的就緒檔案控制代碼從核心空間複製到使用者空間,這樣一來就不用遍歷每個檔案控制代碼,只處理狀態發生變更的,效率自然就提升上去了。
總結一下,epoll技術提供了三個系統呼叫函式:
- epoll_create:用於建立和初始化一些內部使用的資料結構。 - epoll_crl: 用於註冊時間、新增、刪除和修改指定的df及其期待的事件。 - epoll_wait: 用於等待先前指定的fd事件,即就緒的fd。
五、Tornado是如何發揮優勢的(背後不為人知的處理邏輯)
我們先看一段簡單程式碼的demo:
如果你經常用Tornado,那麼對這段程式碼一定非常熟悉了,那麼我們今天的關注點就放在最後一句Torando.ioloop.IOLoop.current().start()程式碼上,先簡單的分析下這句程式碼,前面一部分Torando.ioloop是Tornado的核心模組ioloop模組,IOLoop是ioloop模組的一個類,current()是IOLoop類的一個方法,結果是返回一個當前執行緒的IOLoop的例項,start()也是IOLoop的方法,呼叫後開啟迴圈。
先看一張流程圖:
然後我們分析下Tornado這段程式碼後的整個邏輯流程:
1. 首先Tornado需要建立監聽,會建立一個socket用於監聽,如果有客戶端A請求建立連線之後,Tornado會基於原先的socket新建立一個包含客戶端A連線的有關資訊的socket(分配新的監聽埠),用於監聽和客戶端A的請求。此時對Tornado來說就有兩個socket需要進行監控,原先的socket繼續用來監聽建立新連線,新的socket用於和客戶端A進行通訊,假如沒有epoll技術的話,Tornado需要自己去迴圈詢問哪個socket有新的請求。
2. 有了epoll技術,Tornado只需要把所有的socket丟給epoll,epoll作為管家幫忙監控,然後Torando.ioloop.IOLoop.current().start()開啟迴圈,不斷的去詢問epoll是否有請求需要處理,這就是ioloop所做的工作,也是Tornado的核心部分。
3. 當有客戶端進行請求,epoll就發現有socket可處理,當ioloop再次詢問epoll時,epoll就把需要處理的socket交由Tornado處理
4. Tornado對請求進行處理,取出報文,從報文中獲取請求路徑,然後從tornado.web.Applcation裡配置的路由對映中把請求路徑對映成對應的處理類,如上圖IndexHandler就是處理類。
5. 處理類處理完成後,生成響應,將響應內容封裝成http報文,透過請求時建立的連線(尚未中斷)將響應內容返回給客戶端。
6. 當有多個請求同時發生,Tornado會按順序挨個處理。
看了上面的流程,假如Tornado在處理一個非常耗時的請求時,後面的請求是不是就會被卡死呢?答案是肯定的,所以提到了Tornado的另一個特性—非同步處理,當一個請求特別耗時,Tornado就把它丟在那處理,然後繼續處理下一個請求,確保後面的請求不會被卡死。
Tornado非同步:原生Tornado框架提供非同步網路庫IOLoop和IOStream以及非同步協程庫tornado.gen(必須使用Tornado的web框架和HTTP伺服器,否則非同步介面可能無法使用),方便使用者透過更直接的方法實現非同步程式設計,而不是回撥的方式,官方推薦yield協程方式完成非同步。(非同步是Tornado重要且核心部分,期待下篇技術好文重點介紹)。
透過上面所講,基本上已經對Tornado的整個處理流程瞭解了,總結一下Tornado之所以能同時處理大量連線的原因:
1. 利用高效的epoll技術處理請求,單執行緒/單程序同時處理大量連線。
2. 沒用使用傳統的wsgi協議,而是利用Tornado自己的web框架和http服務形成了一整套WSGI方案進行處理。
3. 非同步處理方式,Tornado提供了非同步介面可供呼叫。
六、Tornado的應用場景
要效能,Tornado 首選;要開發速度,Django 和Flask 都行,區別是Flask 把許多功能交給第三方庫去完成了,因此Flask 更為靈活。Django適合初學者或者小團隊的快速開發,適合做管理類、部落格類網站、或者功能十分複雜需求十分多的網站,Tornado適合高度定製,適合訪問量大,非同步情況多的網站。
以上便是本文是本人學習收集整理後的文章,如有錯誤,請多多留言指教。
關於作者
吳俊傑:達觀資料後端開發工程師,負責達觀資料產品後端開發、產品落地、客戶定製化產品需求等設計。對後端開發使用到的web及伺服器框架、http協議及相關應用方面有比較深入的瞭解。