IOCP編程小結(上)


前段時間接手了一個網絡游戲前端連接服務器的開發工作,由於服務器需要在windows平台上部署,並且需要處理大量的客戶端連接,因此采用IOCP來做為服務器端的編程模型就成了不二選擇。雖然我對服務器開發並不陌生,但我一直以來對IOCP抱着不屑一顧的態度,感覺這個編程模型太過復雜,並不是一個良好的系統設計,所以一直沒有用過。這回重新拿起來研究了一下,經過一個多月的研究和開發,目前服務器已經基本完成,即將着手進行壓力測試。在研究的過程中,從網絡上搜索了IOCP的相關文章和教程不下幾十篇,開源的IOCP服務器框架也看了好幾個,但都差強人意,在一些教程和文章中也存在諸多誤導和不足,所以寫下這組文章來談一下我自己的開發心得。

 

第一篇主要談一些原理

 

高性能服務器的設計原則

在很多編程論壇里經常會看到有人討論如何開發高性能服務器的問題,但是初學者往往會把精力糾結到API的使用上,錯誤的認為使用了一些高級的API就意味着高性能,屬於只見樹木不見森林。以下是我認為高性能服務器設計應該遵循的一些基本原則:

1. 有明確的服務器性能設計目標

在不同應用場合中的服務器對性能的需求是不一樣的,有些需要處理大量的並發連接,有些追求高實時性(低延遲),有些則追求高吞吐量,有些要求大量的IO操作而有些則需要大量的CPU計算。所謂的高性能服務器設計就在於針對具體的性能要求給出專門的設計方案,而通用的適用於普遍場合的服務器設計那就不叫高性能了,因此在設計你的服務器之前搞清楚你的性能設計目標是非常重要的,這將指導你做出正確的選擇。

 

2. 合理的估算和分配服務器資源

服務器的資源包括:網絡帶寬、包吞吐量、CPU資源、內存資源等等。在任何時候服務器的資源都是有限的,制約性能的唯一因素就在於資源的瓶頸,而要把性能最大的發揮出來就需要找出資源的瓶頸,並進行合理的分配和優化。這里舉一個簡單的例子:對於TCP連接來說,雖然它是抽象為數據流協議的,但是在底層實現上是依賴於IP數據報協議,因此在估算服務器能處理的最大字節吞吐量的時候就不能簡單的以網絡帶寬數據來估算,而是要根據IP包吞吐量 * 每TCP包大小來進行估算,在實際中還涉及到RTT(平均延遲時間)及TCP滑動窗口大小,nagle算法的采用等等因素,如果你每次TCP包的發送大小只有幾十字節的話,那么是遠遠達不到實際的理論帶寬的,如果你的服務器是以字節吞吐量為設計目標的話,那么就需要想辦法增加每個TCP包的發送大小。

 

3. 避免不必要的浪費

所謂高性能是節省出來的,這是一句真理。幾乎所有的程序員都是理性的,沒有人會去刻意或者毫無道理的浪費系統資源。但往往我們會在不知不覺中浪費系統資源,這主要源於我們的無知。由於編程語言、接口、庫及框架將底層的細節抽象了,所以當我們只停留在這些抽象層次上,就很難認識到抽象背后隱藏的東西,在不知不覺中浪費了系統資源。具體來說,每一個系統API的調用在程序上看只是一句函數調用而已,但是每個API背后的開銷則是大不相同的,先來看一個簡單的例子:

在一個TCP數據包的構造中通常我們需要先發送一個頭(里面可能只是簡單的標識一下這個包的長度),然后再發送實際的內容,見代碼:

send(socket, &packet_size, 4);
send(socket, packet, packet_size);

 

這樣看上去雖然只是2個簡單的不起眼的API調用,但實際上卻會造成很大的開銷,send本身是一個昂貴的系統級調用,需要占用大量的CPU時間(send的調用需要幾到幾十個us),同時第一send可能會導致底層構造並發送一個只帶有4字節內容的TCP包,而一個TCP頭就需要40字節,這嚴重的降低了網絡利用率。所以,如果我們把這兩段數據拷貝到一個數據緩沖區並調用一次send發送的話,性能就會大大提升,這個例子同時暗示我們:如果有機會可以合並更多小數據包並一次性調用send操作的話,那么性能將會有很大的提升。 

其他方面的例子也很多,例如線程切換的開銷,cache missing的開銷,cache一致性的開銷,鎖的開銷等等。避免浪費聽上去是一句簡單的廢話,但實際上告訴我們的是需要深入的了解抽象背后的細節。

4. 在延遲和吞吐量上做權衡

通訊的延遲和吞吐量往往是矛盾的,我們可以通過一個簡單的類比來解釋這個道理:考慮一個郵差從A點送信到B點,假設用戶每隔2分鍾向A點的郵箱中投遞一封郵件,郵差從A點的郵箱中取出信件后趕往B地,路上需要10分鍾時間,然后將信件放到B的分發點后返回A點,忽略郵差取信和發信所消耗的時間,如此循環往復。 在這個例子里,用戶的郵件送達B點的延遲最壞需要20分鍾最好則需要10分鍾,郵差一個來回需要20分鍾平均可以送10封信,因此郵差一個來回的開銷可以達到的吞吐量為10。接着,我們改變一下條件:讓郵差在返回A點后等待10分鍾后再向B出發,於是郵件送達B點的延遲變為最壞30分鍾最好10分鍾,現在郵差一個來回可以送15封信,吞吐量變大了。在網絡通訊中,數據包的構造、傳送和接收是一個有很大開銷的操作,只有盡可能多的在一次傳輸中傳送更多的數據才能提高吞吐量,在實際的測試中一次TCP發送的數據量至少需要超過1KB,才能接近理論數據吞吐量。但在實際中,一次用戶數據的發送量往往很小(這取決於應用的類型),如果人為的加上一定等待和緩沖,就可以達到以時間換空間的效果。

 

5. 要為最壞和滿負載情況做設計 

“穩定壓倒一切”,對於服務器來說是一句至理名言。服務器的資源是有限的,所能承載的最大負載必然是有限的。正如前段時間杯具的12306鐵路網絡售票系統,想必很多人都深有體會(可惜我從來沒有體會過春運)。在服務器超負荷運行中最杯具的就是稱之為"雪崩效應"的一類問題,當負載達到一個臨界點后服務器性能急轉直下,使得正常的服務也無法進行甚至直接宕機。因此,作為一個有職業素養的服務器端程序員(非臨時工和無證程序員),在設計中必須對各種最壞情況要有預計,並通過前期設計及后期的壓力測試來確定服務器所能達到的滿負載指標,對超負載情況要有保護措施(拒絕服務新的連接以保證服務器的安全),當然在實際的運營中還需要為服務器保留一定的安全邊界以防止各種顛簸釀成杯具。

IOCP編程模型的優缺點

IOCP是一種典型的異步IO設計范式,簡單的說就是當發起一個IO操作后,不等待操作結束就立刻返回,IO操作的結果在另外一個隊列上得到通知並回調。異步IO本身並不是一種高級的東西。相反的,在操作系統底層,所有的IO請求都是異步發起的,然后通過中斷處理對結果進行處理,中斷是一種在硬件層面上的回調機制。因為異步IO在編程上相當困難,特別是對於那些不具備高級特性的語言來說。所以在設計操作系統時,設計師會將這些底層IO的復雜性封裝起來,抽象成容易使用的同步IO調用供上層使用。同步IO的意思是IO操作發起后,調用會等待IO操作結束才返回結果(阻塞模式),或者當IO不能立刻完成時返回錯誤(非阻塞模式),同時再提供一種查詢機制(Select模式),告訴用戶當前的IO可執行狀態 ,通常我們會用Reactor模式將Select封裝起來,將用戶主動查詢變成事件回調(這不影響Select查詢的本質)。而IOCP則將底層的IO復雜性暴露出來,還原出IO異步性的本質,這實際上是一種抽象的倒退,因此IOCP是一種復雜的編程模型。

在連接數少的情況下,Select和IOCP沒有明顯的性能差異,一次IO操作都是2次系統調用,Select是先查詢再發起IO請求,IOCP是先發起IO請求再接收通知。但是Select方式在處理大量非活動連接時是比較低效的,因為每次Select需要對所有的Socket狀態進行查詢,而對非活動的Socket查詢是沒有意義的浪費,另外由於Socket句柄不能設置用戶私有數據,當查詢返回Socket句柄時還需要一個額外的查詢來找到關聯的用戶對象,這兩點是Select低效的關鍵。在搞明白低效的原因之后只要接口稍作改進就可以對此進行優化,Linux下的epoll模型就是對此的一種改進,epoll的改進在於:1. 不再對Socket狀態做查詢,而是對Socket事件做查詢,避免了無用的Socket狀態檢查 2. 在事件對象里可以設置用戶私有數據,避免了從Socket句柄到用戶對象的查詢。這兩點改進使得epoll完全克服了Select模式在大量非活動連接時的低效問題,同時保持了同步IO容易編程的優點,將Select改成epoll是非常方便的。

由於IOCP不需要去檢查Socket的狀態,同樣可以解決Select的低效問題,但代價是程序員不得不面對異步IO的復雜性,使得程序難寫難用,這是IOCP不如epoll優雅的地方。如果windows上有epoll模式的話,那么大部分情況下將會是比IOCP更好的選擇。但是IOCP的這個缺點同時也是他的優點,因為他暴露出了更多的底層細節,讓我們有機會做更多的微調和性能優化。另外一個好消息是,IO處理畢竟只是一個小規模的問題,我的前端服務器只用了大約5000行的C++代碼(沒有使用第三方庫和框架),而真正涉及到IO的代碼應該只占1000行左右,因此只要不厭其煩耐心實現,IOCP也不算那么糟糕。

 

 待續...


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM