探究WSAEventSelect模型
事件通知
事件通知模型要求我們的應用程序針對打算使用的每一個套接字,首先創建一個事件對象。創建方法是調用WSACreateEvent函數,它的定義如下: WSAEVENT WSACreateEvent(void);
函數的返回值很簡單,就是一個創建好的事件對象句柄。事件對象句柄到手後,接下來必須將其與某個套接字關聯在一起,同時註冊自己感興趣的網絡事件類型。調用WSAEventSelect來做到這一點,定義如下:
int WSAEventSelect(
SOCKET s, // 定義的套接字
WSAEVENT hEventObject, // 與套接字關聯在一起的事件對象句柄
long lNetworkEvent // 應用程序感興趣的各種網絡事件類型的一個組合(FD_READ|FD_WRITE|FD_ClOSE)
);
WSAEventSelect創建的事件擁有兩種工作狀態,以及兩種工作模式。其中,兩種工作狀態分別是“已傳信”(signaled)和“未傳信”(non signaled)。工作模式則包括“人工重設”(manual reset)和“自動重設”(auto reset)。WSAEventSelect最開始在一種未傳信的工作狀態中,並用一種人工重設模式,來創建事件句柄。隨着網絡事件觸發了與一個套接字關聯在一起的事件對象,工作狀態便會從“未傳信”轉變成“已傳信”。由於事件對象是在一種人工重設模式中創建的,所以在完成了一個I/O請求的處理之後,我們的應用程序需要負責將工作狀態從已傳信更改爲未傳信。要做到這一點,可調用WSAResetEvent函數,對它的定義如下:BOOL WSAResetEvent(WSAEVENT hEvent);唯一的參數是前面用WSACreateEvent函數創建的事件對象句柄,成功返回TRUE,失敗返回FALSE。當應用程序完成了對一個事件對象的處理後,應調用BOOL WSACloseEvent(WSAEVENT hEvent);函數釋放由hEvent句柄佔用的系統資源。成功返回TRUE,失敗返回FALSE。
一個套接字同一個事件對象句柄關聯在一起後,應用程序便可開始I/O處理;方法是等待網絡事件觸發事件對象句柄的工作狀態。WSAWaitForMultipleEvents函數的設計宗旨便是用來等待一個或多個事件對象句柄,並在事先指定的一個或所有句柄進入“已傳信”狀態後,或在超過了一個規定的時間週期後,立即返回。定義如下:
DWORD WSAWaitForMultipleEvents(
DWORD cEvents,
const WSAEVENT FAR * lphEvents,
BOOL fWaitAll,
DWORD dwTimeout,
BOOL fAlertable
);
其中,cEvents和lphEvents參數定義了由WSAEVENT對象構成的一個數組。在這個數組中,cEvents指定的是事件對象的數量,而lphEvents對應的是一個指針,用於直接引用該數組。要注意的是,WSAWaitForMultipleEvents只能支持由WS AMAXIMUMWAITEVENTS對象規定的一個最大值,在此定義成64個。因此,針對發出WSAWaitForMultipleEvents調用的每個線程,該I/O模型一次最多都只能支持64個套接字。假如想讓這個模型同時管理不止64個套接字,必須創建額外的工作者線程,以便等待更多的事件對象。f WaitAll參數指定了WSAWaitForMultipleEvents如何等待在事件數組中的對象。若設爲T RUE,那麼只有等lphEvents數組內包含的所有事件對象都已進入“已傳信”狀態,函數纔會返回;但若設爲FALSE,任何一個事件對象進入“已傳信”狀態,函數就會返回。就後一種情況來說,返回值指出了到底是哪個事件對象造成了函數的返回。通常,應用程序應將該參數設爲FALSE,一次只爲一個套接字事件提供服務。dwTimeout參數規定了WSAWaitForMul tipleEvents最多可等待一個網絡事件發生有多長時間,以毫秒爲單位,這是一項“超時”設定。超過規定的時間,函數就會立即返回,即使由fWaitAll參數規定的條件尚未滿足也如此。如超時值爲0,函數會檢測指定的事件對象的狀態,並立即返回。這樣一來,應用程序實際便可實現對事件對象的“輪詢”。但考慮到它對性能造成的影響,還是應儘量避免將超時值設爲0。假如沒有等待處理的事件,WSAWaitForMultipleEvents便會返回WSAWAITTIMEOUT。如dwsTimeout設爲WSA INFINITE(永遠等待),那麼只有在一個網絡事件傳信了一個事件對象後,函數纔會返回。最後一個參數是fAlertable,在我們使用WSAEventSelect模型的時候,它是可以忽略的,且應設爲FALSE。該參數主要用於在重疊式I/O模型中,在完成例程的處理過程中使用。
若WSAWaitForMultipleEvents收到一個事件對象的網絡事件通知,便會返回一個值,指出造成函數返回的事件對象。這樣一來,我們的應用程序便可引用事件數組中已傳信的事件,並檢索與那個事件對應的套接字,判斷到底是在哪個套接字上,發生了什麼網絡事件類型。對事件數組中的事件進行引用時,應該用WSAWaitForMultipleEvents的返回值,減去預定義值WSAWAITEVENT0,得到具體的引用值(即索引位置)。如下例所示:
nIndex = WSAWaitForMultipleEvents(......);
hEvent = hEventArray[nIndex - WSA_WAIT_EVENT_0];
知道了造成網絡事件發生的套接字後,調用WSAEnumNetworkEvents函數,調查發生了什麼類型的網絡事件,定義如下:
int WSAEnumNetworkEvents(
SOCKET s, // 網絡事件發生相關聯的套接字
WSAEVENT hEventObject, //重設事件對象,將處在“已傳信”改爲“未傳信”,亦可使用WSAResetEvent替代
LPWSAMETWORKEVENTS lpNetworkEvents // 接收套接字上發生的網絡事件類型以及可能出現的錯誤代碼
);
typedef struct _WSANETWORKEVENTS {
long lNetworkEvent;
int iErrorCode[FD_MAX_EVENTS];
}WSANETWORKEVENTS, FAR * LPWSANETWORKEVENTS;
lNetworkEvents參數指定了一個值,對應於套接字上發生的所有網絡事件類型。注意一個事件進入傳信狀態時,可能會同時發生多個網絡事件類型。例如,一個繁忙的服務器應用可能同時收到FD_READ和FD_WRITE通知。iErrorCode參數指定的是一個錯誤代碼數組,同lNetworkEvents中的事件關聯在一起。針對每個網絡事件類型,都存在着一個特殊的事件索引,名字與事件類型的名字類似,只是要在事件名字後面添加一個“_BIT”後綴字串即可。例如,對FD_READ事件類型來說,iErrorCode數組的索引標識符便是FD_READ_BIT。下述代碼片斷對此進行了闡釋(針對FDREAD事件):
if (NetworkEvents.lNetworkEvents & FD_READ) {
if (NetworkEvents.iErrorCode[FD_READ_BIT] != 0) {
}
}
完成了對WSANETWORKEVENTS結構中的事件的處理之後,我們的應用程序應在所有可用的套接字上,繼續等待更多的網絡事件,以下是一段服務器端代碼(來自與郵電出版社《Windows網絡程序設計》):
int main()
{
// 事件句柄和套接字句柄
WSAEVENT eventArray[WSA_MAXIMUM_WAIT_EVENTS];
SOCKET sockArray[WSA_MAXIMUM_WAIT_EVENTS];
int nEventTotal = 0;
USHORT nPort = 4567; // 監聽端口號
// 創建監聽套接字
SOCKET sListen = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
sockaddr_in sin;
sin.sin_family = AF_INET;
sin.sin_port = htons(nPort);
sin.sin_addr.S_un.S_addr = INADDR_ANY;
if(::bind(sListen, (sockaddr*)&sin, sizeof(sin)) == SOCKET_ERROR)
{
printf(" Failed bind() /n");
return -1;
}
::listen(sListen, 200);
// 創建事件對象,並關聯到新的套接字
WSAEVENT event = ::WSACreateEvent();
::WSAEventSelect(sListen, event, FD_ACCEPT|FD_CLOSE);
// 添加到列表中
eventArray[nEventTotal] = event;
sockArray[nEventTotal] = sListen;
nEventTotal++;
char szText[512];
// 處理網絡事件
while(TRUE)
{
// 在所有對象上等待
int nIndex = ::WSAWaitForMultipleEvents(nEventTotal, eventArray, FALSE, WSA_INFINITE, FALSE);
// 確定事件的狀態
nIndex = nIndex - WSA_WAIT_EVENT_0;
// WSAWaitForMultipleEvents總是返回所有事件對象的最小值,爲了確保所有的事件對象得到執行的機會,對 大於nIndex的事件對象進行輪詢,使其得到執行的機會。
for(int i=nIndex; i<nEventTotal; i++)
{
nIndex = ::WSAWaitForMultipleEvents(1, &eventArray[i], TRUE, 0, FALSE);
if(nIndex == WSA_WAIT_FAILED || nIndex == WSA_WAIT_TIMEOUT)
{
continue;
}
else
{
// 獲取到來的通知消息,WSAEnumNetworkEvents函數會自動重置受信事件
WSANETWORKEVENTS event;
::WSAEnumNetworkEvents(sockArray[i], eventArray[i], &event);
if(event.lNetworkEvents & FD_ACCEPT) // 處理FD_ACCEPT事件
{
if(event.iErrorCode[FD_ACCEPT_BIT] == 0)
{
if(nEventTotal > WSA_MAXIMUM_WAIT_EVENTS)
{
printf(" Too many connections! /n");
continue;
}
SOCKET sNew = ::accept(sockArray[i], NULL, NULL);
WSAEVENT event = ::WSACreateEvent();
::WSAEventSelect(sNew, event, FD_READ|FD_CLOSE|FD_WRITE);
// 添加到列表中
eventArray[nEventTotal] = event;
sockArray[nEventTotal] = sNew;
nEventTotal++;
}
}
else if(event.lNetworkEvents & FD_READ) // 處理FD_READ事件
{
if(event.iErrorCode[FD_READ_BIT] == 0)
{
memset(szText, 0x01, sizeof(szText));
int nRecv = ::recv(sockArray[i], szText, strlen(szText), 0);
if(nRecv > 0)
{
szText[nRecv] = '/0';
printf("%s /n", szText);
}
}
}
else if(event.lNetworkEvents & FD_CLOSE) // 處理FD_CLOSE事件
{
if(event.iErrorCode[FD_CLOSE_BIT] == 0)
{
::closesocket(sockArray[i]);
for(int j=i; j<nEventTotal-1; j++)
{
sockArray[j] = sockArray[j+1];
sockArray[j] = sockArray[j+1];
}
EventTotal--;
}
}
else if(event.lNetworkEvents & FD_WRITE) // FD_WRITE事件破難理解,下面將重點說明。
{
}
}
}
}
return 0;
}
以上介紹了WSAEventSelect模型的流程和用法,大部分內容參考<<Windows網絡編程>>。該模型也較易理解,使用比較普遍,但FD_WRITE事件的觸發時機曾經非常使我困惑,書寫了很多測試用例,總是不能得到滿意的輸出結果。一直認爲,如果某一端(客戶端或服務器端)調用recv阻塞接收,哪在另一段(服務器端或客戶端)一定會觸發FD_WRITE,進而用send來發送數據,後來查閱了MSDN和一些相關材料,才發現這一想法大錯特錯。FD_WRITE並非針對send的,一般是在連線成功後會觸發一次或者緩衝區有多出的空位, 可以容納需要發送的數據時纔會觸發。
FD_READ 事件非常容易掌握. 當有數據發送過來時, WinSock會以FD_READ事件通知你, 對於每一個FD_READ事件,你需要像下面這樣調用recv(): int nRecvData = recv(wParam, &data, sizeof(data), 0); 基本上就是這樣, 別忘了修改上面的wParam。還有,不一定每一次調用recv()都會接收到一個完整的數據包, 因爲數據可能不會一次性全部發送過來. 所以在開始處理接收到的數據之前, 最好對接收到的字節數(即recv()的返回值)進行判斷,看看是否收到的是一個完整的數據包。
FD_WRITE相對來說就麻煩一些。首先,當你建立了一個連接時,會產生一個FD_WRITE事件。但是如果你認爲在收到 FD_WRITE時調用send()就萬事大吉,那就錯了。FD_WRITE事件只在發送緩衝區有多出的空位,可以容納需要發送的數據時纔會觸發。
上面所謂的發送緩衝區,是指系統底層提供的緩衝區。send()先將數據寫入到發送緩衝區中,然後通過網絡發送到接收端。你或許會想,只要不把發送緩衝區填滿,讓發送緩衝區保持足夠多的空位容納需要發送的數據,那麼你就會源源不斷地收到FD_WRITE事件了。嘿嘿,錯了。上面只是說FD_WRITE事件在發送緩衝區有多出的空位時會觸發,但不是在有足夠的空位時觸發,就是說你得先把發送緩衝區填滿。
通常的辦法是在一個無限循環中不斷的發送數據,直到把發送緩衝區填滿。當發送緩衝區被填滿後,send()將會返回 SOCKET_ERROR,WSAGetLastError()會返回WSAWOULDBLOCK。如果當前這個SOCKET處於阻塞(同步)模式,程序會一直等待直到發送緩衝區空出位置然後發送數據;如果SOCKET是非阻塞(異步)的,那麼你就會得到WSAWOULDBLOCK錯誤。於是只要我們首先循環調用send()直到發送緩衝區被填滿,然後當緩衝區空出位置來的時候,系統就會發出FD_WRITE事件。下面是一個處理FD_WRITE事件的例子。
case FD_WRITE: // 可以發送數據了
{
// 進入無限循環
while(TRUE)
{
// 從文件中讀取數據,保存到packet.data裏面.
in.read((char*)&packet.data,MAX_PACKET_SIZE);
// 發送數據
if (send(wparam, (char*)(&packet), sizeof(PACKET), 0) == SOCKET_ERROR)
{
if (WSAGetLastError() == WSAEWOULDBLOCK)
{
// 發送緩衝區已經滿了, 退出循環.
break;
}
else // 其他錯誤
{
// 顯示出錯信息然後退出.
CleanUp();
return(0);
}
}
}
} break;
看到了吧,實現其實一點也不困難。只是弄混了一些概念而已。使用這樣的發送方式,在發送緩衝區變滿的時候就可以退出循環。然後,當緩衝區空出位置來的時候,系統會觸發另外一個FD_WRITE事件,於是你就可以繼續發送數據了。
在你開始使用新學到的知識之前,我還想說明一下FD_WRITE事件的使用時機。如果你不是一次性發送大批量的數據的話,就別想着使用FD_WRITE事件了,原因很簡單-如果你寄期望於在收到FD_WRITE事件時發送數據,但是卻又不能發送足夠的數據填滿發送緩衝區,那麼你就只能收到連接剛剛建立時觸發的那一次FD_WRITE-系統不會觸發更多的FD_WRITE了。所以當你只是發送儘可能少的數據的時候,就忘掉 FD_WRITE 機制吧,在任何你想發送數據的時候直接調用send()。
以上部分是我在CSDN上看到的一篇文章,文章寫得很易懂。其實,如果你想收到FD_WRITE事件而你又無法先填滿發送緩衝區,可以調用WSAAsyncSelect( ..., FD_WRITE)。如果當前發送緩衝區有空位,系統會馬上給你發FD_WRITE 事件。