學習筆記:神祕的 IOCP 完成端口

http://d556.blog.163.com/blog/static/195109520130119618278/


【什麼是IOCP】
是WINDOWS系統的一個內核對象。通過此對象,應用程序可以獲得異步IO的完成通知。
這裏有幾個角色:
角色1:異步IO請求者線程。簡單的說,就是調用WSAxxx()函數(例如函數WSARecv,WSASend)的某個線程。
       由於是“異步”的,當角色1線程看到WSAxxx()函數返回時,它並不能知道本次IO是否真的完成了。
       注:當WSAxxx返回成功true時,實際已經讀到或發送完數據了(同步的獲得IO結果了)。
       爲了統一邏輯,我們還是要放到角色2線程中,統一處理IO結果。
       
角色2:異步IO完成事件處理線程。簡單的說,就是調用GetQueuedCompletionStatus函數的線程。
       角色1投遞的某個異步IO請求M,角色2線程一定能獲得M的處理結果(無非是IO成功或失敗)       
角色3:操作系統。負責角色1和角色2的溝通。OS接收角色1的所有異步IO請求。
       OS處理(實際的IO讀寫)排隊的很多異步IO請求。OS的程序員是很牛的,他們能最大化利用CPU和網絡。
       OS把所有IO結果放入{IOCP完成隊列C}中。
       OS能調度角色2線程的運行和睡眠,能控制角色2線程同時運行的線程個數。
       角色2通過GetQueuedCompletionStatus函數,讀取到{IOCP完成隊列C}中完成的IO請求。

【需要創建幾個角色2線程呢】
CreateIoCompletionPort()函數創建一個完成端口,其中有一個參數是NumberOfConcurrentThreads。
這個參數的含義是:程序員期望的同時運行的角色2線程數。0代表默認爲本機器的CPU個數。
程序員可以創建任意數量的角色2線程。
例如:NumberOfConcurrentThreads設置爲2,而實際創建6個角色2線程,或100個,或0個。

如何理解這兩個數的差異呢?
OS努力維持NumberOfConcurrentThreads個線程併發的運行,即使我創建100個角色2線程。
如果{IOCP完成隊列C}中排隊等待處理的{IO結果項}很少,角色2線程能很快處理完,則實際可能只有1個角色2線程在工作,其他線程都在睡眠(即使NumberOfConcurrentThreads設置成100,也只有一個線程在工作)。
如果{IOCP完成隊列C}中排隊等待處理的{IO結果項}很多,角色2線程處理需要很多CPU時間,則實際可能會有很多角色2線程會被喚醒工作。當然前提是我實際創建了很多角色2線程。極端情況下,如果角色2線程都退出了,則{IOCP完成隊列C}可能會被擠爆了。

爲什麼一般情況下,NumberOfConcurrentThreads設置爲2,而實際創建6個角色2線程呢?
考慮到我們的角色2線程不只是CPU計算,它還可能去讀寫日誌文件,調用Sleep,或訪問某個Mutex對象(造成線程被調度爲睡眠)。這樣,OS會啓用一些“後備軍”角色2線程去處理{IOCP完成隊列C}。所以實際創建6個角色2線程,有幾個可能是後備軍線程。如果我們的角色2線程是純CPU密集計算型的(可能有少量的臨界區訪問,也不會輕易放棄CPU控制權),那麼我們只需要實際創建角色2線程數=CPU個數,多創建了也沒益處(但也沒壞處,可能OS讓他們一直都睡眠,做後備軍)。

【異步讀寫如何控制字節數】

或曰,某個WSASend調用,在網絡正常的情況下,{實際發送字節數}(簡稱T)就是{需要發送的字節數}(簡稱R)。我試驗了一下,從1M的buff,2M的buff...當開到很大的buff時,終於出現T<R的時候。
如果我們的應用需要一次發送很大量的數據時,應該檢查T是否小於R。當發送的字節數不足時,應該繼續發送剩餘的(未發送出去的)部分。

對於WSARecv接收數據,應接收多大的字節數呢?假如應用層協議規定,我們的數據長度不是固定的,這就是一個很棘手的問題。一般情況下,應用層協議規定,一段邏輯上是一組的數據,分包頭部分和包體部分。包頭是固定長度的,包體是變長的。包頭含有如下信息:包體的長度字節數。我們先收一個固定長度的包頭,從中解析出“包體長度信息”,然後我們再次發出一個WSARecv收包體。我稱作這個方法爲“包頭包體兩階段接收法”。

【異步讀寫如何控制超時】

假如我們接受一個數據包,發出WSARecv{異步IO: X}。這個{異步IO: X}可能長時間無法獲得結果。假如對方客戶端惡意的不發送任何數據。IOCP本身機制不提供任何超時控制。只能我們程序員控制這個超時。我們發出一個WSARecv調用後,通過維護某種{數據結構: D},記住此時的時間。在未來的某個時間我們的程序要檢查這個{數據結構: D}, 判斷這個WSARecv調用是否有結果了。當然此{數據結構: D}的狀態改變由{角色2線程}負責。
如果{角色2線程}通過GetQueuedCompletionStatus調用獲得了{異步IO: X}的結果,則改變{數據結構: D}的狀態。我們只要判斷{數據結構: D}的某個狀態未改變,則一定是這個{異步IO: X}未被完成(客戶端沒有發送任何數據)。

控制超時和控制字節數往往有關聯。假如惡意的客戶端只發送部分字節數,我們還要處理這種情況。
假如協議要求100個字節,客戶端一次傳來10個,我們可以毫不客氣的幹掉這個客戶端。這個策略比較狠了些。我們需要溫和一點的策略。可能因爲網絡原因,剩下的90個字節很快就能到來,我們可以繼續在規定時間等接受剩餘的90個字節。如果超時了,才把這個客戶端幹掉。

【IOCP系統資源耗盡的問題】

假如我們有10000個客戶端socket連接,爲了接收他們發送過來的數據,我們需要預先投遞10000個WSARecv。
假如每個異步讀需要應用層程序員提供10k的緩衝區,則一共需要的用戶緩衝區爲 10000*10k=97M 內存。windows要求這97M數據必須被OS“鎖定”,意思大體是需要佔用大量的OS的資源了。所以程序很可能會因爲10000個客戶同時連接,而耗盡資源。WSAENOBUF錯誤同此有關。
解決方法是投遞0字節數請求的WSARecv。僞代碼如下:

WSABUF DataBuf;
DataBuf.len=0;
DataBuf.buf=0;
WSARecv(socket, &DataBuf, 1,...);
當有數據到來時,這個異步IO會從角色2線程中得到結果。由於它是0字節的讀,所以它沒有觸碰任何socket緩衝區的到來的任何數據。我們付出很小的成本(大約每個連接節省了10k)就能知道哪個客戶端的數據到來了。別小看了每個連接節省了這麼點資源,連接數大了節約的總量就很可觀了。如果客戶端數量很少,這個技巧就沒什麼意思了。

【優雅的殺死角色2線程】

PostQueuedCompletionStatus函數會向{IOCP完成隊列C}中push進去一條記錄。這樣角色2線程就能獲得這個“虛僞或模擬”的異步IO完成事件。爲什麼要“假冒”一條{IOCP完成隊列C}的條目呢?用處嗎,程序員自己去想吧(意思是用處多多了)。一般來說,我們用它“優雅的殺死角色2線程”。僞代碼如下:

typedef struct
{
   OVERLAPPED Overlapped;
   OP_CODE op_type; 
   ...
} PER_IO_DATA;
PER_IO_DATA* PerIOData = ...
PerIOData->op_type = OP_KILL; //操作類型是殺死線程
PostQueuedCompletionStatus(...PerIOData...); 
//如果有N個角色2線程,則需要調用N次,這樣{IOCP完成隊列C}中纔能有N個這個的條目。

角色2線程:
PER_IO_DATA* PerIOData=0;
GetQueuedCompletionStatus(...&PerIOData...);
if (PerIOData->op_type == OP_KILL){  return ; } //從線程中自然return,就是優雅的退出線程。

【大頭的錯誤處理】

GetQueuedCompletionStatus函數的錯誤處理比較複雜。

1 如果GetQueuedCompletionStatus返回false:
1.1 如果Overlapped指針非空
    恭喜你,你投遞的異步IO獲得結果了,只不過是失敗的結果。好孬也終於回來個信兒了。
    這可能是socket連接斷了等等。
    1.1.1 如果GetLastError獲得的錯誤號爲ERROR_OPERATION_ABORTED
          一定是有東西調用了CancelIO(socket)了。所有同這個socket相關的異步IO請求都會被取消。
    1.1.2 如果GetLastError 獲得的錯誤號爲其他的東西
          可能是IO沒成功,如socket連接斷開了等等。
1.2 如果Overlapped指針空
    這可不是好消息,因爲這意味着IOCP本身有重大故障了。比如我們意外的把IOCP的句柄CloseHandle了。
    1.2.1 如果GetLastError獲得的錯誤號爲WAIT_TIMEOUT
          可能GetQueuedCompletionStatus設置的超時參數dwMilliseconds不是INFINITE。我們繼續調用GetQueuedCompletionStatus重新等待吧。
    1.2.1 如果GetLastError獲得的錯誤號ERROR_ABANDONED_WAIT_0, 或者其他
          IOCP本身都完蛋了,角色2線程應另找東家了,或者就地自我了斷算了。
2 如果GetQueuedCompletionStatus返回true:
  恭喜你,異步IO成功了。
  通過lpNumberOfBytes, lpCompletionKey, and lpOverlapped這三個參數獲得詳細信息。
  lpNumberOfBytes:實際傳輸的字節數。(可能比需要傳輸的字節數少)
  lpCompletionKey:這就是著名的PerHandleData,可以知道這是哪個socket連接的。
  lpOverlapped:   這就是著名的PER_IO_DATA, 同某次異步IO調用關聯,
        比如某次WSASend(Overlapped參數=0x123)調用,這裏能重新拿到lpOverlapped==0x123。
我們可以根據這個指針,得知這個IO結果是對應着哪次WSASend()調用的結果。         

我滿以爲這個錯誤處理天衣無縫,直到有一次測試。我對一個socke投遞了100個WSARecv。當我故意把客戶端關閉後,這些異步IO不出意外的都在角色2線程的GetQueuedCompletionStatus函數處獲得結果了。令我吃驚的是,GetQueuedCompletionStatus返回爲TRUE!!!,並且GetLastError()返回值是0!!!
令我欣慰的是lpNumberOfBytes值爲0(否則真見鬼了)。所以看到GetQueuedCompletionStatus返回true,不要高興的太早了。

2.1 把lpOverlapped指針解釋成PER_IO_DATA數據結構。如果PerIOData->op_type == OP_KILL,可能這個是PostQueuedCompletionStatus僞造的一個IO完成事件。
2.2 判斷是否(lpNumberOfBytes==0)。如果這個IO結果的確是某個WSAxxx()的結果,而不是PostQueuedCompletionStatus僞造的,則這個IO對應的socket可能斷了。
2.3 (lpNumberOfBytes>0) ,這纔是真正的IO完成的事件呢。可能99.9%的機會,分支跑到這裏的。
 
【在同一個socket上一次投遞多個異步IO】
一次投遞多個WSASend(1234,&Buff1,...); WSASend(1234,&Buff2,...); ... 好像沒問題。
如果一次投遞多個WSARecv(1234,&Buff1,...);WSARecv(1234,&Buff2,...);好像有些需要闡明的問題。

第一:Windows保證按照你投遞WSARecv的順序,把網絡上到達的數據按先後順序放入Buff1,Buff2。
      如果網絡上到來的數據爲 AAAAUUUU, 假設Buff1長度4,Buff2長度4,
      則保證Buff1獲得AAAA,Buff2獲得UUUU
第二:如果有多個角色2線程,可能由於線程調度的“競爭條件race condition”,
      某線程首先執行Buff2的完成處理過程。
      如果我在角色2線程中,打印出收到的數據,可能打印出如下結果:UUUUAAAA。這絕不是違反了TCP協議,       而是多線程的問題。其實解決方案很簡單。說者費事,上僞代碼
typedef struct
{
   OVERLAPPED Overlapped;
   ...
   int Package_Number; //我對每一次IO,夾帶本次調用順序號
   ...
} PER_IO_DATA;

PER_IO_DATA* PerIOData1=...
PerIOData1->Package_Number = 1 ; //第一次調用
WSARecv(1234, &Buff1,...PerIOData1...);

PER_IO_DATA* PerIOData2=...
PerIOData1->Package_Number = 2 ; //第二次調用
WSARecv(1234, &Buff2,...PerIOData2...);

我們需要維護某種數據結構,記住我們發出了兩個WSARecv。
當收到IO結果後,程序需要判斷,只有1,2兩個調用都從角色2線程獲得結果後,才能按順序把Buff1和Buff2拼接,就是符合順序的AAAAUUUU。當然,還有其他更好的方式,這裏只展示基本原理。

第三:真有必對同一個socket一次投遞多個WSARecv嗎?
      這個問題同【IOCP系統資源耗盡的問題】,不矛盾。我們假設在投遞多個WSARecv時,已經預見到網絡上將到來某個socket的大量數據。 根據網絡資料介紹,這樣可以充分發揮多CPU併發運算的能力。我想在雙核CPU機器上,一個CPU處理Buff1,同時另一個CPU處理Buff2。
      如果是少量客戶端連接,每個連接可能突然發生大量數據的傳送,這個做法可能能加快從Socket緩衝區拷貝數據到應用程序Buff的速度(個人揣測)。
      如果是大量客戶端(10000)連接,每個連接傳送的數據量很少,這個做法我個人認爲沒什麼意義。我想CPU數量就2個,不會輕易就閒下來吧?
      有一個重要原因,需要投遞多個buffer給windows。假如我預計到某個socket一次傳過來2M的數據,而
我沒有2M大小的buffer,我只有1M大小的buffer。我需要先調用一次WSARecv,等待收完這1M數據後,再發一個
WSARecv。或者我用其他方法,提供給windows系統2個1M的buff。

第四:假設我們真需要一次投遞多個Buff,接收數據,有必要用多次WSARecv調用嗎?
      這裏有個可能的替代做法,上僞代碼:
      char *raw1 = new char[BUFF_SIZE];
      WSABUF[2] wsabuf;
      wsabuf[0].buf = raw1 ;
      wsabuf[0].len = BUFF_SIZE;

      char *raw2 = new char[BUFF_SIZE];
      wsabuf[1].buf = raw2 ;
      wsabuf[1].len = BUFF_SIZE;

      WSARecv(1234, &wsabuf, 2 ... );  
      //重點在參數2上,指示了WSABUF結構體的個數是2個。一般大量IOCP的例子裏這個參數都是1
      
      這個方法我認爲更簡單,不知道是我自己“2”還是網上的其他人“2”,一次發出多個WSARecv,把這些分散的IO收集起來也是費事的事。UNIX系統的scatter-gather IO類似於這個機制。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章