<kbd id="afajh"><form id="afajh"></form></kbd>
<strong id="afajh"><dl id="afajh"></dl></strong>
    <del id="afajh"><form id="afajh"></form></del>
        1. <th id="afajh"><progress id="afajh"></progress></th>
          <b id="afajh"><abbr id="afajh"></abbr></b>
          <th id="afajh"><progress id="afajh"></progress></th>

          深入理解協(xié)程

          共 12451字,需瀏覽 25分鐘

           ·

          2022-08-03 11:06

          C++ 在互聯(lián)網(wǎng)服務(wù)端開(kāi)發(fā)方向依然占據(jù)著相當(dāng)大的份額;百度,騰訊,甚至以java為主流開(kāi)發(fā)語(yǔ)言的阿里都在大規(guī)模使用C++做互聯(lián)網(wǎng)服務(wù)端開(kāi)發(fā),今天以C++為例子,分析一下要支持協(xié)程,需要考慮哪些問(wèn)題,如何權(quán)衡利弊,反過(guò)來(lái)也可以了解到協(xié)程適合哪些場(chǎng)景。

          第1章 C++協(xié)程近況簡(jiǎn)介

          協(xié)程分兩種,無(wú)棧協(xié)程(stackless)和有棧協(xié)程(stackful),前者無(wú)法解決異步回調(diào)模式中上下文保存與恢復(fù)的問(wèn)題,在此不做論述,文中后續(xù)提到的協(xié)程均指有棧協(xié)程。


          第1節(jié).舊時(shí)代


          在2014年以前,C++服務(wù)端開(kāi)發(fā)是以異步回調(diào)模型為主流,業(yè)務(wù)流程中每一個(gè)需要等待IO處理的節(jié)點(diǎn)都需要切斷業(yè)務(wù)處理流程、保存當(dāng)前處理的上下文、設(shè)置回調(diào)函數(shù),等IO處理完成后再恢復(fù)上下文、接續(xù)業(yè)務(wù)處理流程。

          在一個(gè)典型的互聯(lián)網(wǎng)業(yè)務(wù)處理流程中,這樣的行為節(jié)點(diǎn)多達(dá)十幾個(gè)甚至數(shù)十個(gè)(微服務(wù)間的rpc請(qǐng)求、與redis之類(lèi)的高速緩存的交互、與mysql\mongodb之類(lèi)的DB交互、調(diào)用第三方HttpServer的接口等等);被切割的支離破碎的業(yè)務(wù)處理流程帶來(lái)了幾個(gè)常見(jiàn)的難題:

          • 每個(gè)流程都要定義一個(gè)上下文struct,并手動(dòng)保存與恢復(fù);
          • 每次回調(diào)都會(huì)切斷棧上變量的生命周期,導(dǎo)致需要延續(xù)使用的變量必須申請(qǐng)到堆上或存入上下文結(jié)構(gòu)中;
          • 由于C++是無(wú)GC的語(yǔ)言,碎片化的邏輯給內(nèi)存管理也帶來(lái)了更多挑戰(zhàn);
          • 回調(diào)式的邏輯是“不知何時(shí)會(huì)被觸發(fā)”的,用戶狀態(tài)管理也會(huì)有更多挑戰(zhàn);

          這些具體的難題綜合起來(lái),在工程化角度呈現(xiàn)出的效果就是:代碼編寫(xiě)復(fù)雜,開(kāi)發(fā)周期長(zhǎng),維護(hù)困難,BUG多且防不勝防。


          第2節(jié).新時(shí)代


          2014年騰訊的微信團(tuán)隊(duì)開(kāi)源了一個(gè)C風(fēng)格的協(xié)程框架libco,并在次年的架構(gòu)師峰會(huì)上做了宣講,使業(yè)內(nèi)都認(rèn)識(shí)到異步回調(diào)模式升級(jí)為協(xié)程模式的必要性,從此開(kāi)啟了C++互聯(lián)網(wǎng)服務(wù)端開(kāi)發(fā)的協(xié)程時(shí)代。BAT三家旗下的各個(gè)小部門(mén)、業(yè)內(nèi)很多與時(shí)俱進(jìn)的互聯(lián)網(wǎng)公司都紛紛自研協(xié)程框架,一時(shí)呈百花齊放之態(tài)。

          筆者所在的公司當(dāng)時(shí)也試用了一段時(shí)間libco,修修補(bǔ)補(bǔ)很多次,終究是因?yàn)閱?wèn)題太多而放棄,改用了自研的libgo作為協(xié)程開(kāi)發(fā)框架。

          聊協(xié)程就不能不提到主打協(xié)程功能和CSP模式的golang語(yǔ)言,google從09年發(fā)布golang至今,經(jīng)過(guò)近10個(gè)年頭的發(fā)酵,已成為互聯(lián)網(wǎng)服務(wù)端開(kāi)發(fā)主流開(kāi)發(fā)語(yǔ)言之一,許多項(xiàng)目和開(kāi)發(fā)者從C++、java、php等語(yǔ)言轉(zhuǎn)向golang。筆者自研的libgo也汲取了golang的設(shè)計(jì)理念和多年的實(shí)踐經(jīng)驗(yàn)。

          本文后續(xù)針對(duì)C++協(xié)程框架的設(shè)計(jì)與實(shí)現(xiàn)、與golang這語(yǔ)言級(jí)別支持的協(xié)程的差距在哪里、怎樣盡力彌補(bǔ)這種差距等方面展開(kāi)討論。


          第2章.協(xié)程庫(kù)的設(shè)計(jì)與實(shí)現(xiàn)

          個(gè)人認(rèn)為,C++協(xié)程庫(kù)從實(shí)現(xiàn)完善程度上分為以下幾個(gè)層次


          1.API級(jí)


          實(shí)現(xiàn)協(xié)程上下文切換api,或添加一些便于使用的封裝;特點(diǎn):沒(méi)有協(xié)程調(diào)度。

          代表作:boost.context, boost.coroutine, ucontext(unix), fiber(windows)

          這一層次的協(xié)程庫(kù),僅僅提供了一個(gè)底層api,要想拿來(lái)做項(xiàng)目,還有非常非常遙遠(yuǎn)的距離;不過(guò)這些協(xié)程api可以為我們實(shí)現(xiàn)自己的協(xié)程庫(kù)提供一個(gè)良好的基礎(chǔ)。

          2.玩具級(jí)


          實(shí)現(xiàn)了協(xié)程調(diào)度,無(wú)需用戶手動(dòng)處理協(xié)程上下文切換;特點(diǎn):沒(méi)有HOOK

          代表作:libmill

          這一層次的協(xié)程庫(kù),實(shí)現(xiàn)了協(xié)程調(diào)度(類(lèi)似于操作系統(tǒng)有了進(jìn)程調(diào)度機(jī)制);稍好一些的意識(shí)到了阻塞網(wǎng)絡(luò)io與協(xié)程的不協(xié)調(diào)之處,自己實(shí)現(xiàn)了一套網(wǎng)絡(luò)io相關(guān)函數(shù);

          但是這也意味著涉及網(wǎng)絡(luò)的第三方庫(kù)全部不可用了,比如你想用redis?不好意思,hiredis不能用了,要自己輪一個(gè);你想用mysql?不好意思,mysqlclient不能用了,要自己輪一個(gè)。放棄整個(gè)C/C++生態(tài)全部自己輪,這個(gè)玩笑開(kāi)的有點(diǎn)大,所以只能稱(chēng)之為“玩具級(jí)”。

          3.工業(yè)級(jí)


          以部分正確的方式HOOK了網(wǎng)絡(luò)io相關(guān)的syscall,可以少改甚至不改代碼的兼容大多數(shù)第三方庫(kù);特點(diǎn):沒(méi)有完整生態(tài)

          代表作:libco

          這一層次的協(xié)程庫(kù),但是hook的不夠完善,未能完全模擬syscall的行為,只能兼容行為符合預(yù)想的同步模型的第三方庫(kù),這雖然只能覆蓋一部分的第三方庫(kù),但是通過(guò)嚴(yán)苛的源碼審查、付出代價(jià)高昂的測(cè)試成本,也可以勉強(qiáng)用于實(shí)際項(xiàng)目開(kāi)發(fā)了;

          但其他機(jī)制不夠完善:協(xié)程間通訊、協(xié)程同步、調(diào)試等,因此對(duì)開(kāi)發(fā)人員的要求很高,深諳底層機(jī)制才能寫(xiě)出沒(méi)有問(wèn)題的代碼;再加上hook不完善帶來(lái)的隱患,開(kāi)發(fā)過(guò)程可謂是步步驚心、如履薄冰。

          4.框架級(jí)


          以100%行為模擬的方式HOOK了網(wǎng)絡(luò)io相關(guān)的syscall,可以完全不改代碼兼容大多數(shù)第三方庫(kù);依照專(zhuān)為協(xié)程而生的語(yǔ)言的使用經(jīng)驗(yàn),提供了協(xié)程開(kāi)發(fā)所必須的完整生態(tài);

          代表作:libgo

          這一層次的協(xié)程庫(kù),能夠100%模擬被hook的syscall的行為,能夠兼容任何網(wǎng)絡(luò)io行為的同步模型的第三方庫(kù);由于協(xié)程開(kāi)發(fā)生態(tài)的完善,對(duì)開(kāi)發(fā)人員的要求變得很低,新手也可以寫(xiě)出高效穩(wěn)定的代碼。但由于C++的靈活性,用戶行為是不受限的,所以依然存在幾個(gè)邊邊角角的難點(diǎn)需要開(kāi)發(fā)者注意:沒(méi)有g(shù)c(開(kāi)發(fā)者要了解協(xié)程的調(diào)度時(shí)機(jī)和生命期),TLS的問(wèn)題,用戶不按套路出牌、把邏輯代碼run在協(xié)程之外,粗粒度的線程鎖等等。

          5.語(yǔ)言級(jí)


          語(yǔ)言級(jí)的協(xié)程實(shí)現(xiàn)

          代表作:golang語(yǔ)言

          這一層次的協(xié)程庫(kù),開(kāi)發(fā)者的一切行為都是受限行為,可以實(shí)現(xiàn)無(wú)死角的完善的協(xié)程。

          下面會(huì)盡可能詳盡的討論libgo設(shè)計(jì)中的每一個(gè)重要決策,并會(huì)列舉一些其他協(xié)程庫(kù)的決策的優(yōu)劣與實(shí)現(xiàn)方式

          第1節(jié).協(xié)程上下文切換


          協(xié)程上下文切換有很多種實(shí)現(xiàn)方式:

          • 1.使用操作系統(tǒng)提供的api:ucontext、fiber
            這種方式是最安全可靠的,但是性能比較差。(切換性能大概在200萬(wàn)次/秒左右)
          • 2.使用setjump、longjump:
            代表作:libmill
          • 3.自己寫(xiě)匯編碼實(shí)現(xiàn)
            這種方式的性能可以很好,但是不同系統(tǒng)、甚至不同版本的linux都需要不同的匯編碼,兼容性奇差無(wú)比,代表作:libco
          • 4.使用boost.coroutine
            這種方式的性能很好,boost也幫忙處理了各種平臺(tái)架構(gòu)的兼容性問(wèn)題,缺陷是這東西隨著boost的升級(jí),并不是向后兼容的,不推薦使用
          • 5.使用boost.context
            性能、兼容性都是當(dāng)前最佳的,推薦使用。(切換性能大概在1.25億次/秒左右)

          libgo在這一塊的方案是1+5:

          • 不愿意依賴boost庫(kù)的用戶直接編譯即可選擇第1種方案;
          • 追求更佳性能的用戶編譯時(shí)使用cmake參數(shù)-DENABLE_BOOST_CONTEXT=ON即可選擇第5種方案


          第2節(jié).協(xié)程棧


          我們通常會(huì)創(chuàng)建數(shù)量非常龐大的協(xié)程來(lái)支持高并發(fā),協(xié)程棧內(nèi)存占用情況就變成一個(gè)不容忽視的問(wèn)題了;

          如果采用線程棧相同的大棧方案(linux系統(tǒng)默認(rèn)8MB),啟動(dòng)1000個(gè)協(xié)程就要8GB內(nèi)存,啟動(dòng)10w個(gè)協(xié)程就要800GB內(nèi)存,而每個(gè)協(xié)程真正使用的棧內(nèi)存可以幾百kb甚至幾kb,內(nèi)存使用率極低,這顯然是不可接受的;

          如果采用減少協(xié)程棧的大小,比如設(shè)為128kb,啟動(dòng)1000個(gè)協(xié)程要128MB內(nèi)存,啟動(dòng)10w個(gè)協(xié)程要12.8GB內(nèi)存,這是一個(gè)合理的設(shè)置;但是,我們知道有很多人喜歡直接在棧上申請(qǐng)一個(gè)64kb的char數(shù)組做緩沖區(qū),即使開(kāi)發(fā)者非常小心的不這樣奢侈的使用棧內(nèi)存,也難免第三方庫(kù)做這樣的行為,而只需兩層嵌套就會(huì)棧溢出了。

          棧內(nèi)存不可太大,也不可太小,這其中是很難權(quán)衡的,一旦定死這個(gè)值,就只能針對(duì)特定的場(chǎng)景,無(wú)法做到通用化了;針對(duì)協(xié)程棧的內(nèi)存問(wèn)題,一般有以下幾種方案。


          靜態(tài)棧(Static Stack)


          固定大小的棧,存在上述的難以權(quán)衡的問(wèn)題;

          但是如果把問(wèn)題限定在某一個(gè)范圍,比如說(shuō)我就只用來(lái)寫(xiě)微信后臺(tái)、并且嚴(yán)格review每一個(gè)引入的第三方庫(kù)的源碼,確保其全部謹(jǐn)慎使用棧內(nèi)存,這種方案也是可以作為實(shí)際項(xiàng)目來(lái)使用的。

          典型代表:libco,它設(shè)置了128KB大小的堆棧,15年的時(shí)候我們把它引入我們當(dāng)時(shí)的項(xiàng)目中,其后出現(xiàn)過(guò)多次棧溢出的問(wèn)題。


          分段棧(Segmented Stack)


          gcc提供的“黃金鏈接器”支持一種允許棧內(nèi)存不連續(xù)的編譯參數(shù),實(shí)現(xiàn)原理是在每個(gè)函數(shù)調(diào)用開(kāi)頭都插入一段棧內(nèi)存檢測(cè)的代碼,如果棧內(nèi)存不夠用了就申請(qǐng)一塊新的內(nèi)存,作為棧內(nèi)存的延續(xù)。

          這種方案本應(yīng)是最佳的實(shí)現(xiàn),但如果遇到的第三方庫(kù)沒(méi)有使用這種方式來(lái)編譯(注意:glibc也是這里提到的”第三方庫(kù)"),那就無(wú)法在其中檢測(cè)棧內(nèi)存是否需要擴(kuò)展,棧溢出的風(fēng)險(xiǎn)很大。

          拷貝棧(Copy Stack)


          每次檢測(cè)到棧內(nèi)存不夠用時(shí),申請(qǐng)一塊更大的新內(nèi)存,將現(xiàn)有的棧內(nèi)存copy過(guò)去,就像std::vector那樣擴(kuò)展內(nèi)存。

          在某些語(yǔ)言上是可以實(shí)現(xiàn)這樣的機(jī)制,但C++ 是有指針的,棧內(nèi)存的Copy會(huì)導(dǎo)致指向其內(nèi)存地址的指針失效;又因?yàn)槠渲羔樀撵`活性(可以加減運(yùn)算),修改對(duì)應(yīng)的指針成為了一種幾乎不可能實(shí)現(xiàn)的事情(參照c++ 為什么沒(méi)辦法實(shí)現(xiàn)gc原理,詳見(jiàn)《C++11新特性解析與應(yīng)用》第5章 5.2.4節(jié))。

          共享?xiàng)?Shared Stack)


          申請(qǐng)一塊大內(nèi)存作為共享?xiàng)?比如:8MB),每次開(kāi)始運(yùn)行協(xié)程之前,先把協(xié)程棧的內(nèi)存copy到共享?xiàng)V校\(yùn)行結(jié)束后再計(jì)算協(xié)程棧真正使用的內(nèi)存,copy出來(lái)保存起來(lái),這樣每次只需保存真正使用到的棧內(nèi)存量即可。

          這種方案極大程度上避免了內(nèi)存的浪費(fèi),做到了用多少占多少,同等內(nèi)存條件下,可以啟動(dòng)的協(xié)程數(shù)量更多,libco使用這種方案單機(jī)啟動(dòng)了上千萬(wàn)協(xié)程。

          但是這種方案的缺陷也同樣明顯:

          • 1.協(xié)程切換慢:每次協(xié)程切換,都需要2次Copy協(xié)程棧內(nèi)存,這個(gè)內(nèi)存量基本上都在1KB以上,通常是幾十kb甚至幾百kb,這樣的2次Copy要花費(fèi)很長(zhǎng)的時(shí)間。
          • 2.棧上引用失效導(dǎo)致隱蔽的bug:例如下面的代碼


          bar這個(gè)協(xié)程函數(shù)里面,啟動(dòng)了一個(gè)新的協(xié)程,然后bar等待新協(xié)程結(jié)束后再退出;當(dāng)切換到新協(xié)程時(shí),由于bar協(xié)程的棧已經(jīng)被copy到了其他位置,棧上分配的變量a已經(jīng)失效,此時(shí)調(diào)用a.foo就會(huì)出現(xiàn)難以預(yù)料的結(jié)果。

          這樣的場(chǎng)景在開(kāi)發(fā)中數(shù)不勝數(shù),比如:某個(gè)處理流程需要聚合多個(gè)后端的結(jié)果、父協(xié)程對(duì)子協(xié)程做一些計(jì)數(shù)類(lèi)的操作等等等等

          有人說(shuō)我可以把變量a分配到堆上,這樣的改法確實(shí)可以解決這個(gè)已經(jīng)發(fā)現(xiàn)的bug;那其他沒(méi)發(fā)現(xiàn)的怎么辦呢,難道每個(gè)變量都放到堆上以提前規(guī)避這個(gè)坑?這顯然是不切實(shí)際的。

          早期的libgo也使用過(guò)共享?xiàng)5姆绞剑舱且驗(yàn)樽髡咴趯?shí)際開(kāi)發(fā)中遇到了這樣的問(wèn)題,才放棄了共享?xiàng)5姆绞健?/span>


          虛擬內(nèi)存棧(Virtual Memory Stack)


          既然前面提到的4種協(xié)程棧都有這樣那樣的弊端,那么有沒(méi)有一種方案能夠相對(duì)完美的解決這個(gè)問(wèn)題?答案就是虛擬內(nèi)存棧。

          Linux、Windows、MacOS三大主流操作系統(tǒng)都有這樣一個(gè)虛擬內(nèi)存機(jī)制:進(jìn)程申請(qǐng)的內(nèi)存并不會(huì)立即被映射成物理內(nèi)存,而是僅管理于虛擬內(nèi)存中,真正對(duì)其讀寫(xiě)時(shí)會(huì)觸發(fā)缺頁(yè)中斷,此時(shí)才會(huì)映射為物理內(nèi)存。

          比如:我在進(jìn)程中malloc了1MB的內(nèi)存,但是不做讀寫(xiě),那么物理內(nèi)存占用是不會(huì)增加的;當(dāng)我讀寫(xiě)這塊內(nèi)存的第一個(gè)字節(jié)時(shí),系統(tǒng)才會(huì)將這1MB內(nèi)存中的第一頁(yè)(默認(rèn)頁(yè)大小4KB)映射為物理內(nèi)存,此時(shí)物理內(nèi)存的占用會(huì)增加4KB,以此類(lèi)推,可以做到用多少占多少,冗余不超過(guò)一個(gè)內(nèi)存頁(yè)大小。

          基于這樣一個(gè)機(jī)制,libgo為每個(gè)協(xié)程malloc 1MB的虛擬內(nèi)存作為協(xié)程棧(這個(gè)值是可以定制化的);不做讀寫(xiě)操作就不會(huì)占用物理內(nèi)存,協(xié)程棧使用了多少才會(huì)占用多少物理內(nèi)存,實(shí)現(xiàn)了與共享?xiàng)=频膬?nèi)存使用率,并且不存在共享?xiàng)5膬纱蟊锥恕?/span>
          典型代表:libgo

          第3節(jié).協(xié)程調(diào)度


          像操作系統(tǒng)的進(jìn)程調(diào)度一樣,協(xié)程調(diào)度也有多種方案可選,也有公平調(diào)度和不公平調(diào)度之分。


          棧式調(diào)度


          棧式調(diào)度是典型的不公平調(diào)度:協(xié)程隊(duì)列是一個(gè)棧式的結(jié)構(gòu),每次創(chuàng)建的協(xié)程都置于棧頂,并且會(huì)立即暫停當(dāng)前協(xié)程并切換至子協(xié)程中運(yùn)行,子協(xié)程運(yùn)行結(jié)束(或其他原因?qū)е虑袚Q出來(lái))后,繼續(xù)切換回來(lái)執(zhí)行父協(xié)程;越是處于棧底部的協(xié)程(越早創(chuàng)建的協(xié)程),被調(diào)度到的機(jī)會(huì)越少;

          甚至某些場(chǎng)景下會(huì)產(chǎn)生隱晦的死循環(huán)導(dǎo)致永遠(yuǎn)在棧頂?shù)膬蓚€(gè)協(xié)程間切來(lái)切去,其他協(xié)程全部無(wú)法執(zhí)行。

          典型代表:libco

          星切調(diào)度(非對(duì)稱(chēng)協(xié)程調(diào)度)


          調(diào)度線程 -> 協(xié)程A -> 調(diào)度線程 -> 協(xié)程B -> 調(diào)度線程 -> …

          調(diào)度線程居中,協(xié)程畫(huà)在周?chē){(diào)度順序圖看起來(lái)就像是星星一樣,因此戲稱(chēng)為星切。

          將當(dāng)前可調(diào)度的協(xié)程組織成先進(jìn)先出的隊(duì)列(runnable list),順序pop出來(lái)做調(diào)度;新創(chuàng)建的協(xié)程排入隊(duì)尾,調(diào)度一次后如果狀態(tài)依然是可調(diào)度(runnable)的協(xié)程則排入隊(duì)尾,調(diào)度一次后如果狀態(tài)變?yōu)樽枞亲枞录|發(fā)后也一樣排入隊(duì)尾,是為公平調(diào)度。

          典型代表:libgo

          環(huán)切調(diào)度(對(duì)稱(chēng)協(xié)程調(diào)度)


          調(diào)度線程 -> 協(xié)程A -> 協(xié)程B -> 協(xié)程C -> 協(xié)程D -> 調(diào)度線程 -> …

          調(diào)度線程居中,協(xié)程畫(huà)在周?chē){(diào)度順序圖看起來(lái)呈環(huán)狀,因此戲稱(chēng)為環(huán)切。

          從調(diào)度順序上可以發(fā)現(xiàn),環(huán)切的切換次數(shù)僅為星切的一半,可以帶來(lái)更高的整體切換速度;但是多線程調(diào)度、WorkSteal方面會(huì)帶來(lái)一定的挑戰(zhàn)。

          這種方案也是libgo后續(xù)優(yōu)化的一個(gè)方向

          多線程調(diào)度、負(fù)載均衡與WorkSteal


          本節(jié)的內(nèi)容其實(shí)不是協(xié)程庫(kù)的必選項(xiàng),互聯(lián)網(wǎng)服務(wù)端開(kāi)發(fā)領(lǐng)域現(xiàn)在主流方案都是微服務(wù),單線程多進(jìn)程的模型不會(huì)有額外的負(fù)擔(dān)。

          但是某些場(chǎng)景下多進(jìn)程會(huì)有很昂貴的額外成本(比如:開(kāi)發(fā)一個(gè)數(shù)據(jù)庫(kù)),只能用多線程來(lái)解決,libgo為了有更廣闊的適用性,實(shí)現(xiàn)了多線程調(diào)度和Worksteal。同時(shí)也突破了傳統(tǒng)協(xié)程庫(kù)僅用來(lái)處理網(wǎng)絡(luò)io密集型業(yè)務(wù)的局限,也能適用于cpu密集型業(yè)務(wù),充當(dāng)并行編程庫(kù)來(lái)使用。

          libgo的多線程調(diào)度采用N:M模型,調(diào)度線程數(shù)量可以動(dòng)態(tài)增加,但不能減少;每個(gè)調(diào)度線程持有一個(gè)Processer(后文簡(jiǎn)稱(chēng): P),每個(gè)P持有3個(gè)runnable協(xié)程隊(duì)列(普通隊(duì)列、IO觸發(fā)隊(duì)列、親緣性隊(duì)列),其中普通隊(duì)列保存的是可以被偷取的協(xié)程;當(dāng)某個(gè)P空閑時(shí),會(huì)去其他P的隊(duì)列尾部偷取一些協(xié)程過(guò)來(lái)執(zhí)行,以此實(shí)現(xiàn)負(fù)載均衡。

          為了IO方面降低線程競(jìng)爭(zhēng),libgo會(huì)為每個(gè)調(diào)度線程在必要的時(shí)候單獨(dú)創(chuàng)建一個(gè)epoll;

          關(guān)于每個(gè)epoll的使用,會(huì)在后面的本章第4節(jié).HOOK-網(wǎng)絡(luò)io中展開(kāi)詳細(xì)論述;其他關(guān)于多線程的設(shè)計(jì)會(huì)貫穿全文的逐個(gè)介紹。


          第4節(jié).HOOK



          是否有HOOK是一個(gè)協(xié)程庫(kù)定位到玩具級(jí)和工業(yè)級(jí)之間的重要分水嶺;HOOK的底層實(shí)現(xiàn)是否遵從HOOK的基本守則;決定著用戶是如履薄冰的使用一個(gè)漏洞百出的協(xié)程庫(kù)?還是可以揮灑自如的使用一個(gè)穩(wěn)定健壯的協(xié)程庫(kù)?

          基本守則:HOOK接口表現(xiàn)出來(lái)的行為與被HOOK的接口保持100%一致


          HOOK是一個(gè)精細(xì)活,需要繁瑣的邊界條件測(cè)試,不但要保證返回值與原函數(shù)一致,相應(yīng)的errno也要一致,做的與原函數(shù)越像,能夠支持的三方庫(kù)就越多;但只要不做到100%,使用時(shí)就總是要提心吊膽的,因?yàn)槟銦o(wú)法辨識(shí)哪些三方庫(kù)的哪些邏輯分支會(huì)遇到BUG!

          比如我們?cè)谠囉胠ibco的時(shí)候就遇到這樣一個(gè)問(wèn)題:


          眾所周知,新建的socket默認(rèn)都是阻塞式的,isNonBlock應(yīng)該為false。但是當(dāng)這段代碼執(zhí)行于libco的協(xié)程中時(shí),被hook后的結(jié)果isNonBlock居然是true!

          連接成功后,read的行為更是怪異,既不是阻塞式的無(wú)限等待,也不是非阻塞式的立即返回;而是阻塞1秒后返回-1!

          如果第三方庫(kù)有表情的話,此時(shí)一定是一臉懵逼的。。。

          而且libco的HOOK不能支持真正的全靜態(tài)鏈接,這也是我們放棄它的一個(gè)重要因素。


          網(wǎng)絡(luò)io


          libgo的HOOK設(shè)計(jì)與實(shí)現(xiàn)嚴(yán)格的遵守著HOOK的基本守則,在linux系統(tǒng)上hook的socket函數(shù)列表如下:

          connect、accept read、readv、recv、recvfrom、recvmsg write、writev、send、sendto、sendmsg poll、select、__poll、close

          fcntl、ioctl、getsockopt、setsockopt dup、dup2、dup3

          協(xié)程掛起:

          如果協(xié)程對(duì)一個(gè)或多個(gè)socket的IO阻塞操作(read/write/poll/select)無(wú)法立即完成,那么協(xié)程會(huì)被設(shè)置為io-block狀態(tài)并保存到io-wait隊(duì)列中,將當(dāng)期協(xié)程的sentry保存在socket的等待隊(duì)列中,然后將這一個(gè)或多個(gè)socket添加到當(dāng)前線程所屬的epoll中;

          協(xié)程喚醒:

          如果這一個(gè)或多個(gè)socket被epoll監(jiān)聽(tīng)到協(xié)程關(guān)心的事件觸發(fā)了,對(duì)應(yīng)的協(xié)程就會(huì)被喚醒(設(shè)置成runnable狀態(tài)),并追加到所屬P的IO觸發(fā)隊(duì)列尾部,等待再次被調(diào)度。

          喚醒后的清理:

          協(xié)程被喚醒后的首次調(diào)度,會(huì)從socket的等待隊(duì)列中清除當(dāng)期協(xié)程的sentry,如果socket讀寫(xiě)事件對(duì)應(yīng)的等待隊(duì)列被清空且沒(méi)有設(shè)置為ET模式,則會(huì)調(diào)用epoll_ctl清理epoll對(duì)socket的對(duì)應(yīng)監(jiān)聽(tīng)事件。

          顯而易見(jiàn),調(diào)用void set_et_mode(int fd);接口將頻繁讀寫(xiě)的socket設(shè)置成et模式可以減少epoll相關(guān)的系統(tǒng)調(diào)用,提升性能;libgonet就做了這樣的優(yōu)化。

          關(guān)于阻塞、非阻塞的問(wèn)題,libgo是這樣解決的:

          為了實(shí)現(xiàn)協(xié)程的掛起,socket是必須被轉(zhuǎn)換成非阻塞模式的,libgo在其上封裝了一個(gè)狀態(tài):user_nonblock,表示用戶是否主動(dòng)設(shè)置過(guò)nonblock,并hook相關(guān)函數(shù),屏蔽掉socket真實(shí)的阻塞狀態(tài),對(duì)用戶呈現(xiàn)user_nonblock。

          如果用戶設(shè)置過(guò)nonblock,即user_nonblock == true,則對(duì)用戶呈現(xiàn)一個(gè)非阻塞socket的所有特質(zhì)(調(diào)用讀寫(xiě)函數(shù)都不會(huì)阻塞,而是立即返回)。

          如果用戶沒(méi)有設(shè)置過(guò)nonblock,即socket的真實(shí)狀態(tài)是非阻塞的,但是user_nonblock == false,此時(shí)對(duì)用戶呈現(xiàn)一個(gè)阻塞式socket的所有特質(zhì)(調(diào)用讀寫(xiě)函數(shù)不能立即完成就阻塞等待,并且阻塞時(shí)間等同于RCVTIMEO或SNDTIMEO)。

          為了可以正確維護(hù)user_nonblock狀態(tài),就必須把dup、dup2、dup3這幾個(gè)復(fù)制fd的函數(shù)給hook了,另外fcntl也是可以復(fù)制fd的,也要做出類(lèi)似的處理。

          libgo的HOOK不但可以100%模擬原生syscall的行為,還可以做一些原生syscall沒(méi)能實(shí)現(xiàn)的功能,比如:帶超時(shí)設(shè)置的connect。

          在libgo的協(xié)程中調(diào)用connect之前,可以先調(diào)用void set_connect_timeout(int milliseconds);接口設(shè)置connect的超時(shí)時(shí)長(zhǎng)。

          DNS


          libgo在linux系統(tǒng)上hook的dns函數(shù)列表如下:

          gethostbyname
          gethostbyname2
          gethostbyname_r
          gethostbyname2_r
          gethostbyaddr
          gethostbyaddr_r

          其中,形如getXXbyYY的三個(gè)函數(shù)是其對(duì)應(yīng)的getXXbyYY_r函數(shù)外層封裝了一個(gè)TLS緩沖區(qū)的實(shí)現(xiàn);

          HOOK后的實(shí)現(xiàn)中,libgo使用CLS替代了原生syscall里的TLS的功能。

          通過(guò)觀察glibc源碼發(fā)現(xiàn),形如getXXbyYY_r的三個(gè)函數(shù)內(nèi)部還使用了一個(gè)存在struct thread_info結(jié)構(gòu)體中的TLS變量緩存調(diào)用遠(yuǎn)程dns服務(wù)器使用的socket,實(shí)測(cè)中發(fā)現(xiàn)libco提供的HOOK __res_state函數(shù)的方案是無(wú)效的,getXXbyYY_r會(huì)并發(fā)亂序的讀寫(xiě)同一個(gè)socket,導(dǎo)致混亂的結(jié)果或長(zhǎng)久的阻塞。

          libgo針對(duì)這個(gè)問(wèn)題HOOK了getXXbyYY_r系列函數(shù),在函數(shù)入口使用了一個(gè)線程私有的協(xié)程鎖,解決了同一個(gè)線程的getXXbyYY_r亂序讀寫(xiě)同一個(gè)socket的問(wèn)題;又由于P中的IO觸發(fā)隊(duì)列的存在,getXXbyYY_r由于內(nèi)部的__poll掛起再重新喚醒后,保證了會(huì)在原線程完成后續(xù)代碼的執(zhí)行。

          signal


          linux上的signal是有著不可重入屬性的,在signal處理函數(shù)中處理復(fù)雜的操作極易出現(xiàn)死鎖,libgo提供了解決這個(gè)問(wèn)題的編譯參數(shù):


          其他會(huì)導(dǎo)致阻塞的syscall


          libgo還HOOK了三個(gè)sleep函數(shù):sleep、usleep、nanosleep

          在協(xié)程中直接使用這三個(gè)sleep函數(shù),可以讓當(dāng)前協(xié)程掛起相應(yīng)的時(shí)間。

          第5節(jié).完整生態(tài)


          依照golang近10年的實(shí)踐經(jīng)驗(yàn)來(lái)看,我們很容易發(fā)現(xiàn)協(xié)程是核心功能,但只有協(xié)程是遠(yuǎn)遠(yuǎn)不夠的。我們還需要很多周邊生態(tài)來(lái)輔助協(xié)程更好地完成并發(fā)任務(wù)。

          Channel


          和線程一樣,協(xié)程間也是需要交換數(shù)據(jù)。

          很多時(shí)候我們需要一個(gè)能夠屏蔽協(xié)程同步、多線程調(diào)度等各種底層細(xì)節(jié)的,簡(jiǎn)單的,保證數(shù)據(jù)有序傳遞的通訊方式,golang中channel的設(shè)計(jì)就剛好滿足了我們的需求。

          libgo仿照golang制作了Channel功能,通過(guò)如下代碼:


          即創(chuàng)建了一個(gè)不帶額外緩沖區(qū)的、傳遞int的channel,重載了操作符<<和>>,使用


          向其寫(xiě)入一個(gè)整數(shù)1,正如golang中channel的行為一樣,此時(shí)如果沒(méi)有另一個(gè)協(xié)程使用


          嘗試讀取,當(dāng)前協(xié)程會(huì)被掛起等待。

          如果使用


          則表示從channel中讀取一個(gè)元素,但是不再使用它。channel的這種掛起協(xié)程等待的特性,也通常用于父協(xié)程等待子協(xié)程處理完成后再向下執(zhí)行。

          也可以使用


          創(chuàng)建一個(gè)帶有長(zhǎng)度為10的緩沖區(qū)的channel,正如golang中channel的行為一樣,對(duì)這樣的channel進(jìn)行寫(xiě)操作,緩沖區(qū)寫(xiě)滿之前協(xié)程不會(huì)掛起。

          這適用于有大批量數(shù)據(jù)需要傳遞的場(chǎng)景。

          協(xié)程鎖、協(xié)程讀寫(xiě)鎖


          在任何C++協(xié)程庫(kù)的使用中,都應(yīng)該慎重使用或禁用線程鎖,比如下面的代碼


          協(xié)程A首先被調(diào)度,加鎖后調(diào)用sleep導(dǎo)致當(dāng)前協(xié)程掛起,注意此時(shí)mtx已然是被鎖定的。

          然后協(xié)程B被調(diào)度,要等待mtx被解鎖才能繼續(xù)執(zhí)行下去,由于mtx是線程鎖,會(huì)阻塞調(diào)度線程,協(xié)程A再也不會(huì)有機(jī)會(huì)被調(diào)度,從而形成死鎖。

          這是一個(gè)典型的邊角問(wèn)題,因?yàn)槲覀儫o(wú)法阻止C++程序員在使用協(xié)程庫(kù)的同時(shí)再使用線程同步機(jī)制。

          其實(shí)我們可以提供一個(gè)協(xié)程鎖來(lái)解決這一問(wèn)題,比如下面的代碼


          代碼與前一個(gè)例子幾乎一樣,唯一的區(qū)別是mtx的鎖類(lèi)型從線程鎖變成了libgo提供的協(xié)程鎖。

          協(xié)程A首先被調(diào)度,加鎖后調(diào)用sleep導(dǎo)致當(dāng)前協(xié)程掛起,注意此時(shí)mtx已然是被鎖定的。

          然后協(xié)程B被調(diào)度,要等待mtx被解鎖才能繼續(xù)執(zhí)行下去,由于mtx是協(xié)程鎖,協(xié)程鎖在等待時(shí)會(huì)掛起當(dāng)前協(xié)程而不是阻塞線程,協(xié)程A在sleep時(shí)間結(jié)束后會(huì)被喚醒并被調(diào)度,協(xié)程A退出foo函數(shù)時(shí)會(huì)解鎖,解鎖的行為又會(huì)喚醒協(xié)程B,協(xié)程B被調(diào)度時(shí)再次鎖定mtx,然后順利完成整個(gè)邏輯。

          libgo還提供了協(xié)程讀寫(xiě)鎖:co_rwmutex

          另外,即便開(kāi)發(fā)者有意識(shí)的規(guī)避第一個(gè)例子那樣的場(chǎng)景,也很容易踩到另外一個(gè)線程鎖導(dǎo)致的坑,比如在使用zookeeper-client這樣會(huì)啟動(dòng)后臺(tái)線程來(lái)call回調(diào)函數(shù)的第三方庫(kù)時(shí):


          看起來(lái)好像沒(méi)什么問(wèn)題,但其實(shí)routine里面的線程鎖會(huì)阻塞整個(gè)調(diào)度線程,使得其他協(xié)程都無(wú)法被及時(shí)調(diào)度。

          針對(duì)這種情況最優(yōu)雅的處理方式就是使用Channel,因?yàn)閘ibgo提供的Channel不僅可以用于協(xié)程間交換數(shù)據(jù),也可以用于協(xié)程與線程間交換數(shù)據(jù),可以說(shuō)是專(zhuān)門(mén)針對(duì)zk這類(lèi)起后臺(tái)線程的第三方庫(kù)設(shè)計(jì)的。


          定時(shí)器


          libgo框架的主調(diào)度器提供了一個(gè)基于紅黑樹(shù)的定時(shí)器,會(huì)在調(diào)度線程的主循環(huán)中被執(zhí)行,這樣的設(shè)計(jì)可以與epoll更好地協(xié)同工作,無(wú)論是定時(shí)器還是epoll監(jiān)聽(tīng)的fd都可以最及時(shí)的觸發(fā)。

          使用co_timer_add接口可以添加一個(gè)定時(shí)任務(wù),co_timer_add接口接受兩個(gè)參數(shù),第一個(gè)參數(shù)是可以是std::chrono::system_clock::time_point,也可以是std::chrono::steady_clock::time_point,還可以是std::chrono庫(kù)里的一個(gè)duration。第二個(gè)參數(shù)接受一個(gè)回調(diào)函數(shù),可以是函數(shù)指針、仿函數(shù)、lambda等等;

          當(dāng)?shù)谝粋€(gè)參數(shù)使用system_clock::time_point時(shí),表示定時(shí)任務(wù)跟隨系統(tǒng)時(shí)間的變化而變化,可以通過(guò)調(diào)整操作系統(tǒng)的時(shí)間設(shè)置提前或延緩定時(shí)任務(wù)的執(zhí)行。

          當(dāng)?shù)谝粋€(gè)參數(shù)使用另外兩種類(lèi)型時(shí),定時(shí)任務(wù)不隨系統(tǒng)時(shí)間的變化而變化。

          co_timer_add接口返回一個(gè)co::TimerId類(lèi)型的定時(shí)任務(wù)id,可以用來(lái)取消定時(shí)任務(wù)。

          取消定時(shí)任務(wù)有種方式:co_timer_cancel和co_timer_block_cancel,均會(huì)返回一個(gè)bool類(lèi)型表示是否取消成功。

          使用co_timer_cancel,會(huì)立即返回,即使定時(shí)任務(wù)正在被執(zhí)行。

          使用co_timer_block_cancel,如果定時(shí)任務(wù)正在被執(zhí)行,則會(huì)阻塞地等待任務(wù)完成后返回false;否則會(huì)立即返回;

          需要注意的是co_timer_block_cancel的阻塞行為是使用自旋鎖實(shí)現(xiàn)的,如果定時(shí)任務(wù)耗時(shí)較長(zhǎng),co_timer_block_cancel的阻塞行為不但會(huì)阻塞當(dāng)前調(diào)度線程,還會(huì)產(chǎn)生高昂的cpu開(kāi)銷(xiāo);這個(gè)接口是設(shè)計(jì)用來(lái)在libgo內(nèi)部使用的,請(qǐng)用戶謹(jǐn)慎使用!

          CLS(Coroutine Local Storage)(協(xié)程本地存儲(chǔ))


          CLS類(lèi)似于TLS(Thread Local Storage);

          這個(gè)功能是HOOK DNS函數(shù)族的基石,沒(méi)有CLS的協(xié)程庫(kù)是無(wú)法HOOK DNS函數(shù)族的。

          libgo提供了一個(gè)行為是TLS超集的CLS功能,CLS變量可以定義在全局作用域、塊作用域(函數(shù)體內(nèi))、類(lèi)的靜態(tài)成員,除此TLS也支持的這三種場(chǎng)景外,還可以作為類(lèi)的非靜態(tài)成員。

          注:libco也有CLS功能,但是僅支持全局作用域

          CLS的使用方式參見(jiàn)tutorail文件夾下的sample13_cls.cpp教程代碼。

          線程池


          除了前文提到的各種邊角問(wèn)題之外,還有一個(gè)非常常見(jiàn)的邊角問(wèn)題:文件IO 筆者曾經(jīng)努力嘗試過(guò)HOOK文件IO操作,但很不幸linux系統(tǒng)中,文件fd是無(wú)法使用poll、select、epoll正確監(jiān)聽(tīng)可讀可寫(xiě)狀態(tài)的;linux提供的異步文件IO系統(tǒng)調(diào)用nio又不支持操作系統(tǒng)的文件緩存,不適合用來(lái)實(shí)現(xiàn)HOOK(這會(huì)導(dǎo)致用戶的所有文件IO都不經(jīng)過(guò)系統(tǒng)緩存而直接操作硬盤(pán),這是一種不恰當(dāng)?shù)淖龇?。

          除此之外也還會(huì)有其他不能HOOK或未被HOOK的阻塞syscall,因此需要一個(gè)線程池機(jī)制來(lái)解決這種阻塞行為對(duì)協(xié)程調(diào)度的干擾。

          libgo提供了一個(gè)宏:co_await,來(lái)輔助用戶完成線程池與協(xié)程的交互。


          在協(xié)程中使用


          可以把func投遞到線程池中,并且掛起當(dāng)前協(xié)程,直到func完成后協(xié)程會(huì)被喚醒,繼續(xù)執(zhí)行下去。也可以使用


          等待bar在線程池中完成,并將bar的返回值寫(xiě)入變量a中。co_await也同樣可以在協(xié)程之外被調(diào)用。

          另外,為了用戶更靈活的定制線程數(shù)量,也為了libgo不偷起后臺(tái)線程的操守;線程池并不會(huì)自行啟動(dòng),需要用戶自行啟動(dòng)一個(gè)或多個(gè)線程執(zhí)行co_sched.GetThreadPool().RunLoop();

          調(diào)試

          libgo作為框架級(jí)的協(xié)程庫(kù),調(diào)試機(jī)制是必不可少的。

          • 1.可以設(shè)置co_sched.GetOptions().debug打印一些log,具體flag見(jiàn)config.h
          • 2.可以設(shè)置一個(gè)協(xié)程事件監(jiān)聽(tīng)器,詳見(jiàn)tutorial文件夾下的sample12_listener.cpp教程代碼
          • 3.編譯時(shí)添加cmake參數(shù):-DENABLE_DEBUGGER=ON 開(kāi)啟debug信息收集后,可以使用co::CoDebugger類(lèi)獲取一些調(diào)試信息,詳見(jiàn)debugger.h的注釋
          • 4.后續(xù)還會(huì)提供更多調(diào)試手段

          協(xié)程之外(運(yùn)行在線程上的代碼)

          前文提到了很多功能都可以在線程上執(zhí)行:Channel、co_await、co_mutex、定時(shí)器、CLS

          跨平臺(tái)

          libgo支持三大主流系統(tǒng):linux、windows、mac-os


          linux是主打平臺(tái),也是libgo運(yùn)行性能最好的平臺(tái),master分支永遠(yuǎn)支持linux

          win分支支持windows系統(tǒng),會(huì)不定期的將master分支的新功能合入其中

          mac的情況同windows

          上層封裝

          筆者另有一個(gè)開(kāi)源庫(kù):libgonet,是基于libgo封裝的linux協(xié)程網(wǎng)絡(luò)庫(kù),使用起來(lái)極為方便。

          如果你要開(kāi)發(fā)一個(gè)網(wǎng)絡(luò)服務(wù)或rpc框架,更推薦從libgonet寫(xiě)起,畢竟即使有協(xié)程,socket相關(guān)的處理也并不輕松。

          未來(lái)的發(fā)展方向

          • 1.目前是使用go、go_stack、go_dispatch三個(gè)不同的宏來(lái)設(shè)置協(xié)程的屬性,這種方式不夠靈活,后續(xù)要改成:go stack(1024 * 1024) dispatch(::co::egod_robin) func; 這樣的語(yǔ)法形式,可以更靈活的定制協(xié)程屬性。
          • 2.基于(1)的新語(yǔ)法,實(shí)現(xiàn)“協(xié)程親緣性”功能,將協(xié)程綁定到指定線程上,并防止被steal。
          • 3.優(yōu)化協(xié)程切換速度:
            A)使用環(huán)切調(diào)度替代現(xiàn)在的星切調(diào)度(CoYeild時(shí)選擇下一個(gè)切換目標(biāo)),必要時(shí)才切換回線程處理epoll、定時(shí)器、sleep等邏輯,同時(shí)協(xié)調(diào)好多線程調(diào)度
            B)調(diào)度器的Run函數(shù)里面做了很多協(xié)程切換之外的事情,盡量降低這部分在非必要時(shí)的cpu消耗,比如:有任務(wù)加入定時(shí)器是設(shè)置一個(gè)tls標(biāo)記為true,只有標(biāo)記為true時(shí)才去處理定時(shí)器相關(guān)邏輯。
            C)調(diào)度器中的runnable隊(duì)列使用了自旋鎖,沒(méi)有競(jìng)爭(zhēng)時(shí)對(duì)原子變量的操作也是比較昂貴的,runnable隊(duì)列可以優(yōu)化成多寫(xiě)一讀,僅在寫(xiě)入端加鎖的隊(duì)列。
          • 4.協(xié)程對(duì)象Task內(nèi)存布局調(diào)優(yōu),tls池化,每個(gè)池使用多寫(xiě)一讀鏈表隊(duì)列,申請(qǐng)時(shí)僅在當(dāng)前線程的池中申請(qǐng),可以免鎖,釋放時(shí)均衡每個(gè)線程的池水水位,可以塞入其他線程的池中。
          • 5.libgo之外,會(huì)進(jìn)一步尋找和當(dāng)前已經(jīng)比較成熟的非協(xié)程的開(kāi)發(fā)框架的結(jié)合方案,讓還未能用上協(xié)程的用戶低成本的用上協(xié)程。

          libgo開(kāi)源地址: 
          https://github.com/yyzybb537/libgo
          作者:Li_Mr
          https://my.oschina.net/yyzybb/blog/1817226

          版權(quán)申明:內(nèi)容來(lái)源網(wǎng)絡(luò),版權(quán)歸原創(chuàng)者所有。除非無(wú)法確認(rèn),都會(huì)標(biāo)明作者及出處,如有侵權(quán),煩請(qǐng)告知,我們會(huì)立即刪除并致歉!

          瀏覽 21
          點(diǎn)贊
          評(píng)論
          收藏
          分享

          手機(jī)掃一掃分享

          分享
          舉報(bào)
          評(píng)論
          圖片
          表情
          推薦
          點(diǎn)贊
          評(píng)論
          收藏
          分享

          手機(jī)掃一掃分享

          分享
          舉報(bào)
          <kbd id="afajh"><form id="afajh"></form></kbd>
          <strong id="afajh"><dl id="afajh"></dl></strong>
            <del id="afajh"><form id="afajh"></form></del>
                1. <th id="afajh"><progress id="afajh"></progress></th>
                  <b id="afajh"><abbr id="afajh"></abbr></b>
                  <th id="afajh"><progress id="afajh"></progress></th>
                  天天爱三级 | 51黄片 | 国产精品欧美7777777 | 日韩美女毛片 | 久久免费少妇视频 |