如果是編寫一個伺服器demo,比較簡單,隻要會socket程式設計就能實作一個簡單C/S程式,但如果是實作一個健壯可靠的伺服器則需要考慮很多問題。下面我們看看需要考慮哪些問題。
為何要維持心跳,TCP難道不是一個安全可靠的連接配接麼?正常情況下,C端和S端無論是誰掉線,對方都能感覺到。進而進行後續處理,比如釋放維持的資源并通知業務層進行相應的業務處理。
如果TCP通道非常繁忙,C端和S端都能通過正常的業務通信感覺到對方的存在與否。但如果TCP通道長時間無資料往來,這種感覺就無法主動擷取到,這時就需要通過心跳包來進行檢測。 看看下面的情況:
用戶端突然斷電、當機等,這種情況下對方都來不及跟你道别就駕鶴西遊了,隻留下伺服器擱那傻等。
比如:網線突然脫落或防火牆強行關閉TCP通道。防火牆為何會關閉TCP通道呢?
防火牆認為C端和S端長時間沒通信,可能感情破裂了,是以繼續維持兩者之間的聯系毫無意義,是以單方面宣布兩者離婚,強制執行,立即生效。
上面是我猜的,實際情況是防火牆出于對伺服器的愛,防火牆時刻監視着所有連接配接到伺服器上的TCP通道,如果有長期占着茅坑不拉屎的連接配接,防火牆就會認為該連接配接是惡意的,是在對伺服器耍流氓,是以有必要立即斷開連接配接。
此時C端和S端雖然都活着,但兩者之間已經陰陽兩地,不可能在碰面了。
上述情況下,如果不進行心跳檢測,伺服器長期運作後,可能存在大量的“僵屍”連接配接,進而過多的占用系統資源。對于業務層來說如果不及時處理這些“僵屍” 可能造成業務處理的混亂。
為何要處理逾時? 我們通常了解的逾時處理,大部分是基于套接字(socket)這層,逾時有可能是網絡擁塞導緻,也有可能是上述的突然死亡或突然失聯導緻。
如果send或recv長期無法完成,則有可能是TCP通道失效或對方已不在服務區,是以伺服器端有必要主動進行關閉操作。對于逾時,你可以粗暴的直接關閉連接配接,也可以在嘗試N次發送或接收都逾時後進行關閉。
對于這種socket逾時,我們隻需要通過setsockopt函數在網絡層進行逾時設定。對于阻塞套接字而言,這種方式是可行的,但對于異步模型,這種方式則無法采用,比如IOCP模型。在IOCP模型下,所有投遞的讀、寫操作都需要業務層進行逾時判斷。
上面的逾時大家都比較清楚,其實逾時處理最重要的作用是防止惡意連接配接,進而增強伺服器的健壯性。
以HTTP協定為例,伺服器需要讀取HTTP請求頭,這個請求頭會以兩個連續的回車換行(\r\n)來标記結束。
伺服器隻有讀取完請求頭後才能進行下一步的解析和業務處理工作。如果請求方在發送一半請求頭後,遲遲不發送結束标記,就會導緻伺服器傻等,因為伺服器會認為一次完成會話(HTTP Sesstion)并沒有結束。
或者,對方在content-length字段中指明長度為100位元組,卻隻給伺服器發送了99位元組後跑路。如果沒有逾時,伺服器會一直癡癡的等着這最後一個位元組的到來。
是以有必要在逾時後進行會話關閉,否則這種惡意連接配接會很輕松的耗盡伺服器有限的連接配接資源。
是以處理逾時,不僅能解決網絡層的意外問題,也能有效解決業務層的耍流氓行為。 當然逾時也可能導緻誤傷,但相較于整體安全而言,這點誤傷是可以了解的,大不了重聯,重新培養感情。
這個好了解,上面的心跳檢測,需要定時器來周期性的發起(如果你的逾時判斷不是依賴socket自帶實作機制,即通過setsockopt函數設定KEEP_ALIVE參數來實作的話)。ngnix、redis、libuv(nodejs使用的底層庫)等伺服器都有自己的定時器實作邏輯,設計一個好的定時器有助于減少不必要的資源浪費。
定時器可以幫伺服器維持心跳檢測,同時也能幫伺服器做一些自身維護方面的工作,比如定期檢查記憶體、CPU使用情況,定期同步(儲存)資料等。
此外,處理逾時也需要定時器來進行檢測,對于IOCP模型,無法通過setsockopt函數來設定套接字層的逾時,隻能通過業務層來自己實作,也就是對于每個發出的IO請求(讀寫操作)記錄時間,并在IO請求完畢後更新時間。定時器要周期性的檢查所有IO請求是否完成,或者是否逾時。比如投遞一個寫操作,如果長時間沒有寫完畢,則需要進行逾時處理。
對于上萬連接配接,該如何設計自己的定時器? 如果對每個連接配接socket(TCP連接配接)都啟動一個定時器進行逾時或心跳檢測,則定時器本身就會消耗大量的系統資源,顯然這種方式是不明智的。
如果隻啟動一個定時器,去檢測成千上萬連接配接,則需要考慮如何在CPU空閑或IO空閑時的去做這些事。比如當伺服器準備向某個TCP通道發送心跳包時,該通道正在進行正常業務會話,此時心跳包可能會幹擾正常的業務資料。比如CPU很繁忙的時候,如何讓你的定時器進行錯峰檢測。
也就是說,你的定時器要根據你的伺服器業務特點親自實作,并融合到整體的IO排程中。
這個和現實中的無罪推論相反。伺服器設計上,一定要假設所有請求可能都是非法的,要做有罪推論。 我們不能想當然的認為每個連接配接請求都會按照标準的協定與伺服器通信。
大部分協定都是通過特定的結束标記(\r\n)來表示一次完整的請求或資料響應的完成。比如HTTP、FTP、TELNET、POP3、SMTP等協定。上古時期,早期作業系統UNIX(或DOS),使用者操作界面就是控制台,控制台的輸入輸出方式就決定了使用者隻能通過敲擊鍵盤将協定指令輸入到網絡,這也就導緻了回車換行"\r\n"會作為一次指令結束的辨別。 比如HTTP協定,與主機建立連接配接後,輸入"GET / HTTP/1.1\r\n"即可擷取網站的首頁。
還是以HTTP協定為例,HTTP請求頭是以兩個回車換行(\r\n\r\n)來标記結束。如果對方一直發送資料,而不發送結束标記該如何處理? 假設我們開辟一個4K(4096)位元組的緩沖區用于接收HTTP請求頭,對方發送的請求頭超過4K怎麼辦,當然你可以remalloc記憶體繼續接收,但如果是惡意請求呢?比如對方一直發送資料,直到把你的伺服器記憶體消耗殆盡。這時候就需要我們設定一個閥值,超過該值時要立即斷開連接配接。
這種方式可以了解為對講機模式,一句話講完後必須要帶上一句over,屬于後付費 。對方在你沒有發送over之前無法知道最終資料有多長。這種後付費方式容易讓對方吃霸王餐,比如吃完之後沒說over(沒付錢)就跑了。。。。
還有一種協定不是以“over”标記符來表示請求的完整性。而是通過請求頭中的“長度字段”來表示後續資料的大小。這種方式可以了解為封包方式。 屬于預付費 ,就是一開始就告訴對方自己要發送資料的大小,或者告訴對方自己有多少錢,可以消費多少,讓對方提前準備好緩沖區。
這種方式下會有一個固定大小的封包頭,封包頭的字段有嚴格的定義,用于訓示後續資料的實際情況或者意義。
後付費能吃霸王餐,預付費也是可以的,也就是資料長度可能是假的,長度字段雖然是1000個位元組,但最後給你2000個怎麼辦?或者隻給你500個怎麼辦?
以websocket協定為例,雖然websocket協定是基于HTTP協定,但這僅限于建立會話階段。一旦會話建議,websocket就會通過固定格式的封包來進行資料交流。這種情況下我們要嚴格檢驗封包的格式,比如長度是否合法。
此外,對于所有recv來說,一次接收的資料不一定是你想要的結果,不是緩沖區開辟了多大,對方就一次性發給你多大。極端情況下,對方可以一個位元組一個位元組的發送資料,這時候你就要進行資料的封裝和實時校驗。
上述情況都會涉及到記憶體的配置設定和通路,一旦處理不當就可能造成系統資源耗盡或這伺服器的直接coredown。
從上面我們可以看到,記憶體的配置設定和銷毀是頻繁發生的事,伺服器長期運作就會導緻記憶體碎片的産生。我的這篇文章對此有詳細的說明。
《【超值分享】為何寫伺服器程式需要自己管理記憶體,從改造std::string字元串操作說起》
寫累了,到此為止吧,考慮的問題還有很多,比如你的上層業務是IO密集型還是CPU密集型,這就會對你程式架構産生影響,比如是否考慮使用線程池?這就是為何redis采用單線程,nginix采用多線程的原因之一。