早年間, 支持多個用戶并發(fā)訪問的服務(wù)應(yīng)用,往往采用多進(jìn)程方式,即針對每一個 TCP 網(wǎng)絡(luò)連接創(chuàng)建一個服務(wù)進(jìn)程。在 2000 年左右,比較流行使用 CGI 方式編寫 Web 服務(wù),當(dāng)時人們用的比較多的 Web 服務(wù)器是基于多進(jìn)程模式開發(fā)的 Apache1.3.x 系列,因為進(jìn)程占用系統(tǒng)資源較多,所以人們開始使用多線程方式編寫 Web 應(yīng)用服務(wù),線程占用的資源更少,這使單臺服務(wù)器支撐的用戶并發(fā)度提高了,但依然存在資源浪費(fèi)的問題。因為在多進(jìn)程或多線程編程方式下,均采用了阻塞通信方式,對于慢連接請求,會使服務(wù)端的進(jìn)程或線程因『等待』客戶端的請求數(shù)據(jù)而不能做別的事情,白白浪費(fèi)了操作系統(tǒng)的調(diào)度時間和系統(tǒng)資源。這種一對一的服務(wù)方式在廣域網(wǎng)的環(huán)境下顯示變得不夠廉價,于是人們開始采用非阻塞網(wǎng)絡(luò)編程方式來提升服務(wù)端網(wǎng)絡(luò)并發(fā)度,比較著名的 Web 服務(wù)器 Nginx 就是非阻塞通信服務(wù)的典型代表,另外還有象 Java Netty 這樣的非阻塞網(wǎng)絡(luò)開發(fā)庫。
非阻塞網(wǎng)絡(luò)編程一直以高并發(fā)和高難度而著稱,這種編程方式雖然有效的提升了服務(wù)器的利用率和處理能力,但卻對廣大程序員提出了較大挑戰(zhàn),因為非阻塞 IO 的編程方式往往會把業(yè)務(wù)邏輯分隔的支離破碎,需要在通信過程中記錄大量的中間狀態(tài),而且還需要處理各種異常情況,最終帶來的后果就是開發(fā)周期長、復(fù)雜度高,而且難于維護(hù)。
阻塞式網(wǎng)絡(luò)編程實現(xiàn)容易但并發(fā)度不高,非阻塞網(wǎng)絡(luò)編程并發(fā)度高但編寫難,針對這兩種網(wǎng)絡(luò)編程方式的優(yōu)缺點(diǎn),人們提出了使用協(xié)程方式編寫網(wǎng)絡(luò)程序的思想。其實協(xié)程本身并不是一個新概念,早在 2000 年前 Windows NT 上就出現(xiàn)了『纖程』的 API,號稱可以創(chuàng)建成千上萬個纖程來處理業(yè)務(wù),在 BSD Unix 上可以用來實現(xiàn)協(xié)程切換的 API <ucontext.h> 在 2002 年就已經(jīng)存在了,當(dāng)然另外用于上下文跳轉(zhuǎn)的 API<setjmp.h> 出現(xiàn)的更早(1993 年)。雖然協(xié)程的概念出現(xiàn)的較早,但人們終不能發(fā)現(xiàn)其廣泛的應(yīng)用場景,象『longjmp』這些 API 多用在一些異常跳轉(zhuǎn)上,如 Postfix(著名的郵件 MTA)在處理網(wǎng)絡(luò)異常時用其實現(xiàn)程序跳轉(zhuǎn)。直到 Russ Cox 在 Go 語言中加入了協(xié)程(Goroutine)的功能,使用協(xié)程進(jìn)行高并發(fā)網(wǎng)絡(luò)編程才變得的簡單易行。
Russ Cox 早在 2002 年就編寫了一個簡單的網(wǎng)絡(luò)協(xié)程庫 libtask(https://swtch.com/libtask/ ),代碼量不多,卻可以使我們比較清晰地看到『通過使網(wǎng)絡(luò) IO 協(xié)程化,使編寫高并發(fā)網(wǎng)絡(luò)程序變得如此簡單』。
網(wǎng)絡(luò)協(xié)程的本質(zhì)是將應(yīng)用層的阻塞式 IO 過程在底層轉(zhuǎn)換成非阻塞 IO 過程,并通過程序運(yùn)行棧的上下文切換使 IO 準(zhǔn)備就緒的協(xié)程交替運(yùn)行,從而達(dá)到以簡單方式編寫高并發(fā)網(wǎng)絡(luò)程序的目的。既然網(wǎng)絡(luò)協(xié)程的底層也是非阻塞 IO 過程,所以在介紹網(wǎng)絡(luò)協(xié)程基本原理前,我們先了解一下非阻塞網(wǎng)絡(luò)通信的基本過程。
下面給出了非阻塞網(wǎng)絡(luò)編程的常見設(shè)計方式:
使用操作系統(tǒng)提供的多路復(fù)用事件引擎 API(select/poll/epoll/kqueue etc),將網(wǎng)絡(luò)套接字的網(wǎng)絡(luò)讀寫事件注冊到事件引擎中;
當(dāng)套接字滿足可讀或可寫條件時,事件引擎設(shè)置套接字對應(yīng)的事件狀態(tài)并返回給調(diào)用者;
調(diào)用者根據(jù)套接字的事件狀態(tài)分別『回調(diào)』對應(yīng)的處理過程;
對于大部分基于 TCP 的網(wǎng)絡(luò)應(yīng)用,數(shù)據(jù)的讀寫往往不是一次 IO 就能完成的,因此,一次會話過程就會有多次 IO 讀寫過程,在每次 IO 過程中都需要緩存讀寫的數(shù)據(jù),直至本次數(shù)據(jù)會話完成。

下圖以非阻塞讀為例展示了整個異步非阻塞讀及回調(diào)處理過程:

相對于阻塞式讀的處理過程,非阻塞過程要復(fù)雜很多:
一次完整的 IO 會話過程會被分割成多次的 IO 過程;
每次 IO 過程需要緩存部分?jǐn)?shù)據(jù)及當(dāng)前會話的處理狀態(tài);
要求解析器(如:Json/Xml/Mime 解析器)最好能支持流式解析方式,否則就需要讀到完整數(shù)據(jù)后才能交給解析器去處理,當(dāng)遇到業(yè)數(shù)據(jù)較大時就需要分配較大的連續(xù)內(nèi)存塊,必然會造成系統(tǒng)的內(nèi)存分配壓力;
當(dāng)前大部分后臺系統(tǒng)(如數(shù)據(jù)庫、存儲系統(tǒng)、緩存系統(tǒng))所提供的客戶端驅(qū)動都是阻塞式的,無法直接應(yīng)用在非阻塞通信應(yīng)用中,從而限制了非阻塞通信方式的應(yīng)用范圍;
多次 IO 過程將應(yīng)用的業(yè)務(wù)處理邏輯分割的支離破碎,大大增加了業(yè)務(wù)編寫過程的復(fù)雜度,降低了開發(fā)效率,同時加大了后期的不易維護(hù)性。
(一)概念: 在了解使用協(xié)程編寫網(wǎng)絡(luò)程序之前,需要先了解幾個概念:
最小調(diào)度單元: 當(dāng)前大部分操作系統(tǒng)的最小調(diào)度單元是線程,即在單核或多核 CPU 環(huán)境中,操作系統(tǒng)是以線程為基本調(diào)度單元的,操作系統(tǒng)負(fù)責(zé)將多個線程任務(wù)喚入喚出;
上下文切換: 當(dāng)操作系統(tǒng)需要將某個線程掛起時,會將該線程在 CPU 寄存器中的棧指針、狀態(tài)字等保存至該線程的內(nèi)存棧中;當(dāng)操作系統(tǒng)需要喚醒某個被掛起的線程時(重新放置在 CPU 中運(yùn)行),會將該線程之前被掛起的棧指針重新置入 CPU 寄存器中,并恢復(fù)之前保留的狀態(tài)字等信息,從而使該線程繼續(xù)運(yùn)行;通過這樣的掛起與喚醒操作,便完成了不同線程間的上下文切換;
并行與網(wǎng)絡(luò)并發(fā): 并行是指同一『時刻』同時運(yùn)行的任務(wù)數(shù),并行任務(wù)數(shù)量取決于 CPU 核心數(shù)量;而網(wǎng)絡(luò)并發(fā)是指在某一『時刻』網(wǎng)絡(luò)連接的數(shù)量;類似于二八定律,在客戶端與服務(wù)端保持 TCP 長連接時,大部分連接是空閑的,所以服務(wù)端只需響應(yīng)少量活躍的網(wǎng)絡(luò)連接即可,如果服務(wù)端采用多路復(fù)用技術(shù),即使使用單核也可以支持 100K 個網(wǎng)絡(luò)并發(fā)連接。
(二)協(xié)程的切換過程
既然操作系統(tǒng)進(jìn)行任務(wù)調(diào)度的最小單元是線程,所以操作系統(tǒng)無法感知協(xié)程的存在,自然也就無法對其進(jìn)行調(diào)度;
因此,存在于線程中的大量協(xié)程需要相互協(xié)作,合理地占用 CPU 時間片,在合適的運(yùn)行點(diǎn)(如:網(wǎng)絡(luò)阻塞點(diǎn))主動讓出 CPU,給其它協(xié)程提供運(yùn)行的機(jī)會,這也正是『協(xié)程』這一概念的由來。每個協(xié)程一般都會經(jīng)歷如下過程:

協(xié)程之間的切換一般可分為『星形切換』和『環(huán)形切換』,參照下圖:

當(dāng)有大量的協(xié)程需要運(yùn)行時,在『環(huán)形切換』模式下,前一個協(xié)程運(yùn)行完畢后直接『喚醒』并切換至下一個協(xié)程,而無需象『星形切換』那樣先切換至調(diào)度原點(diǎn),再從調(diào)度原點(diǎn)來『喚醒』下一個協(xié)程;因『環(huán)形切換』比『星形切換』節(jié)省了一次上下文的切換過程,所以『環(huán)形切換』方式的切換效率更高。
(三)網(wǎng)絡(luò)過程協(xié)程化
下圖是使用網(wǎng)絡(luò)過程協(xié)程化示意圖:

在網(wǎng)絡(luò)協(xié)程庫中,內(nèi)部有一個缺省的 IO 調(diào)度協(xié)程,其負(fù)責(zé)處理與網(wǎng)絡(luò) IO 相關(guān)的協(xié)程調(diào)度過程,故稱之為 IO 調(diào)度協(xié)程:
每一個網(wǎng)絡(luò)連接綁定一個套接字句柄,該套接字綁定一個協(xié)程;
當(dāng)對網(wǎng)絡(luò)套接字進(jìn)行讀或?qū)懓l(fā)生阻塞時,將該套接字添加至 IO 調(diào)度協(xié)程的事件引擎中并設(shè)置讀寫事件,然后將該協(xié)程掛起;這樣所有處于讀寫等待狀態(tài)的網(wǎng)絡(luò)協(xié)程都被掛起,且與之關(guān)聯(lián)的網(wǎng)絡(luò)套接字均由 IO 調(diào)度協(xié)程的事件引擎統(tǒng)一監(jiān)控管理;
當(dāng)某些網(wǎng)絡(luò)套接字滿足可讀或可寫條件時,IO 調(diào)度協(xié)程的事件引擎返回這些套接字的狀態(tài),IO 調(diào)度協(xié)程找到與這些套接字綁定的協(xié)程對象,然后將這些協(xié)程追加至協(xié)程調(diào)度隊列中,使其依次運(yùn)行;
IO 事件協(xié)程內(nèi)部本身是由系統(tǒng)事件引擎(如:Linux 下的 epoll 事件引擎)驅(qū)動的,其內(nèi)部 IO 事件的驅(qū)動機(jī)制和上面介紹的非阻塞過程相似,當(dāng)某個套接字句柄『準(zhǔn)備就緒』時,IO 調(diào)度協(xié)程便將其所綁定的協(xié)程添加進(jìn)協(xié)程調(diào)度隊列中,待本次 IO 調(diào)度協(xié)程返回后,會依次運(yùn)行協(xié)程調(diào)度隊列里的所有協(xié)程。
(四)網(wǎng)絡(luò)協(xié)程示例
下面給出一個使用協(xié)程方式編寫的網(wǎng)絡(luò)服務(wù)器程序(更多示例參見:https://github.com/iqiyi/libfiber/tree/master/samples ):

該網(wǎng)絡(luò)協(xié)程服務(wù)器程序處理流程為:
創(chuàng)建一個監(jiān)聽協(xié)程,使其『堵』在 accept() 調(diào)用上,等待客戶端連接;
啟動協(xié)程調(diào)度器,啟動新創(chuàng)建的監(jiān)聽協(xié)程及內(nèi)部的 IO 調(diào)度協(xié)程;
監(jiān)聽協(xié)程每接收一個網(wǎng)絡(luò)連接,便創(chuàng)建一個客戶端協(xié)程去處理,然后監(jiān)聽協(xié)程繼續(xù)等待新的網(wǎng)絡(luò)連接;
客戶端協(xié)程以『阻塞』方式讀寫網(wǎng)絡(luò)連接數(shù)據(jù);網(wǎng)絡(luò)連接處理完畢,則關(guān)閉連接,協(xié)程退出。
從該例子可以看出,網(wǎng)絡(luò)協(xié)程的處理過程都是順序方式,比較符合人的思維習(xí)慣;我們很容易將該例子改成線程方式,處理邏輯和協(xié)程方式相似,但協(xié)程方式更加輕量、占用資源更少,并發(fā)能力更強(qiáng)。
簡單的表面必定隱藏著復(fù)雜的底層設(shè)計,因為網(wǎng)絡(luò)協(xié)程過程在底層還是需要轉(zhuǎn)為『非阻塞』處理過程,只是使用者并未感知而已。
在介紹了網(wǎng)絡(luò)協(xié)程的基本原理后,本章節(jié)主要介紹 libfiber 網(wǎng)絡(luò)協(xié)程的核心設(shè)計要點(diǎn),為網(wǎng)絡(luò)協(xié)程應(yīng)用實踐化提供了基本的設(shè)計思路。
libfiber 采用了單線程調(diào)度方式,主要是為了避免設(shè)計上的復(fù)雜度及效率上的影響。
如果設(shè)計成 多線程調(diào)度模式 ,則必須首先需要考慮如下幾點(diǎn):
多核環(huán)境下 CPU 緩存的親和性: CPU 本身配有高效的多級緩存,雖然 CPU 多級緩存容量較內(nèi)存小的多,但其訪問效率卻遠(yuǎn)高于內(nèi)存,在單線程調(diào)度方式下,可以方便編譯器有效地進(jìn)行 CPU 緩存使用優(yōu)化,使運(yùn)行指令和共享數(shù)據(jù)盡可能放置在 CPU 緩存中,而如果采用多線程調(diào)度方式,多個線程間共享的數(shù)據(jù)就可能使 CPU 緩存失效,容易造成調(diào)度線程越多,協(xié)程的運(yùn)行效率越低的問題;
多線程分配任務(wù)時的同步問題: 當(dāng)多個線程需要從公共協(xié)程任務(wù)資源中獲取協(xié)程任務(wù)時,需要增加『鎖』保護(hù)機(jī)制,一旦產(chǎn)生大量的『鎖』沖突,則勢必會造成運(yùn)行性能的嚴(yán)重?fù)p耗;
事件引擎操作優(yōu)化: 在多線程調(diào)度則很難進(jìn)行如此優(yōu)化,下面會介紹在單線程調(diào)度模式下的事件引擎操作優(yōu)化。
當(dāng)然,設(shè)計成 單線程調(diào)度 也需解決如下問題:
(1)、如何有效地使用多核:
在單線程調(diào)度方式下,該線程內(nèi)的多個協(xié)程在運(yùn)行時僅能使用單核,解決方案為:
啟動多個進(jìn)程,每個進(jìn)程運(yùn)行一個線程,該線程運(yùn)行一個協(xié)程調(diào)度器 ;
同一進(jìn)程內(nèi)啟動多個線程,每個線程運(yùn)行獨(dú)立的協(xié)程調(diào)度器;
(2)、多個線程之間的資源共享:
因為協(xié)程調(diào)度是不跨線程的,在設(shè)計協(xié)程互斥鎖時需要考慮:
協(xié)程鎖需要支持『同一線程內(nèi)的協(xié)程之間、不同線程的協(xié)程之間、協(xié)程線程與非協(xié)程線程之間』的互斥;
網(wǎng)絡(luò)連接池的線程隔離機(jī)制,需要為每個線程建立各自獨(dú)立的連接池,防止連接對象在不同線程的協(xié)程之間共享,否則便會造成同一網(wǎng)絡(luò)連接在不同線程的協(xié)程之間使用,破壞單線程調(diào)度規(guī)則;
需要防止線程內(nèi)的某個協(xié)程『瘋狂』占用 CPU 資源,導(dǎo)致本線程內(nèi)的其它協(xié)程得不到運(yùn)行的機(jī)會,雖然此類問題在多線程調(diào)度時也會造成問題,但顯然在單線程調(diào)度時造成的后果更為嚴(yán)重。
libfiber 的事件引擎支持當(dāng)今主流的操作系統(tǒng),從而為 libfiber 的跨平臺特性提供了有力的支撐,下面為 libfiber 事件引擎所支持的平臺:
Linux:sekect/poll/epoll,epoll 為 Linux 內(nèi)核級事件引擎,采用事件觸發(fā)機(jī)制,不象 select/poll 的輪循方式,所以 epoll 在處理大并發(fā)網(wǎng)絡(luò)連接時運(yùn)行效率更高;BSD/MacOS:select/poll/kqueue,其中 kqueue 為內(nèi)核級事件引擎,在處理高并發(fā)連接時具有更高的性能;
Windows: select/poll/iocp/Windows 窗口消息,其中 iocp 為 Windows 平臺下的內(nèi)核級高效事件引擎;
libfiber 支持采用界面消息引擎做為底層的事件引擎,這樣在編寫 Windows 界面程序的網(wǎng)絡(luò)模塊時便可以使用協(xié)程方式了,之前人們在 Windows 平臺編寫界面程序的網(wǎng)絡(luò)模塊時,一般采用如下兩種方式:
(1)、采用非阻塞方式,網(wǎng)絡(luò)模塊與界面模塊在同一線程中;
(2)、將網(wǎng)絡(luò)模塊放到獨(dú)立的線程中運(yùn)行,運(yùn)行結(jié)果通過界面消息『傳遞』到界面線程中;
現(xiàn)在 libfiber 支持 Windows 界面消息引擎,我們就可以在界面線程中直接創(chuàng)建網(wǎng)絡(luò)協(xié)程,直接進(jìn)行阻塞式網(wǎng)絡(luò)編程。
(Windows 界面網(wǎng)絡(luò)協(xié)程示例:https://github.com/iqiyi/libfiber/tree/master/samples/WinEchod )
大家在談?wù)摼W(wǎng)絡(luò)協(xié)程程序的運(yùn)行效率時,往往只重視協(xié)程的切換效率,卻忽視了事件引擎對于性能影響的重要性,雖然現(xiàn)在很網(wǎng)絡(luò)協(xié)程庫所采用的事件引擎都是內(nèi)核級的,但仍需要合理使用才能發(fā)揮其最佳性能。
在使用 libfiber 的早期版本編譯網(wǎng)絡(luò)協(xié)程服務(wù)程序時,雖然在 Linux 平臺上也是采用了 epoll 事件引擎,但在對網(wǎng)絡(luò)協(xié)程服務(wù)程序進(jìn)行性能壓測(使用用系統(tǒng)命令 『# perf top -p pid』 觀察運(yùn)行狀態(tài))時,卻發(fā)現(xiàn) epoll_ctl API 占用了較高的 CPU,分析原因是 epoll_ctl 使用次數(shù)過多導(dǎo)致的:因為 epoll_ctl 內(nèi)部在對套接字句柄進(jìn)行添加、修改或刪除事件操作時,需要先通過紅黑樹的查找算法找到其對應(yīng)的內(nèi)部套接字對象(紅黑樹的查找效率并不是 O (1) 的),如果 epoll_ctl 的調(diào)用次數(shù)過多必然會造成 CPU 的占用較高。
因為 TCP 數(shù)據(jù)在傳輸時是流式的,這就意味著數(shù)據(jù)接收者經(jīng)常需要多次讀操作才能獲得完整的數(shù)據(jù),反映到網(wǎng)絡(luò)協(xié)程處理流程上,如下圖所示:

仔細(xì)觀察上面處理流程,可以發(fā)現(xiàn)在圖中的標(biāo)注 4(喚醒協(xié)程)和標(biāo)注 5(掛起協(xié)程)之間的兩個事件操作:標(biāo)注 2 取消讀事件 與 標(biāo)注 3 注冊讀事件,再結(jié)合 標(biāo)注 1 注冊讀事件,完全可以把注 2 和標(biāo)注 3 處的兩個事件取消,因為標(biāo)注 1 至標(biāo)注 3 的目標(biāo)是 注冊讀事件。最后,通過緩存事件操作的中間狀態(tài),合并中間態(tài)的事件操作過程,使 libfiber 的 IO 處理性能提升 20% 左右。
下圖給出了采用 libfiber 編寫的回顯服務(wù)器與采用其它網(wǎng)絡(luò)協(xié)程庫編寫的回顯服務(wù)器的性能對比(對比單核條件下的 IO 處理能力):

在 libfiber 中之所以可以針對中間的事件操作過程進(jìn)行合并處理,主要是因為 libfiber 的調(diào)度過程是單線程模式的,如果想要在多線程調(diào)度器中合并中間態(tài)的事件操作則要難很多:在多線程調(diào)度過程中,當(dāng)套接字所綁定的協(xié)程因 IO 可讀被喚醒時,假設(shè)不取消該套接字的讀事件,則該協(xié)程被某個線程『拿走』后,恰巧該套接字又收到新數(shù)據(jù),內(nèi)核會再次觸發(fā)事件引擎,協(xié)程調(diào)度器被喚醒,此時協(xié)程調(diào)度器也許就不知該如何處理了。
對于象 libfiber 這樣的采用單線程調(diào)度方案的協(xié)程庫而言,如果互斥加鎖過程僅限于同一個調(diào)度線程內(nèi)部,則實現(xiàn)一個協(xié)程互斥鎖是比較容易的,下圖為 libfiber 中單線程內(nèi)部使用的協(xié)程互斥鎖的處理流程圖(參考源文件:fiber_lock.c):

同一線程內(nèi)的協(xié)程在等待鎖資源時,該協(xié)程將被掛起并被加入鎖等待隊列中,當(dāng)加鎖協(xié)程解鎖后會喚醒鎖等待隊列中的頭部協(xié)程,單線程內(nèi)部的協(xié)程互斥鎖正是利用了協(xié)程的掛起和喚醒機(jī)制。
雖然 libfiber 的協(xié)程調(diào)度器是單線程模式的,但卻可以啟動多個線程使每個線程運(yùn)行獨(dú)立的協(xié)程調(diào)度器,如果一些資源需要在多個線程中的協(xié)程間共享,則就需要有一把可以跨線程使用的協(xié)程互斥鎖。將 libfiber 應(yīng)用在多線程的簡單場景時,直接使用系統(tǒng)提供的線程鎖就可以解決很多問題,但線程鎖當(dāng)遇到如下場景時就顯得無能為力:

上述顯示了系統(tǒng)線程互斥鎖在 libfiber 多線程使用場景中遇到的死鎖問題:
線程 A 中的協(xié)程 A1 成功對線程鎖 1 加鎖;
線程 B 中的協(xié)程 B2 對線程鎖 2 成功加鎖;
當(dāng)線程 A 中的協(xié)程 A2 要對線程鎖 2 加鎖而阻塞時,則會使線程 A 的協(xié)程調(diào)度器阻塞,從而導(dǎo)致線程 A 中的所有協(xié)程因宿主線程 A 被操作系統(tǒng)掛起而停止運(yùn)行,同樣,線程 B 也會因協(xié)程 B1 阻塞在線程鎖 1 上而被阻塞,最終造成了死鎖問題。
使用系統(tǒng)線程鎖時產(chǎn)生上述死鎖的根本原因是單線程調(diào)度機(jī)制以及操作系統(tǒng)的最小調(diào)度單元是線程,系統(tǒng)對于協(xié)程是無感知的。因此,在 libfiber 中專門設(shè)計了可用于在線程的協(xié)程之間使用的事件互斥鎖(源碼參見 fiber_event.c),其設(shè)計原理如下:

該可用于在線程之間的協(xié)程進(jìn)行互斥的事件互斥鎖的處理流程為:
協(xié)程 B(假設(shè)其屬于線程 b)已經(jīng)對事件鎖加鎖后;
協(xié)程 A(假設(shè)其屬于線程 a)想對該事件鎖加鎖時,對原子數(shù)加鎖失敗后創(chuàng)建 IO 管道,將 IO 讀管道置入該事件鎖的 IO 讀等待隊列中,此時協(xié)程 A 被掛起;
當(dāng)協(xié)程 B 對事件鎖解鎖時,會首先獲得協(xié)程 A 的讀管道,解鎖后再向管道中寫入消息,從而喚醒協(xié)程 A;
協(xié)程 A 被喚醒后讀取管道中的消息,然后再次嘗試對事件鎖中的原子數(shù)加鎖,如加鎖成功便可以繼續(xù)運(yùn)行,否則會再次進(jìn)入睡眠狀態(tài)(有可能此事件鎖又被其它協(xié)程提前搶占)。
在上述事件鎖的加 / 解鎖處理過程中,使用原子數(shù)和 IO 管道的好處是:
通過使用原子數(shù)可以使協(xié)程快速加鎖空閑的事件鎖,原子數(shù)在多線程或協(xié)程環(huán)境中的行為相同的,可以保證安全性;
當(dāng)鎖被占用時,該協(xié)程進(jìn)入 IO 管道讀等待狀態(tài)而被掛起,這并不會影響其所屬的線程調(diào)度器的正常運(yùn)行;在 Linux 平臺上可以使用 eventfd 代替管道,其占用資源更少。
在使用線程編程時,都知道線程條件變量的價值:在線程之間傳遞消息時往往需要組合線程條件變量和線程鎖。因此,在 libfiber 中也設(shè)計了協(xié)程條件變量(源碼見 fiber_cond.c),通過組合使用 libfiber 中的協(xié)程事件鎖(fiber_event.c)和協(xié)程條件變量,用戶便可以編寫出用于在線程之間、線程與協(xié)程之間、線程內(nèi)的協(xié)程之間、線程間的協(xié)程之間進(jìn)行消息傳遞的消息隊列。下圖為使用 libfiber 中協(xié)程條件變量時的交互過程:

這是一個典型的 生產(chǎn)者 - 消費(fèi)者 問題,通過組合使用協(xié)程條件變量和事件鎖可以輕松實現(xiàn)。
使用網(wǎng)絡(luò)協(xié)程庫編寫的網(wǎng)絡(luò)服務(wù)很容易實現(xiàn)高并發(fā)功能,可以接入大量的客戶端連接,但是后臺系統(tǒng)(如:數(shù)據(jù)庫)卻未必能支持高并發(fā),即使是支持高并的緩存系統(tǒng)(如 Redis),當(dāng)網(wǎng)絡(luò)連接數(shù)比較高時性能也會下降,所以協(xié)程服務(wù)模塊不能將前端的并發(fā)壓力傳遞到后端,給后臺系統(tǒng)造成很大壓力,我們需要提供一種高并發(fā)連接卸載機(jī)制,以保證后臺系統(tǒng)可以平穩(wěn)地運(yùn)行,在 libfiber 中提供了協(xié)程信號量(源碼見:fiber_semc.c)。
下面是使用 libfiber 中的協(xié)程信號量對于后臺系統(tǒng)的并發(fā)連接進(jìn)行卸載保護(hù)的示意圖:

當(dāng)有大量協(xié)程需要訪問后臺系統(tǒng)時,通過協(xié)程信號量將大量的協(xié)程『擋在外面』,只允許部分協(xié)程與后端系統(tǒng)建立連接。
注: 目前 libfiber 的協(xié)程信號量僅用在同一線程內(nèi)部,還不能跨線程使用,要想在多線程環(huán)境中使用,需在每個線程內(nèi)部創(chuàng)建獨(dú)立的協(xié)程信號量。
網(wǎng)絡(luò)協(xié)程既然面向網(wǎng)絡(luò)應(yīng)用場景,自然離不開域名的協(xié)程化支持,現(xiàn)在很多網(wǎng)絡(luò)協(xié)程庫的設(shè)計者往往忽視了這一點(diǎn),有些網(wǎng)絡(luò)協(xié)程庫在使用系統(tǒng) API 進(jìn)行域名解析時為了防止阻塞協(xié)程調(diào)度器,將域名解析過程(即調(diào)用 gethostbyname/getaddrinfo 等系統(tǒng) API)扔給獨(dú)立的線程去執(zhí)行,當(dāng)域名解析并發(fā)量較大時必然會造成很多線程資源被占用。
在 libfiber 中集成了第三方 dns 源碼,實現(xiàn)了域名解析過程的協(xié)程化,占用更低的系統(tǒng)資源,基本滿足了大部分服務(wù)端應(yīng)用系統(tǒng)對于域名解析的需求。
在網(wǎng)絡(luò)協(xié)程廣泛使用前,很多網(wǎng)絡(luò)庫很早就存在了,并且大部分這些網(wǎng)絡(luò)庫都是阻塞式的,要改造這些網(wǎng)絡(luò)庫使之協(xié)程化的成本是非常巨大的,我們不可能采用協(xié)程方式將這些網(wǎng)絡(luò)庫重新實現(xiàn)一遍,目前一個廣泛采用的方案是 Hook 與 IO 及網(wǎng)絡(luò)相關(guān)的系統(tǒng)中 API,在 Unix 平臺上 Hook 系統(tǒng) API 相對簡單,在初始化時,先加載并保留系統(tǒng) API 的原始地址,然后編寫一個與系統(tǒng) API 函數(shù)名相同且參數(shù)也相同的函數(shù),將這段代碼與應(yīng)用代碼一起編譯,則編譯器會優(yōu)先使用這些 Hooked API,下面的代碼給出了在 Unix 平臺上 Hook 系統(tǒng) API 的簡單示例:

在 libfiber 中 Hook 了大部分與 IO 及網(wǎng)絡(luò)相關(guān)的系統(tǒng) API,下面列出 libfiber 所 Hook 的系統(tǒng) API:
IO 相關(guān) API
讀 API:read/readv/recv/recvfrom/recvmsg;
寫 API:write/writev/send/sendto/sendmsg/sendfile64;
網(wǎng)絡(luò)相關(guān) 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 已經(jīng)可以使 Mysql 客戶端庫、一些 HTTP 通信庫及 Redis 客戶端庫的網(wǎng)絡(luò)通信協(xié)程化,這樣在使用網(wǎng)絡(luò)協(xié)程編寫服務(wù)端應(yīng)用程序時,大大降低了編程復(fù)雜度及改造成本。
為了使愛奇藝用戶可以快速流暢地觀看視頻內(nèi)容,就需要 CDN 系統(tǒng)盡量將數(shù)據(jù)緩存在 CDN 邊緣節(jié)點(diǎn),使用戶就近訪問,但因為邊緣節(jié)點(diǎn)的存儲容量有限、數(shù)據(jù)淘汰等原因,總會有一些數(shù)據(jù)在邊緣節(jié)點(diǎn)不存在,當(dāng)用戶訪問這些數(shù)據(jù)時,便需要回源軟件去源站請求數(shù)據(jù)并下載到本地,在愛奇藝自建 CDN 系統(tǒng)中此回源軟件的名字為『奇迅』,相對于一些開源的回源緩存軟件(如:Squid,Apache Traffic,Nginx 等),『奇迅』需要解決以下問題:
合并回源:當(dāng)多個用戶訪問同一段數(shù)據(jù)內(nèi)容時,回源軟件應(yīng)合并相同請求,只向源站發(fā)起一個請求,一方面可以降低源站的壓力,同時可以降低回源帶寬;
斷點(diǎn)續(xù)傳:當(dāng)數(shù)據(jù)回源時如果因網(wǎng)絡(luò)或其它原因造成回源連接中斷,則回源軟件應(yīng)能在原來數(shù)據(jù)斷開位置繼續(xù)下載剩余數(shù)據(jù);
隨機(jī)位置下載:因為很多用戶喜歡跳躍式點(diǎn)播視頻內(nèi)容,為了能夠在快速響應(yīng)用戶請求的同時節(jié)省帶寬,要求回源軟件能夠快速從視頻數(shù)據(jù)的任意位置下載、同時停止下載用戶跳過的內(nèi)容;
數(shù)據(jù)完整性:為了防止數(shù)據(jù)在傳輸過程中因網(wǎng)絡(luò)、機(jī)器或軟件重啟等原因造成損壞,需要對已經(jīng)下載的塊數(shù)據(jù)和完整數(shù)據(jù)做完整性校驗;
下面為愛奇藝自研緩存與回源軟件『奇迅』的軟件架構(gòu)及特點(diǎn)描述:
在愛奇藝的自建 CDN 系統(tǒng)中,作為數(shù)據(jù)回源及本地緩存的核心軟件,奇迅承擔(dān)了重要角色,該模塊采用多線程多協(xié)程的軟件架構(gòu)設(shè)計,如下所示奇迅回源架構(gòu)設(shè)計的特點(diǎn)總結(jié)如下:
| 特性 | 說明 |
|---|---|
| 高并發(fā) | 采用網(wǎng)絡(luò)協(xié)程方式,支持高并發(fā)接入,同時簡化程序設(shè)計 |
| 高性能 | 采用線程池 + 協(xié)程 + 連接池 + 內(nèi)存池技術(shù),提高業(yè)務(wù)處理性能 |
| 高吞吐 | 采用磁盤內(nèi)存映射及零拷貝技術(shù),提升磁盤及網(wǎng)絡(luò) IO 吞吐能力 |
| 低回源 | 合并相同請求,支持部分回源及部分緩存,大大降低回源帶寬 |
| 開播快 | 采用流式數(shù)據(jù)讀取方式,提升視頻開播速度 |
| 可擴(kuò)展 | 模塊化分層設(shè)計,易于擴(kuò)展新功能 |
| 易維護(hù) | 采用統(tǒng)一服務(wù)器編程框架,易管理,好維護(hù) |
奇迅的前后端通信模塊均采用網(wǎng)絡(luò)協(xié)程方式,分為前端連接接入層和后端下載任務(wù)層,為了有效地使用多核,前后端模塊均啟動多個線程(每個線程運(yùn)行一個獨(dú)立的協(xié)程調(diào)度器);對于前端連接接入模塊,由于采用協(xié)程方式,所以:
支持更高的客戶端并發(fā)連接;
允許更多慢連接的存在,而不會消耗更多秕資源;
更有助于客戶端與奇迅之間保持長連接,提升響應(yīng)性能。
對于后端下載模塊,由于采用協(xié)程方式,在數(shù)據(jù)回源時允許建立更多的并發(fā)連接去多個源站下載數(shù)據(jù),從而獲得更快的下載速度;同時,為了節(jié)省帶寬,奇迅采用合并回源策略,即當(dāng)前端多個客戶端請求同一段數(shù)據(jù)時,下載模塊將會合并相同的請求,向源站發(fā)起一份數(shù)據(jù)請求,在合并回源請求過程中,因數(shù)據(jù)共享原因,必然存在如 “3.3.2、多線程之間的協(xié)程互斥”章節(jié)所提到的多個線程之間的協(xié)程同步互斥的需求,通過使用 libfiber 中的事件鎖完美地解決了一這需求(其實,當(dāng)初事件鎖就是為了滿足奇迅的這一需求而設(shè)計編寫)。
采用協(xié)程方式編寫的回源與緩存軟件『奇迅』上線后,愛奇藝自建 CDN 視頻卡頓比小于 2%,CDN 視頻回源帶寬小于 1%。
隨著愛奇藝用戶規(guī)模的迅速壯大,對于像 DNS 服務(wù)這樣非常重要的基礎(chǔ)設(shè)施的要求也越來越高,開源軟件(如:Bind)已經(jīng)遠(yuǎn)遠(yuǎn)不能滿足要求,下面是項目初期對于自研 DNS 系統(tǒng)的基本要求:
高性能:要求單機(jī) QPS 可以達(dá)到百萬級以上;同時,DNS View 變化不影響 QPS;
高容錯:支持集群部署,可以做到單一節(jié)點(diǎn)故障而不會影響 DNS 服務(wù)質(zhì)量;
高彈性:DNS 服務(wù)節(jié)點(diǎn)可以按需要進(jìn)行擴(kuò)充與刪減;網(wǎng)卡 IP 地址發(fā)生變化時,軟件可以自動綁定新地址及關(guān)閉舊地址,保證服務(wù)連接性;
數(shù)據(jù)增量更新:當(dāng)業(yè)務(wù)的域名解析地址發(fā)生變更時,可以快速地同步至 DNS 服務(wù),使解析生效;
下面是愛奇藝自研 DNS 的軟件架構(gòu)及特點(diǎn)介紹:
DNS 做為互聯(lián)網(wǎng)的基礎(chǔ)設(shè)施,在整個互聯(lián)網(wǎng)中發(fā)揮著舉足輕重的作用,愛奇藝為了滿足自身業(yè)務(wù)的發(fā)展需要,自研了高性能 DNS(簡稱 HPDNS),該 DNS 的軟件架構(gòu)如下圖所示:

HPDNS 服務(wù)的特點(diǎn)如下:
| 優(yōu)點(diǎn) | 說明 |
|---|---|
| 高性能 | 啟用 Linux 3.0 內(nèi)核的 REUSEPORT 功能,提升多線程并行收發(fā)包的能力 采用 Linux 3.0 內(nèi)核的 recvmmsg/sendmmsg API,提升單次 IO 數(shù)據(jù)包收發(fā)能力 采用內(nèi)存預(yù)分配策略,減少內(nèi)存動態(tài)分配 / 釋放時的“鎖”沖突 針對 TCP 服務(wù)模式,采用網(wǎng)絡(luò)協(xié)程框架,最大化 TCP 并發(fā)能力 |
| 高可用 | 采用 RCU(Read Copy Update)方式更新視圖數(shù)據(jù)及配置項,無需停止服務(wù),且不影響性能 網(wǎng)卡 IP 地址變化自動感知(即可自動添加新 IP 或摘除老 IP 而不必停止服務(wù)) 采用 Keepalived 保證服務(wù)高可用 |
| 易管理 | 由 master 服務(wù)管理模塊管理 DNS 進(jìn)程,控制 DNS 進(jìn)程的啟動、停止、重讀配置 / 數(shù)據(jù)、異常重啟及異常報警等 |
由于 DNS 協(xié)議要求 DNS 服務(wù)端需要同時支持 UDP 及 TCP 兩種通信方式,除了要求 UDP 模塊具備高性能外,對 TCP 模塊也要求支持高并發(fā)及高性能,該模塊的網(wǎng)絡(luò)通信部分使用 libfiber 編寫,從而支持更高的并發(fā)連接,同時具備更高的性能,又因啟用多個線程調(diào)度器,從而可以更加方便地使用多核。
愛奇藝自研的高性能 DNS 的單機(jī)處理能力(非 DPDK 版本)可以達(dá)到 200 萬次 / 秒以上;將業(yè)務(wù)域名變更后的信息同步至全網(wǎng)自建 DNS 節(jié)點(diǎn)可以在一分鐘內(nèi)完成。
本文講述了愛奇藝開源項目 libfiber 網(wǎng)絡(luò)協(xié)程庫的設(shè)計原理及核心設(shè)計要點(diǎn),方便讀者了解網(wǎng)絡(luò)協(xié)程的設(shè)計原理及運(yùn)行機(jī)制,做到知其然且知其所以然;還從愛奇藝自身的項目實踐出發(fā),總結(jié)了在應(yīng)用網(wǎng)絡(luò)協(xié)程編程時遇到的問題及解決方案,使讀者能夠更加全面地了解編寫網(wǎng)絡(luò)協(xié)程類應(yīng)用的注意事項。
本文轉(zhuǎn)載自公眾號愛奇藝技術(shù)產(chǎn)品團(tuán)隊
節(jié)點(diǎn)互動(廣東)科技有限公司, 一家專注于 APP開發(fā) + 小程序開發(fā) + 微信開發(fā) + 系統(tǒng)開發(fā) + 網(wǎng)站開發(fā) 的專業(yè)互聯(lián)網(wǎng)應(yīng)用服務(wù)提供商。5年實戰(zhàn)開發(fā)經(jīng)驗,高校合作基地,多年行業(yè)深耕經(jīng)驗,經(jīng)營范圍涵蓋中山、珠海、江門、東莞等廣東各地,節(jié)點(diǎn)互動助力傳統(tǒng)行業(yè)快速轉(zhuǎn)型,為眾多企業(yè)提供創(chuàng)新性互聯(lián)網(wǎng)應(yīng)用產(chǎn)品。