最近幾天用C++重新寫了之前的HTTP服務器,對以前的代碼進行改進。新的HTTP服務器采用Reactor模式,有多個線程並且每個線程有一個EventLoop,主程序將任務分發到每個線程,其中采用的是輪盤調度來均勻分配任務。
服務器的源代碼放在Github。以前的舊版本也放在我的GitHub上,在Oh-Server倉庫中。新代碼又新建了一個倉庫。
HTTP基礎知識
寫HTTP服務器當然要了解HTTP的基礎知識。HTTP/1.1由RFC2616定義,它和TCP/IP協議族內的其他協議相同,是用於客戶和服務器之
間的通信。請求訪問資源的一端成為客戶端,而提供資源響應的一端成為服務器端。我們要寫的是服務端。
HTTP請求報文
HTTP協議規定,請求從客戶端發出,然后服務器響應該請求。一個HTTP請求報文的例子如下所示:
GET / HTTP/1.1
Host: www.cnblogs.com
User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:49.0) Gecko/20100101 Firefox/49.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Connection: keep-alive
這是一個真實的HTTP請求的例子,其中每一行都以\r\n
結尾。由於我們寫的是簡單的服務器,所以我們只關心其中的幾行。
第一行稱為請求行,GET
是請求方法,表示獲取資源,除此之外還有POST
方法、PUT
方法、HEAD
方法、DELETE
方法和OPTIONS
方法等。由於我們寫一個簡單的服務器,所以暫時僅支
持GET
方法。/
是URI,表示客戶希望訪問的資源的URI。HTTP/1.1
是HTTP協議的版本,此例中表示1.1版本。我們需要解析請求行,需要解析出方法字段、URI和HTTP協議版本。
第二行是Host
字段,表示所請求的資源所在的主機名和端口號。
第三行User-Agent
是客戶的瀏覽器的類型,此例是運行在Ubuntu上的Firefox瀏覽器。
第四行Accept
表示客戶接受的資源的類型。
第四行Accept-Language
表示客戶接受的語言類型。
第五行Connection
表示服務器在發送完客戶請求的數據之后是否斷開TCP連接。keep-alive
表示不斷開,close
表示斷開。
HTTP應答報文
HTTP/1.1 200 OK
Server: Apache/2.2.22 (Debian)
Content-length: 1223
Content-Type: text/html
第一行為應答行,HTTP/1.1
是協議版本,200
是狀態碼,OK
是狀態短語,表示請求正常。
第二行Server
表示服務器的類型,此例中是Apache服務器。
第三行Content-length
表示實體的長度,單位字節。
第四行Content-Type
表示實體的文件類型。
程序運行流程
編譯完畢后在終端中輸入 ./Servant 8080
開始運行服務器程序,再打開瀏覽器輸入localhost:8080
訪問我們寫的HTTP服務器。
服務器在Servant.cpp
中定義的mian函數中監聽到了客戶瀏覽器發送的連接請求,主函數accept
客戶連接並選擇一個線程將客戶的已連接套接字注冊到此線程的EventLoop
中。
EventLoop
是一個事件循環,每個循環都是在試圖從其中的epoll中獲取活動的套接字描述符並交給Handler
類中處理。
Handler
類是處理客戶HTTP請求的類,它首先將客戶的原始請求轉發給Parser
類處理,從而獲取客戶請求的解析結果,Parser
類將解析后的結果存入HTTPRequest
類型的結構體中。Handler
類根據解析后的結果首先測試客戶請求的文件是否存在,如果不存在將返回錯誤。如果文件存在但客戶的權限不允許那么也返回客戶錯誤信息。
每個客戶請求處理完畢后就關閉此套接字然后EventLoop
繼續循環。
解析HTTP請求
HTTP服務器的一個重要任務是解析HTTP請求,源代碼中Parser.h
和Parser.cpp
文件中定義的Parser
類就是干這個的。為了表示解析后的結果我們定義了
一個結構體HTTPRequest
結構存儲解析后的結果,定義如下:
// 解析請求后的數據存儲在HTTPRequest結構體中
typedef struct
{
std::string method; // 請求的方法
std::string uri; // 請求的uri
std::string version; // HTTP版本
std::string host; // 請求的主機名
std::string connection; // Connection首部
} HTTPRequest;
各個字段的意義正如注釋中所示。Parser
類的構造函數接受一個字符串類型的參數,解析后的值存儲在_parseResult
結構體中,並提供一個接口getParseResult
函數訪問解析
后的結果。解析的順序如下:
首先parseLine
函數按照\r\n
作為分隔解析出每一行請求,並把結果存儲在_lines
中,其中每一個元素是一行請求。
然后調用parseRequestLine
函數解析請求行,函數按空格解析各個字段,並把解析得到的方法、URI和HTTP版本存入HTTPRequest
類型的結構體中。
最后調用parseHeaders
函數解析其他頭部字段,並將結果存入HTTPRequest
類型的結構體中。
線程池
要想提高服務器的性能,使用線程池是一種很好的方法,它避免了單線程的低效率,並且避免了每次創建一個線程的額外開銷。源代碼中EventLoopThreadPool.h
文件定義了線程池的類EventLoopThreadPool
。其中的每個線程都是一個EventLoopThread
,EventLoopThread
是EventLoop
和Thread
類的結合體,意思就是每個線程運行一個EventLoop
。每個線程的運行函數就是EventLoop
類中定義的loop
函數。線程池中線程的數目由主函數設置,我設置為4。線程的數目不宜過多也不宜過少,過多的話會增加CPU的調度開銷;過少的話不能發揮多核CPU的性能。所以線程池中常駐線程的數目應該等於CPU核心數,以盡量減少任務切換帶來的額外開銷並充分發揮處理器的性能。
具體實現請參考源代碼。
其他模塊的作用
由於我們寫的是非阻塞的HTTP服務器,所以緩沖區是必須要有的。在讀取客戶瀏覽器的請求和發送服務器的響應時我們會先將不完整的請求和響應暫存到緩沖區中,等到數據全部讀完或者寫完后再一並發送或者交給其他模塊處理。所以Buffer.cpp
和Buffer.h
文件中定義的Buffer
類就顯得非常有用了。
Buffer類有readFd
和sendFd
函數分別用於讀取客戶的請求和發送服務器的響應。Buffer類底層存儲的是一個char
型的vector
,每次添加數據就調用push_back
將數據添加到字符數組末尾。_readIndex
和_writeIndex
分別表示開始讀的索引和開始寫的索引,用這兩個索引可以方便的讀和寫數據。
I/O復用
我們使用I/O復用技術的epoll系列函數來監聽套接字並通知主函數。至於為什么選擇epoll
而不是select
或者poll
,是因為epoll
采用回調的方式通知事件;而select
和poll
采用的
都是輪詢的方式,每次調用都要掃描整個注冊文件描述符集合,並將其中就緒的描述符返回給用戶。因此它們的時間復雜度是O(n),而epoll
由於采用回調的方式所以其時間復雜度為O(1)。
但是當活動連接比較多的時候epoll_wait
的效率未必比select
和poll
高多少,因為此時回調函數觸發的比較頻繁,所以epoll_wait
用於連接數量多但是活動連接比較少的情況。
其他注意的地方
-
對於監聽套接字設置
SO_REUSEADDR
套接字選項,以允許服務器在其派生的子進程正在處理客戶請求的過程中重啟服務器進程。 -
忽略SIGPIPE信號,當一個進程向某個已收到RST的套接字執行寫操作時,內核向該進程發送一個
SIGPIPE
信號,該信號的默認動作是終止進程,因此進程必須捕獲它以免被終止。
參考資料:
UNIX網絡編程(第三版)卷一 人民郵電出版社
深入理解計算機系統(第二版) 機械工業出版社
Linux多線程服務端編程 電子工業出版社
Linux高性能服務器編程 機械工業出版社