C 网络库都干了什么?
雖然市面上已經有很多成熟的網絡庫,但是編寫一個自己的網絡庫依然讓我獲益匪淺,這篇文章主要包含:
TCP 網絡庫都干了些什么?
編寫時需要注意哪些問題?
CppNet 是如何解決的。
首先,大家都知道操作系統原生的socket都是同步阻塞的,你每調用一次發送接口,線程就會阻塞在那里,直到將數據復制到了發送窗體。那發送窗體滿了怎么辦,阻塞的 socket 會一直等到有位置了或者超時。你每調用一次接收接口,線程就會阻塞在那里,直到接收窗體收到了數據。同步阻塞的弊端顯而易見,上廁所的時候不能玩手機,不是每個人都能受得了??蛻舳丝梢詥为毥⒁粋€線程一直阻塞等待接收,那服務器每個 socket 都建一個線程阻塞等待豈不悲哉,apache 這么用過,所以有了 Nginx。那能不能創建一個異步的 socket 調用之后直接返回,什么時候執行完了,無論成功還是失敗再通知回來,實現所謂 IO 復用?好消息是現在操作系統大都實現了異步 socket,CppNet 中 Windows 上通過 WSASocket 創建異步的 socket,在 Linux 上通過 fcntl 修改 socket 屬性添加上 O_NONBLOCK。
有了異步 socket,調用的時候不論成功與否,網絡 IO 接口都會立馬返回,成功或失敗,發送了多少數據,回頭再通知你?,F在調用是很舒暢,那怎么獲取結果通知呢?這在不同操作系統就有了不同的實現。早些年的時候有過 select 和 poll,但是各有各的弊端,這個不是本文重點,在此不再詳述?,F在在windows上使用?IOCP,在 Linux 上使用?epoll?做事件觸發,基本已經算是共識。有了 IOCP 和 epoll,我們調用網絡接口的時候,要把這個過程或者干脆叫做任務,通知給事件觸發模型,讓操作系統來監控哪個 socket 數據發送完了,哪個 socket 有新數據接收了,然后再通知給我們。到這里,基本實現異步的socket讀寫該有的東西已經全部備齊。
還有一點不同的是,IOCP 在接收發送數據的時候,會自己默默的干活兒,干完了,再通知給你。你告訴 IOCP 我要發送這些數據,IOCP 就會默默的把這些數據寫進發送窗體,然后告訴你說:“ 頭兒,我干完了 ” 。你告訴 IOCP 我要讀取這個 socket 的數據,IOCP 就會默默的接收這個socket的數據,然后告訴你:“頭兒,我給您帶過來了”。這就著實讓人省心,你甚至不用再去調用 socket 的原生接口 。epoll 則不同,其內部只是在監測這個socket是否可以發送或讀取數據(當然還有建連等),不會像 IOCP 那樣把活兒干完了再告訴你。你告訴 epoll 我要監測這個 socket 的發送和讀取事件,當事件到來的時候,epoll 不會管怎么干活兒,只會冷淡的敲敲窗戶告訴你:”有事兒了,出來干活兒吧“。IOCP 像是一個懂得討領導歡心的老油條,epoll 則完全是一個初入職場的毛頭小子。這就是?Proactor?和?Reactor?模式的區別?,F在客戶端就是領導的位置,所以CppNet 實現為一個 Proactor 模式的網絡庫,讓客戶端干最少的活兒。ASIO 也實現為 Proactor ,而 libevent 實現為 Reactor 模式 。
我們現在把剛才說的過程總結一下,首先需要把 socket 設置非阻塞,然后不同平臺上將事件通知到不同事件觸發模型上,監測到事件時,回調通知給上層。這就是一個網絡庫要有的核心功能,所有其他的東西都是在給這個過程做輔助。
聽起來非常簡單,接下來就說下編寫網絡庫的時候會遇到哪些問題和CppNet的實現。
首先的問題是跨平臺,如何抽象操作系統的接口,對上層實現透明調用。不論是 epoll 還是 socket 接口,Windows 和 Linux 提供的接口都有差異,如何做到對調用方完全透明?這就需要調用方完全知道自己需要什么功能的接口,然后將自己需要的接口聲明在一個公有的頭文件里,在定義時 CppNet 通過 __linux__? 宏在編譯期選擇不同的實現代碼。__linux__ 宏在 Linux 平臺編譯的時候會自動定義。如果不是上層必須的接口,則不同平臺自己定義文件實現內部消化,不會讓上層感知。網絡事件驅動抽象出一個虛擬基類,提前聲明好所有網絡通知相關接口,不同平臺自己繼承去實現。Nginx 雖然是 C 語言編寫,但是通過函數指針來實現類似的構成。
大家已經知道 epoll 和 IOCP 是不同模式的事件模型,如何把 epoll 也封裝成? Proactor 模式?這就需要要在 epoll 之上添加一個實際調用網絡收發接口的干活兒層。CppNet 實現上分為三層:
不同層之間通過回調函數向上通知。其中網絡事件層將 epoll 和 IOCP 抽象出相同的接口,在 socket 層不同平臺上做了不同的調用,Windows 層直接調用接口將已經接收到的數據拷貝出來,而 Linux 平臺則需要在收到通知時調用發送數據接口或者將該 socket 接收窗體的數據全部讀取而出。為什么要將數據全部讀取出來?這又設計到 epoll 的兩種觸發模式,水平觸發和邊緣觸發。
水平觸發( LT )?:只要有一個 socket 的接收窗體有數據,那么下一輪 epoll_wait 返回就會通知這個 socket 有讀事件觸發。意味著如果本次觸發讀取事件的時候,沒有將接收窗體中的數據全部取出,那么下一次 epoll_wait 的時候,還會再通知這個 socket 的讀取事件,即使兩次調用中間沒有新的數據到達。
邊緣觸發( ET )?:一個 socket 收到數據之后,只會觸發一次讀取事件通知,若是沒有將接收窗體的數據全部讀取,那么下一輪 epoll_wait 也不會再觸發該 socket 的讀事件,而是要等到下一次再接收到新的數據時才會再次觸發。
水平觸發比邊緣觸發效率要低一些,在 epoll 內部實現上,用了兩個數據結構,用紅黑樹來管理監測的 socket,每個節點上對應存放著 socket handle 和觸發的回調函數指針。一個活動 socket 事件鏈表,當事件到來時回調函數會將收到的事件信息插入到活動鏈表中。邊緣觸發模式時,每次 epoll_wait 時只需要將活動事件鏈表取出即可,但是水平觸發模式時,還需要將數據未全部讀取的 socket 再次放置到鏈表中。
CppNet 采用的是邊緣觸發模式。邊緣觸發在讀取數據的時候有個問題叫做讀饑渴,何為讀饑渴?
讀饑渴:就是如果兩個 socket 在同一個線程中觸發了讀取事件,而前一個 socket 的數據量較大,后一個 socket 就會一直等待讀取,對客戶端看來就是服務器反應慢。
凡事無完美, 究竟選擇哪種模式,具體如何取舍就需要更多業務場景上的考量了。
前面提到,IOCP 不光負責的干了數據讀取發送的活兒,甚至還兼職管理了線程池。在初始化 IOCP handle 的時候,有一個參數就是告知其創建幾個網絡 IO 線程,但是 epoll 沒有管這么多。在編寫網絡庫的時候就需要考慮,是將一個 epoll handle 放在多個線程中使用,還是每個線程都建立一個自己的 epoll handle?
如果每個線程一個 epoll handle ,則所有接收到的客戶端 socket 終其一生都只會生活在一個線程中,連接,數據交互,直到銷毀,具體處于哪個線程則交給了內核控制(通過端口復用處理驚群),這就會導致線程間負載不均衡,因為 socket 連接時長,數據大小都可能不同,但是鎖碰撞會降到最低。
如果所有線程共享一個 epoll handle,則要考慮線程數據同步的問題,如果一個 socket 在一個線程讀取的時候,又在另一個線程觸發了讀取,該如何處理?epoll 可以通過設置 EPOLLONESHOT 標識來防止此類問題,設置這個標識后,每次觸發讀取之后都需要重置這個標識,才會再次觸發。
人生就是一個不斷選擇的過程,沒有最完美,只有最合適。CppNet 可以通過初始化時的參數控制,在 Linux 實現上述兩種方式。
一直再說數據讀取的事兒,下面說說建立連接。
大家知道,服務器上創建 socket 之后綁定地址和端口,然后調用 accept 來等待連接請求。等待意味著阻塞,前邊已經提到了,我們用到的 socket 已經全部設置為非阻塞模式了,你調用了 accept,也不會乖乖的阻塞在哪里了,而是迅速返回,有沒有連接到來,還得接著判斷。這么麻煩的事情當然還是交給操作系統來操作,和數據收發相同,我們也把監聽 socket 放到事件觸發模型里,但是,要放到哪個里呢?IOCP 只有一個 handle,所以沒的選擇,我們投遞了監聽任務之后,IOCP 會自己判斷從哪個線程中返回建立連接的操作。
epoll 則又是道多選題,如果用了每個線程一個 epoll handle 的模式,所有線程都監測著監聽的 socket,那么連接到來的時候所有線程都會被喚醒,是為驚群。這個可以借鑒一下 Nginx,通過一個簡單的算法來控制哪些線程(Nginx 是進程)去競爭一個全局的鎖,競爭到鎖的線程將監聽 socket 放置到 epoll 中,順帶著還均衡了一下線程的負載。現在我們有了另外一個選擇,通過設置 socket ?SO_REUSEADDR 標識,讓多個 socket 綁定到同一個端口上!讓操作系統來控制喚醒哪個線程。
寫到現在,連接,數據收發已經基本實現,該如何管理收發數據的緩存呢?隨時拋給上層,還是做個中間緩存?
這又涉及到一個拆包的問題,大家知道,TCP 發送的是?byte 流,并沒有包的概念,如果你把半個客戶端發送來的的消息體返回給服務器,服務器也沒有辦法執行響應操作,只能等待剩下的部分到來。所以最好是加一層緩存,這個緩存大小無法提前預知,需要動態分配,還要兼顧效率,減少復制。CppNet 在 socket 層添加了 loop-buffer 數據結構來管理接收和發送的字節流。實現如其名,底層是來自內存池的固定大小內存塊,通過兩個指針控制來循環的讀寫,上層是一個由剛才所說的內存塊組成的鏈表,也通過兩個指針控制來循環讀寫。這樣每次添加數據時,都是順序的追加操作,沒有之前舊數據的移動,實現最少的內存拷貝。
那有了緩存之后,如何快速的將要發送和接收的數據放置到緩存區呢?我一開始是直接在 recv 和 send 的地方建立一個棧上的臨時緩存,讀取到數據之后再將棧緩存上的數據寫到 loop-buffer 上,這樣無疑多了一次數據復制的代價。Linux系統提供了?writev?和?readv?接口,集中寫和分散讀,每次讀寫的時候都直接將申請好的內存塊交給內核來復制數據,然后再通過返回值移動指針來標識數據位置,配合 loop-buffer 相得益彰。
CppNet 前后歷時半載,歷經兩司,到現在終于有所小成,作文以記之。
github:https://github.com/caozhiyi/CppNet
來源:https://zhuanlan.zhihu.com/p/80634656
總結
以上是生活随笔為你收集整理的C 网络库都干了什么?的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 电信信号差怎么办
- 下一篇: C 中命名空间的五大常见用法