網路編程的技術要點__編程
來源:互聯網
上載者:User
首先向雲風致敬, 他的 skynet 給了我很多啟發. skynet 的核心是多線程環境下的訊息管理, 如何讓訊息安全高效的從一個服務傳遞到另一個服務. 服務線上程池中運行. skynet 實現了一個輕巧而高效的網路模組. 我用c++重寫了這個部分, 並做了一些修改使它結構更清晰, 更方便使用. 這篇文章描述的就是這個網路模組的實現. 它基於 skynet v1.0.0-alpha10 (2015-8-17). 我盡量不貼代碼, 只用文字來描述.
概述 網路模組內部不會啟動新的線程, 它在程式的主線程中運行. 由於網路io通常不是效能的熱點, 一個線程是可行的. 需要注意的是, 如果所有功能都在一個線程處理, 會有一些隱患(下文提到), 通常我們需要為網路層單起一個線程. 網路模組提供幾個介面, 用來監聽網路地址, 發起串連, 中斷連線. 發送資料. 這些介面都是非阻塞的. 同時它們也是安全執行緒的, 可以用多個線程來調用這些介面. 網路模組提供一個訊息泵, 彈出網路層的狀態和收到的資料. 使用者可以根據具體情境, 在主線程處理這些資料, 也可以用新的背景工作執行緒來處理.
Epoll 使用 epoll 來做多工IO介面. epoll 使用預設的水平觸發模式. 用 Epoll 類來封裝 epoll 的操作. 一個 Epoll 對象只有一個資料成員, 就是 epoll fd. 它有這幾個介面: 建立, 銷毀, 刪除fd 添加需要監控的 fd: 添加的同時監聽這個 fd 的讀事件. 修改某個 fd 需要監控的事件類型: 通常我們總是需要監聽讀事件, 讀事件根據參數來判斷是否需要監聽. 擷取事件資訊: 返回一個事件數目組, 每個數群組成員是一個結構體, 結構體包含這樣的資訊: 哪個fd發生了讀/寫事件.
簡單的 Socket 用一個Socket類來封裝 socket fd. 每個串連對應一個 socket 對象. socket 對象必然包含一個 fd. 由於我們要求非阻塞的寫資料, 所以socket對象還擁有一個 WriteBuffer. 這是最簡單的情況.
SocketManager 在實際中, 網路連接會頻繁的建立或斷開. 不停的執行個體化然後又銷毀Socket對象, 顯然是低效的. 一個改進的方法是將不需要的 socket 對象放到池中, 需要的時候, 從池中拿出來, 初始化內部資料後繼續使用. 這個方法使 socket 對象的數量逐漸增加, 然後穩定在某個數量. 另一方法是一開始就靜態執行個體化一個足夠大的socket對象數組. 數組的大小可以很簡單的估算出來. 我們通過浪費一些記憶體換來了穩定的結構. 我採用的是第二種方法.
SocketManager的實現很簡單. 它提供一個介面來返回一個未被使用的socket. 需要注意的是, 這個介面必須是安全執行緒的.
改進的 Socket 為了支援 SocketManager, 簡單的Socket類需要一些額外的資訊. id: 用來區分其它socket對象, 實際上它等於該對象在數組中的下標. state: 是否被使用的狀態, 實際上還有許多其它狀態 使用者資料: 使用者可以在裡面儲存一些資訊. 比如該串連屬於哪個模組(服務).
Socket 的寫緩衝 這個緩衝用來暫時記錄需要發送的資料, 在 fd 可寫時, 發送它們. 為了避免字串拷貝的開銷, 我們約定, 應用程式層負責為需要發送的資料分配記憶體, 網路層的緩衝只儲存傳遞過來的指標. 資料發送完成後, 網路層釋放這塊記憶體. 這是一個重要的設計. 緩衝內部資料結構是一個先進先出的隊列(鏈表實現). 每條資料對應一個節點.
每個節點記錄三個資料: 資料地址, 資料長度, 未經處理資料地址
發送資料的流程是這樣的: 應用程式層需要發送資料, socket id, buffer, size 網路層找到這個 socket, 如果它的寫緩衝是空的, 直接發送(非阻塞的). 如果資料全部發送完, 操作完成. 如果資料沒有發送完, 或者緩衝不是空的, 把 buffer, size, offset(可能一部分資料已經發送了)記錄到緩衝中. 網路層非同步, 在 fd 可寫的時候, 發送前端節點的的資料, 根據發送的大小, 維護位移. 如果某個節點的資料都發送完了, 釋放這個節點的 buffer. 然後刪除這個節點.
Socket 的讀緩衝 skynet並沒有為socket實現讀緩衝. 每當socket收到資料, 網路層的訊息泵就把這些資料傳遞出去. 這是因為網路層不涉及邏輯, 它不知道如何解析資料. 正如發送資料時, 網路層只是發送一個記憶體塊, 它不知道記憶體塊裡是什麼資料, 這些資料又是如何組織. 讓邏輯層來解析和處理資料, 是一種適用面更廣的設計.
用管道來同步 根實際情境, 你可以讓把伺服器的所有任務都在一個線程裡處理. 但是更多的時候, 為了減低耦合, 利用多CPU, 網路模組在一個獨立的線程中運行. 而網路的使用者, 可能來自多個線程. 所以, 我們需要同步應用程式層對網路層的操作. 一個安全執行緒的訊息佇列可以很好的完成這個任務. 還有一個更簡潔的辦法, 那就是使用管道. 用管道來作為應用程式層和網路層的橋樑. 使用者向管道寫入操作, 管道保證操作同步, 網路線程從管道讀出操作並執行. 管道大大減少了代碼的複雜度.
網路層的訊息指令非常簡短, 絕大多數指令只有十幾個位元組. 發送資料的指令傳遞的是資料指標, 非常短小. 管道保證, 一次往管道裡寫入4096位元組以內的資料, 都是原子操作. 我們不用煩心考慮同步問題. 另一方面, 管道的緩衝空間是64K. 由於網路層能快速的處理指示, 這個容量夠用了. 考慮最壞的情況, 管道緩衝滿了, 那麼寫管道的操作會阻塞. 等到管道裡的指令被處理, 騰出空間, 資料再被寫入. 所以指令不會丟失.
考慮一種特殊情況: 寫管道操作和網路線程是同一線程, 並且管道滿了. 由於寫操作被阻塞, 網路線程無法處理指示, 系統就陷入死結. 所以, 應用程式層和網路層最好是屬於不同的線程.
監聽流程 我用監聽操作的邏輯流程來舉例說明應用程式層和網路層是如何配合的. 以use開頭的操作屬於應用線程(代碼可能在網路層), 用net開頭的操作屬於網路線程.
use. 調用Listen函數. use. 建立socket fd, 綁定網路地址, 開始監聽. use. 將一個狀態為free的Socket對象互斥的置為reserve. use. 把一條監聽指令寫入管道, 指令參數包括 socket fd, Socket id. use. Listen函數返回 Socket id.
net. 從管道中讀出監聽指令. net. 根據 Socket id, 找到socket對象, 初始化它, 把它的狀態置為plisten.
use. 調用Start函數, 傳入 Listen函數返回的Socket id use. 啟動指令寫入管道.
net. 從管道讀出啟動指令. net. 找到Socket對象, 把socket fd 添加到 epoll. Socket狀態置為 listen. net. 處理串連事件.
流程結束.
Socket 的狀態 socket的初始狀態時free, 表明它未被使用. 如果要使用socket, 狀態從 free 變為 reserve, 表明它已經被預定了, 做什麼還不知道.
如果要用某個socket來監聽, 狀態從 reserve 變為 plisten, 此時它沒有被添加到epoll . 系統初始化完成後, 網路啟動, socket fd 添加到 epoll, 狀態從 plisten 變為 listen. 它可以處理串連請求了.
如果有串連請求, 把 一個socket 的狀態從 free 變為 reserve, 再變為 paccept, 此時它沒有被添加到epoll . 如果應用程式層決定接受這個串連, 把 socket fd 添加到 epoll, 狀態從 paccept 變為 connected. 它可以發送和接收資料了.
如果要用某個socket來發起串連. 把 一個socket 的狀態從 free 變為 reserve, 發起串連並把fd 添加進epoll. 狀態是 connecting, 串連成功後(三向交握完成), 狀態變為 connected. 它可以發送和接收資料了.
如果要關閉一個socket, 緩衝裡有資料, 狀態先設為 halfclose, 資料發送完後, 設為 free. 狀態時 halfclose的socket不會再讀資料, 但是會繼續把緩衝的資料發送完.
訊息泵 好像水泵從水池裡抽水一樣, 應用程式層迴圈調用MsgLoop函數, 從網路層提取資訊. 如果迴圈代碼在一個獨立的線程裡執行, 這個線程就是網路線程. 訊息泵會拋出這些訊息: data: socket 收到資料, 返回資料指標和長度, 應用程式層使用完後要釋放指標指向的記憶體. close: 串連關閉 open: 開始監聽或串連建立. accept: 有串連請求. exit: 網路層退出.
異常處理 發起串連時, 我們將socket設定為非阻塞的, 再調用 connect方法, 此時會立即返回一個錯誤碼. 如果錯誤碼是 EINPROGRESS, 說明串連進行中(三向交握). 我們把 fd 添加到 epoll 中. 當串連完成的時候, epoll 會發出一個事件. 如果是其它錯誤碼, 說明串連失敗了. 終止串連操作.
往管道讀寫資料的時候, 如果返回 EINTR, 說明操作被系統中斷, 需要重新寫. 如果是其它錯誤, 列印一條錯誤記錄檔.
往socket寫資料的時候, 如果返回 EINTR(被中斷), 可以馬上重寫, 如果返回 EAGAIN(被阻塞), 過一會再寫. 如果是其它錯誤, 列印一條錯誤記錄檔, 然後中斷連線.
往socket讀資料的時候, 如果返回 EINTR(被中斷), 重新讀, 如果返回 EAGAIN(被阻塞), 列印一條錯誤記錄檔, 重新讀. 如果是其它錯誤, 中斷連線.
網路常量 這些常量可以根據具體情境進行調整.
建立epoll的時候, 要告訴核心監聽的數目有多大, 對應的 EPOLL_FD_MAX = 1024;
socket 監聽網路地址時, 需要指定半串連隊列的大小, 對應的 LISTEN_BACKLOG = 32;
從socket fd 讀資料時, 由於我們事先不知道會讀出多少資料, 在動態申請記憶體時, 一開始申請的大小是一個常量: MIN_READ_BUFFER = 64; 每個socket的read_buffer_size是獨立的, 而且會根據前一條資料的大小來動態調整: 增大一倍或縮小一倍.
由於Socket對象是被靜態初始化的, 它們的數量要大於 EPOLL_FD_MAX , 而且我們希望可以快速的找到一個 free狀態的socket對象. 所以它們的數量很多: MAX_SOCKET = 65536;