1. 程式人生 > >非阻塞模式WinSock程式設計入門 使用 WSAAsyncSelect模型

非阻塞模式WinSock程式設計入門 使用 WSAAsyncSelect模型

非阻塞模式WinSock程式設計入門

介紹

WinSock是Windows提供的包含了一系列網路程式設計介面的套接字程式庫。在這篇文章中,我們將介紹如何把它的非阻塞模式引入到應用程式中。文章中所討論的通訊均為面向連線的通訊(TCP),為清晰起見,文章對程式碼中的一些細枝末節進行了刪減,大家可以依照文末的連結下載完整的工程原始碼來獲取這部分內容。

阻塞模式WinSock

         下述虛擬碼給出了阻塞模式下WinSock的使用方式。

[cpp] view plaincopyprint?
  1. //---------------------------------------   
  2. // 伺服器   
  3. //----------------------------------------   
  4. // WinSock初始化   
  5. WSAStartup();  
  6. // 建立伺服器套接字   
  7. SOCKET server = socket();  
  8. // 繫結到本機埠   
  9. bind(server);   
  10. // 開始監聽   
  11. listen(server);   
  12. // 接收到客戶端連線,分配一個客戶端套接字   
  13. SOCKET client = accept(server);   
  14. // 使用新分配的客戶端套接字進行訊息收發   
  15. send(client);   
  16. recv(client);  
  17. // 關閉客戶端套接字   
  18. closesocket(client);   
  19. // 關閉伺服器套接字   
  20. closesocket(server);  
  21. // 解除安裝WinSock   
  22. WSACleanup();  
   [cpp] view plaincopyprint?
  1. //---------------------------------------   
  2. // 客戶端   
  3. //---------------------------------------   
  4. WSAStartup();  
  5. // 建立客戶端套接字   
  6. SOCKET client = socket();  
  7. // 繫結本機埠   
  8. bind(client);  
  9. // 連線到伺服器   
  10. ServerAddress server;  
  11. connect(client, server);  
  12. // 確立連線後收發訊息   
  13. recv(client);  
  14. send(client);  
  15. // 關閉客戶端套接字   
  16. closesocket(client);  
  17. 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?
  1. #define WM_CUSTOM_NETWORK_MSG (WM_USER + 100)  

伺服器端,在監聽之前,將監聽套接字置為非阻塞模式,並且標明其感興趣的事件為FD_ACCEPT。

[cpp] view plaincopyprint?
  1. …  
  2. WSAAsyncSelect(server, wnd, WM_CUSTOM_NETWORK_MSG, FD_ACCEPT);  
  3. // 開始監聽   
  4. listen(server);  
  

客戶端,在連線之前,將套接字置為非阻塞模式,並標明其感興趣的事件為FD_CONNECT。

[cpp] view plaincopyprint?
  1. …  
  2. WSAAsyncSelect(client, wnd, WM_CUSTOM_NETWORK_MSG, FD_CONNECT);  
  3. // 連線到伺服器   
  4. ServerAddress server;  
  5. connect(client, server);  
  

接著,在Windows訊息處理函式中,我們將處理監聽事件、連線事件、及讀寫事件,方便起見,這裡將伺服器和客戶端的處理程式碼放在了一起。

[cpp] view plaincopyprint?
  1. LRESULT CALLBACK WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam)  
  2. {  
  3.     switch (message)  
  4.     {  
  5.     …  
  6.     case WM_CUSTOM_NETWORK_MSG: // 自定義的網路訊息碼   
  7.         {  
  8.             SOCKET socket = (SOCKET)wParam; // 發生網路事件的套接字   
  9.             long event = WSAGETSELECTEVENT(lParam); // 事件   
  10.             int error = WSAGETSELECTERROR(lParam); // 錯誤碼   
  11.             switch (event)  
  12.             {  
  13.             case FD_ACCEPT: // 伺服器收到新客戶端的連線請求   
  14.                 {  
  15.                     // 接收到客戶端連線,分配一個客戶端套接字   
  16.                     SOCKET client = accept(socket);   
  17.                     // 將新分配的客戶端套接字置為非阻塞模式,並標明其感興趣的事件為讀、寫及關閉   
  18.                     WSAAsyncSelect(client, hWnd, message, FD_READ | FD_WRITE | FD_CLOSE);  
  19.                 }  
  20.                 break;  
  21.             case FD_CONNECT: // 客戶端連線到伺服器的操作返回結果   
  22.                 {  
  23.                     // 成功連線到伺服器,將客戶端套接字置為非阻塞模式,並標明其感興趣的事件為讀、寫及關閉   
  24.                     WSAAsyncSelect(socket, hWnd, message, FD_READ | FD_WRITE | FD_CLOSE);  
  25.                 }  
  26.                 break;  
  27.             case FD_READ: // 收到網路包,需要讀取   
  28.                 {  
  29.                     // 使用套接字讀取網路包   
  30.                     recv(socket);  
  31.                 }  
  32.                 break;  
  33.             case FD_WRITE:  
  34.                 {  
  35.                     // FD_WRITE的處理後面會具體討論   
  36.                 }  
  37.                 break;  
  38.             case FD_CLOSE: // 套接字的連線方(而非本地socket)關閉訊息   
  39.                 {  
  40.                 }  
  41.                 break;  
  42.             default:  
  43.                 break;  
  44.             }  
  45.         }  
  46.         break;  
  47.     …  
  48.     }  
  49.     …  
  50. }  
  

以上就是非阻塞模式WinSock的應用框架,WSAAsyncSelect模型將套接字和Windows訊息機制很好地粘合在一起,為使用者非同步SOCKET應用提供了一種較優雅的解決方案。

擴充套件討論

         WinSock在系統底層為套接字收發網路資料各提供一個緩衝區,接收到的網路資料會快取在這裡等待應用程式讀取,待發送的網路資料也會先寫進這裡之後通過網路傳送。

相關的,針對FD_READ和FD_WRITE事件的讀寫處理,因涉及的內容稍微複雜而容易使人困惑,這裡需要特別進行討論。

         在FD_READ事件中,使用recv()函式讀取網路包資料時,由於事先並不知道完整網路包的大小,所以需要多次讀取直到讀完整個緩衝區。這就需要類似如下程式碼的呼叫:

[cpp] view plaincopyprint?
  1. void* buf = 0;  
  2. int size = 0;  
  3. while (true)  
  4. {  
  5.     char tmp[128];  
  6.     int bytes = recv(socket, tmp, 128, 0);  
  7.     if (bytes <= 0)  
  8.         break;  
  9.     else  
  10.     {  
  11.         int new_size = size + bytes;  
  12.         buf = realloc(buf, new_size);  
  13.         memcpy((void*)(((char*)buf) + size), tmp, bytes);  
  14.         size = new_size;  
  15.     }  
  16. }  
  17. // 此時資料已經從緩衝區全部拷貝到buf中,你可以在這裡對buf做一些操作   
  18. …  
  19. 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?
  1. // 關閉FD_READ事件通知   
  2. WSAAsyncSelect(socket, hWnd, message, FD_WRITE | FD_CLOSE);  
  3. // 讀取網路包   
  4. …  
  5. // 再次開啟FD_READ事件通知   
  6. 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。該原始碼展示了採用非阻塞模式程式設計的伺服器和客戶端,建立連線後,在伺服器視窗輸入空格會向所有客戶端傳送一條字串訊息。原始碼中對網路通訊部分做了簡單封裝,所以程式碼結構會和文中的虛擬碼稍有不同。

謝謝您的閱讀!