愛奇藝網路協程編寫高併發應用實踐

愛奇藝技術產品團隊發表於2020-07-15


本⽂以愛奇藝開源的⽹絡協程庫( )為例,講解⽹絡協程的設計原理、程式設計實踐、效能最佳化等⽅⾯內容。
概述
早年間, ⽀持多個⽤戶併發訪問的服務應⽤,往往採⽤多程式⽅式,即針對每⼀個 TCP ⽹絡連線建立⼀個服務程式。在 2000 年左右,⽐較流⾏使⽤ CGI ⽅式編寫 Web 服務,當時⼈們⽤的⽐較多的 Web 伺服器是基於多程式模式開發的 Apache1.3.x 系列,因為程式佔⽤系統資源較多,所以⼈們開始使⽤多執行緒⽅式編寫 Web 應用服務,執行緒佔⽤的資源更少,這使單臺伺服器⽀撐的⽤戶併發度提⾼了,但依然存在資源浪費的問題。因為在多程式或多執行緒程式設計⽅式下,均採⽤了阻塞通訊⽅式,對於慢連線請求,會使服務端的程式或執行緒因『等待』客戶端的請求資料⽽不能做別的事情,⽩⽩浪費了作業系統的排程時間和系統資源。這種⼀對⼀的服務⽅式在⼴域⽹的環境下顯示變得不夠廉價,於是⼈們開始採⽤⾮阻塞⽹絡程式設計⽅式來提升服務端網路併發度,⽐較著名的 Web 伺服器 Nginx 就是⾮阻塞通訊服務的典型代表,另外還有象 Java Netty 這樣的⾮阻塞⽹絡開發庫。
⾮阻塞⽹絡程式設計⼀直以⾼併發和⾼難度⽽著稱,這種程式設計⽅式雖然有效的提升了伺服器的利⽤率和處理能力,但卻對⼴⼤程式設計師提出了較⼤挑戰,因為⾮阻塞 IO 的程式設計⽅式往往會把業務邏輯分隔的⽀離破碎,需要在通訊過程中記錄⼤量的中間狀態,⽽且還需要處理各種異常情況,最終帶來的後果就是開發週期⻓、複雜度⾼,⽽且難於維護。
阻塞式⽹絡程式設計實現容易但併發度不⾼,⾮阻塞⽹絡程式設計併發度⾼但編寫難,針對這兩種⽹絡程式設計⽅式的優缺點,⼈們提出了使⽤協程⽅式編寫⽹絡程式的思想。其實協程本身並不是⼀個新概念,早在2000年前Windows NT 上就出現了『纖程』的 API,號稱可以建立成千上萬個纖程來處理業務,在 BSD Unix 上可以⽤來實現協程切換的 API <ucontext.h> 在 2002 年就已經存在了,當然另外⽤於上下⽂跳轉的 API<setjmp.h> 出現的更早(1993年)。雖然協程的概念出現的較早,但⼈們終不能發現其廣泛的應⽤場景,象『longjmp』這些 API 多⽤在⼀些異常跳轉上,如 Postfix(著名的郵件MTA)在處理⽹絡異常時⽤其實現程式跳轉。直到 Russ Cox 在 Go 語⾔中加⼊了協程(Goroutine)的功能,使⽤協程進⾏⾼併發⽹絡程式設計才變得的簡單易⾏。
Russ Cox 早在 2002 年就編寫了⼀個簡單的⽹絡協程庫 libtask( ),程式碼量不多,卻可以使我們⽐較清晰地看到『透過使⽹絡 IO 協程化,使編寫⾼併發⽹絡程式變得如此簡單』。


⼆、⽹絡協程基本原理

⽹絡協程的本質是將應⽤層的阻塞式 IO 過程在底層轉換成⾮阻塞 IO 過程,並透過程式運⾏棧的上下⽂切換使 IO 準備就緒的協程交替運⾏,從⽽達到以簡單⽅式編寫⾼併發⽹絡程式的⽬的。既然⽹絡協程的底層也是⾮阻塞IO過程,所以在介紹⽹絡協程基本原理前,我們先了解⼀下⾮阻塞⽹絡通訊的基本過程。


2.1、⽹絡⾮阻塞程式設計


下⾯給出了⾮阻塞⽹絡程式設計的常⻅設計⽅式:


• 使⽤作業系統提供的多路復⽤事件引擎 API(select/poll/epoll/kqueue etc),將⽹絡套接字的⽹絡讀寫事件註冊到事件引擎中;
• 當套接字滿⾜可讀或可寫條件時,事件引擎設定套接字對應的事件狀態並返回給調⽤者;
• 調⽤者根據套接字的事件狀態分別『回撥』對應的處理過程;
• 對於⼤部分基於 TCP 的⽹絡應⽤,資料的讀寫往往不是⼀次 IO 就能完成的,因此,一次會話過程就會有多次 IO 讀寫過程,在每次 IO 過程中都需要快取讀寫的資料,直⾄本次資料會話完成。


愛奇藝網路協程編寫高併發應用實踐


下圖以⾮阻塞讀為例展示了整個非同步⾮阻塞讀及回撥處理過程:
愛奇藝網路協程編寫高併發應用實踐
相對於阻塞式讀的處理過程,⾮阻塞過程要複雜很多:


⼀次完整的 IO 會話過程會被分割成多次的 IO 過程;

每次 IO 過程需要快取部分資料及當前會話的處理狀態;

要求解析器(如:Json/Xml/Mime 解析器)最好能⽀持流式解析⽅式,否則就需要讀到完整資料後才能交給解析器去處理,當遇到業資料較⼤時就需要分配較⼤的連續記憶體塊,必然會造成系統的記憶體分配壓⼒;

當前⼤部分後臺系統(如資料庫、儲存系統、快取系統)所提供的客戶端驅動都是阻塞式的,⽆法直接應⽤在⾮阻塞通訊應⽤中,從⽽限制了⾮阻塞通訊⽅式的應⽤範圍;


多次 IO 過程將應⽤的業務處理邏輯分割的⽀離破碎,⼤⼤增加了業務編寫過程的複雜度,降低了開發效率,同時加⼤了後期的不易維護性。
2.2、⽹絡協程程式設計
(一)概念:在瞭解使⽤協程編寫⽹絡程式之前,需要先了解⼏個概念:


  • 最⼩排程單元:當前⼤部分作業系統的最⼩排程單元是執行緒,即在單核或多核 CPU 環境中,作業系統是以執行緒為基本排程單元的,作業系統負責將多個執行緒任務喚⼊喚出;
  • 上下⽂切換: 當作業系統需要將某個執行緒掛起時,會將該執行緒在 CPU 暫存器中的棧指標、狀態字等儲存⾄該執行緒的記憶體棧中;當作業系統需要喚醒某個被掛起的執行緒時(重新放置在CPU中運⾏),會將該執行緒之前被掛起的棧指標重新置⼊ CPU 暫存器中,並恢復之前保留的狀態字等資訊,從⽽使該執行緒繼續運⾏;透過這樣的掛起與喚醒操作,便完成了不同執行緒間的上下⽂切換;
  • 並⾏與⽹絡併發:並⾏是指同⼀『時刻』同時運⾏的任務數,並⾏任務數量取決於 CPU 核⼼數量;⽽⽹絡併發是指在某⼀『時刻』⽹絡連線的數量;類似於⼆⼋定律,在客戶端與服務端保持 TCP ⻓連線時,⼤部分連線是空閒的,所以服務端只需響應少量活躍的⽹絡連線即可,如果服務端採⽤多路復⽤技術,即使使⽤單核也可以⽀持 100K 個⽹絡併發連線。

(二)協程的切換過程


既然作業系統進⾏任務排程的最⼩單元是執行緒,所以作業系統⽆法感知協程的存在,⾃然也就⽆法對其進⾏排程;


因此,存在於執行緒中的⼤量協程需要相互協作,合理地佔⽤ CPU 時間⽚,在合適的運⾏點(如:⽹絡阻塞點)主動讓出 CPU,給其它協程提供運⾏的機會,這也正是『協程』這一概念的由來。每個協程一般都會經歷如下過程:

愛奇藝網路協程編寫高併發應用實踐


協程之間的切換⼀般可分為『星形切換』和『環形切換』,參照下圖:
愛奇藝網路協程編寫高併發應用實踐
當有⼤量的協程需要運⾏時,在『環形切換』模式下,前⼀個協程運⾏完畢後直接『喚醒』並切換⾄下⼀個協程,⽽⽆需象『星形切換』那樣先切換⾄排程原點,再從排程原點來『喚醒』下⼀個協程;因『環形切換』⽐『星形切換』節省了⼀次上下⽂的切換過程,所以『環形切換』⽅式的切換效率更⾼。
(三)⽹絡過程協程化


下圖是使用網路過程協程化示意圖:

愛奇藝網路協程編寫高併發應用實踐

在網路協程庫中,內部有一個預設的IO排程協程,其負責處理與網路IO相關的協程排程過程,故稱之為IO排程協程:

  • 每⼀個⽹絡連線繫結⼀個套接字控制程式碼,該套接字繫結⼀個協程;

  • 當對⽹絡套接字進⾏讀或寫發生阻塞時,將該套接字新增⾄ IO 排程協程的事件引擎中並設定讀寫事件,然後將該協程掛起;這樣所有處於讀寫等待狀態的⽹絡協程都被掛起,且與之關聯的⽹絡套接字均由 IO 排程協程的事件引擎統⼀監控管理;
  • 當某些⽹絡套接字滿⾜可讀或可寫條件時,IO 排程協程的事件引擎返回這些套接字的狀態,IO 排程協程找到與這些套接字繫結的協程物件,然後將這些協程追加至協程排程佇列中,使其依次運⾏;
  • IO 事件協程內部本身是由系統事件引擎(如:Linux 下的 epoll 事件引擎)驅動的,其內部 IO 事件的驅動機制和上⾯介紹的⾮阻塞過程相似,當某個套接字控制程式碼『準備就緒』時,IO 排程協程便將其所繫結的協程新增進協程排程佇列中,待本次 IO 排程協程返回後,會依次運⾏協程排程佇列⾥的所有協程。


(四)⽹絡協程示例
下⾯給出⼀個使⽤協程⽅式編寫的⽹絡伺服器程式(更多示例參見:/tree/master/samples ):

愛奇藝網路協程編寫高併發應用實踐
該⽹絡協程伺服器程式處理流程為:


  • 建立⼀個監聽協程,使其『堵』在 accept() 調⽤上,等待客戶端連線;

  • 啟動協程排程器,啟動新建立的監聽協程及內部的 IO 排程協程;

  • 監聽協程每接收⼀個網路連線,便建立⼀個客戶端協程去處理,然後監聽協程繼續等待新的網路連線;

  • 客戶端協程以『阻塞』⽅式讀寫⽹絡連線資料;網路連線處理完畢,則關閉連線,協程退出。


從該例⼦可以看出,⽹絡協程的處理過程都是順序⽅式,⽐較符合⼈的思維習慣;我們很容易將該例⼦改成執行緒⽅式,處理邏輯和協程⽅式相似,但協程⽅式更加輕量、佔⽤資源更少,併發能⼒更強。
簡單的表⾯必定隱藏著複雜的底層設計,因為⽹絡協程過程在底層還是需要轉為『⾮阻塞』處理過程,只是使⽤者並未感知⽽已。
⽹絡協程核⼼設計要點


在介紹了⽹絡協程的基本原理後,本章節主要介紹 libfiber ⽹絡協程的核⼼設計要點,為⽹絡協程應⽤實踐化提供了基本的設計思路。


3.1、協程排程
libfiber 採⽤了單執行緒排程⽅式,主要是為了避免設計上的複雜度及效率上的影響。
如果設計成多執行緒排程模式,則必須⾸先需要考慮如下幾點:


  • 多核環境下 CPU 快取的親和性:CPU 本身配有⾼效的多級快取,雖然 CPU 多級快取容量較記憶體⼩的多,但其訪問效率卻遠⾼於記憶體,在單執行緒排程⽅式下,可以⽅便編譯器有效地進⾏ CPU 快取使⽤最佳化,使運⾏指令和共享資料儘可能放置在 CPU 快取中,⽽如果採⽤多執行緒排程⽅式,多個執行緒間共享的資料就可能使 CPU 快取失效,容易造成排程執行緒越多,協程的運⾏效率越低的問題;

  • 多執行緒分配任務時的同步問題:當多個執行緒需要從公共協程任務資源中獲取協程任務時,需要增加『鎖』保護機制,⼀旦產⽣⼤量的『鎖』衝突,則勢必會造成運⾏效能的嚴重損耗;

  • 事件引擎操作最佳化:在多執行緒排程則很難進⾏如此最佳化,下⾯會介紹在單執行緒排程模式下的事件引擎操作最佳化。


當然,設計成單執行緒排程也需解決如下問題:
1、如何有效地使⽤多核:
在單執行緒排程⽅式下,該執行緒內的多個協程在運⾏時僅能使⽤單核,解決⽅案為:


  • 啟動多個程式,每個程式運⾏⼀個執行緒,該執行緒執行一個協程排程器;

  • 同⼀程式內啟動多個執行緒,每個執行緒運⾏獨⽴的協程排程器;


2)、多個執行緒之間的資源共享:
因為協程排程是不跨執行緒的,在設計協程互斥鎖時需要考慮:


  • 協程鎖需要⽀持『同⼀執行緒內的協程之間、不同執行緒的協程之間、協程執行緒與⾮協程執行緒之間』的互斥;

  • ⽹絡連線池的執行緒隔離機制,需要為每個執行緒建⽴各⾃獨⽴的連線池,防⽌連線物件在不同執行緒的協程之間共享,否則便會造成同⼀⽹絡連線在不同執行緒的協程之間使⽤,破壞單執行緒排程規則;

  • 需要防⽌執行緒內的某個協程『瘋狂』佔⽤ CPU 資源,導致本執行緒內的其它協程得不到運⾏的機會,雖然此類問題在多執行緒排程時也會造成問題,但顯然在單執行緒排程時造成的後果更為嚴重。

3.2、協程事件引擎設計


3.2.1、跨平臺性


libfiber 的事件引擎⽀持當今主流的作業系統,從⽽為 libfiber 的跨平臺特性提供了有⼒的⽀撐,下⾯為 libfiber 事件引擎所⽀持的平臺:


Linuxsekect/poll/epoll,epoll 為 Linux 核心級事件引擎,採⽤事件觸發機制,不象 select/poll 的輪循⽅式,所以 epoll 在處理⼤併發⽹絡連線時運⾏效率更⾼;BSD/MacOS:select/poll/kqueue,其中kqueue 為核心級事件引擎,在處理高併發連線時具有更⾼的效能;
Windows: select/poll/iocp/Windows 窗⼝訊息,其中 iocp 為 Windows 平臺下的核心級⾼效事件引擎;


libfiber ⽀持採⽤界⾯訊息引擎做為底層的事件引擎,這樣在編寫 Windows 界⾯程式的⽹絡模組時便可以使⽤協程⽅式了,之前⼈們在 Windows 平臺編寫界⾯程式的⽹絡模組時,⼀般採⽤如下兩種⽅式:


(1)⽤⾮阻塞⽅式,⽹絡模組與界⾯模組在同⼀執行緒中;
(2)、將⽹絡模組放到獨⽴的執行緒中運⾏,運⾏結果透過界⾯訊息『傳遞』到界⾯執行緒中


現在 libfiber ⽀持 Windows 界⾯訊息引擎,我們就可以在界⾯執行緒中直接建立⽹絡協程,直接進⾏阻塞式⽹絡程式設計。


(Windows 界⾯⽹絡協程示例:/tree/master/samples/WinEchod )
3.2.2、運⾏效率


⼤家在談論⽹絡協程程式的運⾏效率時,往往只重視協程的切換效率,卻忽視了事件引擎對於效能影響的重要性,雖然現在很⽹絡協程庫所採⽤的事件引擎都是核心級的,但仍需要合理使⽤才能發揮其最佳效能。

在使⽤ libfiber 的早期版本編譯⽹絡協程服務程式時,雖然在 Linux 平臺上也是採⽤了 epoll 事件引擎,但在對⽹絡協程服務程式進⾏效能壓測(使⽤⽤系統命令 『# perf top -p pid』 觀察運⾏狀態)時,卻發現 epoll_ctl API 佔⽤了較⾼的 CPU,分析原因是 epoll_ctl 使⽤次數過多導致的:因為 epoll_ctl 內部在對套接字控制程式碼進⾏新增、修改或刪除事件操作時,需要先透過紅⿊樹的查詢演算法找到其對應的內部套接字物件(紅⿊樹的查詢效率並不是O (1)的),如果 epoll_ctl 的調⽤次數過多必然會造成 CPU 的佔⽤較⾼。


因為 TCP 資料在傳輸時是流式的,這就意味著資料接收者經常需要多次讀操作才能獲得完整的資料,反映到⽹絡協程處理流程上,如下圖所示:
愛奇藝網路協程編寫高併發應用實踐
仔細觀察上⾯處理流程,可以發現在圖中的標註4(喚醒協程)和標註5(掛起協程)之間的兩個事件操作:標註2取消讀事件標註3註冊讀事件,再結合 標註1註冊讀事件,完全可以把注2和標註3處的兩個事件取消,因為標註1⾄標註3的⽬標是 註冊讀事件。最後,透過快取事件操作的中間狀態,合併中間態的事件操作過程,使 libfiber 的 IO 處理效能提升 20% 左右。
下圖給出了採⽤ libfiber 編寫的回顯伺服器與採⽤其它⽹絡協程庫編寫的回顯伺服器的效能對⽐(對⽐單核條件下的 IO 處理能⼒):
愛奇藝網路協程編寫高併發應用實踐


在 libfiber 中之所以可以針對中間的事件操作過程進⾏合併處理,主要是因為 libfiber 的排程過程是單執行緒模式的,如果想要在多執行緒排程器中合併中間態的事件操作則要難很多:在多執行緒排程過程中,當套接字所繫結的協程因IO 可讀被喚醒時,假設不取消該套接字的讀事件,則該協程被某個執行緒『拿⾛』後,恰巧該套接字又收到新資料,核心會再次觸發事件引擎,協程排程器被喚醒,此時協程排程器也許就不知該如何處理了。


3.3、協程同步機制


3.3.1、單⼀執行緒內部的協程互斥


對於象 libfiber 這樣的採⽤單執行緒排程⽅案的協程庫⽽⾔,如果互斥加鎖過程僅限於同⼀個排程執行緒內部,則實現⼀個協程互斥鎖是⽐較容易的,下圖為 libfiber 中單執行緒內部使⽤的協程互斥鎖的處理流程圖(參考源⽂件:fiber_lock.c):
愛奇藝網路協程編寫高併發應用實踐
同⼀執行緒內的協程在等待鎖資源時,該協程將被掛起並被加⼊鎖等待佇列中,當加鎖協程解鎖後會喚醒鎖等待佇列中的頭部協程,單執行緒內部的協程互斥鎖正是利⽤了協程的掛起和喚醒機制。
3.3.2、多執行緒之間的協程互斥
雖然 libfiber 的協程排程器是單執行緒模式的,但卻可以啟動多個執行緒使每個執行緒運⾏獨⽴的協程排程器,如果⼀些資源需要在多個執行緒中的協程間共享,則就需要有⼀把可以跨執行緒使⽤的協程互斥鎖。將 libfiber 應⽤在多執行緒的簡單場景時,直接使⽤系統提供的執行緒鎖就可以解決很多問題,但執行緒鎖當遇到如下場景時就顯得⽆能為⼒:
愛奇藝網路協程編寫高併發應用實踐
上述顯示了系統執行緒互斥鎖在 libfiber 多執行緒使⽤場景中遇到的死鎖問題:
執行緒A 中的協程A1 成功對執行緒鎖1加鎖;


執行緒B 中的協程B2 對執行緒鎖2成功加鎖;


當執行緒A中的協程A2 要對執行緒鎖2加鎖⽽阻塞時,則會使執行緒A的協程排程器阻塞,從⽽導致執行緒A中的所有協程因宿主執行緒A被作業系統掛起而停止執行,同樣,執行緒B 也會因協程B1 阻塞線上程鎖1上⽽被阻塞,最終造成了死鎖問題。
使用系統執行緒鎖時產⽣上述死鎖的根本原因是單執行緒排程機制以及作業系統的最⼩排程單元是執行緒,系統對於協程是⽆感知的。因此,在 libfiber 中專⻔設計了可⽤於線上程的協程之間使⽤的事件互斥鎖(原始碼參⻅ fiber_event.c),其設計原理如下:
愛奇藝網路協程編寫高併發應用實踐
該可⽤於線上程之間的協程進⾏互斥的事件互斥鎖的處理流程為:
協程B(假設其屬於執行緒b)已經對事件鎖加鎖後;
協程A(假設其屬於執行緒a)想對該事件鎖加鎖時,對原⼦數加鎖失敗後建立IO管道,將IO讀管道置⼊該事件鎖的IO讀等待佇列中,此時協程A被掛起;
當協程B 對事件鎖解鎖時,會⾸先獲得協程A 的讀管道,解鎖後再向管道中寫⼊訊息,從⽽喚醒協程A;
協程A 被喚醒後讀取管道中的訊息,然後再次嘗試對事件鎖中的原⼦數加鎖,如加鎖成功便可以繼續運⾏,否則會再次進⼊睡眠狀態(有可能此事件鎖⼜被其它協程提前搶佔)。
在上述事件鎖的加/解鎖處理過程中,使⽤原⼦數和IO管道的好處是:


  • 透過使⽤原⼦數可以使協程快速加鎖空閒的事件鎖,原⼦數在多執行緒或協程環境中的⾏為相同的,可以保證安全性;

  • 當鎖被佔⽤時,該協程進入IO管道讀等待狀態而被掛起,這並不會影響其所屬的執行緒排程器的正常執行;在 Linux 平臺上可以使⽤ eventfd 代替管道,其佔⽤資源更少。

3.3.3、協程條件變數

在使⽤執行緒程式設計時,都知道執行緒條件變數的價值:線上程之間傳遞訊息時往往需要組合執行緒條件變數和執行緒鎖。因此,在 libfiber 中也設計了協程條件變數(原始碼⻅ fiber_cond.c),透過組合使⽤ libfiber 中的協程事件鎖(fiber_event.c)和協程條件變數,⽤戶便可以編寫出⽤於線上程之間、執行緒與協程之間、執行緒內的協程之間、執行緒間的協程之間進⾏訊息傳遞的訊息佇列。下圖為使⽤ libfiber 中協程條件變數時的互動過程:


愛奇藝網路協程編寫高併發應用實踐
這是⼀個典型的 ⽣產者-消費者 問題,透過組合使⽤協程條件變數和事件鎖可以輕鬆實現。


3.3.4、協程訊號量


使⽤⽹絡協程庫編寫的⽹絡服務很容易實現⾼併發功能,可以接⼊⼤量的客戶端連線,但是後臺系統(如:資料庫)卻未必能⽀持⾼併發,即使是⽀持⾼並的快取系統(如 Redis),當網路連線數比較⾼時效能也會下降,所以協程服務模組不能將前端的併發壓⼒傳遞到後端,給後臺系統造成很⼤壓⼒,我們需要提供⼀種⾼併發連線解除安裝機制,以保證後臺系統可以平穩地運⾏,在 libfiber 中提供了協程訊號量(原始碼⻅:fiber_semc.c)。
下⾯是使⽤ libfiber 中的協程訊號量對於後臺系統的併發連線進行解除安裝保護的示意圖:
愛奇藝網路協程編寫高併發應用實踐
當有⼤量協程需要訪問後臺系統時,透過協程訊號量將⼤量的協程『擋在外⾯』,只允許部分協程與後端系統建⽴連線。
注: ⽬前 libfiber 的協程訊號量僅⽤在同⼀執行緒內部,還不能跨執行緒使⽤,要想在多執行緒環境中使⽤,需在每個執行緒內部建立獨⽴的協程訊號量。


3.4、域名解析


⽹絡協程既然⾯向⽹絡應用場景,⾃然離不開域名的協程化⽀持,現在很多⽹絡協程庫的設計者往往忽視了這⼀點,有些⽹絡協程庫在使⽤系統 API 進⾏域名解析時為了防⽌阻塞協程排程器,將域名解析過程(即調⽤gethostbyname/getaddrinfo 等系統 API)扔給獨⽴的執行緒去執⾏,當域名解析併發量較⼤時必然會造成很多執行緒資源被佔⽤。
在 libfiber 中整合了第三⽅ dns 原始碼,實現了域名解析過程的協程化,佔⽤更低的系統資源,基本滿⾜了⼤部分服務端應⽤系統對於域名解析的需求。


3.5、Hook 系統 API

在網路協程廣泛使用前,很多⽹絡庫很早就存在了,並且⼤部分這些⽹絡庫都是阻塞式的,要改造這些⽹絡庫使之協程化的成本是⾮常巨⼤的,我們不可能採⽤協程⽅式將這些⽹絡庫重新實現⼀遍,⽬前⼀個⼴泛採⽤的⽅案是 Hook 與 IO 及網路相關的系統中 API,在 Unix 平臺上 Hook 系統 API 相對簡單,在初始化時,先載入並保留系統 API 的原始地址,然後編寫⼀個與系統 API 函式名相同且引數也相同的函式,將這段程式碼與應⽤程式碼⼀起編譯,則編譯器會優先使⽤這些 Hooked API,下⾯的程式碼給出了在 Unix 平臺上 Hook 系統 API 的簡單示例:



愛奇藝網路協程編寫高併發應用實踐


在 libfiber 中Hook 了⼤部分與 IO 及⽹絡相關的系統 API,下⾯列出 libfiber 所 Hook 的系統 API:


IO 相關 API


讀 API:read/readv/recv/recvfrom/recvmsg;

寫API:write/writev/send/sendto/sendmsg/sendfile64;

⽹絡相關 API


套接字 API:socket/listen/accept/connect;
事件引擎 API:select/poll,epoll(epoll_create, epoll_ctl, epoll_wait);


域名解析 API:gethostbyname/gethostbyname_r, getaddrinfo/freeaddrinfo。

透過 Hook API ⽅式,libfiber 已經可以使 Mysql 客戶端庫、⼀些 HTTP 通訊庫及 Redis 客戶端庫的⽹絡通訊協程化,這樣在使⽤⽹絡協程編寫服務端應⽤程式時,⼤⼤降低了程式設計複雜度及改造成本。


愛奇藝核⼼業務的協程實踐
4.1、CDN 核⼼模組使⽤協程


4.1.1、專案背景


為了使愛奇藝使用者可以快速流暢地觀看影片內容,就需要 CDN 系統儘量將資料快取在 CDN 邊緣節點,使使用者就近訪問,但因為邊緣節點的儲存容量有限、資料淘汰等原因,總會有一些資料在邊緣節點不存在,當使用者訪問這些資料時,便需要回源軟體去源站請求資料並下載到本地,在愛奇藝自建 CDN 系統中此回源軟體的名字為『奇迅』,相對於一些開源的回源快取軟體(如:Squid,Apache Traffic,Nginx 等),『奇迅』需要解決以下問題:


合併回源:當多個使用者訪問同一段資料內容時,回源軟體應合併相同請求,只向源站發起一個請求,一方面可以降低源站的壓力,同時可以降低迴源頻寬;

斷點續傳:當資料回源時如果因網路或其它原因造成回源連線中斷,則回源軟體應能在原來資料斷開位置繼續下載剩餘資料;

隨機位置下載:因為很多使用者喜歡跳躍式點播影片內容,為了能夠在快速響應使用者請求的同時節省頻寬,要求回源軟體能夠快速從影片資料的任意位置下載、同時停止下載使用者跳過的內容;

資料完整性:為了防止資料在傳輸過程中因網路、機器或軟體重啟等原因造成損壞,需要對已經下載的塊資料和完整資料做完整性校驗;


下面為愛奇藝自研快取與回源軟體『奇迅』的軟體架構及特點描述:
4.1.2、軟體架構


在愛奇藝的自建 CDN 系統中,作為資料回源及本地快取的核心軟體,奇迅承擔了重要角色,該模組採用多執行緒多協程的軟體架構設計,如下所示奇迅回源架構設計的特點總結如下:




特性
說明
高併發
採用網路協程方式,支援高併發接入,同時簡化程式設計
高效能
採用執行緒池 + 協程 + 連線池 + 記憶體池技術,提高業務處理效能
高吞吐
採用磁碟記憶體對映及零複製技術,提升磁碟及網路 IO 吞吐能力
低迴源
合併相同請求,支援部分回源及部分快取,大大降低迴源頻寬
開播快
採用流式資料讀取方式,提升影片開播速度
可擴充套件
模組化分層設計,易於擴充套件新功能
易維護
採用統一伺服器程式設計框架,易管理,好維護


奇迅的前後端通訊模組均採用網路協程方式,分為前端連線接入層和後端下載任務層,為了有效地使用多核,前後端模組均啟動多個執行緒(每個執行緒執行一個獨立的協程排程器);對於前端連線接入模組,由於採用協程方式,所以:
支援更高的客戶端併發連線;
允許更多慢連線的存在,而不會消耗更多秕資源;


更有助於客戶端與奇迅之間保持長連線,提升響應效能。

對於後端下載模組,由於採用協程方式,在資料回源時允許建立更多的併發連線去多個源站下載資料,從而獲得更快的下載速度;同時,為了節省頻寬,奇迅採用合併回源策略,即當前端多個客戶端請求同一段資料時,下載模組將會合並相同的請求,向源站發起一份資料請求,在合併回源請求過程中,因資料共享原因,必然存在如 “3.3.2、多執行緒之間的協程互斥”章節所提到的多個執行緒之間的協程同步互斥的需求,透過使用 libfiber 中的事件鎖完美地解決了一這需求(其實,當初事件鎖就是為了滿足奇迅的這一需求而設計編寫)。

4.1.3、專案成果

採用協程方式編寫的回源與快取軟體『奇迅』上線後,愛奇藝自建CDN影片卡頓比小於 2%,CDN 影片回源頻寬小於 1%。




4.2、效能 DNS 模組使協程

4.2.1、專案背景


隨著愛奇藝使用者規模的迅速壯大,對於像 DNS 服務這樣非常重要的基礎設施的要求也越來越高,開源軟體(如:Bind)已經遠遠不能滿足要求,下面是專案初期對於自研 DNS 系統的基本要求:
高效能:要求單機 QPS 可以達到百萬級以上;同時,DNS View 變化不影響 QPS;
高容錯:支援叢集部署,可以做到單一節點故障而不會影響 DNS 服務質量;
高彈性:DNS服務節點可以按需要進行擴充與刪減;網路卡 IP 地址發生變化時,軟體可以自動繫結新地址及關閉舊地址,保證服務連線性;
資料增量更新:當業務的域名解析地址發生變更時,可以快速地同步至 DNS 服務,使解析生效;
下面是愛奇藝自研 DNS 的軟體架構及特點介紹:


4.2.2、軟體架構

DNS 做為網際網路的基礎設施,在整個網際網路中發揮著舉足輕重的作用,愛奇藝為了滿足自身業務的發展需要,自研了高效能 DNS(簡稱 HPDNS),該 DNS 的軟體架構如下圖所示:


愛奇藝網路協程編寫高併發應用實踐


HPDNS 服務的特點如下:

優點

說明



高效能

啟用 Linux 3.0 核心的 REUSEPORT 功能,提升多執行緒並行收發包的能力

採用 Linux 3.0 核心的 recvmmsg/sendmmsg API,提升單次 IO 資料包收發能力

採用記憶體預分配策略,減少記憶體動態分配/釋放時的衝突

針對 TCP 服務模式,採用網路協程框架,最大化 TCP 併發能力


高可用

採用RCURead Copy Update)方式更新檢視資料及配置項,無需停止服務,且不影響效能

網路卡 IP 地址變化自動感知(即可自動新增新 IP 或摘除老IP而不必停止服務)

採用 Keepalived 保證服務高可用

易管理

 master 服務管理模組管理 DNS 程式,控制 DNS 程式的啟動、停止、重讀配置/資料、異常重啟及異常報警等

由於 DNS 協議要求 DNS 服務端需要同時支援 UDP 及 TCP 兩種通訊方式,除了要求 UDP 模組具備高效能外,對 TCP 模組也要求支援高併發及高效能,該模組的網路通訊部分使用 libfiber 編寫,從而支援更高的併發連線,同時具備更高的效能,又因啟用多個執行緒排程器,從而可以更加方便地使用多核。

4.2.3、專案成果


愛奇藝自研的高效能 DNS 的單機處理能力(非 DPDK 版本)可以達到 200 萬次/秒以上;將業務域名變更後的資訊同步至全網自建 DNS 節點可以在一分鐘內完成。


總結

本文講述了愛奇藝開源專案 libfiber 網路協程庫的設計原理及核心設計要點,方便讀者瞭解網路協程的設計原理及執行機制,做到知其然且知其所以然;還從愛奇藝自身的專案實踐出發,總結了在應用網路協程程式設計時遇到的問題及解決方案,使讀者能夠更加全面地瞭解編寫網路協程類應用的注意事項。

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/69945252/viewspace-2704731/,如需轉載,請註明出處,否則將追究法律責任。

相關文章