最近重構了去年造的一個輪子 Vino。Vino 旨在實現一個輕量而且可以保證性能的 Web Server,僅關注 Web Server 的本質部分。在重構過程當中,Vino 借鑑了許多優秀開源項目的思想,如 Nginx、Mongoose 和 Webbench。所以,對比上一個版本的 Vino,如今的 Vino 不只性能獲得提高,並且設計也更爲優雅、健壯 :D。html
本文將會對 Vino 目前所具有的關鍵特性進行闡述,並總結開發過程當中的一點心得。nginx
Vino 總體採用了基於事件驅動的單線程 + Non-Blocking 模型。採用單線程模型,避免了系統分配多線程及線程之間通訊的開銷,同時下降了內存的耗用。因爲採用了單線程模型,爲了更好的提升線程利用率,Vino 將默認 Blocking 的 I/O 設置爲 Non-Blocking I/O,即在線程讀/寫數據的過程當中,若是緩衝區爲空/緩衝區滿,線程不會阻塞,而是當即返回,並設置 errno。git
Vino 最初的靈感來源於 Computer Systems: A Programmer's Perspective 一書講述網絡編程時實現的一個簡單的 Web Server,每到來一個請求,Web Server 都會 fork 一個進程去處理。顯然,在高併發的場景下,這種模型是不合理的。每次 fork 進程會帶來巨大的開銷,而且系統中進程的數量是有限的。同時,伴隨多進程帶來的進程調度的開銷也不可小覷,CPU 會花費大量的時間用於決定調用哪個進程。進程調度引起的進程上下文之間的切換,也須要耗費至關大的資源。github
很容易聯想到採用多線程模型來替代多進程模型,相比於多進程模型,多線程模型佔用的系統資源會大大下降,可是本質上並無減少線程調度帶來的開銷。爲了減少由線程調度致使的開銷,咱們能夠採用線程池模型,即固定線程的數量,可是問題依舊存在:由於 Linux 默認 I/O 是阻塞(Blocking)的,若是線程池中全部的線程同時阻塞於正在處理的請求,那麼新到來的請求就沒有線程去處理了。所以,若是咱們用 Non-Blocking 的 I/O 替換默認的 Blocking I/O,線程將不會阻塞於數據的讀寫,問題即可獲得解決。web
Vino 支持 HTTP 長鏈接(Persistent Connections),即多個請求能夠複用同一個 TCP 鏈接,以此減小由 TCP 創建/斷開鏈接所帶來的性能開銷。每到來一個請求,Vino 會對請求進行解析,判斷請求頭中是否存在 Connection: keep-alive 請求頭。若是存在,在處理完一個請求後會保持鏈接,並對數據緩衝區(用於保存請求內容,響應內容)及狀態標記進行重置,不然,關閉鏈接。編程
關於 HTTP Keep-Alive 的優點,RFC 2616 有着更完善的總結,引用以下。網絡
- By opening and closing fewer TCP connections, CPU time is saved in routers and hosts (clients, servers, proxies, gateways, tunnels, or caches), and memory used for TCP protocol control blocks can be saved in hosts.
- HTTP requests and responses can be pipelined on a connection. Pipelining allows a client to make multiple requests without waiting for each response, allowing a single TCP connection to be used much more efficiently, with much lower elapsed time.
- Network congestion is reduced by reducing the number of packets caused by TCP opens, and by allowing TCP sufficient time to determine the congestion state of the network.
- Latency on subsequent requests is reduced since there is no time spent in TCP's connection opening handshake.
- HTTP can evolve more gracefully, since errors can be reported without the penalty of closing the TCP connection. Clients using future versions of HTTP might optimistically try a new feature, but if communicating with an older server, retry with old semantics after an error is reported.
若是一個請求在創建鏈接後遲遲沒有發送數據,或者對方忽然斷電,應該如何處理?咱們須要實現定時器來處理超時的請求。Vino 定時器的實現參考了 Nginx 的設計,Nginx 使用一顆紅黑樹來存儲各個定時事件,每次事件循環時從紅黑樹中不斷找出最小(早)的事件,若是超時則觸發超時處理。爲了簡化實現,在 Vino 中,我實現了一個小頂堆來存儲定時事件,若是被處理的定時事件同時支持長鏈接,那麼在該請求處理完畢後會更新該請求對應的定時器,也就是從新計時。定時器相關代碼見 vn_event_timer.h 和 vn_event_timer.c。數據結構
因爲網絡的不肯定性,咱們並不能保證一次就能讀取全部的請求數據。所以,對於每個請求,咱們都會開闢一段緩衝區用於保存已經讀取到的數據。同時,咱們須要同時對讀取到的數據進行解析,以保證讀取到的數據都是合理的數據,例如,假設目前緩衝區內的數據爲 GET /index.html HTT,那麼下一次讀取到的字符必須爲 P,不然,應當即檢測出當前請求是一個異常的請求,並主動關閉當前的鏈接。多線程
基於以上分析,咱們須要實現一個 HTTP 狀態機(Parser)來維持當前的解析狀態,Vino 狀態機的實現參考了 Nginx 的設計,並對 Nginx 的實現作了簡化。HTTP Parser 相關代碼見 vn_http_parse.h 和 vn_http_parse.c。併發
咱們通常使用 malloc/calloc/free 來分配/釋放內存,可是這些函數對於一些須要長時間運行的程序來講會有一些弊端。頻繁使用這些函數分配和釋放內存,會致使內存碎片,不容易讓系統直接回收內存。典型的例子就是大併發頻繁分配和回收內存,會致使進程的內存產生碎片,而且不會立馬被系統回收。
使用內存池分配內存,能夠在必定程度上提高內存分配的效率,不須要每次都調用 malloc/calloc 函數。同時,使用內存池使得內存管理更加簡單。在 Vino 中,針對每個請求,Vino 都會爲其分配一或多個內存池(各個內存池造成一個單鏈表),在請求處理完畢後,一併釋放全部的內存。
Vino 內存池的實現依舊參考了 Nginx 的實現,並作了簡化,Memory Pool 相關代碼見 vn_palloc.h 和 vn_palloc.c。
在開發 Vino 的過程當中,還有許多須要考慮和權衡的地方。響應請求時,若是用戶請求的是一個很大的文件,致使寫緩衝區滿,咱們如何更好的設計響應緩衝區?如何更高效的設計底層數據結構(如字符串、鏈表、小頂堆等)?如何更優雅的解析命令行參數?如何對特定信號進行處理?如何更健壯的處理錯誤信息?當代碼的數量達到必定程度後,如何更快的定位異常代碼?
Vino 的開發 & 重構暫時告一段落,源碼放在了 GitHub 上。固然,Vino 還有許多不足之處,以及未實現的特性。
寫這篇文章,但願對初學者有所幫助。
[1] Vino, https://github.com/tinylcy/vino .
[2] Computer Systems: A Programmer's Perspective, http://csapp.cs.cmu.edu/ .
[3] Advanced Programming in the UNIX Environment (3rd Edition), https://www.amazon.ca/Advance... .
[4] Unix Network Programming, Volume 1, https://www.amazon.ca/Unix-Ne... .
[5] Nginx, https://github.com/nginx/nginx .
[6] Mongoose, https://github.com/cesanta/mo... .
[7] Web Bench, http://home.tiscali.cz/~cz210... .
[8] Zaver, https://github.com/zyearn/zaver .
[9] RFC 2616, https://tools.ietf.org/html/r... .
[10] How to use epoll? A complete example in C, https://banu.com/blog/2/how-t... .