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