6.2.1 接受連接的方法

Winsock 擴展函數 AcceptEx 是唯一能夠使用重疊 I/O 接受客戶連接的函數。下面主要深入探討使用該函數接收連接的問題。

前面已經討論過,當客戶連接進來時,服務器需要創建一個套接字來負責維護與一個客戶端的會話。使用 AcceptEx 函數之前必須創建一些套接字,并且這些套接字必須是未綁定、未連接的,即使它們可能在調用 TransmitFile, TransmitPackets, 或 DisconnectEx 后可以重用。

響應服務器必須總是具有足夠的 AcceptEx 在站崗,以便在有客戶連接請求時調用。但是,并沒有具體的數量能夠保證服務器能夠立即響應連接。我們知道在調用 listen 將監聽套接字置于監聽狀態后, TCP/IP 堆棧會自動接受到來的連接,直到達到 listen 的 backlog 參數設定的限制。對于 Windows NT 服務器而言,支持的 backlog 的最大值為 200 。如果服務器投遞了 15 個 AcceptEx 調用,然后突然有 50 個客戶請求連接服務器,它們的連接請求都不會遭到拒絕。服務器投遞的 AcceptEx I/O 會滿足前面的 15 個連接,剩下的 35 個連接都被系統默認連接了。檢查一下 backlog 的值發現,系統還有能力默認接受 165 個連接。之后,如果服務器投遞 AcceptEx 調用,它們會立即成功返回,因為系統會將默認接收的連接放入 “ 等待連接隊列 ” 中。

服務器的特性是決定要投遞多少個 AcceptEx 操作的重要因素。例如,希望處理大量短時間即時連接的客戶要比處理少量長時間連接的客戶投遞更多的 AcceptEx I/O 。一個好的策略是允許 AcceptEx 的調用數量在最小值和最大值之間變化。具體做法是,應用程序跟蹤未決的 AcceptEx I/O 的數量,當一個或多個 I/O 完成使這個未決 I/O 數量變得比最小值還小時,就再投遞額外的 AcceptEx I/O 。

在 Windows 2000 和以后的 Windows 操作系統版本中, Winsock 提供了一種機制,用來確定應用程序是否投遞了足夠的 AcceptEx 調用。創建監聽套接字時,使用 WSAEventSelect 函數為監聽套接字關聯一個事件對象,注冊 FD_ACCEPT 事件。如果投遞的 AcceptEx 操作用完,但是仍有客戶請求接入(系統根據 backlog 值決定是否接受這些連接),事件對象就是受信,說明應該投遞額外的 AcceptEx 操作了。這實際上還是利用事件對象來使調用線程處于一種 “ 可警告狀態 ” ,當有客戶連接請求時,就根據當前 AcceptEx 操作是否用完來警告(通知)是否需要投遞新的 AcceptEx 操作來處理新的客戶連接。

使用 AcceptEx 處理連接的另外一個功能就是在處理連接時還可以接收用戶發來的第一塊數據(前提是為 AcceptEx 提供了接收緩沖區),這對于那些請求連接的同時發送了一些數據過來的客戶來說很適用。但是,此時,除非接收連接的同時接收到了客戶發送過來的一些數據,否則 AcceptEx 是不會返回的。

為了滿足客戶的需求,服務器不得不投遞更多的接受 I/O ,這會占用大量的系統資源。如果客戶僅調用 connect 函數連接服務器,長時間既不發送數據,也不關閉連接,就可能造成 AcceptEx 投遞的大量重疊 I/O 操作不能返回。這就是 “ 惡意連接 ” 。為此,服務器應該記錄每個 AcceptEx 投遞的未決 I/O, 定時掃描它們,設置 SO_CONNECT_TIME 參數調用 getsockopt 檢查它們連接的時間,如果超時,就將連接關閉。如果使用 WSAEventSelect 模型來通知有連接事件,則當事件受信時,是檢查客戶套接字( AcceptSocket )是否真正連接了。

每當調用 AcceptEx 接受客戶端連接時,它也在等待接受客戶發送過來的第一個數據塊,這時不允許投遞另外一個 AcceptEx 。當 AcceptEx 返回后,如果事件對象再次受信則表明有新的連接到來。需要注意的是,無論何時,千萬不要關閉一個調用 AcceptEx 還沒有返回的套接字( AcceptSocket ),因為這會導致內存泄露。因為從內部執行邏輯看,當沒有連接的套接字句柄被關閉時,調用 AcceptEx 所涉及到的內核模式的數據結構并不會清除掉,直到有新的連接建立或者監聽套接字被關閉。

盡管在一個等待完成通知的工作者線程中,投遞一個 AcceptEx 操作,看起來既簡單又合情合理,但是應盡量避免這樣做,因為創建套接字還是很耗費資源的。另外,也不要在工作者線程中進行任何復雜的計算,以便處理器可以盡快的在接到完成通知后進行后續處理。創建套接字耗費資源的一個原因在于 Winsock 2.0 本身的架構很復雜,成功地創建一個套接字可能需要調用很多內核服務。因此,服務器應該在單獨線程中創建套接字,投遞 AcceptEx 操作。當調用線程投遞的 AcceptEx 重疊操作完成時,一個受信的事件將會通知處理線程。

6.2.2 數據傳輸問題

數據傳輸是通信程序執行的核心操作。當一個客戶與服務器建立連接后,它們的主要工作就是傳輸數據,因為數據是信息的表示。由上一節幾種 I/O 模型的性能測試分析可知,當連接數量很大時,數據吞吐量是一個重要的性能考核指標。

從性能角度考慮,所有的數據傳輸最好都應采用重疊 I/O 處理。默認情況下,系統為每個 socket 分配一個的接受緩沖區和一個發送緩沖區,用來緩存接收和發送的數據。但在重疊 I/O 中,這些緩沖區往往不用,可以傳遞參數 SO_SNDBUF 或 SO_RCVBUF 調用 setsockopt ,來將它們設置為 0 。

讓我們來看看,當發送緩沖區沒有設置為 0 時,系統是怎么處理一個典型的 send 操作的。當一個應用程序調用 send 函數時,如果有充足的緩沖空間,需要發送的數據將被拷貝到套接字的發送緩沖區, send 函數立即成功返回,并且一個完成通知被拋出。另外一個方面,如果套接字的發送緩沖區已滿,則應用程序提供的發送緩沖區被鎖定,再次對 send 函數的調用將會返回 WSA_IO_PENDING 錯誤。當發送緩沖區中的數據被處理(例如,提交給傳輸層處理)時, Winsock 實際上直接處理鎖定在緩沖區中的數據,也即繞過套接字的發送緩沖區,直接從應用程序緩沖區中提交數據給傳輸層。

接收數據的情況恰好相反。當一個重疊的 receive 請求拋出后,如果數據已經接收成功,它會被緩存在套接字接收緩沖區。數據會拷貝到應用程序緩沖區(直到飽和)。 receive 調用返回,并且一個完成通知被拋出。當套接字緩沖區被設置為空時,如果調用重疊的 receive 操作將返回 WSA_IO_PENDING 錯誤。當有數據到達時,它將繞過套接字緩沖區而直接被拷貝到應用程序緩沖區。

設置單套接字緩沖區為 0 ,并不能提高性能,因為只要一直有大量的重疊接發請求被拋出,就不會有額外的內存拷貝。設置套接字發送緩沖區為空比設置套接字接收緩沖區為空對系統的性能影響要小。因為應用程序的發送緩沖區會被經常鎖定直到它被提交給傳輸層處理。然而,若將接收緩沖區設置為 0 ,并且沒有重疊的 receive 調用,任何傳進來的數據只能緩存在傳輸層。傳輸層驅動程序只會緩存滑動窗口尺寸的數據,即 17KB— 傳輸層可以分配的緩沖區大小的上限。實際的緩沖區要比 17KB 小。傳輸層緩沖區(針對一次連接)是在非分頁池之外分配的,這意味著,當服務建立了 1000 個連接時,即使沒有拋出 receive 請求,非分頁池中也會分配 17MB 的內存。而非分頁池是很珍貴的資源,除非服務器可以保證總是有接收請求拋出,否則套接字接收緩沖區應該不需設置。

只有在一些特殊情況下,對套接字接收緩沖區不予設置將會導致性能降低。考慮服務器需要處理成千上萬個客戶連接,而每個連接上又都沒有投遞 receive 請求的情況,如果客戶端零星地發送數據過來,傳輸進來的數據將被緩存在套接字接收緩沖區中。當服務器處理一個 receive 重疊 I/O 時,它會做一些不必要的工作。當完成通知到達時,重疊操作會處理一個 I/O 請求包( IRP )。在這種情形下,服務器不能保留很多拋出的 receive 請求。因此,最好使用簡單的非阻塞接收函數。

6.3 內存資源管理問題

由于機器硬件條件所限,系統資源是有限的,因此不得不考慮內存資源的管理問題。從上一節對不同 I/O 模型進行的性能測試結果分析可知,維持大規模的通信連接,不僅會耗費掉大量內存,而且對 CPU 的占用也是很高的。

對于配置比較高的服務器而言,處理成千上萬個連接并不成問題。但是隨著連接量的劇增,內存資源的限制將逐漸凸現。最有可能遇到的兩個限制因素就是鎖定頁和非分頁池。鎖定頁的限制不是太嚴重,更應該避免的是非分頁池被耗盡。每一次調用重疊的 send 或 receive 請求,提交的緩沖區都可能被鎖住。當內存被鎖定時,它就不能從物理內存換出。操作系統對鎖定內存的數量是有限制的,當達到極限時,重疊操作將會返回 WSAENOBUFS 錯誤。如果服務器在每個連接上投遞多個重疊接收操作,隨著客戶連接數量的增多,極限就會達到。如果期望服務器能夠處理高并發通信,服務器可以在每個連接上投遞一個 0 字節的接受操作,這樣就不會有內存鎖定。 0 字節的接受完成以后,服務器可以簡單地執行一個非阻塞的接收函數來獲取緩存在套接字接收緩沖區中的所有數據。當非阻塞接收調用返回 WSAEWOULDBLOCK 時,就表示不再有未決的數據了。這種方法非常適合用來設計那些希望通過犧牲每個套接字上的吞吐率來獲取更大規模并發連接的服務器。

當然,最好還要了解客戶端與服務器通信的方式。在上面的例子中,當 0 字節的接收完成后,再投遞一個異步接收操作,將接收到所有緩存在套接字接收緩沖區中的數據。如果服務器知道客戶端將會連續不斷發送數據,那么當 0 字節的接收完成后,假如客戶端將發送大數據塊(超過單套接字緩沖區 8KB 的容量)過來,服務器將拋出一個或多個重疊的接收操作。

另外一個需要重點考慮的問題就是系統所需頁的數量。當系統鎖定傳遞給重疊操作的內存時,它是在頁邊界上進行的。在 x86 體系結構上,內存頁的大小為 4KB 。如果一個操作投遞了 1KB 的緩沖區,系統實際上會為它鎖定 4KB 大小的內存塊。為避免這種浪費,重疊發送和接收緩沖區的大小應該是頁大小的倍數。可以使用 GetSystemInfo 這個 API 來獲知當前系統頁的大小。

如果突破非分頁池極限,將會導致更嚴重的錯誤,并且很難恢復。非分頁池是內存的一部分,它常駐內存,并且永遠不會被交換出去。內核模式的系統組件,如驅動程序,通常使用非分頁池,其中包括 Winsock 和協議驅動程序,例如 tcpip.sys 。每個套接字的創建將消耗一小部分非分頁池,用于維持套接字狀態信息。當套接字綁定到一個地址后, TCP/IP 堆棧將分配額外的非分頁池來保存本地地址的信息。當一個對等套接字接入后, TCP/IP 堆棧也將分配部分非分頁池來保存遠程地址信息。基本上,一個建立連接的套接字占用 2KB 非分頁池內存 , 而 accept 或 AcceptEx 返回的套接字則占用 1.5KB 非分頁池內存。之所以出現這個區別,是因為服務器本地地址信息已經存儲在監聽套接字中,故 accept 或 AcceptEx 返回的套接字只需保存遠程主機地址信息。此外,每個在套接字上投遞的重疊操作都需要給 I/O 請求包( IRP )分配內存,一個 IRP 使用大約 500B 非分頁池內存。

從以上分析可以看出,為每個連接分配的非分頁池內存并不是很大。然而,隨著客戶連接量逐增,服務器對非分頁池的使用將是非常大的。考慮運行在只有 1GB 物理內存的 Windows 2000 或以后版本 Windows 系統上的服務器,將有 256MB 的內存非配給非分頁池。通常,非分頁池大小是機器物理內存的 1/4 , Windows 2000 及以后版本的 Windows 系統上,非分頁池大小為 256MB ( /1GB ),而 Windows NT 4.0 限制為 128MB ( 1GB )。擁有 256MB 的非分頁池的服務器可以支持 50,000 或更大的連接量。但是必須限制重疊的 accept 數量,以及在已經建立連接的重疊收發操作。在這個例子中,如果已經建立連接的套接字,按每個 1.5KB 計算,將耗費 75MB 的非分頁池內存。如果采用了上面提及的投遞 0 字節接收的方法,這樣為每個連接分配的 IRP 將占用 25MB 的非分頁池內存。

如果系統耗盡了非分頁池,會有兩種可能的后果。在最好的情況下, Winsock 調用將返回 WSAENOBUFS 錯誤。最糟糕的情況是系統崩潰,這種情況通常是系統沒能正確處理內存非配的問題造成的。沒有一種可行的方案能夠恢復非分頁池耗盡的錯誤,并且也沒有可行的方案來監視非分頁池可分配的大小,因為非分頁池耗盡導致系統崩潰。

由以上探討,可以得出結論,沒有一種方法可以確定服務器到底支持多大的并發連接和重疊操作,并且也不可能準確地獲知非分頁池是否耗盡或者鎖定內存頁數超過極限。因為它們都將導致 Winsock 調用都返回相同的錯誤 —WSAENOBUFS 。因為以上因素,針對服務器的測試必須測試不同數量的連接情況以及重疊操作完成情況,以便在并發通信規模和數據吞吐率這兩個指標之間選擇一種折中的方案。如果在方案中強加限制,以防止服務器耗盡非分頁池,則返回 WSAENOBUFS 錯誤時,我們就知道是因為超過了鎖定頁的限制。并且可以以一種更優化的處理方式編寫程序,如進一步限制一些待決的操作或關閉某些連接。

包重新排序問題

這個問題與伸縮性沒有多大關聯,但是卻是實際通信中不得不考慮的一個問題,因為它涉及到能否正確通信的問題。

雖然使用完成端口的 I/O 操作總是會按照它們被提交的順序完成,但是線程調度問題可能會導致關聯到完成端口上的工作不能按正常順序完成。例如,有兩個 I/O 工作線程,應該接收 “ 字節塊 1 ,字節塊 2 ,字節塊 3” ,但是你可能以錯誤順序接收這 3 個字節塊: “ 字節塊 2 ,字節塊 1 ,字節塊 3” 。這也意味著在完成端口上投遞發送請求發送數據時,數據實際也會以錯誤順序被發送出去。

當然,如果只使用一個工作線程,僅提交一個 I/O 調用,是不存在順序問題的。因為同一時刻,一個工作線程只能處理一個 I/O 操作。但是,這樣就沒有發揮出完成端口的真正優點。

如第 3 章《自定義應用層通信協議》所述,一個簡單的解決方法就是為每個封包添加一個協議頭。協議頭主要是一個封包的實際字節數,如自定義 Package 包的第一個字段 m_nCmdLen 就是這個包占用的字節數。通信的接受方通過分析協議頭分析本次通信有多少數據要接收,然后繼續讀后面的數據,直到一個封包被完整接收完才接收下一個封包。

當服務器一次僅做一個異步調用時,上述封包協議頭的解決方案是很有效的。但是,如果要充分發揮 IOCP 服務器的潛力,肯定有多個未決的異步讀操作等待數據的到來。這意味著,多個一步操作不能按順序完成,未決讀 I/O 返回的字節流不能按順序處理,接收到的字節流可能組合成正確的封包,也有可能組合成錯誤的封包。因此,要解決這個問題,還必須為提交的讀 I/O 分配序列號。

?

說明:

本文主要譯自《 Network programming for microsoft windows 》一書的 6.2 節《可伸縮的服務器體系結構》和 6.3 節《資源管理》。

其中包重新排序問題,參考 王艷平著 《 Windows 網絡與通信程序設計 》 4.3.4 節《包重新排序問題》 。