用c++開發一個簡版http服務器

初衷

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

使用方法

首先下載源碼:源碼地址react

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

  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服務。git

這個服務器支持了:

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

運行環境

Unbtun 16.04.2 內核版本是4.8github

如何實現一個Web服務器:

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

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

2.每一個子進程都是一個reactor,採用epoll和非阻塞I/O實現事件循環。以下圖:
api

  • 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模塊開發與架構解析》

相關文章
相關標籤/搜索