1. 程式人生 > >如何寫一個Web伺服器

如何寫一個Web伺服器

最近兩個月的業餘時間在寫一個私人專案,目的是在Linux下寫一個高效能Web伺服器,名字叫Zaver。主體框架和基本功能已完成,還有一些高階功能日後會逐漸增加,程式碼放在了github。Zaver的框架會在程式碼量儘量少的情況下接近工業水平,而不像一些教科書上的toy server為了教原理而捨棄了很多原本server應該有的東西。在本篇文章中,我將一步步地闡明Zaver的設計方案和開發過程中遇到的困難以及相應的解決方法。

為什麼要重複造輪子

幾乎每個人每天都要或多或少和Web伺服器打交道,比較著名的Web Server有Apache Httpd、Nginx、IIS。這些軟體跑在成千上萬臺機器上為我們提供穩定的服務,當你開啟瀏覽器輸入網址,Web伺服器就會把資訊傳給瀏覽器然後呈現在使用者面前。那既然有那麼多現成的、成熟穩定的web伺服器,為什麼還要重新造輪子,我認為理由有如下幾點:

  • 夯實基礎。一個優秀的開發者必須有紮實的基礎,造輪子是一個很好的途徑。學編譯器?邊看教材變寫一個。學作業系統?寫一個原型出來。程式設計這個領域只有自己動手實現了才敢說真正會了。現在正在學網路程式設計,所以就打算寫一個Server。

  • 實現新功能。成熟的軟體可能為了適應大眾的需求導致不會太考慮你一個人的特殊需求,於是只能自己動手實現這個特殊需求。關於這一點Nginx做得相當得好了,它提供了讓使用者自定義的模組來定製自己需要的功能。

  • 幫助初學者容易地掌握成熟軟體的架構。比如Nginx,雖然程式碼寫得很漂亮,但是想完全看懂它的架構,以及它自定義的一些資料結構,得查相當多的資料和參考書籍,而這些架構和資料結構是為了提高軟體的可伸縮性和效率所設計的,無關高併發server的本質部分,初學者會迷糊。而Zaver用最少的程式碼展示了一個高併發server應有的樣子,雖然沒有Nginx效能高,得到的好處是沒有Nginx那麼複雜,server架構完全展露在使用者面前。

教科書上的server

學網路程式設計,第一個例子可能會是Tcp echo伺服器。大概思路是server會listen在某個埠,呼叫accept等待客戶的connect,等客戶連線上時會返回一個fd(file descriptor),從fd裡read,之後write同樣的資料到這個fd,然後重新accept,在網上找到一個非常好的程式碼實現,連結點這裡。如果你還不太懂這個程式,可以把它下載到本地編譯執行一下,用telnet測試,你會發現在telnet裡輸入什麼,馬上就會顯示什麼。如果你之前還沒有接觸過網路程式設計,可能會突然領悟到,這和瀏覽器訪問某個網址然後資訊顯示在螢幕上,整個原理是一模一樣的!

學會了這個echo伺服器是如何工作的以後,在此基礎上拓展成一個web server非常簡單,因為HTTP是建立在TCP之上的,無非多一些協議的解析。client在建立TCP連線之後發的是HTTP協議頭和(可選的)資料,server接受到資料後先解析HTTP協議頭,根據協議頭裡的資訊發回相應的資料,瀏覽器把資訊展現給使用者,一次請求就完成了。

這個方法是一些書籍教網路程式設計的標準例程,比如《深入理解計算機系統》(CSAPP)在講網路程式設計的時候就用這個思路實現了一個最簡單的server,程式碼實現在這裡,非常短,值得一讀,特別是這個server即實現了靜態內容又實現了動態內容,雖然效率不高,但已達到教學的目的。之後這本書用事件驅動優化了這個server,關於事件驅動會在後面講。

雖然這個程式能正常工作,但它完全不能投入到工業使用,原因是server在處理一個客戶請求的時候無法接受別的客戶,也就是說,這個程式無法同時滿足兩個想得到echo服務的使用者,這是無法容忍的,試想一下你在用微信,然後告訴你有人在用,你必須等那個人走了以後才能用。

然後一個改進的解決方案被提出來了:accept以後fork,父程序繼續accept,子程序來處理這個fd。這個也是一些教材上的標準示例,示例程式碼在這裡。表面上,這個程式解決了前面只能處理單客戶的問題,但基於以下幾點主要原因,還是無法投入工業的高併發使用。

  • 每次來一個連線都fork,開銷太大。任何講Operating System的書都會寫,執行緒可以理解為輕量級的程序,那程序到底重在什麼地方?《Linux Kernel Development》有一節(Chapter3)專門講了呼叫fork時,系統具體做了什麼。地址空間是copy on write的,所以不造成overhead。但是其中有一個複製父程序頁表的操作,這也是為什麼在Linux下建立程序比建立執行緒開銷大的原因,而所有執行緒都共享一個頁表(關於為什麼地址空間是COW但頁表不是COW的原因,可以思考一下)。

  • 程序排程器壓力太大。當併發量上來了,系統裡有成千上萬程序,相當多的時間將花在決定哪個程序是下一個執行程序以及上下文切換,這是非常不值得的。

  • 在heavy load下多個程序消耗太多的記憶體,在程序下,每一個連線都對應一個獨立的地址空間;即使線上程下,每一個連線也會佔用獨立。此外父子程序之間需要發生IPC,高併發下IPC帶來的overhead不可忽略。

換用執行緒雖然能解決fork開銷的問題,但是排程器和記憶體的問題還是無法解決。所以程序和執行緒在本質上是一樣的,被稱為process-per-connection model。因為無法處理高併發而不被業界使用。

一個非常顯而易見的改進是用執行緒池,執行緒數量固定,就沒上面提到的問題了。基本架構是有一個loop用來accept連線,之後把這個連線分配給執行緒池中的某個執行緒,處理完了以後這個執行緒又可以處理別的連線。看起來是個非常好的方案,但在實際情況中,很多連線都是長連線(在一個TCP連線上進行多次通訊),一個執行緒在收到任務以後,處理完第一批來的資料,此時會再次呼叫read,天知道對方什麼時候發來新的資料,於是這個執行緒就被這個read給阻塞住了(因為預設情況下fd是blocking的,即如果這個fd上沒有資料,呼叫read會阻塞住程序),什麼都不能幹,假設有n個執行緒,第(n+1)個長連線來了,還是無法處理。

怎麼辦?我們發現問題是出在read阻塞住了執行緒,所以解決方案是把blocking I/O換成non-blocking I/O,這時候read的做法是如果有資料則返回資料,如果沒有可讀資料就返回-1並把errno設定為EAGAIN,表明下次有資料了我再來繼續讀(man 2 read)。

這裡有個問題,程序怎麼知道這個fd什麼時候來資料又可以讀了?這裡要引出一個關鍵的概念,事件驅動/事件迴圈。

事件驅動(Event-driven)

如果有這麼一個函式,在某個fd可以讀的時候告訴我,而不是反覆地去呼叫read,上面的問題不就解決了?這種方式叫做事件驅動,在linux下可以用select/poll/epoll這些I/O複用的函式來實現(man 7 epoll),因為要不斷知道哪些fd是可讀的,所以要把這個函式放到一個loop裡,這個就叫事件迴圈(event loop)。一個示例程式碼如下:

while (!done)
{
  int timeout_ms = max(1000, getNextTimedCallback());
  int retval = epoll_wait(epds, events, maxevents, timeout_ms);

  if (retval < 0) {
     處理錯誤
  } else {
    處理到期的 timers

    if (retval > 0) {
      處理 IO 事件
    }
  }
}

在這個while裡,呼叫epoll_wait會將程序阻塞住,直到在epoll裡的fd發生了當時註冊的事件。
這裡有個非常好的例子來展示epoll是怎麼用的。
需要註明的是,select/poll不具備伸縮性,複雜度是O(n),而epoll的複雜度是O(1),在Linux下工業程式都是用epoll(其它平臺有各自的API,比如在Freebsd/MacOS下用kqueue)來通知程序哪些fd發生了事件,至於為什麼epoll比前兩者效率高,請參考這裡

事件驅動是實現高效能伺服器的關鍵,像Nginx,lighttpd,Tornado,NodeJs都是基於事件驅動實現的。

Zaver

結合上面的討論,我們得出了一個事件迴圈+ non-blocking I/O + 執行緒池的解決方案,這也是Zaver的主題架構(同步的事件迴圈+non-blocking I/O又被稱為Reactor模型)。
事件迴圈用作事件通知,如果listenfd上可讀,則呼叫accept,把新建的fd加入epoll中;是普通的連線fd,將其加入到一個生產者-消費者佇列裡面,等工作執行緒來拿。
執行緒池用來做計算,從一個生產者-消費者佇列裡拿一個fd作為計算輸入,直到讀到EAGAIN為止,儲存現在的處理狀態(狀態機),等待事件迴圈對這個fd讀寫事件的下一次通知。

開發中遇到的問題

Zaver的執行架構在上文介紹完畢,下面將總結一下我在開發時遇到的一些困難以及一些解決方案。
把開發中遇到的困難記錄下來是個非常好的習慣,如果遇到問題查google找到個解決方案直接照搬過去,不做任何記錄,也沒有思考,那麼下次你遇到同樣的問題,還是會重複一遍搜尋的過程。有時我們要做程式碼的創造者,不是程式碼的“搬運工”。做記錄定期回顧遇到的問題會使自己成長更快。

  • 如果將fd放入生產者-消費者佇列中後,拿到這個任務的工作執行緒還沒有讀完這個fd,因為沒讀完資料,所以這個fd可讀,那麼下一次事件迴圈又返回這個fd,又分給別的執行緒,怎麼處理?

答:這裡涉及到了epoll的兩種工作模式,一種叫邊緣觸發(Edge Triggered),另一種叫水平觸發(Level Triggered)。ET和LT的命名是非常形象的,ET是表示在狀態改變時才通知(eg,在邊緣上從低電平到高電平),而LT表示在這個狀態才通知(eg,只要處於低電平就通知),對應的,在epoll裡,ET表示只要有新資料了就通知(狀態的改變)和“只要有新資料”就一直會通知。

舉個具體的例子:如果某fd上有2kb的資料,應用程式只讀了1kb,ET就不會在下一次epoll_wait的時候返回,讀完以後又有新資料才返回。而LT每次都會返回這個fd,只要這個fd有資料可讀。所以在Zaver裡我們需要用epoll的ET,用法的模式是固定的,把fd設為nonblocking,如果返回某fd可讀,迴圈read直到EAGAIN(如果read返回0,則遠端關閉了連線)。

  • 當server和瀏覽器保持著一個長連線的時候,瀏覽器突然被關閉了,那麼server端怎麼處理這個socket?

答:此時該fd在事件迴圈裡會返回一個可讀事件,然後就被分配給了某個執行緒,該執行緒read會返回0,代表對方已關閉這個fd,於是server端也呼叫close即可。

  • 既然把socket的fd設定為non-blocking,那麼如果有一些資料包晚到了,這時候read就會返回-1,errno設定為EAGAIN,等待下次讀取。這是就遇到了一個blocking read不曾遇到的問題,我們必須將已讀到的資料儲存下來,並維護一個狀態,以表示是否還需要資料,比如讀到HTTP Request Header, GET /index.html HTT就結束了,在blocking I/O裡只要繼續read就可以,但在nonblocking I/O,我們必須維護這個狀態,下一次必須讀到’P’,否則HTTP協議解析錯誤。

答:解決方案是維護一個狀態機,在解析Request Header的時候對應一個狀態機,解析Header Body的時候也維護一個狀態機,Zaver狀態機的時候參考了Nginx在解析header時的實現,我做了一些精簡和設計上的改動。

  • 怎麼較好的實現header的解析

答:HTTP header有很多,必然有很多個解析函式,比如解析If-modified-since頭和解析Connection頭是分別呼叫兩個不同的函式,所以這裡的設計必須是一種模組化的、易拓展的設計,可以使開發者很容易地修改和定義針對不同header的解析。Zaver的實現方式參考了Nginx的做法,定義了一個struct陣列,其中每一個struct存的是key,和對應的函式指標hock,如果解析到的headerKey == key,就調hock。定義程式碼如下

zv_http_header_handle_t zv_http_headers_in[] = {
    {"Host", zv_http_process_ignore},
    {"Connection", zv_http_process_connection},
    {"If-Modified-Since", zv_http_process_if_modified_since},
    ...
    {"", zv_http_process_ignore}
};
  • 怎樣儲存header

答:Zaver將所有header用連結串列連線了起來,連結串列的實現參考了Linux核心的雙鏈表實現(list_head),它提供了一種通用的雙鏈表資料結構,程式碼非常值得一讀,我做了簡化和改動,程式碼在這裡

  • 壓力測試

答:這個有很多成熟的方案了,比如http_load, webbench, ab等等。我最終選擇了webbench,理由是簡單,用fork來模擬client,程式碼只有幾百行,出問題可以馬上根據webbench原始碼定位到底是哪個操作使Server掛了。另外因為後面提到的一個問題,我仔細看了下Webbench的原始碼,並且非常推薦C初學者看一看,只有幾百行,但是涉及了命令列引數解析、fork子程序、父子程序用pipe通訊、訊號handler的註冊、構建HTTP協議頭的技巧等一些程式設計上的技巧。

  • 用Webbech測試,Server在測試結束時掛了

答:百思不得其解,不管時間跑多久,併發量開多少,都是在最後webbench結束的時刻,server掛了,所以我猜想肯定是這一刻發生了什麼“事情”。
開始除錯定位錯誤程式碼,我用的是打log的方式,後面的事實證明在這裡這不是很好的方法,在多執行緒環境下要通過看log的方式定位錯誤是一件比較困難的事。最後log輸出把錯誤定位在向socket裡write對方請求的檔案,也就是系統呼叫掛了,write掛了難道不是返回-1的嗎?於是唯一的解釋就是程序接受到了某signal,這個signal使程序掛了。於是用strace重新進行測試,在strace的輸出log裡發現了問題,系統在write的時候接受到了SIGPIPE,預設的signal handler是終止程序。SIGPIPE產生的原因為,對方已經關閉了這個socket,但程序還往裡面寫。所以我猜想webbench在測試時間到了之後不會等待server資料的返回直接close掉所有的socket。抱著這樣的懷疑去看webbench的原始碼,果然是這樣的,webbench設定了一個定時器,在正常測試時間會讀取server返回的資料,並正常close;而當測試時間一到就直接close掉所有socket,不會讀server返回的資料,這就導致了zaver往一個已被對方關閉的socket裡寫資料,系統傳送了SIGPIPE。

解決方案也非常簡單,把SIGPIPE的訊號handler設定為SIG_IGN,意思是忽略該訊號即可。

不足

目前Zaver還有很多改進的地方,比如:

  • 現在新分配記憶體都是通過malloc的方式,之後會改成記憶體池的方式

  • 還不支援動態內容,後期開始考慮增加php的支援

  • HTTP/1.1較複雜,目前只實現了幾個主要的(keep-alive, browser cache)的header解析

  • 不活動連線的超時過期還沒有做

總結

本文介紹了Zaver,一個結構簡單,支援高併發的http伺服器。
基本架構是事件迴圈 + non-blocking I/O + 執行緒池。
Zaver的程式碼風格參考了Nginx的風格,所以在可讀性上非常高。另外,Zaver提供了配置檔案和命令列引數解析,以及完善的Makefile和原始碼結構,也可以幫助任何一個C初學者入門一個專案是怎麼構建的。
目前我的wiki就用Zaver託管著。

參考資料