深入理解非同步Web伺服器 Tornado

發表於2015-12-27

(伯樂線上轉註:本文英文原文寫於 2009 年,趙傑翻譯於 2011 年)

這篇文章的目的在於對Tornado這個非同步伺服器軟體的底層進行一番探索。我採用自底向上的方式進行介紹,從輪訓開始,向上一直到應用層,指出我認為有趣的部分。

所以,如果你有打算要閱讀Tornado這個web框架的原始碼,又或者是你對一個非同步web伺服器是如何工作的感興趣,我可以在這成為你的指導。

通過閱讀這篇文章,你將可以:

  • 自己寫一個Comet架構程式的伺服器端部分,即使你是從拷貝別人的程式碼開始。
  • 如果你想在Tornado框架上做開發,通過這篇文章你將更好的理解Tornado web框架。
  • Tornado和Twisted的爭論上,你將更有見解。

介紹

假設你還不知道Tornado是什麼也不知道為什麼應該對它感興趣,那我將用簡短的話來介紹Tornado這個專案。如果你已經對它有了興趣,你可以跳去看下一節內容。

Tornado是一個用Python編寫的非同步HTTP伺服器,同時也是一個web開發框架。該框架服務於FriendFeed網站,最近Facebook也在使用它。FriendFeed網站有使用者數多和應用實時性強的特點,所以效能和可擴充套件性是很受重視的。由於現在它是開源的了(這得歸功於Facebook),我們可以徹底的對它是如何工作的一探究竟。

我覺得對非阻塞式IO (nonblocking IO) 和非同步IO (asynchronous IO  AIO)很有必要談一談。如果你已經完全知道他們是什麼了,可以跳去看下一節。我儘可能的使用一些例子來說明它們是什麼。

讓我們假設你正在寫一個需要請求一些來自其他伺服器上的資料(比如資料庫服務,再比如新浪微博的open api)的應用程式,然後呢這些請求將花費一個比較長的時間,假設需要花費5秒鐘。大多數的web開發框架中處理請求的程式碼大概長這樣:


如果這些程式碼執行在單個執行緒中,你的伺服器只能每5秒接收一個客戶端的請求。在這5秒鐘的時間裡,伺服器不能幹其他任何事情,所以,你的服務效率是每秒0.2個請求,哦,這太糟糕了。

當然,沒人那麼天真,大部分伺服器會使用多執行緒技術來讓伺服器一次接收多個客戶端的請求,我們假設你有20個執行緒,你將在效能上獲得20倍的提高,所以現在你的伺服器效率是每秒接受4個請求,但這還是太低了,當然,你可以通過不斷地提高執行緒的數量來解決這個問題,但是,執行緒在記憶體和排程方面的開銷是昂貴的,我懷疑如果你使用這種提高執行緒數量的方式將永遠不可能達到每秒100個請求的效率。

如果使用AIO,達到每秒上千個請求的效率是非常輕鬆的事情。伺服器請求處理的程式碼將被改成這樣:

AIO的思想是當我們在等待結果的時候不阻塞,轉而我們給框架一個回撥函式作為引數,讓框架在有結果的時候通過回撥函式通知我們。這樣,伺服器就可以被解放去接受其他客戶端的請求了。

然而這也是AIO不太好的地方:程式碼有點不直觀了。還有,如果你使用像Tornado這樣的單執行緒AIO伺服器軟體,你需要時刻小心不要去阻塞什麼,因為所有本該在當前返回的請求都會像上述處理那樣被延遲返回。

關於非同步IO,比當前這篇過分簡單的介紹更好的學習資料請看 The C10K problem

原始碼

該專案由github託管,你可以通過如下命令獲得,雖然通過閱讀這篇文章你也可以不需要它是吧。

在tornado的子目錄中,每個模組都應該有一個.py檔案,你可以通過檢查他們來判斷你是否從已經從程式碼倉庫中完整的遷出了專案。在每個原始碼的檔案中,你都可以發現至少一個大段落的用來解釋該模組的doc string,doc string中給出了一到兩個關於如何使用該模組的例子。

IOLoop模組

讓我們通過檢視ioloop.py檔案直接進入伺服器的核心。這個模組是非同步機制的核心。它包含了一系列已經開啟的檔案描述符(譯者:也就是檔案指標)和每個描述符的處理器(handlers)。它的功能是選擇那些已經準備好讀寫的檔案描述符,然後呼叫它們各自的處理器(一種IO多路複用的實現,其實就是socket眾多IO模型中的select模型,在Java中就是NIO,譯者注)。

可以通過呼叫add_handler()方法將一個socket加入IO迴圈中:

_handlers這個字典型別的變數儲存著檔案描述符(其實就是socket,譯者注)到當該檔案描述符準備好時需要呼叫的方法的對映(在Tornado中,該方法被稱為處理器)。然後,檔案描述符被註冊到epoll(unix中的一種IO輪詢機制,貌似,譯者注)列表中。Tornado關心三種型別的事件(指發生在檔案描述上的事件,譯者注):READ,WRITE 和 ERROR。正如你所見,ERROR是預設為你自動新增的。

self._impl是select.epoll()selet.select()兩者中的一個。我們稍後將看到Tornado是如何在它們之間進行選擇的。

現在讓我們來看看實際的主迴圈,不知何故,這段程式碼被放在了start()方法中:

poll()方法返回一個形如(fd: events)的鍵值對,並賦值給event_pairs變數。由於當一個訊號在任何一個事件發生前到來時,C函式庫中的poll()方法會返回EINTR(實際是一個值為4的數值),所以”Interrupted system call”這個特殊的異常需要被捕獲。更詳細的請檢視man poll

在內部的while迴圈中,event_pairs中的內容被一個一個的取出,然後相應的處理器會被呼叫。pipe 異常在這裡預設不進行處理。為了讓這個類適應更一般的情況,在http處理器中處理這個異常是一個更好的方案,但是選擇現在這樣處理或許是因為更容易一些。

註釋中解釋了為什麼使用字典的popitem()方法,而不是使用更普遍一點的下面這種做法(指使用迭代,譯者注):

原因很簡單,在主迴圈期間,這個_events字典變數可能會被處理器所修改。比如remove_handler()處理器。這個方法把fd(即檔案描述符,譯者注)從_events字典中取出(extracts,意思是取出並從_events中刪除,譯者注),所以即使fd被選擇到了,它的處理器也不會被呼叫(作者的意思是,如果使用for迭代迴圈_events,那麼在迭代期間_events就不能被修改,否則會產生不可預計的錯誤,比如,明明呼叫了remove_handler()方法刪除了某個鍵值對,但是該handler還是被呼叫了,譯者注)。

(意義不大的)迴圈結束技巧

怎麼讓這個主迴圈停止是很有技巧性的。self._running變數被用來在執行時從主迴圈中跳出,處理器可以通過呼叫stop()方法把它設定為False。通常情況下,這就能讓主迴圈停止了,但是stop()方法還能被一個訊號處理器所呼叫,所以,如果1)主迴圈正阻塞在poll()方法處,2)服務端沒有接收到任何來自客戶端的請求3)訊號沒有被OS投遞到正確的執行緒中,你將不得不等待poll()方法出現超時情況後才會返回。考慮到這些情況並不時常發生,還有poll()方法的預設超時時間只不過是0.2秒,所以這種讓主迴圈停止的方式還算過得去。

但不管怎樣,Tornado的開發者為了讓主迴圈停止,還是額外的建立了一個沒有名字的管道和對應的處理器,並把管道的一端放在了輪詢檔案描述符列表中。當需要停止時,在管道的另一端隨便寫點什麼,這能高效率的(意思是馬上,譯者注)喚醒主迴圈在poll()方法處的阻塞(貌似Java NIO的Windows實現就用了這種方法,譯者注)。這裡節選了一些程式碼片段:

實際上,上述程式碼中存在一個bug:那個只讀檔案描述符r,雖然是用來讀的,但在註冊時卻附加上了WRITE型別的事件,這將導致該註冊實際不會被響應。正如我先前所說的,用不用專門找個方法其實沒什麼的,所以我對他們沒有發現這個方法不起作用的事實並不感到驚訝。我在mail list中報告了這個情況,但是尚未收到答覆。

定時器

另外一個在IOLoop模組中很有特點的設計是對定時器的簡單實現。一系列的定時器會被以是否過期的形式來維護和儲存,這用到了python的bisect模組:

在主迴圈中,所有過期了的定時器的回撥會按照過期的順序被觸發。poll()方法中的超時時間會動態的進行調整,調整的結果就是如果沒有新的客戶端請求,那麼下一個定時器就好像沒有延遲一樣的被觸發(意思是如果沒有新的客戶端的請求,poll()方法將被阻塞直到超時,這個超時時間的設定會根據下一個定時器與當前時間之間的間隔進行調整,調整後,超時的時間會等同於距離下一個定時器被觸發的時間,這樣在poll()阻塞完後,下一個定時器剛好過期,譯者注)。

選擇select方案

讓我們現在快速的看一下poll和select這兩種select方案的實現程式碼。Python已經在版本2.6的標準庫中支援了epoll,你可以通過在select模組上使用hasattr()方法檢測當前Python是否支援epoll。如果python版本小於2.6,Tornado將用它自己的基於C的epoll模組。你可以在tornado/epoll.c檔案中找到它原始碼。如果最後這也不行(因為epoll不是每個Linux都有的),它將回退到selec._Select並把_EPoll類包裝成和select.epoll一樣的api介面。在你做效能測試之前,請確定你能使用epoll,因為select在有大量檔案描述符情況下的效率非常低。

通過上述閱讀,我們的介紹已經涵蓋了大部分IOLoop模組。正如廣告中介紹的那樣,它是一段優雅而又簡單的程式碼。

從sockets到流

讓我們來看看IOStream模組。它的目的是提供一個對非阻塞式sockets的輕量級抽象,它提供了三個方法:

  • read_until(),從socket中讀取直到遇到指定的字串。這為在讀取HTTP頭時遇到空行分隔符自動停止提供了方便。
  • read_bytes(),從socket中讀取指定數量的位元組。這為讀取HTTP訊息的body部分提供了方便。
  • write(),將指定的buffer寫入socket並持續監測直到這個buffer被髮送。

所有上述的方法都可以通過非同步方式在它們完成時觸發回撥函式。

write()方法提供了將呼叫者提供的資料加以緩衝直到IOLoop呼叫了它的(指write方法的,譯者注)處理器的功能,因為到那時候就說明socket已經為寫資料做好了準備:

該方法只是用socket.send()來處理WRITE型別的事件,直到EWOULDBLOCK異常發生或者buffer被髮送完畢。

讀資料的方法和上述過程正好相反。讀事件的處理器持續讀取資料直到緩衝區被填滿為止。這就意味著要麼讀取指定數量的位元組(如果呼叫的是read_bytes()),要麼讀取的內容中包含了指定的分隔符(如果呼叫的是read_util()):

如下所示的_consume方法是為了確保在要求的返回值中不會包含多餘的來自流的資料,並且保證後續的讀操作會從當前位元組的下一個位元組開始(先將流中的資料讀到self.read_buffer中,然後根據要求進行切割,返回切割掉的資料,保留切割後的資料供下一次的讀取,譯者注):


還值得注意的是在上述_handle_read()方法中read buffer的上限——self.max_buffer_size。預設值是100MB,這似乎對我來說是有點大了。舉個例子,如果一個攻擊者和服務端建立了100個連線,並持續傳送不帶頭結束分隔符的頭資訊,那麼Tornado需要10GB的記憶體來處理這些請求。即使記憶體ok,這種數量級資料的複製操作(比如像上述_consume()方法中的程式碼)很可能使伺服器超負荷。我們還注意到在每次迭代中_handle_read()方法是如何在這個buffer中搜尋分隔符的,所以如果攻擊者以小塊形式傳送大量的資料,服務端不得不做很多次搜尋工作。歸根結底,你應該想要將這個引數和諧掉,除非你真的很希望那樣(Bottom of line, you might want to tune this parameter unless you really expect requests that big 不大明白怎麼翻譯,譯者注)並且你有足夠的硬體條件。

HTTP 伺服器

有了IOLoop模組和IOStream模組的幫助,寫一個非同步的HTTP伺服器只差一步之遙,這一步就在httpserver.py中完成。

HTTPServer類它自己只負責處理將接收到的新連線的socket新增到IOLoop中。該監聽型的socket自己也是IOLoop的一部分,正如在listen()方法中見到的那樣:

除了繫結給定的地址和埠外,上述程式碼還設定了”close on exec”和”reuse address”這兩個標誌位。前者在應用程式建立子程式的時候特別有用。在這種情況下,我們不想讓套接字保持開啟的狀態(任何設定了”close on exec”標誌位的檔案描述符,都不能被使用exec函式方式建立的子程式讀寫,因為該檔案描述符在exec函式呼叫前就會被自動釋放,譯者注)。後者用來避免在伺服器重啟的時候發生“該地址以被使用”這種錯誤時很有用。

正如你所見到的,後備連線所允許的最大數目是128(注意,listen方法並不是你想象中的“開始在128埠上監聽”的意思,譯者注)。這意味著如果有128個連線正在等待被accept,那麼直到伺服器有時間將前面128個連線中的某幾個accept了,新的連線都將被拒絕。我建議你在做效能測試的時候將該引數調高,因為當新的連線被拋棄的時候將直接影響你做測試的準確性。

在上述程式碼中註冊的_handle_events()處理器用來accept新連線,並建立相關的IOStream物件和初始化一個HTTPConnection物件,HTTPConnection物件負責處理剩下的互動部分:


可以看到這個方法在一次迭代中accept了所有正在等待處理的連線。也就是說直到EWOULDBLOCK異常發生while True迴圈才會退出,這也就意味著當前沒有需要處理accept的連線了。

HTTP頭的部分的解析工作開始於HTTPConnection類的建構函式__init()__():


如果你很想知道xheaders引數的意義,請看這段註釋:
如果xheaders為True,我們將支援把所有請求的HTTP頭解析成XRealIp和XScheme格式,而原先我們將HTTP頭解析成remote IP和HTTP scheme格式。這種格式的HTTP頭在Tornado執行於反向代理或均衡負載伺服器的後端時將非常有用。

_on_headers()回撥函式實際用來解析HTTP頭,並在有請求內容的情況下通過使用read_bytes()來讀取請求的內容部分。_on_request_body()回撥函式用來解析POST的引數並呼叫應用層提供的回撥函式:


將結果寫回客戶端的工作在HTTPRequest類中處理,你可以在上面的_on_headers()方法中看到具體的實現。HTTPRequest類僅僅將寫回的工作代理給了stream物件。

未完待續?

通過這篇文章,我已經涵蓋了從socket到應用層的所有方面。這應該能給你關於Tornado是如何工作的一個清晰的理解。總之,我認為Tornado的程式碼是非常友好的,我希望你也這樣認為。

Tornado框架還有很大一部分我們沒有探索,比如wep.py(應該是web.py,譯者注)這個實際與你應用打交道的模組,又或者是template engine模組。如果我有足夠興趣的話,我也會介紹這些部分。可以通過訂閱我的RSS feed來鼓勵我。

相關文章