非阻塞模式WinSock程式設計入門 使用 WSAAsyncSelect模型
非阻塞模式WinSock程式設計入門
介紹
WinSock是Windows提供的包含了一系列網路程式設計介面的套接字程式庫。在這篇文章中,我們將介紹如何把它的非阻塞模式引入到應用程式中。文章中所討論的通訊均為面向連線的通訊(TCP),為清晰起見,文章對程式碼中的一些細枝末節進行了刪減,大家可以依照文末的連結下載完整的工程原始碼來獲取這部分內容。
阻塞模式WinSock
下述虛擬碼給出了阻塞模式下WinSock的使用方式。
[cpp] view plaincopyprint?- //---------------------------------------
- // 伺服器
- //----------------------------------------
- // WinSock初始化
- WSAStartup();
- // 建立伺服器套接字
- SOCKET server = socket();
- // 繫結到本機埠
- bind(server);
- // 開始監聽
- listen(server);
- // 接收到客戶端連線,分配一個客戶端套接字
- SOCKET client = accept(server);
- // 使用新分配的客戶端套接字進行訊息收發
- send(client);
- recv(client);
- // 關閉客戶端套接字
- closesocket(client);
- // 關閉伺服器套接字
- closesocket(server);
- // 解除安裝WinSock
- WSACleanup();
- //---------------------------------------
- // 客戶端
- //---------------------------------------
- WSAStartup();
- // 建立客戶端套接字
- SOCKET client = socket();
- // 繫結本機埠
- bind(client);
- // 連線到伺服器
- ServerAddress server;
- connect(client, server);
- // 確立連線後收發訊息
- recv(client);
- send(client);
- // 關閉客戶端套接字
- closesocket(client);
- WSACleanup();
程式碼中,伺服器端的accept(),客戶端的connect(),以及伺服器和客戶端中共同的recv()、send()函式均會產生阻塞。
伺服器在呼叫accept()後不會返回,直到接收到客戶端的連線請求;
客戶端在呼叫connect()後不會返回,直到對伺服器連線成功或者失敗;
伺服器和客戶端在呼叫recv()後不會返回,直到接收到並讀取完一條訊息;
伺服器和客戶端在呼叫send()後不會返回,直到傳送完待發送的訊息。
如果這兩段程式碼被放在Windows程式的主執行緒中,你會發現訊息迴圈被阻塞,程式不再響應使用者輸入及重繪請求。為了解決這個問題,你可能會想到開闢另外一個執行緒來執行這些程式碼。這是可行的,但是考慮到每個SOCKET都不應該被其他SOCKET的操作所阻塞,是不是需要為每個SOCKET開闢一個執行緒?再考慮到同一SOCKET的一個讀寫操作也不應該被另外一個讀寫操作所阻塞,是不是應該再為每個SOCKET的讀和寫分別開闢一個執行緒?一般來說,這種自實現的多執行緒解決方案帶來的諸多執行緒管理方面的問題,是你絕對不會想要遇到的。
非阻塞模式WinSock
所幸的是,WinSock同時提供了非阻塞模式,並提出了幾種I/O模型。最常見的I/O模型有select模型、WSAAsyncSelect模型及WSAEventSelect模型,下面選擇其中的WSAAsyncSelect模型進行介紹。
使用WSAAsyncSelect模型將非阻塞模式引入到應用程式中的過程看起來很簡單,事實上你只需要多新增一個函式就夠了。
int WSAAsyncSelect(SOCKET s, HWND hWnd, unsigned int wMsg, long lEvent);
該函式會自動將套接字設定為非阻塞模式,並且把發生在該套接字上且是你所感興趣的事件,以Windows訊息的形式傳送到指定的視窗,你需要做的就是在傳統的訊息處理函式中處理這些事件。引數hWnd表示指定接受訊息的視窗控制代碼;引數wMsg表示訊息碼值(這意味著你需要自定義一個Windows訊息碼);引數IEvent表示你希望接受的網路事件的集合,它可以是如下值的任意組合:
FD_READ, FD_WRITE, FD_OOB, FD_ACCEPT, FD_CONNECT, FD_CLOSE
之後,就可以在我們熟知的Windows訊息處理函式中處理這些事件。如果在某一套接字s上發生了一個已命名的網路事件,應用程式視窗hWnd會接收到訊息wMsg。引數wParam即為該事件相關的套接字s;引數lParam的低欄位指明瞭發生的網路事件,lParam的高欄位則含有一個錯誤碼,事件和錯誤碼可以通過下面的巨集從lParam中取出:
#define WSAGETSELECTEVENT(lParam) LOWORD(lParam)
#define WSAGETSELECTERROR(lParam) HIWORD(lParam)
下面繼續使用虛擬碼來幫助闡述如何將上一節的阻塞模式WinSock應用升級到非阻塞模式。
首先自定義一個Windows訊息碼,用於標識我們的網路訊息。
[cpp] view plaincopyprint?- #define WM_CUSTOM_NETWORK_MSG (WM_USER + 100)
伺服器端,在監聽之前,將監聽套接字置為非阻塞模式,並且標明其感興趣的事件為FD_ACCEPT。
[cpp] view plaincopyprint?- …
- WSAAsyncSelect(server, wnd, WM_CUSTOM_NETWORK_MSG, FD_ACCEPT);
- // 開始監聽
- listen(server);
客戶端,在連線之前,將套接字置為非阻塞模式,並標明其感興趣的事件為FD_CONNECT。
[cpp] view plaincopyprint?- …
- WSAAsyncSelect(client, wnd, WM_CUSTOM_NETWORK_MSG, FD_CONNECT);
- // 連線到伺服器
- ServerAddress server;
- connect(client, server);
接著,在Windows訊息處理函式中,我們將處理監聽事件、連線事件、及讀寫事件,方便起見,這裡將伺服器和客戶端的處理程式碼放在了一起。
[cpp] view plaincopyprint?- LRESULT CALLBACK WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam)
- {
- switch (message)
- {
- …
- case WM_CUSTOM_NETWORK_MSG: // 自定義的網路訊息碼
- {
- SOCKET socket = (SOCKET)wParam; // 發生網路事件的套接字
- long event = WSAGETSELECTEVENT(lParam); // 事件
- int error = WSAGETSELECTERROR(lParam); // 錯誤碼
- switch (event)
- {
- case FD_ACCEPT: // 伺服器收到新客戶端的連線請求
- {
- // 接收到客戶端連線,分配一個客戶端套接字
- SOCKET client = accept(socket);
- // 將新分配的客戶端套接字置為非阻塞模式,並標明其感興趣的事件為讀、寫及關閉
- WSAAsyncSelect(client, hWnd, message, FD_READ | FD_WRITE | FD_CLOSE);
- }
- break;
- case FD_CONNECT: // 客戶端連線到伺服器的操作返回結果
- {
- // 成功連線到伺服器,將客戶端套接字置為非阻塞模式,並標明其感興趣的事件為讀、寫及關閉
- WSAAsyncSelect(socket, hWnd, message, FD_READ | FD_WRITE | FD_CLOSE);
- }
- break;
- case FD_READ: // 收到網路包,需要讀取
- {
- // 使用套接字讀取網路包
- recv(socket);
- }
- break;
- case FD_WRITE:
- {
- // FD_WRITE的處理後面會具體討論
- }
- break;
- case FD_CLOSE: // 套接字的連線方(而非本地socket)關閉訊息
- {
- }
- break;
- default:
- break;
- }
- }
- break;
- …
- }
- …
- }
以上就是非阻塞模式WinSock的應用框架,WSAAsyncSelect模型將套接字和Windows訊息機制很好地粘合在一起,為使用者非同步SOCKET應用提供了一種較優雅的解決方案。
擴充套件討論
WinSock在系統底層為套接字收發網路資料各提供一個緩衝區,接收到的網路資料會快取在這裡等待應用程式讀取,待發送的網路資料也會先寫進這裡之後通過網路傳送。
相關的,針對FD_READ和FD_WRITE事件的讀寫處理,因涉及的內容稍微複雜而容易使人困惑,這裡需要特別進行討論。
在FD_READ事件中,使用recv()函式讀取網路包資料時,由於事先並不知道完整網路包的大小,所以需要多次讀取直到讀完整個緩衝區。這就需要類似如下程式碼的呼叫:
[cpp] view plaincopyprint?- void* buf = 0;
- int size = 0;
- while (true)
- {
- char tmp[128];
- int bytes = recv(socket, tmp, 128, 0);
- if (bytes <= 0)
- break;
- else
- {
- int new_size = size + bytes;
- buf = realloc(buf, new_size);
- memcpy((void*)(((char*)buf) + size), tmp, bytes);
- size = new_size;
- }
- }
- // 此時資料已經從緩衝區全部拷貝到buf中,你可以在這裡對buf做一些操作
- …
- free(buf);
這一切看起來都沒有什麼問題,但是如果程式執行起來,你會收到比預期多出許多的FD_READ事件。如MSDN所述,正常的情況下,應用程式應當為每一個FD_READ訊息僅呼叫一次recv()函式。如果一個應用程式需要在一個FD_READ事件處理中呼叫多次recv(),那麼它將會收到多個FD_READ訊息,因為每次未讀完緩衝區的recv()呼叫,都會重新觸發一個FD_READ訊息。針對這種情況,我們需要在讀取網路包前關閉掉FD_READ訊息通知,讀取完這後再進行恢復,關閉FD_READ訊息的方法很簡單,只需要呼叫WSAAsyncSelect時引數lEvent中FD_READ欄位不予設定即可。
[cpp] view plaincopyprint?- // 關閉FD_READ事件通知
- WSAAsyncSelect(socket, hWnd, message, FD_WRITE | FD_CLOSE);
- // 讀取網路包
- …
- // 再次開啟FD_READ事件通知
- WSAAsyncSelect(socket, hWnd, message, FD_WRITE | FD_CLOSE | FD_READ);
第二個需要討論的是FD_WRITE事件。這個事件指明緩衝區已經準備就緒,有了多出的空位可以讓應用程式寫入資料以供傳送。該事件僅在兩種情況下被觸發:
1. 套接字剛建立連線時,表明準備就緒可以立即傳送資料。
2. 一次失敗的send()呼叫後緩衝區再次可用時。如果系統緩衝區已經被填滿,那麼此時呼叫send()傳送資料,將返回SOCKET_ERROR,使用WSAGetLastError()會得到錯誤碼WSAEWOULDBLOCK表明被阻塞。這種情況下當緩衝區重新整理出可用空間後,會嚮應用程式傳送FD_WRITE訊息,示意其可以繼續傳送資料了。
所以說收到FD_WRITE訊息並不單純地等同於這是使用send()的唯一時機。一般來說,如果需要傳送訊息,直接呼叫send()傳送即可。如果該次呼叫返回值為SOCKET_ERROR且WSAGetLastError()得到錯誤碼WSAEWOULDBLOCK,這意味著緩衝區已滿暫時無法傳送,此刻我們需要將待發資料儲存起來,等到系統發出FD_WRITE訊息後嘗試重新發送。也就是說,你需要針對FD_WRITE構建一套資料重發的機制,文末的工程原始碼裡包含有這套機制以供大家參考,這裡不再贅述。
結語
至此,如何在非阻塞模式下使用WinSock進行程式設計介紹完畢,這個框架可以滿足大多數網路遊戲客戶端及部分伺服器的通訊需求。更多應用層面上的問題(如TCP粘包等)這裡沒有討論,或許會在以後的文章中給出。
文章相關工程原始碼請移步此處下載http://download.csdn.net/source/2852485。該原始碼展示了採用非阻塞模式程式設計的伺服器和客戶端,建立連線後,在伺服器視窗輸入空格會向所有客戶端傳送一條字串訊息。原始碼中對網路通訊部分做了簡單封裝,所以程式碼結構會和文中的虛擬碼稍有不同。
謝謝您的閱讀!