雖然市面上已經有不少成熟的網絡庫,可是編寫一個本身的網絡庫依然讓我獲益匪淺,這篇文章主要包含:linux
首先,你們都知道操做系統原生的socket都是同步阻塞的,你每調用一次發送接口,線程就會阻塞在那裏,直到將數據複製到了發送窗體。那發送窗體滿了怎麼辦,阻塞的socket會一直等到有位置了或者超時。你每調用一次接收接口,線程就會阻塞在那裏,直到接收窗體收到了數據。同步阻塞的弊端顯而易見,上廁所的時候不能玩手機,不是每一個人都能受得了。客戶端能夠單獨創建一個線程一直阻塞等待接收,那服務器每一個socket都建一個線程阻塞等待豈不悲哉,apache這麼用過,因此有了Nginx。那能不能建立一個異步的socket調用以後直接返回,何時執行完了,不管成功仍是失敗再通知回來,實現所謂IO複用?好消息是如今操做系統大都實現了異步socket,CppNet中windows上經過WSASocket建立異步的socket,在linux上經過fcntl修改socket屬性添加上O_NONBLOCK。git
有了異步socket,調用的時候不論成功與否,網絡IO接口都會立馬返回,成功或失敗,發送了多少數據,回頭再通知你。如今調用是很舒暢,那怎麼獲取結果通知呢?這在不一樣操做系統就有了不一樣的實現。早些年的時候有過select和poll,可是各有各的弊端,這個不是本文重點,在此再也不詳述。如今在windows上使用IOCP,在Linux上使用epoll作事件觸發,基本已經算是共識。有了IOCP和epoll,咱們調用網絡接口的時候,要把這個過程或者乾脆叫作任務,通知給事件觸發模型,讓操做系統來監控哪一個socket數據發送完了,哪一個socket有新數據接收了,而後再通知給咱們。到這裏,基本實現異步的socket讀寫該有的東西已經所有備齊。github
還有一點不一樣的是,IOCP在接收發送數據的時候,會本身默默的幹活兒,幹完了,再通知給你。你告訴IOCP我要發送這些數據,IOCP就會默默的把這些數據寫進發送窗體,而後告訴你說 :「 頭兒,我幹完了 」 。你告訴IOCP我要讀取這個socket的數據,IOCP就會默默的接收這個socket的數據,而後告訴你:「頭兒,我給您帶過來了」。 這就着實讓人省心,你甚至不用再去調用socket的原生接口 。epoll則不一樣,其內部只是在監測這個socket是否能夠發送或讀取數據(固然還有建連等),不會像IOCP那樣把活兒幹完了再告訴你。你告訴epoll我要監測這個socket的發送和讀取事件,當事件到來的時候,epoll不會管怎麼幹活兒,只會冷淡的敲敲窗戶告訴你:」有事兒了,出來幹活兒吧「。 IOCP 像是一個懂得討領導歡心的老油條,epoll則徹底是一個初入職場的毛頭小子。這就是Proactor和Reactor模式的區別。如今客戶端就是領導的位置,因此CppNet實現爲一個 Proactor 模式的網絡庫,讓客戶端幹最少的活兒。ASIO也實現爲 Proactor ,而Libevent實現爲 Reactor模式 。算法
咱們如今把剛纔說的過程總結一下,首先須要把socket設置非阻塞,而後不一樣平臺上將事件通知到不一樣事件觸發模型上,監測到事件時,回調通知給上層。這就是一個網絡庫要有的核心功能,全部其餘的東西都是在給這個過程作輔助。apache
聽起來很是簡單,接下來就說下編寫網絡庫的時候會遇到哪些問題和CppNet的實現。windows
首先的問題是跨平臺,如何抽象操做系統的接口,對上層實現透明調用。不管是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數據結構來管理接收和發送的字節流。實現如其名,底層是來自內存池的固定大小內存塊,經過兩個指針控制來循環的讀寫,上層是一個由剛纔所說的內存塊組成的鏈表,也經過兩個指針控制來循環讀寫。這樣每次添加數據時,都是順序的追加操做,沒有以前舊數據的移動,實現最少的內存拷貝。詳情請看 loop-buffer。
那有了緩存以後,如何快速的將要發送和接收的數據放置到緩存區呢?我一開始是直接在recv和send的地方創建一個棧上的臨時緩存,讀取到數據以後再將棧緩存上的數據寫到loop-buffer上,這樣無疑多了一次數據複製的代價。Linux系統提供了writev和readv接口,集中寫和分散讀,每次讀寫的時候都直接將申請好的內存塊交給內核來複制數據,而後再經過返回值移動指針來標識數據位置,配合loop-buffer相得益彰。
CppNet先後歷時半載,歷經兩司,到如今終於有所小成,做文以記之。
不揣淺陋,與你們交流。
github請戳這裏。