天天看點

IOCP程式設計小結(中)

上一篇主要談了一些基本理念,本篇将談談我個人總結的一些IOCP程式設計技巧。

網絡遊戲前端伺服器的需求和設計

  首先介紹一下這個伺服器的技術背景。在分布式網絡遊戲伺服器中,前端連接配接伺服器是一種很常見的設計。他的職責主要有:

  1. 為用戶端和後端的遊戲邏輯伺服器提供一個軟體路由 —— 用戶端一旦和前端伺服器建立TCP連接配接以後就可以通過這個連接配接和後端的遊戲伺服器進行通訊,而不再需要和後端的伺服器再建立新的連接配接。

  2. 承擔來自用戶端的IO壓力 —— 一組典型的網絡遊戲伺服器需要服務少則幾千多則上萬(休閑遊戲則可以多達幾十萬)的遊戲用戶端,這個IO處理的負載相當可觀,由一組前端伺服器承載這個IO負擔可以有效的減輕後端伺服器的IO負擔,并且讓後端伺服器也隻需要關心遊戲邏輯的實作,有效的實作IO和業務邏輯的解耦。

  架構如圖:

IOCP程式設計小結(中)

  對于網絡遊戲來說,用戶端與伺服器之間需要進行頻繁的通訊,但是每個資料包的尺寸基本都很小,典型的大小為幾個位元組到幾十個位元組不等,同時使用者上行的資料量要比下行資料量小的多。不同的遊戲類型對延遲的要求不太一樣,FPS類的遊戲希望延遲要小于50ms,MMO類型的100~400ms,一些休閑類的棋牌遊戲1000ms左右的延遲也是可以接受的。是以,網絡遊戲的通訊是以優化延遲的同時又必須兼顧小包的合并以防止網絡擁塞,哪個因素為主則需要根據具體的遊戲類型來決定。

  技術背景就介紹這些,後面介紹的IOCP連接配接伺服器就是以這些需求為設計目标的。

對IOCP伺服器架構的考察

  在動手實作這個連接配接伺服器之前,我首先考察了一些現有的開源IOCP伺服器架構庫,老牌的如ACE,整個庫太多龐大臃腫,代碼也顯老态,無好感。boost.asio據說是個不錯的網絡架構也支援IOCP,我編譯運作了一下他的例子,然後嘗試着閱讀了一下asio的代碼,感覺非常恐怖,完全弄不清楚内部是怎麼實作的,于是放棄。asio秉承了boost一貫的變态作風,将C++的語言技巧淩駕于設計和代碼可讀性之上,這是我非常反對的。其他一些不入流的IOCP架構也看了一些,真是寫的五花八門什麼樣的實作都有,總體感覺下來IOCP确實不太容易把握和抽象,是以才導緻五花八門的實作。最後,還是決定自己重新造輪子。

服務架構的抽象

  任何的伺服器架構從本質上說都是封裝一個事件(Event)消息循環。而應用層隻要向架構注冊事件處理函數,響應事件并進行處理就可以了。一般的同步IO處理架構是先收到IO事件然後再進行IO操作,這類的事件處理架構我們稱之為Reactor。而IOCP的特殊之處在于使用者是先發起IO操作,然後接收IO完成的事件,次序和Reactor是相反的,這類的事件處理架構我們稱之為Proactor。從詞根Re和Pro上,我們也可以容易的了解這兩者的差别。除了網絡IO事件之外,伺服器應該還可以響應Timer事件及使用者自定義事件。架構做的事情就是把這些事件統統放到一個消息隊列裡,然後從隊列中取出事件,調用相應的事件處理函數,如此循環往複。

  IOCP為我們提供了一個系統級的消息隊列(稱之為完成隊列),事件循環就是圍繞着這個完成隊列展開的。在發起IO操作後系統會進行異步處理(如果能立刻處理的話也會直接處理掉),當操作完成後自動向這個隊列投遞一條消息,不管是直接處理還是異步處理,最後總會投遞完成消息。

  順便提一下:這裡存在一個性能優化的機會:當IO操作能夠立刻完成的話,如果讓系統不要再投遞完成消息,那麼就可以減少一次系統調用(這至少可以節省幾個微秒的開銷),做法是調用SetFileCompletionNotificationModes(handle, FILE_SKIP_COMPLETION_PORT_ON_SUCCESS),具體的可以查閱MSDN。

  對于使用者自定義事件可以使用Post來投遞。對于Timer事件,我的做法則是實作一個TimerHeap的資料結構,然後在消息循環中定期檢查這個TimerHeap,對逾時的Timer事件進行排程。

  IOCP完成隊列傳回的消息是一個OVERLAPPED結構體和一個ULONG_PTR complete_key。complete_key是在使用者将Socket handle關聯到IOCP的時候綁定的,其實用性不是很大,而OVERLAPPED結構體則是在使用者發起IO操作的時候設定的,并且OVERLAPPED結構可以由使用者通過繼承的方式來擴充,是以如何用好OVERLAPPED結構在螺絲殼裡做道場,就成了封裝好IOCP的關鍵。

  這裡,我使用了一個C++模闆技巧來擴充OVERLAPPED結構,先看代碼:

IOCP程式設計小結(中)

struct IOCPHandler

{

    virtual void Complete(ULONG_PTR key, DWORD size) = 0;

    virtual void OnError(ULONG_PTR key, DWORD error){}

    virtual void Destroy() = 0;

};

struct Overlapped : public OVERLAPPED

    IOCPHandler* handler;

template<class T>

struct OverlappedWrapper : T

    Overlapped overlap;

    OverlappedWrapper(){

        ZeroMemory(&overlap, sizeof(overlap));

        overlap.handler = this;

    }

    operator OVERLAPPED*(){return &overlap;}

IOCP程式設計小結(中)

  IOCPHandler是使用者對象的接口,使用者擴充這個接口來實作IO完成事件的處理。然後通過一個OverlappedWrapper<T>的模闆類将使用者對象和OVERLAPPED結構封裝成一個對象,T類型就是使用者擴充的對象,由于使用者對象位于OVERLAPPED結構體的前面,是以我們會将OVERLAPPED的指針傳遞給IO操作的API,同時我們在OVERLAPPED結構的後面還放置了一個使用者對象的指針,當GetQueuedCompletionStatus接收到OVERLAPPED結構體指針後,我們通過這個指針就可以找到使用者對象的位置了,然後調用虛函數Complete或者OnError就可以了。

  圖解一下對象結構:

IOCP程式設計小結(中)

在事件循環裡的處理方法 :

IOCP程式設計小結(中)

DWORD size;

ULONG_PTR key;

Overlapped* overlap;

BOOL ret = ::GetQueuedCompletionStatus(_iocp, &size, &key, (LPOVERLAPPED*)&overlap, dt);

if(ret){

    if(overlap == 0){

        OnExit();

        break;

    overlap->handler->Complete(key, size);

    overlap->handler->Destroy();

}

else {

    DWORD err = GetLastError();

    if(err == WAIT_TIMEOUT)

        UpdateTimer();

    else if(overlap) {

        overlap->handler->OnError(key, err);

        overlap->handler->Destroy();

IOCP程式設計小結(中)

   在這裡利用我們利用了C++的多态來擴充OVERLAPPED結構,在架構層完全不用關心接收到的是什麼IO事件,隻需要應用層自己關心就夠了,同時也避免了使用醜陋的難于擴充的switch..case結構。

  對于異步操作來說,最讓人痛苦的事情就是需要把原本順序邏輯的代碼強行拆分成多塊來回調,這使得代碼中原本蘊含的順序邏輯被打散,并且在各個代碼塊裡的上下文變量無法共享,必須另外生成一個對象放置這些上下文變量,而這又引發一個對象生存期管理的問題,對于沒有GC的C++來說尤其痛苦。解決異步邏輯的痛苦之道目前有兩種方案:一種是用coroutine(協作式線程)将異步邏輯變成同步邏輯,在Windows上可以使用Fiber來實作coroutine;另一種方案是使用閉包,閉包原本是函數式語言的特性,在C++裡并沒有,不過幸運的是我們可以通過一個稍微麻煩一點的方法來模拟閉包行為。coroutine在解決異步邏輯方面是最拿手的,特别是一個函數裡需要依次進行多個異步操作的時候尤其強大(在這種情況下閉包也相形見拙),但是另一方面coroutine的實作比較複雜,線程的手工排程常常把人繞暈,對于IOCP這種異步操作比較有限的場景有點殺雞用牛刀的感覺。是以最後我還是決定使用C++來模拟閉包行為。

  這裡示範一個典型的異步IO用法,看代碼: 

IOCP程式設計小結(中)

一個異步發送的例子:

   這個例子中,我們在函數内部定義了一個SendHandler對象,模拟出了一個閉包的行為,我們可以把需要用到的上下文變量放置在SendHandler内,當下次回調的時候就可以通路到這些變量了。本例中,我們在SendHandler裡記了一個cookie,其作用是當異步操作傳回時,可能這個Client對象已經被回收了,這個時候如果再調用EndSend必然會導緻錯誤的結果,是以我們通過cookie來判斷這個Client對象是否是那個異步操作發起時的Client對象。

  使用閉包雖然沒有coroutine那樣漂亮的順序邏輯結構,但是也足夠友善你把各個異步回調代碼串起來,同時在閉包内共享需要用到的上下文變量。另外,最新版的C++标準對閉包有了原生的支援,實作起來會更友善一些,如果你的編譯器足夠新的話可以嘗試使用新的C++特性。

  

IO工作線程 單線程vs多線程

  在絕大多數講解IOCP的文章中都會建議使用多個工作線程來處理IO事件,并且把工作線程數設定為CPU核心數的2倍。根據我的印象,這種說法的出處來自于微軟早期的官方文檔。不過,在我看來這完全是一種誤導。IOCP的設計初衷就是用盡可能少的線程來處理IO事件,是以使用單線程處理本身是沒有問題的,這可以使實作簡化很多。反之,用多線程來處理的話,必須處處小心線程安全的問題,同時也會涉及到加鎖的問題,而不恰當的加鎖反而會使性能急劇下降,甚至不如單線程程式。有些同學可能會認為使用多線程可以發揮多核CPU的優勢,但是目前CPU的速度足夠用來處理IO事件,一般現代CPU的單個核心要處理一塊千兆網卡的IO事件是綽綽有餘的,最多的可以同時處理2塊網卡的IO事件,瓶頸往往在網卡上。如果是想通過多塊網卡提升IO吞吐量的話,我的建議是使用多程序來橫向擴充,多程序不但可以在單台實體伺服器上進行擴充,并且還可以擴充到多台實體伺服器上,其伸縮性要比多線程更強。

   當時微軟提出的這個建議我想主要是考慮到在IO線程中除了IO處理之外還有業務邏輯需要處理,使用多線程可以解決業務邏輯阻塞的問題。但是将業務邏輯放在IO線程裡處理本身不是一種好的設計模式,這沒有很好的做到IO和業務解耦,同時也限制了伺服器的伸縮性。良好的設計應該将IO和業務解耦,使用多程序或者多線程将業務邏輯放在另外的程序或者線程裡進行處理,而IO線程隻需要負責最簡單的IO處理,并将收到的消息轉發到業務邏輯的程序或者線程裡處理就可以了。我的前端連接配接伺服器也是遵循了這種設計方法。

   

關閉發送緩沖區實作自己的nagle算法

  IOCP最大的優勢就是他的靈活性,關閉socket上的發送緩沖區就是一例。很多人認為關閉發送緩沖的價值是可以減少一次記憶體拷貝的開銷,在我看來這隻是撿了一粒芝麻而已。主流的千兆網卡其最大資料吞吐量不過區區120MB/s,而記憶體資料拷貝的吞吐量是10GB/s以上,多一次120MB/s資料拷貝,僅消耗1%的記憶體帶寬,意義非常有限。

  在普通的Socket程式設計中,我們隻有打開nagle算法或者不打開的選擇,政策的選擇和參數的微調是沒有辦法做到的。而當我們關閉發送緩沖之後,每次Send操作一定會等到資料發送到對方的協定棧裡并且收到ACK确認才會傳回完成消息,這就給了我們一個實作自定義的nagle算法的機會。對于網絡遊戲這種需要頻繁發送小資料包,打開nagle算法可以有效的合并發送小資料包以降低網絡IO負擔,但另一方面也加大了延遲,對遊戲性造成不利影響。有了關閉發送緩沖的特性之後,我們就可以自行決定nagle算法的實作細節,在上一個send操作沒有結束之前,我們可以決定是立刻發送新的資料(以降低延遲),還是累積資料等待上一個send結束或者逾時後再發送。更複雜一點的政策是可以讓伺服器容忍多個未結束的send操作,當超出一個門檻值後再累積資料,使得在IO吞吐量和延遲上達到一個合理的平衡。

發送緩沖的配置設定政策

  前面提到了關閉socket的發送緩沖,那麼就涉及到我們自己如何來配置設定發送緩沖的問題。

  一種政策是給每個Socket配置設定一個固定大小的環形緩沖區。這會存在一個問題:當緩沖區内累積的未發送資料加上新發送的資料大小超出了緩沖區的大小,這個時候就會碰上麻煩,要麼阻塞以等待前面的資料發送完畢(但是IO線程不可以阻塞),要麼幹脆直接把Socket關閉,一個妥協的辦法是盡可能把發送緩沖區設定的大一些,但這又會白白浪費很多記憶體。

  另一種政策是讓所有的用戶端socket共享一個非常大的環形緩沖區,假設我們保留一個1G的記憶體區域給這個環形緩沖區,每次需要向用戶端發送資料時就從這個環形緩沖區配置設定記憶體,當緩沖區配置設定到底了再繞到開頭重新配置設定。由于這個緩沖區非常大,1G的記憶體對千兆網卡來說至少需要花費10s才能發送完,并且在實際應用中這個時間會遠超10s。是以當新的資料從頭開始配置設定的時候,老的資料早已經發送掉了,不用擔心将老的資料覆寫,即使碰到網絡阻塞,一個資料包超過10s還未發送掉的話,我們也可以通過逾時判斷主動關閉這個socket。

socket池和對象池的配置設定政策

  允許socket重用是IOCP另一個優勢,我們可以在server啟動時,根據我們對最大服務人數的預計,将所有的socket資源都配置設定好。一般來說每個socket必需對應一個client對象,用來記錄一些用戶端的資訊,這個對象池也可以和socket綁定并預先配置設定好。在服務運作前将所有的大塊對象的記憶體資源都預先配置設定好,用一個FreeList來做對象池的配置設定,在用戶端下線之後再将資源回收到池中。這樣就可以避免在服務運作過程中動态的配置設定大的對象,而一些需要臨時配置設定的小對象(例如OVERLAPPED結構),我們可以使用諸如tcmalloc之類的通用記憶體配置設定器來做,tcmalloc内部使用小對象池算法,其配置設定性能和穩定性非常好,并且他的接口是非侵入式的,我們仍然可以在代碼裡保留malloc/free及new/delete。很多服務在長期運作之後出現運作效率降低,記憶體占用過大等問題,都跟頻繁的配置設定和釋放記憶體導緻出現大量的記憶體碎片有關。是以做好伺服器的記憶體配置設定管理是至關重要的一環。

待續....

下一篇将通過幾個壓力測試和profiling的例子,來分析伺服器的性能和瓶頸所在,請大家關注。

繼續閱讀