1. 程式人生 > >IOCP 實現一個簡單高併發伺服器程式

IOCP 實現一個簡單高併發伺服器程式

 前言:原始碼使用比較高階的IOCP技術,它能夠有效的為多個客戶端服務,利用IOCP程式設計API,它也提供了一些實際問題的解決辦法,並且提供了一個簡單的帶回復的檔案傳輸的客戶端/伺服器。 1.1 要求: l 文章要求讀者熟悉C++, TCP/IP, 套接字(socket)程式設計, MFC, 和多執行緒。 l 原始碼使用Winsock 2.0和IOCP技術,並且要求: Ø Windows NT/2000 or later: Requires Windows NT 3.5 or later. Ø Windows 95/98/ME: 不支援 Ø Visual C++ .NET, or a fully updated Visual C++ 6.0. 1.2 摘要: 在你開發不同型別的軟體,不久之後或者更晚,你必須得面對客戶端/伺服器端的發展。對程式設計師來說,寫一個全面的客戶端/伺服器的程式碼是很困難的。這篇文章提供了一個簡單的,但卻強大的客戶端/伺服器原始碼,它能夠被擴充套件到許多客戶端/伺服器的應用程式中。原始碼使用高階的IOCP技術,這種技術能高效的為多個客戶端提供服務。IOCP技術提供了一種對一個執行緒—一個客戶端(one-thread-one client)這種瓶頸問題(很多中問題的一個)的有效解決方案。它使用很少的一直執行的執行緒和非同步輸入/輸出,傳送/接收。IOCP技術被廣泛應用於各自高效能的伺服器,像Apache等。原始碼也提供了一系列的函式,在處理通訊、客戶端/伺服器接收/傳送檔案函式、還有執行緒池處理等方面都會經常用到。文章主要關注利用IOCP應用API函式的實際解決方案,也提供了一個全面的程式碼文件。此外,也為你呈現了一個能處理多個連線、同時能夠進行檔案傳輸的簡單回覆客戶端/伺服器。
2.1. 介紹: 這片文章提供了一個類,它是一個應用於客戶端和伺服器的原始碼,這個類使用IOCP和非同步函式,我們稍後會進行介紹。這個原始碼是根據很多程式碼和文章得到的。 利用這些簡單的原始碼,你能夠: l 服務/連線多個客戶端和伺服器。 l 非同步傳送和接收檔案。 l 為了處理沉重的客戶端/伺服器請求,建立並管理一個邏輯工作者執行緒池。(logical worker thread pool)。 我們很難找到充分的,但簡單的能夠應對客戶端/伺服器通訊的原始碼。在網上發現的原始碼即複雜(超過20個類),又不能提供足夠的功能。本問的程式碼儘量簡單,也有好的文件。我們將簡要介紹Winsock API 2.0提供的IOCP技術,編碼時遇到的疑難問題,以及這些問題的應對方案。
2.2. 非同步輸入/輸出完成埠(IOCP)簡介 一個伺服器應用程式,假如不能夠同時為多個客戶端提供服務,那它就沒有什麼意義。通常的非同步I/O呼叫,還有多執行緒都是這個目的。準確的說,一個非同步I/O呼叫能夠立即返回,儘管有阻塞的I/O呼叫。同時,I/O非同步呼叫的結果必須和主執行緒同步。這可以用很多種方法實現,同步可以通過下面方法實現: l 利用事件——當非同步呼叫完成時設定的訊號。這種方法的優點是執行緒必須檢查和等待這個訊號被設定。 l 使用GetOverlappedResult函式——這個方法和上面方法有相同的優點。 l 使用非同步程式呼叫(APC)——這種方法有些缺點。第一,APC總是在正被呼叫的執行緒的上下文中被呼叫;第二,呼叫執行緒必須暫停,等待狀態的改變。
l 使用IOCP——這種方法的缺點是有些疑難問題必須解決。使用IOCP編碼多少有些挑戰。 2.2.1 為什麼使用IOCP 使用IOCP,我們能夠克服 一個執行緒 —— 一個客戶端問題。我們知道,假如軟體不是執行在一個真實的多處理器機器上,它的效能會嚴重下降。執行緒是系統的資源,它們即不是無限的,也不便宜。 IOCP提供了一種利用有限的(I/O工作執行緒)公平的處理多客戶端的輸入/輸出問題的解決辦法。執行緒並不被阻塞,在無事可作的情況下也不使CPU迴圈。 2.3. 什麼是IOCP 我們已經知道,IOCP僅僅是一個執行緒同步物件,有點像訊號量(semaphore),因此IOCP並不是一個難懂的概念。一個IOCP物件和很多支援非同步I/O呼叫的I/O物件相聯絡。執行緒有權阻塞IOCP物件,直到非同步I/O呼叫完成。 3 IOCP如何工作 為了得到更多資訊,建議你參考其它的文章(1, 2, 3, see References)。 使用IOCP,你必須處理3件事情。將一個套接字繫結到一個完成埠,使用非同步I/O呼叫,和使執行緒同步。為了從非同步I/O呼叫得到結果,並知道一些事情,像哪個客戶端進行的呼叫,我們必須傳遞兩個引數:CompletionKey引數還有OVERLAPPED結構體 3.1. CompletionKey引數 CompletionKey引數是第一個引數,是一個DWORD型別的變數。你可以給它傳遞你想要的任何值,這些值總是和這個引數聯絡。通常,指向結構體的指標,或者包含客戶端指定物件的類的指標被傳遞給這個引數。在本文的原始碼中,一個ClientContext結構體的指標被傳遞給CompletionKey引數。 3.2. OVERLAPPED引數 這個引數通常被用來傳遞被非同步I/O呼叫的記憶體。要重點強調的是,這個資料要被加鎖,並且不要超出實體記憶體頁,我們之後進行討論。 3.3. 將套接字和完成埠進行繫結 一旦建立了完成埠,通過呼叫CreateIoCompletionPort函式可以將一個套接字和完成埠進行繫結,像下面的方法:
BOOL IOCPS::AssociateSocketWithCompletionPort(SOCKET socket,  
 HANDLE hCompletionPort, DWORD dwCompletionKey) 
 { 
 HANDLE h = CreateIoCompletionPort((HANDLE) socket,  
 hCompletionPort, dwCompletionKey, m_nIOWorkers); 
 return h == hCompletionPort; 
 } 
3.4. 進行非同步I/O呼叫 通過呼叫WSASend,WSARecv函式,進行實際的非同步呼叫。這些函式也需要包含將要被用到的記憶體指標的引數WSABUF。通常情況下,當伺服器/客戶端想要執行一個I/O呼叫操作,它們並不直接去做,而是傳送到完成埠,這些操作被I/O工作執行緒執行。這是因為,要公平的分配CPU。通過給完成埠傳遞一個狀態,進行I/O呼叫。象下面這樣:
BOOL bSuccess = PostQueuedCompletionStatus(m_hCompletionPort,  
 pOverlapBuff->GetUsed(),  
 (DWORD) pContext  &pOverlapBuff->m_ol); 
3.5. 執行緒的同步 通過呼叫GetQueuedCompletionStatus函式進行執行緒的同步(看下面)。這個函式也提供了CompletionKey引數 OVERLAPPED引數。
BOOL GetQueuedCompletionStatus( 
 HANDLE CompletionPort, // handle to completion port 
 LPDWORD lpNumberOfBytes, // bytes transferred 
 PULONG_PTR lpCompletionKey, // file completion key 
 LPOVERLAPPED *lpOverlapped, // buffer 
 DWORD dwMilliseconds // optional timeout value 
 ); 
3.6. 四個棘手的IOCP編碼問題和它們的對策 使用IOCP會遇到一些問題,有些問題並不直觀。在使用IOCP的多執行緒場景中,並不直接控制執行緒流,這是因為執行緒和通訊之間並沒有聯絡。在這部分,我們將提出四個不同的問題,在使用IOCP開發客戶端/伺服器程式時會遇到它們。它們是: l WSAENOBUFS出錯問題。 l 資料包的重排序問題。 l 訪問紊亂(access violation)問題。 3.6.1 WSAENOBUFS出錯問題。 這個問題並不直觀,並且很難檢查。因為,乍一看,它很像普通的死鎖,或者記憶體洩露。假設你已經弄好了你的伺服器並且能夠很好的執行。當你對伺服器進行承受力測試的時候,它突然掛機了。如果你幸運,你會發現這和WSAENOBUFS出錯有關。 伴隨著每一次的重疊傳送和接收操作,有資料的記憶體提交可能會被加鎖。當記憶體被鎖定時,它不能越過實體記憶體頁。作業系統會強行為能夠被鎖定的記憶體的大小設定一個上限。當達到上限時,重疊操作將失敗,併發送WSAENOBUFS錯誤。 假如一個伺服器在在每個連線上提供了很多重疊接收,隨著連線數量的增長,很快就會達到這個極限。如果伺服器能夠預計到要處理相當多的併發客戶端的話,伺服器可以在每個連線上僅僅回覆一個0位元組的接收。這是因為沒有接收操作和記憶體無關,記憶體不需要被鎖定。利用這個方法,每一個套接字的接收記憶體都應該被完整的保留,這是因為,一旦0位元組的接收操作完成,伺服器僅僅為套接字的接收記憶體的所以資料記憶體返回一個非阻塞的接收。利用WSAEWOULDBLOCK當非阻塞接收失敗時,也沒有資料被阻塞。這種設計的目的是,在犧牲資料吞吐量的情況下,能夠處理最大量的併發連線。當然,對於客戶端如何和伺服器互動,你知道的越多越好。在以前的例子中,每當0位元組的接收完成,返回儲存了的資料,馬上執行非阻塞接收。假如伺服器知道客戶端突然傳送資料,當0位元組接收一旦完成,為防止客戶端傳送一定數量的資料(大於每個套接字預設的8K記憶體大小),它可以投遞一個或多個重疊接收。 原始碼提供了一個簡單的解決WSAENOBUFS錯誤的可行方案。對於0位元組記憶體,我們採用WSARead()函式(見OnZeroByteRead())。當呼叫完成,我們知道資料在TCP/IP棧中,通過採用幾個非同步WSARead()函式讀取MAXIMUMPACKAGESIZE的記憶體。這個方法在資料達到時僅僅鎖定實體記憶體,解決了WSAENOBUFS問題。但是這個方案降低了伺服器的吞吐量(見第9部分的Q6和A6例子)。 3.6.2 資料包的重排序問題 在參考文獻3中也討論了這個問題。儘管使用IOCP,可以使資料按照它們被髮送的順序被可靠的處理,但是執行緒表的結果是實際工作執行緒的完成順序是不確定的。例如,假如你有兩個I/O工作執行緒,並且你應該接收“位元組資料塊1、位元組資料塊2 、位元組資料塊3”,你可以按照錯誤的順序處理它們,也就是“位元組資料塊2、位元組資料塊1 、位元組資料塊3”。這也意味著,當你通過把傳送請求投遞到IO完成埠來發送資料時,資料實際上是被重新排序後傳送的。 這個問題的一個實際解決辦法是,為我們的記憶體類增加順序號,並按照順序號處理記憶體。意思是,具有不正確號的記憶體被儲存備用,並且因為效能原因,我們將記憶體儲存在希哈表中(例如m_SendBufferMap和m_ReadBufferMap)。 要想得到更多這個方案的資訊,請檢視原始碼,並在IOCPS類中檢視下面的函式: l GetNextSendBuffer (..) 和GetNextReadBuffer(..), 為了得到排序的傳送或接收記憶體。 l IncreaseReadSequenceNumber(..)和IncreaseSendSequenceNumber(..), 為了增加順序號。 3.6.3 非同步阻塞讀和位元組塊包處理問題 大多數伺服器協議是一個包,這個包的基礎是第一個X位的描述頭,它包含了完整包的長度等詳細資訊。伺服器可以解讀這個頭,可以算出還需要多少資料,並一直解讀,直到得到一個完整的包。在一個時間段內,伺服器通過非同步讀取呼叫是很好的。但是,假若我們想全部利用IOCP伺服器的潛力,我們應該有很多的非同步讀操作等待資料的到達。意思是很多非同步讀無順序完成(像在3.6.2討論的),通過非同步讀操作無序的返回位元組塊流。還有,一個位元組塊流(byte chunk streams)能包含一個或多個包,或者包的一部分,如圖1所示: 圖1 這個圖表明部分包(綠色)和完整的包(黃色)在位元組塊流中是如何非同步到達的。 這意味著我們要想成功解讀一個完整包,必須處理位元組流資料塊(byte stream chunks)。還有,我們必須處理部分包,這使得位元組塊包的處理更加困難。完整的方案可以在IOCP類裡的ProcessPackage(..)函式中找到。 3.6.4 訪問紊亂(access violation)問題。 這是一個次要問題,是編碼設計的結果,而不是IOCP的特有問題。倘若客戶端連線丟失,並且一個I/O呼叫返回了一個錯誤標識,這樣我們知道客戶端已經不在了。在CompletionKey引數中,我們為它傳遞一個包含了客戶端特定資料的結構體的指標。假如我們釋放被ClientContext結構體佔用的記憶體,被同一個客戶端執行I/O呼叫所返回的錯誤碼,我們為ClientContext指標傳遞雙位元組的CompletionKey變數,試圖訪問或刪除CompletionKey引數,這些情況下會發生什麼?一個訪問紊亂髮生了。 這個問題的解決辦法是為ClientContext結構體增加一個阻塞I/O呼叫的計數(m_nNumberOfPendlingIO)當我們知道沒有阻塞I/O呼叫時我們刪除這個結構體。EnterIoLoop(..)函式和 ReleaseClientContext(..).函式就是這樣做的。 3.7 原始碼總攬 原始碼的目標是提供一些能處理與IOCP有關的問題的程式碼。原始碼也提供了一些函式,它們在處理通訊、客戶端/伺服器接收/傳送檔案函式、還有執行緒池處理等方面會經常用到。 圖2 原始碼IOCPS類函式總攬 我們有很多I/O工作執行緒,它們通過完成埠(IOCP)處理非同步I/O呼叫,這些工作執行緒呼叫一些能把需要大量計算的請求放到一個工作佇列著中的虛擬函式。邏輯工作執行緒從佇列中渠道任務,進行處理,並通過使用一些類提供的函式將結果返回。圖形使用者介面(GUI)通常使用Windows訊息,通過函式呼叫,或者使用共享的變數,和主要類進行通訊。 圖3 圖3顯示了類的總攬。 圖3中的類歸納如下: l CIOCPBuffer:管理被非同步I/O呼叫使用的記憶體的類。 l IOCPS:處理所有通訊的主要類。 l JobItem:包含被邏輯工作執行緒所執行工作的結構體。 l ClientContext:儲存客戶端特定資訊的結構體(例如:狀態、資料 )。 3.7.1 記憶體設計——CIOCPBuffer類 當使用非同步I/O呼叫時,我們必須為I/O操作提供一個私有記憶體空間。當我們分配記憶體時要考慮下面一些情況: l 分配和釋放記憶體是很費時間的,因此我們要反覆利用分配好的記憶體。所以,我們像下面所示將記憶體儲存在一個連線表中。 · // Free Buffer List.. · · CCriticalSection m_FreeBufferListLock; · CPtrList m_FreeBufferList; · // OccupiedBuffer List.. (Buffers that is currently used) · · CCriticalSection m_BufferListLock; · CPtrList m_BufferList; · // Now we use the function AllocateBuffer(..) · // to allocate memory or reuse a buffer. l 有時,當一個非同步I/O呼叫完成時,我們可能在記憶體中有部分包,因此我們為了得到一個完整的訊息,需要分離記憶體。在CIOCPS類中的函式SplitBuffer()可以實現這一目標。我們有時也需要在兩個記憶體間複製資訊, CIOCPS類中的AddAndFlush()函式可以實現。 l 我們知道,我們為我們的記憶體增加序列號和狀態變數(IOZeroReadCompleted())。 l 我們也需要位元組流和資料相互轉換的方法,在CIOCPBuffer類中提供了這些函式。 在我們的CIOCPBuffer類中,有上面所有問題的解決辦法。 3.8 如何使用原始碼 從IOCP中派生你自己的類,使用虛擬函式,使用IOCPS類提供的函式(例如:執行緒池)。使用執行緒池,通過使用少數的執行緒,為你為各種伺服器或客戶端高效的管理大量的連線提供了可能。 3.8.1 啟動和關閉伺服器/客戶端 啟動伺服器,呼叫下面的函式:
BOOL Start(int nPort=999,int iMaxNumConnections=1201, 
 int iMaxIOWorkers=1,int nOfWorkers=1, 
 int iMaxNumberOfFreeBuffer=0, 
 int iMaxNumberOfFreeContext=0, 
 BOOL bOrderedSend=TRUE,  
 BOOL bOrderedRead=TRUE, 
 int iNumberOfPendlingReads=4); 
l nPortt :伺服器將監聽的埠號(在客戶端模式設為-1)。 l iMaxNumConnections:最多允許連線數。 l iMaxIOWorkers :輸入/輸出工作執行緒數。 l nOfWorkers:邏輯工作者數(在執行時能被改變)。 l iMaxNumberOfFreeBuffer :保留的重複利用的記憶體的最大數量(-1:無 ,0:無窮)。 l iMaxNumberOfFreeContext :保留的重複利用的客戶端資訊的最大數量(-1:無 ,0:無窮)。 l bOrderedRead :用來進行順序讀。 l bOrderedSend :用來進行順序傳送。 l iNumberOfPendlingReads :等待資料的非同步讀迴圈的數量。在連線到一個遠端的連線時呼叫下面的函式:
Connect(const CString &strIPAddr, int nPort) 
l strIPAddr :遠端伺服器的IP地址。 l nPort:埠。 關閉伺服器,呼叫函式:ShutDown()。 例如:
MyIOCP m_iocp; 
if(!m_iocp.Start(-1,1210,2,1,0,0)) 
AfxMessageBox("Error could not start the Client"); 
…. 
m_iocp.ShutDown(); 

5.1 檔案傳輸

使用Winsock 2.0的TransmitFile 函式傳輸檔案。TransmitFile 函式在連線的套接字控制代碼上傳輸檔案資料。此函式使用作業系統的緩衝管理機制接收檔案資料,在套接字上提供高效能的檔案傳輸。在非同步檔案傳輸上有以下幾個重要方面: l 除非TransmitFile函式返回,否則不能再對套接字執行 傳送 或 寫入操作,不然會破壞檔案的傳輸。在執行PrepareSendFile(..) 函式後,所有對ASend函式的呼叫都是不允許的。 l 由於系統是連續讀檔案資料,開啟檔案控制代碼的FILE_FLAG_SEQUENTIAL_SCAN特性可以提高快取效能。 l 在傳送檔案(TF_USE_KERNEL_APC)時,我們使用核心的非同步程式呼叫。TF_USE_KERNEL_APC的使用可以帶來明顯的效能提升。很可能(儘管不一定),帶有TransmitFile的執行緒的上下文環境的初始化會有沉重的計算負擔;這種情況下可以防止反覆執行APC(非同步程式呼叫)。 檔案傳輸的順序如下:伺服器通過呼叫PrepareSendFile(..)函式初始化檔案傳輸。客戶端接收到檔案資訊時,通過呼叫PrepareReceiveFile(..)函式準備接收,並且給伺服器傳送一個包來開始檔案傳輸。在伺服器收到包後,它呼叫使用高效能的TransmitFile函式的StartSendFile(..)函式傳輸指定的檔案。

6 原始碼例子

提供的原始碼是一個模擬客戶端/伺服器的例子,它也提供了檔案傳輸功能。在原始碼中,從類IOCP派生出的類MyIOCP處理客戶端和伺服器端的通訊。在4.1.1 部分提到了這個虛擬函式的用法。 在客戶端,或者伺服器端的程式碼中,虛擬函式NotifyReceivedPackage是重點。描述如下:
void MyIOCP::NotifyReceivedPackage(CIOCPBuffer *pOverlapBuff, 
 int nSize,ClientContext *pContext) 
 { 
 BYTE PackageType=pOverlapBuff->GetPackageType(); 
 switch (PackageType) 
 { 
 case Job_SendText2Client : 
 Packagetext(pOverlapBuff,nSize,pContext); 
 break; 
 case Job_SendFileInfo : 
 PackageFileTransfer(pOverlapBuff,nSize,pContext); 
 break;  
 case Job_StartFileTransfer:  
 PackageStartFileTransfer(pOverlapBuff,nSize,pContext); 
 break; 
 case Job_AbortFileTransfer: 
 DisableSendFile(pContext); 
 break;}; 
 } 
這個函式處理進來的訊息和遠端連線傳送的請求。在這種情形下,它只不過進行一個簡單的回覆或者傳輸檔案。原始碼分為兩部分,IOCP和IOCPClient, 它們是連線的雙方。

6.1 編譯器問題

在使用VC++ 6.0 或者 .NT時,在處理類CFile時可能會出現一些奇怪的錯誤。像下面這樣:
“if (pContext->m_File.m_hFile !=  
INVALID_HANDLE_VALUE) <-error C2446: '!=' : no conversion " 
"from 'void *' to 'unsigned int'” 
在你更新標頭檔案(*.h),或者更新你的VC++ 6.0版本後,或者只是改變型別轉換錯誤,都可能會解決這些問題。經過一些修改,這個客戶端/伺服器的原始碼在沒有MFC的情況下也能使用。

7 注意點和解決規則

在你將此程式碼用於其它型別的程式時,有一些程式設計的陷阱和原始碼有關,使用“多執行緒程式設計”可以避免。不確定的錯誤是那些隨時發生的錯誤,並且通過執行相同的出錯的任務的順序這種方式很難降低這些不確定的錯誤。這類錯誤是存在的最嚴重的錯誤,一般情況下,它們出錯是因為原始碼設計執行的核心的出錯上。當伺服器執行多個IO工作執行緒時,為連線的客戶端服務,假如程式設計人員沒有考慮原始碼的多執行緒環境,就可能會發生像違反許可權這種不確定的錯誤。

解決規則 #1:

像下面例子那樣,絕不在使用上下文 “鎖”之前鎖定客戶端的上下文(例如ClientContext)之前進行讀/寫。通知函式(像:Notify*(ClientContext *pContext))已經是“執行緒安全的”,你訪問ClientContext的成員函式,而不考慮上下文的加鎖和解鎖。
//Do not do it in this way 
// …  
If(pContext->m_bSomeData) 
pContext->m_iSomeData=0; 
// … 
// Do it in this way.  
//…. 
pContext->m_ContextLock.Lock();  
If(pContext->m_bSomeData)  
pContext->m_iSomeData=0;  
pContext->m_ContextLock.Unlock();  
//… 
當然,你要明白,當你鎖定一個上下文時,其他的執行緒或GUI都將等待它。

解決規則 #2:

要避免,或者“特別注意”使用那些有複雜的“上下文鎖”,或在一個“上下文鎖”中有其他型別的鎖的程式碼。因為它們很容易導致“死鎖”。(例如:A等待B,B等待C,而C等待A => 死鎖)。
pContext-> m_ContextLock.Lock(); 
//… code code ..  
pContext2-> m_ContextLock.Lock();  
// code code..  
pContext2-> m_ContextLock.Unlock();  
// code code..  
pContext-> m_ContextLock.Unlock(); 
上面的程式碼可以導致一個死鎖。

解決規則 #3:

絕不要在通知函式(像Notify*(ClientContext *pContext))的外面訪問一個客戶端的上下文。假如你必須這樣做,務必使用m_ContextMapLock.Lock(); m_ContextMapLock.Unlock()對它進行封裝。如下面程式碼所示:
ClientContext* pContext=NULL ;  
m_ContextMapLock.Lock();  
pContext = FindClient(ClientID);  
// safe to access pContext, if it is not NULL 
// and are Locked (Rule of thumbs#1:)  
//code .. code..  
m_ContextMapLock.Unlock();  
// Here pContext can suddenly disappear because of disconnect.  
// do not access pContext members here.