1. 程式人生 > >【原創】IOCP程式設計之聚集散播

【原創】IOCP程式設計之聚集散播

做為IOCP應用中重要的一個方法就是被稱為“聚集-散播”的方法。非常遺憾的是在很多介紹IOCP使用的資料中,我幾乎沒有見過有專門介紹此方法的文章,因此本文就重點講述此方法。

在使用IOCP操作大量的TCP連線並處理IO請求的時候,一個很讓我們頭疼的事情就是所謂的“粘包”問題,即當傳送方傳送的資料包尺寸小於接收方緩衝,同時又連續傳送資料的情況下,兩個資料包被一起接收,接收端就需要將包重新拆分,如果遇到第二個包不完整的情況處理起來就更麻煩,線上程池的環境下,這還需要考慮多執行緒同步以保證資料一致性的問題。當然在我的系列文章中以及本人的網路課程中都提示過一個方法就是使用阻塞式的recv操作呼叫,將一個tcp-socket中的資料都接收完,但是這個方法其實面臨著巨大的風險,試想如果是一個惡意的傳送端,不停的傳送尺寸非常小的資料包時,接收端就不得不使用一個活動的執行緒不斷的接收這些資料,從而佔用執行緒池的執行緒資源,造成接收端的癱瘓。

另一方面在傳送端,當我們需要傳送多個不同記憶體位置的資料時,我們必須要提供一個“封包”機制將不同的資料包memcpy進一個一致連續的記憶體塊,然後一次性提交發送,對於一個高吞吐量設計同時利用了多執行緒或執行緒池技術的傳送端來說,這中間的複雜性,以及效能浪費是相當可觀的。

綜上其實質就是頭疼的“記憶體連續性操作”問題(至少我這麼定義這個問題),在多執行緒/執行緒池環境下操作記憶體的複雜度是很高的,搞過此類問題的同學肯定深有體會,甚至有些初學者有可能直接被這個看似簡單的問題搞得焦頭爛額。當然抱怨通常不是解決問題的方法,唯有認真學習、分析和解決問題才是最終道路。

在使用IOCP的過程中,我們關注的肯定是IOCP的高效能、高併發特性,在使用C++操作IOCP的過程中,自然而然還需要關注額外的記憶體管理問題,傳送和接收緩衝區的合併/拆分都需要我們付出額外的代價,稍不留神併發一致性問題、記憶體洩漏、非法訪問等等問題都會爆發。實際在IOCP中為了降低合併/拆分記憶體的複雜度,便提供了一個重要的特性——“聚集-散播”操作。

為了理解“聚集-散播”操作的原理,讓我們繼續想像一個使用場景,先從傳送端來思考,當我們需要傳送多個數據時,為了提高效率,有些設計中我們往往將過小的資料包合併成一個大包來發送,或者乾脆就是每個小包都呼叫一次send傳送,由SOCKET底層去考慮合併與拆分的問題。在這種情況下,其實有兩個問題,第一個就是小的資料塊需要合併成一個大的資料塊,然後一次性發送,這中間就有大量的低效memcpy操作,如果資料塊來自不同的執行緒,還需要考慮多執行緒同步問題;另一個問題就是send的呼叫問題,如果是多執行緒環境時send的先後順序是無法保證的,從而導致資料先後次序的錯亂,對於順序很重要的資料來說這是個大問題,比如在網遊中,你不能先發送玩家game over了,才傳送玩家接受到攻擊的資料。

本質上這個問題就是說,我們既要保證小塊資料的一致性,又想避免小塊資料拼裝成大包的低效memcpy操作。從另一個方面來說,其實如果你懂的網絡卡驅動底層的操作的話,還可以想到實際在驅動層面也有一個類似的memcpy將資料從記憶體中複製到到網絡卡的傳送快取中。這樣細想下來,實際上一個傳送資料操作本身中就伴隨著兩個(也可能是多個)重複的memcpy操作,那麼有沒有辦法讓第二個memcpy同時完成第一個memcpy操作的工作呢?

實際上在IOCP中“聚集”操作就是來做這個操作,首先讓我們來看一下WSASend方法的原型:

int WSASend(
    _In_  SOCKET                             s,
    _In_  LPWSABUF                           lpBuffers,
    _In_  DWORD                              dwBufferCount,
    _Out_ LPDWORD                            lpNumberOfBytesSent,
    _In_  DWORD                              dwFlags,
    _In_  LPWSAOVERLAPPED                    lpOverlapped,
    _In_  LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine
);

關於這個函式幹什麼的我就不囉嗦了,這裡我們重點觀察第二個引數和第三個引數,如果你是個C/C++的老程,通過引數名稱你就應該明白這其實說明第二個引數是一個WSABUF型別的陣列,第三個引數就是陣列元素個數。而WSABUF結構的原型是這樣的:

typedef struct __WSABUF {
    u_long   len;
    char FAR *buf;
} WSABUF, *LPWSABUF;

冰雪聰明的你,應該已經反應過來,實際上我們可以宣告一個WSABUF型別的陣列,再將所有的待發送的緩衝一個個按順序放到這個陣列的每個元素中,然後只需要一次呼叫WSASend方法,那麼Windows系統內部就會按照你定義的WSABUF陣列的順序傳送這些資料,並且會在底層(驅動層那個memcpy)時拼接成一個完整的包(連續的記憶體塊)。這樣一來對於一個拼包操作(一大堆alloc、memcpy等操作),就被一個數組賦值替代了,如果你還不明白這對效率提升有什麼意義,那麼你就需要好好的回爐再學學記憶體管理的基礎知識了。ok,整體的可以像下面這樣傳送一個完整協議的包:

......
WSABUF wbPacket[2] = {};

wbPacket[0].buf = lpHead;
wbPacket[0].len = sizeof(ST_HEAD);
wbPacket[1].buf = lpBody;
wbPacket[1].len = lBodyLen;
......
DWORD dwSent = 0;
int iRet = WSASend( sock2Server , wbPacket , 2 , &dwSent , 0 , NULL , NULL );
......

其實看到這裡,你應該有一種恍然大悟的感覺,並且一定會說,原來還可以這麼玩!仔細看看上面的程式碼並且一琢磨,你立刻就會發現,原來需要alloc一塊記憶體,並且memcpy lpHead和lpBody進Buffer的操作真的不見了,一個數組就搞定了。

         同樣對於接收端來說,WSARecv也有類似的引數,原型如下:

int WSARecv(
    _In_    SOCKET                             s,
    _Inout_ LPWSABUF                           lpBuffers,
    _In_    DWORD                              dwBufferCount,
    _Out_   LPDWORD                            lpNumberOfBytesRecvd,
    _Inout_ LPDWORD                            lpFlags,
    _In_    LPWSAOVERLAPPED                    lpOverlapped,
    _In_    LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine
);

同樣我們可以像下面這樣一次性接收並同時完成“拆包”操作,也就是“散播”操作:

......
WSABUF wbPacket[2] = {};

wbPacket[0].buf = lpHead;
wbPacket[0].len = sizeof(ST_HEAD);
wbPacket[1].buf = lpBody;
wbPacket[1].len = lBodyLen;
......
DWORD dwRecv = 0;
int iRet = WSARecv( sock2Server , wbPacket , 2 , &dwRecv , 0 , NULL , NULL );
......

當然實際中操作需要比上面這兩個例子複雜的多,但是本質上都是減少了不必要的alloc及memcpy等記憶體操作,從根本上提高了收發資料的效率。這時其實我最想的就是,假如WriteFile和ReadFile也能如此使用,那畫面該有多美~~~

    至此,IOCP系列的文章算完整的告一個段落了,這篇文章實際來的有點太晚了,居然過了好幾年,才將這最後一篇寫完,深感愧疚!這也是多年來我的老毛病,很多時候都在深度學習,產出太少,實際有很多經驗體會都不能及時成文分享給大家,請大家諒解。同時以後我將及時改正這個陋習,更加勤奮的為大家分享我的心得和經驗。也謝謝各位網友對我部落格的長期關注,在此鞠躬!