如何用c++開發一個簡版web服務器

初衷

在閱讀了TLPI和深入理解計算機系統之後,學會了如何使用linux系統api,想在寫代碼的過程中來加深自己對知識的理解,更想用這些知識來去做一個更酷的東西,而不僅僅是教課書上的簡單服務器。而且在實現過程中往往能學到教科書外的東西。
私以爲項目爲導向是學習編程的最好方法。而且沒有什麼比自己創造一個東西有趣。
“將一個實際的瀏覽器指向自己的服務器,看着他顯示一個複雜的帶有文本和圖片的web頁面,真是非常令人興奮。"

使用方法

首先下載源碼:源碼地址

然後將web頁面所需的html文件放在/var/www目錄下

  1. $ cd /src , 進入到src目錄
  2. $ make , 產生可執行文件HttpServer
  3. $ ./HttpServer \<ipv4 address> \<port number> \<process number> \<connect number per process>

    例如:./HttpServer 127.0.0.1 8080 5 1000 ,這一步是開啓web-server服務。

這個服務器支持了:

  1. 目前僅僅支持HTTP/1.1的GET方法。
  2. 暫時不支持動態內容。
  3. 完整的Http報文請求行和頭部解析
  4. 簡單的連接池,進程池和內存池管理
  5. 簡單的負載均衡。
  6. 支持HTTP/1.1長連接
  7. 實現了一個二叉堆,對定時時間進行管理(目前只有超時連接事件)。

運行環境

Unbtun 16.04.2 內核版本是4.8

如何實現一個Web服務器:

  1. 本服務器採用進程池,epoll和非阻塞I/O實現高效的半同步/半異步模式。如下圖:

主進程只管理監聽socket,連接socket都由進程池中的worker進行管理。當有新的連接到來時,主進程會通過socketpair創建的套接字和worker進程通信,通知子進程接收新連接。子進程正確接收連接之後,會把該套接字上的讀寫事件註冊到自己的epll內核事件表中。之後該套接字上的任何I/O操作都由被選中的worker來處理,直到客戶關閉連接或超時。

  1. 每個子進程都是一個reactor,採用epoll和非阻塞I/O實現事件循環。如下圖:
  • a. epoll負責監聽事件的發生,有事件到來將調用相應的事件處理單元進行處理

    • i. 對一個連接來說,主要監聽的就是讀就緒事件和寫就緒事件。

      • 1). 通過非阻塞I/o和事件循環來將阻塞進程的方法分解。例如:每次recv新數據時,如果recv返回EAGAIN錯誤,都不會一直循環recv,而是將現有數據先處理,然後記錄當前連接狀態,然後將讀事件接着放到epoll隊列中監聽等待下一個數據到來。因爲每次都不會儘可能的將I/O上的數據讀取,所以我採用了水平觸發而不是邊沿觸發。send同理。
    • ii. 統一事件源:

      • 1). 信號:信號是一種異步事件,信號處理函數和程序的主循環是兩條不同的執行路線,很顯然,信號處理函數需要儘可能的執行完成,以確保信號不被屏蔽(信號是不會排隊的)。一個典型的解決方案是把信號的主要處理邏輯放到事件循環裏,當信號處理函數被觸發時只是通過管道將信號通知給主循環接收和處理信號,只需要將和信號處理函數通信的管道的可讀事件添加到epoll裏。這樣信號就能和其他I/O事件一樣被處理。

        • a).忽略SIGPIPE信號(當讀寫一個對端關閉的連接時),將爲SIGINT,SIGTERM,SIGCHILD(對父進程來說標識有子進程狀態發生變化,一般是子進程結束)設置信號處理函數。
      • 2). 定時器事件。使用timefd,同樣通過監聽timefd上的可讀事件來統一事件源。將其設置爲邊沿觸發,不然timefd水平觸發將一直告知該事件。

        • a). 超時將通過連接池回收連接。
  • b. 連接池和內存池的實現:

    • i. 連接池:連接池採用一個map<int,Conn>和一個set<Conn>實現。連接池在構造時,將根據傳入的參數new固定數目的Conn(Conn的構造函數並不會爲定時器,接收和發送緩衝申請空間),且後續數目不可變。然後連接結構的地址放入到set裏。新連接到來時將從set裏取出一個空閒連接,然後將其初始化,並放入map,map裏保存的時套接字和對應連接地址的key-value對。連接關閉時,將回收連接,從mao中移除,然後放入到set裏。
    • ii. 內存池:內存池的實現是通過連接類來完成的。連接類在第一次被初始化時即第一次被使用,將申請相應的定時器,接收和發送緩存。之後將不會將申請的內存銷燬,直到進程結束。通過這樣來降低申請和釋放內存的次數來減少內存碎片以及節約時間。
  • c. 連接:
    每個連接都應該有一個bool Init(int connfd,size_t recv_buffer_size,size_t send_buffer_size);函數,一個Return_Code process(OptType status)函數。前一個函數會在第一次被調用時分配內存,後一個函數將根據操作類型,來決定要進行的是讀還是寫操作。同時根據操作結果返回相應的狀態,來決定要給epoll添加什麼事件。
  • d. 時間堆的實現(定時器的精度目前爲s):
    採用最小堆來實現。每次都將所有定時器中的超時時間最小的定時器的超時間隔作爲心博間隔。刪除和更新定時器的時間複雜度都是O(logK)k是其在堆中的位置。
  • e. 負載均衡:
    當一次事件循環結束,子進程的連接數目有變化時,將通過和父進程通信的管道來通知自父進程自己的連接數。當新的連接到來時,父進程將選取連接數最少的一個進程,將新的連接發送給他。
  • f. Http報文請求行和頭部解析:

    • i. 通過狀態機來實現HTTP報文的解析。因爲一個請求有可能不是在一個tcp包中到來,所以需要記錄狀態機的狀態,以及上次check到的位置。在解析完HTTP報文後,還需要保存解析的結果,然後根據解析結果,來產生相應response。該部分實現參考了《Linux高性能服務器編程》中的實現。

爲什麼這麼設計

1. 爲什麼採用多進程而不是單進程多線程:

a. 雖然說多線程的切換開銷比多進程低。如果每一個進程都工作在一個cpu上,那麼切換的開銷完全可以省去,而且因爲我們採用的是進程池,進程的數目在啓動時是可以設置的,而且並不會在程序的執行過程中頻繁的開新進程和銷燬就進程,所以進程銷燬和產生這塊開銷也避免了。
b. 同時,多進程的編碼難度比多線程要低的多,而且也不用過多的考慮到線程安全問題。
c. 綜上,我選擇了多進程。

2. 爲什麼採用時間堆?

a. 首先和雙鏈表相比,最小堆的時間複雜度是優於他的。和時間輪比,雖然添加和刪除定時器的時間複雜度是O(1),但是其執行一個定時的時間複雜度是O(n),同時其精度和時間輪的槽間隔有關。而最小堆則更適合處理這種每次timer模塊需要頻繁找最小的key(最早超時的事件)然後處理後刪除的場景。其刪除一個定時器是O(lgk)(如果考慮延遲刪除的話,會是O(1),但是考慮到我要複用定時器,所以執行了嚴格的刪除),添加是O(lgn),執行則時O(1)。nigix使用的是紅黑樹,但是“memory locality比heap要差一些,實際速度略慢”,即使用最小堆更容易命中cache。libev使用的是更高效的4叉堆。爲了簡化實現,我採用了二叉堆來實現timer的功能。

3. 爲什麼採用連接池和內存池?

a. 和上面所說的一樣,爲了更好的利用資源,減少內存碎片,降低頻繁的申請和銷燬內存的開銷。

測試

  1. 我編寫了一個簡單的Echo類,來測試時間堆,連接池和進程池。然後測試http_conn。最後再將各個模塊結合起來進行測試。
  2. 最大的體會就是,在多模塊編程的時候,一定一定一定要進行單元測試,再相應的模塊沒問題了之後,再聯合起來進行測試。
  3. 同時,代碼完成之後,寫相應的類的接口和函數說明,再自己code review一遍,也是很重要的檢錯方法。
  4. 最詭異的bug往往都是因爲最愚蠢的錯誤。例:我i在某個調用epoll_ctl(int epollfd,int option,int fd,struct epoll_event *evlist)函數中,將option和fd參數位置換了,導致一直epoll_ctl失敗。調試了一天,最後才發現,參數位置寫錯了,然而其他地方的調用位置都寫對了。
  5. 調試工具:使用GDB進行調試,使用valgrind進行內存泄漏的檢測。
    a. 因爲我是申請了很多內存都沒有釋放,而且放在內存池和連接池中,所以導致一個內存依舊reachable的,但是當進程結束時,其會被操作系統回收所以它不算是真正的memory 。只有當你申請了一塊內存,而又丟失了指針之後,纔是真正的內存leak。
  6. 當然,還沒有進行壓力測試,打算下一步進行壓力測試。目前只測試過200個連接而已。

不足

  1. 首先就是隻支持get方法,也不支持動態內容。
  2. 可以增加配置文件的讀取,而不是通過啓動時候設置的參數
  3. 日誌系統,目前還只是簡單的封裝了一下printf,在調試的時候打開,不調試的時候關閉。真正上線的服務器是會需要一個高效而又不影響運行的日誌系統的。
  4. 可修改性。比如做到在不重啓服務器的情況下,提供給用戶不用的功能,比如動態修改進程數目,動態修改併發限制等等等等
  5. 模塊化設計。還是需要儘量降低模塊之間的耦合度。雖然對Conn類只要求提供兩個函數接口,但是其實內存池的管理是Conn做的,可否將內存池也交給連接池來管理。還有定時器的設計。目前只比較適合於連接超時事件。
  6. 需要將服務器進程該爲守護進程等等等等

收穫

  1. 首先肯定是增加了自己的編碼能力。
  2. 加深了自己對linux系統api的瞭解
  3. 學到了更多關於linux服務器的知識。同時也驚歎於各種大師的智慧。我只是一個站在巨人的肩膀上重複造輪子的小人兒。
  4. 想太多是沒用的,先考慮實現,再考慮性能。在寫代碼前想太多是沒有意義的。Talk is cheap,show me the code。
  5. Code review的重要性!就算是自己review自己的代碼,都能發現一些顯而易見的錯誤。
  6. 文檔文檔文檔!記錄自己實現,整理自己的api,都有助於自己思考和編碼。
  7. 學習GDB和valgrind使用,學習了makefile的編寫。

參考資料:

感謝和感嘆於大牛的智慧,編碼的路上,還需要繼續努力。
《linux多線程服務端編程》
《深入理解計算機系統》
《Linx/unix編程手冊》
《linux高性能服務器編程》
《深入理解Ngix模塊開發與架構解析》

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章