title: WinSock2 API
tags: [WinSock, 網絡編程, WinSock2.0 API, 動態加載, WinSock 異步函數]
date: 2018-07-21 10:36:09
categories: Windows 網絡編程
keywords: WinSock, 網絡編程, WinSock2.0 API, 動態加載, WinSock 異步函數
WinSock中提供的5種網絡模型已經可以做到很高效了,特別是完成端口,它的高效的原因在於它不僅另外開啟了線程來處理完成通知而不是占用主程序的時間,同時也在於我們在完成端口中運用了大量異步IO處理函數。比如WSASend
、WSARecv
等等。為了高效的處理網絡IO,WinSock提供了大量這樣的異步函數。這篇博文主要探討這些函數的用法和他們與傳統的巴克利套接字相比更加高效的秘密
AcceptEx
其實在使用TCP協議編程時,接受連接的過程也是需要進行收發包操作的,具體的過程請參考TCP的三次握手。針對這種特性WinSock提供了對應的異步操作函數AcceptEx
。函數原型如下:
BOOL AcceptEx(
SOCKET sListenSocket,
SOCKET sAcceptSocket,
PVOID lpOutputBuffer,
DWORD dwReceiveDataLength,
DWORD dwLocalAddressLength,
DWORD dwRemoteAddressLength,
LPDWORD lpdwBytesReceived,
LPOVERLAPPED lpOverlapped
);
sListenSocket: 監聽套接字
sAcceptSocket:該參數是一個SOCKET的句柄,一旦連接成功建立,那么會使用該SOCKET作為通信的SOCKET
lpOutputBuffer:是三個數據一體化的緩沖區的指針,這三個數據分別是接收連接時順帶接收客戶端發過來的數據的緩沖,之后是本地地址結構的緩沖,最后是遠程客戶端地址結構的指針
dwReceiveDataLength:是lpOutputBuffe
r的緩沖長度
dwLocalAddressLength:是本地地址結構長度,其值等於sizeof(SOCKADDR)+16
dwRemoteAddressLength:是遠程客戶端地址結構長度,其值也等於sizeof(SOCKADDR)+16
lpdwBytesReceived:該參數用於返回接受連接請求時接收的數據的長度
lpOverlapped:就是重疊I/O需要的結構
第一個參數是一個十分重要的參數,這個參數是AcceptEx
比較高效的一個重要的原因。從功能上來看它與傳統的accept
函數並沒有什么區別,都是接受客戶端連接的。它與accpet
相比比較高效的原因如下:
- 從內部機理來說
accpet
在內部其實有一個創建SOCKET的操作,當函數成功后會返回這個SOCKET,所以AcceptEx
與accept
相比少了一個創建SOCKET的操作,它的功能更加純粹,這就給了我們一個啟示:我們可以在初始化的時候創建大量的SOCKET,並投遞到AcceptEx
中,這樣在接受連接時省去了創建SOCKET的時間,能夠更快速的響應客戶端的連接。 - 由於
AcceptEx
不用創建SOCKET,所以它也將accept
內部對socket設置的操作給省去了,也少了一些其他的附帶操作,比如地址的解析,其實這里我們可以簡單的理解為lpOutputBuffer
中保存的信息就是TCP三次握手中的SYN包和ACK包,這些包的信息需要在函數返回后由用戶通過其他方法來解析,而accpet
幫我們解析了,所以AcceptEx
比accept
更加高效
因為AcceptEx
的設計目標純粹就是為了性能,所以監聽套接字的屬性不會被代表客戶端通訊的套接字自動繼承。要繼承屬性(包括socket內部接受/發送緩存大小等等)就必須調用setsockopt
使用選項SO_UPDATE_ACCEPT_CONTEXT
,如下:
int nRet = ::setsockopt(skClient, SOL_SOCKET ,SO_UPDATE_ACCEPT_CONTEXT ,(char *)&skListen, sizeof(skListen));
AcceptEx
函數除了能夠接受客戶端的連接之外,它也可以在接受連接的同時接收客戶端隨着連接請求一塊發過來的數據,只要我們設置dwReceiveDataLength
參數大於0,並在lpOutputBuffer
中分配相應的緩沖即可,但是這里會存在一個安全問題,當我們設置了這些之后,如果客戶端只發送連接請求,但是不發送數據,AcceptEx
會一直等待,如果有大量這樣的客戶端,那么可能會給服務器造成大量的資源浪費從而不能及時的服務其他正常客戶端。要防止這樣的情況,可以采用下列措施:
- 設置
dwReceiveDataLength
為0,並且不分配對應的緩沖,也就是關閉這個接收數據的功能。 - 啟動一個監視線程對用於連接的SOCKET輪詢調用:
int iSecs;
int iBytes = sizeof( int );
getsockopt( hAcceptSocket, SOL_SOCKET, SO_CONNECT_TIME, (char *)&iSecs, &iBytes ); //獲取SOCKET連接時間
iSecs 為 -1 表示還未建立連接, 否則就是已經連接的時間.
當iSecs超過某個筏值時,就果斷斷開這個連接
GetAcceptExSockAddr
前面說AcceptEx不會對地址進行解析,而是返回一個經過編碼的地址信息,可以將它理解為原始的三次握手包。而函數GetAcceptExSockAddr
的主要作用就是通過原始的二進制數據得到對應的地址結構。函數原型如下:
void GetAcceptExSockaddrs(
PVOID lpOutputBuffer,
DWORD dwReceiveDataLength,
DWORD dwLocalAddressLength,
DWORD dwRemoteAddressLength,
LPSOCKADDR* LocalSockaddr,
LPINT LocalSockaddrLength,
LPSOCKADDR* RemoteSockaddr,
LPINT RemoteSockaddrLength);
lpOutputBuffer:之前提供給AcceptEx函數的緩沖,如果AcceptEx
調用成功,會在這個緩沖中寫入地址信息,GetAcceptExSockaddrs
通過這個緩沖中保存的地址信息來解析出地址結構
dwReceiveDataLength:接收到的數據長度,注意這個長度不是lpOutputBuffer
,而是客戶端隨着連接請求一起發送過來的其他數據的長度,其實這里應該理解為地址信息在緩沖中的偏移
dwLocalAddressLength:本地地址信息的長度,這個長度為sizeof(SOCKADDR)+16
dwRemoteAddressLength:遠程客戶端的地址信息的長度,長度為sizeof(SOCKADDR)+16
LocalSockaddr:解析出來的本地地址結構
LocalSockaddrLength:本地地址結構的長度,這個參數是一個輸出參數
RemoteSockaddr: 解析出來的遠程客戶端的地址結構
RemoteSockaddrLength:解析出來的遠程客戶端的地址長度,這個參數是一個輸出參數
這里為什么要返回本地的地址結構呢,主要有兩個原因:
- 一般的服務器可能有多塊網卡,返回本地地址我們就可以知道服務器用哪塊網卡與客戶端通信
- 服務器用來監聽的端口與用來進行通信的端口不是同一個,返回本地地址我們就能夠知道服務器在使用哪個端口與客戶端通信
TransmitFile
對於一些網絡應用來說,發送文件有時是一個基本的功能,比如:web服務,FTP服務等。在Winsock中為此而專門提供了一個高效傳輸文件的API——TransmitFile。函數原型如下:
BOOL TransmitFile(
SOCKET hSocket,
HANDLE hFile,
DWORD nNumberOfBytesToWrite,
DWORD nNumberOfBytesPerSend,
LPOVERLAPPED lpOverlapped,
LPTRANSMIT_FILE_BUFFERS lpTransmitBuffers,
DWORD dwFlags);
這個函數主要工作在TCP協議上
hSocket:與客戶端進行通信的SOCKET
hFile:是對應文件的句柄
nNumberOfBytesToWrite:表示發送文件的長度,這個長度可以小於文件長度
nNumberOfBytesPerSend:當文件較大時,可以進行拆包發送,這個參數表示每個數據包的大小,如果這個參數為0,將采用系統默認的包大小,NT內核中默認大小為64K
lpOverlapped:重疊I/O需要的結構
lpTransmitBuffers:是一個TRANSMIT_FILE_BUFFERS結構體,利用它可以指定在文件開始發送前需要發送的額外數據以及文件發送結束后需要發送的額外數據,這個參數也可以置為NULL,僅表示發送文件數據。它的結構如下所示:
typedef struct _TRANSMIT_FILE_BUFFERS
{
PVOID Head;
DWORD HeadLength;
PVOID Tail;
DWORD TailLength;
} TRANSMIT_FILE_BUFFERS;
dwFlags:它是一個按位組合的標識。它的各個標識的含義如下
標識 | 含義 |
---|---|
TF_DISCONNECT | 在傳輸文件結束后,開始一個傳輸層斷開動作 |
TF_REUSE_SOCKET | 重置套接字,使其可以被AcceptEx等函數重用,這個標志需要與TF_DISCONNECT標志合用 |
TF_USE_DEFAULT_WORKER | 指定發送文件使用系統默認線程,這對傳輸大型文件很有利 |
TF_USE_SYSTEM_THREAD | 使用系統線程發送文件,它與TF_USE_DEFAULT_WORKER作用相同 |
TF_USE_KERNEL_APC | 指定利用內核APC隊列來代替工作線程來處理文件傳輸. 需要注意的是系統內核APC隊列只在應用程序進入等待狀態時才工作. 但不一定非要一個可警告狀態的等待 |
TF_WRITE_BEHIND | 指定TransmitFile函數盡可能立即返回,而不管遠端是否確認已收到數據.這個標志不能與TF_DISCONNECT和TF_REUSE_SOCKET一起使用 |
可以使用TF_DISCONNECT
加上TF_REUSE_SOCKET
來回收SOCKET,以便像AcceptEx這樣的函數可以重新利用。此時應該指定hFile為NULL,但這不是這個函數的主業(我覺得應該讓專門的函數干專門的事,自己在封裝函數的時候也應該要注意,不要向Win32 API這樣使用各種標志來控制函數的功能)
同時TransmitFile函數只有在服務器版Windows上才能發揮其全部功能。而在專業版或家庭版等Windows上它被限定為最多同時有兩個調用在傳輸,而其他的調用都被置為排隊等待狀態。
發送文件這個功能,是一個十分簡單的功能,無非是應用層不斷從磁盤文件中讀取文件並使用WSASend這樣的異步函數來發送,另一端不斷用WSARecv接收並寫入到文件中,為了性能在讀寫文件時也可以用IOCP的方式,那么為什么微軟為了這么一個簡單的功能還要獨自封裝一個函數,難道它封裝的函數就一定比我們自己實現的性能高?
上圖揭示了TransmitFile
能夠高效工作的秘密,一般我們來封裝這個功能的時候會調用ReadFile,此時由內核層讀取文件並將文件文件內容保存在內核的內存空間中,然后通過系統調用們將內容拷貝到R3層,在調用WSASend的時候會將文件內容再從R3層拷貝到R0層,這個過程經過系統調用們,需要調用各種函數,並且進行各種驗證。這個操作是十分耗時的。
而TransmitFile
則相對要高效的多,既然最終是要發送文件,那么它將內容從文件中讀取出來后直接將R0層中保存的文件內容通過SOCKET發送出去,有的時候直接采用文件映射的方式將磁盤地址映射到網卡中,直接由網卡讀取並發送,這樣又省去了從內核中讀取文件並拷貝到網卡緩存中的操作。所以它比我們自己封裝來的更加高效。
TransmitPackets
有的時候需要發送超大型數據(有時是幾十G)到客戶端,有時甚至需要發送多個文件到客戶端。這個時候TransmitFile
就不再有效了。請注意TransmitFile
的第三個參數 nNumberOfBytesToWrite
是一個DWORD類型,這也就標明這個函數最大只能發送4GB的文件,而對於更大的文件它就無能為力了,為了發送更大的文件WinSock專門封裝了一個函數——TransmitPackets
BOOL PASCAL TransmitPackets(
SOCKET hSocket,
LPTRANSMIT_PACKETS_ELEMENT lpPacketArray,
DWORD nElementCount,
DWORD nSendSize,
LPOVERLAPPED lpOverlapped,
DWORD dwFlags
);
這個函數不但可以在面向連接(面向流)式的協議(TCP)上工作,還可以在無連接式的數據報協議(UDP)上工作,而TransmitFile函數只能工作在TCP上
hSocket:表示發送所用的SOCKET
lpPacketArray:它是一個結構體數組的指針,這個結構體表示發送文件的相關信息,結構體的定義如下:
typedef struct _TRANSMIT_PACKETS_ELEMENT {
ULONG dwElFlags;
ULONG cLength;
union {
struct {
LARGE_INTEGER nFileOffset;
HANDLE hFile;
};
PVOID pBuffer;
};
} TRANSMIT_PACKETS_ELEMENT;
這個結構體主要包含3個部分,第一個部分是一個標志,表示該如何解釋后面的部分,這個標志有如下幾個值
標志 | 含義 |
---|---|
TP_ELEMENT_FILE | 標明它將發送一個文件,此時會使用共用體中的結構體部分 |
TP_ELEMENT_MEMORY | 標明它將要將發送內存中的一段空間的數據,此時會使用共用體中pBuffer部分 |
TP_ELEMENT_EOP | 而最后一個標志用於輔助說明前兩個標志,說明當前結構表示的數據應當作為一個結束包來發送,也就說之前所有的數據到當前這個結構描述的數據應當視為一個包 |
第二部分是cLength用以說明當前結構描述的數據長度/發送文件內容的長度
第三個部分聯合定義根據第一個部分的實際標志值,用於說明是一個文件還是一個內存塊,當是一個文件時還可以指定一個64位長整數型的文件內偏移,這為應用利用TransmitPackets發送大於4GB的文件創造了可能.當偏移為-1時,表示從文件當前指針位置開始發送
需要注意的是因為TransmitPackets能夠很快的處理數據發送,因此可能會造成大量待發送數據堆積在下層協議的協議棧上.而對於無連接的面向數據報的協議來說,有時協議驅動會選擇將它們簡單丟棄.
另外對於TransmitPackets來說也只有服務器版的Windows能夠發揮它全部的性能,而對於家庭版和專業版來說,最多能夠同時處理兩個TransmitPackets調用,其它的調用都會被排隊處理
最后TransmitPackets在發送文件時工作機理與TransmitFile是類似的,而TransmitPackets可以發送多個文件,並且可以發送超大文件(大於4GB),在發送內存塊上,TransmitPackets也有很多優化,調用者可以放心的將超大的緩沖塊傳遞給TransmitPackets而不必過多的擔心
ConnectEx
作為客戶端應用來說,或者說一些需要反連接工作的應用來說(如:Active FTP方式的服務器),使用傳統的connect進行阻塞式或非阻塞式的編程都無法得到很好的性能響應
它的定義如下:
BOOL PASCAL ConnectEx(
SOCKET s,
const struct sockaddr* name,
int namelen,
PVOID lpSendBuffer,
DWORD dwSendDataLength,
LPDWORD lpdwBytesSent,
LPOVERLAPPED lpOverlapped
);
s: 進行連接操作的SOCKET句柄,這個SOCKET句柄需要事先綁定,這里與調用普通的connect函數不同,它需要先調用bind函數將本地地址與SOCKET綁定
name:要連接的遠端服務器的地址結構
namelen:就是遠端地址結構的長度
lpSendBuffer,dwSendDataLength,lpdwBytesSent三個參數共同用於描述在連接到服務器成功之后向服務器直接發送的數據緩沖,長度以及實際發送的數據長度
lpOverlapped就是重疊I/O操作需要的結構體
與AcceptEx類似,在連接成功后,需要調用 setsocketopt
來設置SOCKET的屬性。
與傳統的connect
函數不同,ConnectEx
函數要求一個已經綁定過的SOCKET句柄參數,其實這也是將connect內部的綁定操作排除在真正connect操作之外的一種策略。最終連接的操作也會很快的就被完成,而綁定可以提前甚至在初始化的時候就完成。這樣做也是為了能夠快速的處理網絡事件。
DisConnectEx
前面在TransmitFile中說它可以使用TF_DISCONNECT
加上TF_REUSE_SOCKET
來回收SOCKET,也提到應該用專門的函數來干專門的事,這里ConnectEx就是專門的函數。它主要的作用與普通的closesocket
函數類似。
BOOL DisconnectEx(
SOCKET hSocket,
LPOVERLAPPED lpOverlapped,
DWORD dwFlags,
DWORD reserved
);
hSocket :表示將要被回收的SOCKET
lpOverlapped:重疊IO所使用的結構
dwFlags:它是一個標志值,表示是否需要回收SOCKET,如果為0則表示不需要回收,此時它的作用與closesocket
類似。如果為TF_REUSE_SOCKET
表示將回收SOCKET
reserved:是一個保留值直接傳0即可
當以重疊I/O的方式調用DisconnectEx
時,若該SOCKET還有未完成的傳輸調用時,該函數會返回FALSE
,並且最終錯誤碼是WSA_IO_PENDING
,即斷開/回收操作將在傳輸完成后執行。如果使用了重疊IO,同樣在完成之后會調用完成處理函數。
如果未采用重疊IO操作,那么函數會阻塞,直到數據發送完成並斷開連接。
擴展函數的動態加載
之前介紹的這一系列Winsock2.0的擴展API,最好都動態加載之后再行調用,因為它們具體的導出位置在不同平台上變動太大,如果靜態聯編的話,會給開發編譯工作帶來巨大的麻煩,所以使用運行時動態加載來調用這些API。
但是這些函數的加載與加載普通的dll函數不同,為了方便操作,WinSock提供了一套完整的支持。這表示我們不需要知道它們所在的dll,我們可以直接使用WinSock提供的方法,即使以后它們所在的dll文件變化了,也不會影響我們的使用。
加載它們需要使用到函數WSAIoctl,函數原型如下:
int WSAIoctl(
SOCKET s,
DWORD dwIoControlCode,
LPVOID lpvInBuffer,
DWORD cbInBuffer,
LPVOID lpvOutBuffer,
DWORD cbOutBuffer,
LPDWORD lpcbBytesReturned,
LPWSAOVERLAPPED lpOverlapped,
LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine
);
這個函數的使用方法與ioctlsocket
相似。這里不對它的詳細用法進行討論。這里就簡單的說說該怎么用它加載這些函數。
要加載WinSock API,首先需要將第二個控制碼參數設置為SIO_GET_EXTENSION_FUNCTION_POINTER,表示獲取擴展API的指針。設置了這個參數后,lpvInBuffer參數需要設置成相應函數的GUID,下面列舉了各個函數的GUID值
GDUI | 函數 |
---|---|
WSAID_ACCEPTEX | AcceptEx |
WSAID_CONNECTEX | ConnectEx |
WSAID_DISCONNECTEX | DisconnectEx |
WSAID_GETACCEPTEXSOCKADDRS | GetAcceptExSockaddrs |
WSAID_TRANSMITFILE | TransmitFile |
WSAID_TRANSMITPACKETS | TransmitPackets |
WSAID_WSARECVMSG | WSARecvMsg |
WSAID_WSASENDMSG | WSASendMsg |
函數的指針通過 lpvOutBuffer 參數返回,而cbOutBuffer表示接受緩沖的長度,lpcbBytesReturned表示返回數據的長度。后面兩個參數都與完成IO有關,所以這里可以直接給NULL。
下面是一個加載AcceptEx函數的例子
typedef
BOOL
(PASCAL FAR * LPFN_ACCEPTEX)(
IN SOCKET sListenSocket,
IN SOCKET sAcceptSocket,
IN PVOID lpOutputBuffer,
IN DWORD dwReceiveDataLength,
IN DWORD dwLocalAddressLength,
IN DWORD dwRemoteAddressLength,
OUT LPDWORD lpdwBytesReceived,
IN LPOVERLAPPED lpOverlapped
);
LPFN_ACCEPTEX pFun = NULL;
SOCKET sTemp = WSASocket(af, type, protocol, NULL, NULL, 0);
GUID funGuid = WSAID_ACCEPTEX;
if (INVALID_SOCKET != skTemp)
{
DWORD dwOutBufferSize = 0;
int Ret = ::WSAIoctl(skTemp, SIO_GET_EXTENSION_FUNCTION_POINTER, &funGuid, sizeof(funGuid), &pFun, sizeof(pFun), &dwOutBufferSize, NULL, NULL);
}
這里調用WSAIoctl加載擴展函數時需要傳入SOCKET句柄,它其實是利用傳入的SOCKET的相關信息來導出對應版本的擴展函數,比如這里我們傳入的是一個用在TCP協議之上的SOCKET,所以它會返回一個使用TCP協議的API,利用這個SOCKET,這個函數以及它返回的API真正做到了與協議無關。