當前位置:
首頁 > 最新 > C+協程的近況、設計與實現中的細節和決策

C+協程的近況、設計與實現中的細節和決策

來自:Li_Mr 的博客

https://my.oschina.net/yyzybb/blog/1817226

摘要: 講述C++協程的近況、設計與實現中的細節與決策

時至2018年的今天,C++ 在互聯網服務端開發方向依然佔據著相當大的份額;百度,騰訊,甚至以java為主流開發語言的阿里都在大規模使用C++做互聯網服務端開發,而這恰恰是本文想要討論的範疇。

第1章 C++協程近況簡介

協程分兩種,無棧協程(stackless)和有棧協程(stackful),前者無法解決非同步回調模式中上下文保存與恢復的問題,在此不做論述,文中後續提到的協程均指有棧協程。

第1節.舊時代

在2014年以前,C++服務端開發是以非同步回調模型為主流,業務流程中每一個需要等待IO處理的節點都需要切斷業務處理流程、保存當前處理的上下文、設置回調函數,等IO處理完成後再恢復上下文、接續業務處理流程。

在一個典型的互聯網業務處理流程中,這樣的行為節點多達十幾個甚至數十個(微服務間的rpc請求、與redis之類的高速緩存的交互、與mysqlmongodb之類的DB交互、調用第三方HttpServer的介面等等);被切割的支離破碎的業務處理流程帶來了幾個常見的難題:

每個流程都要定義一個上下文struct,並手動保存與恢復;

每次回調都會切斷棧上變數的生命周期,導致需要延續使用的變數必須申請到堆上或存入上下文結構中;

由於C++是無GC的語言,碎片化的邏輯給內存管理也帶來了更多挑戰;

回調式的邏輯是「不知何時會被觸發」的,用戶狀態管理也會有更多挑戰;

這些具體的難題綜合起來,在工程化角度呈現出的效果就是:代碼編寫複雜,開發周期長,維護困難,BUG多且防不勝防。

第2節.新時代

筆者所在的公司當時也試用了一段時間libco,修修補補很多次,終究是因為問題太多而放棄,改用了自研的libgo作為協程開發框架。

聊協程就不能不提到主打協程功能和CSP模式的golang語言,google從09年發布golang至今,經過近10個年頭的發酵,已成為互聯網服務端開發主流開發語言之一,許多項目和開發者從C++、java、php等語言轉向golang。筆者自研的libgo也汲取了golang的設計理念和多年的實踐經驗。

本文後續針對C++協程框架的設計與實現、與golang這種語言級別支持的協程的差距在哪裡、怎樣儘力彌補這種差距等方面展開討論。

第2章.協程庫的設計與實現

個人認為,C++協程庫從實現完善程度上分為以下幾個層次

1.API級

實現協程上下文切換api,或添加一些便於使用的封裝; 特點:沒有協程調度。

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

這一層次的協程庫,僅僅提供了一個底層api,要想拿來做項目,還有非常非常遙遠的距離;不過這些協程api可以為我們實現自己的協程庫提供一個良好的基礎。

2.玩具級

實現了協程調度,無需用戶手動處理協程上下文切換;特點:沒有HOOK

代表作:libmill

這一層次的協程庫,實現了協程調度(類似於操作系統有了進程調度機制);稍好一些的意識到了阻塞網路io與協程的不協調之處,自己實現了一套網路io相關函數;

但是這也意味著涉及網路的第三方庫全部不可用了,比如你想用redis?不好意思,hiredis不能用了,要自己輪一個;你想用mysql?不好意思,mysqlclient不能用了,要自己輪一個。放棄整個C/C++生態全部自己輪,這個玩笑開的有點大,所以只能稱之為「玩具級」。

3.工業級

以部分正確的方式HOOK了網路io相關的syscall,可以少改甚至不改代碼的兼容大多數第三方庫;特點:沒有完整生態

代表作:libco

這一層次的協程庫,但是hook的不夠完善,未能完全模擬syscall的行為,只能兼容行為符合預想的同步模型的第三方庫,這雖然只能覆蓋一部分的第三方庫,但是通過嚴苛的源碼審查、付出代價高昂的測試成本,也可以勉強用於實際項目開發了;

但其他機制不夠完善:協程間通訊、協程同步、調試等,因此對開發人員的要求很高,深諳底層機制才能寫出沒有問題的代碼;再加上hook不完善帶來的隱患,開發過程可謂是步步驚心、如履薄冰。

4.框架級

以100%行為模擬的方式HOOK了網路io相關的syscall,可以完全不改代碼兼容大多數第三方庫;依照專為協程而生的語言的使用經驗,提供了協程開發所必須的完整生態;

代表作:libgo

這一層次的協程庫,能夠100%模擬被hook的syscall的行為,能夠兼容任何網路io行為的同步模型的第三方庫;由於協程開發生態的完善,對開發人員的要求變得很低,新手也可以寫出高效穩定的代碼。但由於C++的靈活性,用戶行為是不受限的,所以依然存在幾個邊邊角角的難點需要開發者注意:沒有gc(開發者要了解協程的調度時機和生命期),TLS的問題,用戶不按套路出牌、把邏輯代碼run在協程之外,粗粒度的線程鎖等等。

5.語言級

語言級的協程實現

代表作:golang語言

這一層次的協程庫,開發者的一切行為都是受限行為,可以實現無死角的完善的協程。

下面會儘可能詳盡的討論libgo設計中的每一個重要決策,並會列舉一些其他協程庫的決策的優劣與實現方式

第1節.協程上下文切換

協程上下文切換有很多種實現方式:

1.使用操作系統提供的api:ucontext、fiber

這種方式是最安全可靠的,但是性能比較差。(切換性能大概在200萬次/秒左右)

2.使用setjump、longjump:

代表作:libmill

3.自己寫彙編碼實現

這種方式的性能可以很好,但是不同系統、甚至不同版本的linux都需要不同的彙編碼,兼容性奇差無比,代表作:libco

4.使用boost.coroutine

這種方式的性能很好,boost也幫忙處理了各種平台架構的兼容性問題,缺陷是這東西隨著boost的升級,並不是向後兼容的,不推薦使用

5.使用boost.context

性能、兼容性都是當前最佳的,推薦使用。(切換性能大概在1.25億次/秒左右)

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

不願意依賴boost庫的用戶直接編譯即可選擇第1種方案;

追求更佳性能的用戶編譯時使用cmake參數-DENABLE_BOOST_CONTEXT=ON即可選擇第5種方案

第2節.協程棧

我們通常會創建數量非常龐大的協程來支持高並發,協程棧內存佔用情況就變成一個不容忽視的問題了;

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

如果採用減少協程棧的大小,比如設為128kb,啟動1000個協程要128MB內存,啟動10w個協程要12.8GB內存,這是一個合理的設置;但是,我們知道有很多人喜歡直接在棧上申請一個64kb的char數組做緩衝區,即使開發者非常小心的不這樣奢侈的使用棧內存,也難免第三方庫做這樣的行為,而只需兩層嵌套就會棧溢出了。

棧內存不可太大,也不可太小,這其中是很難權衡的,一旦定死這個值,就只能針對特定的場景,無法做到通用化了; 針對協程棧的內存問題,一般有以下幾種方案。

靜態棧(Static Stack)

固定大小的棧,存在上述的難以權衡的問題;

典型代表:libco,它設置了128KB大小的堆棧,15年的時候我們把它引入我們當時的項目中,其後出現過多次棧溢出的問題。

分段棧(Segmented Stack)

gcc提供的「黃金鏈接器」支持一種允許棧內存不連續的編譯參數,實現原理是在每個函數調用開頭都插入一段棧內存檢測的代碼,如果棧內存不夠用了就申請一塊新的內存,作為棧內存的延續。

這種方案本應是最佳的實現,但如果遇到的第三方庫沒有使用這種方式來編譯(注意:glibc也是這裡提到的」第三方庫"),那就無法在其中檢測棧內存是否需要擴展,棧溢出的風險很大。

拷貝棧(Copy Stack)

每次檢測到棧內存不夠用時,申請一塊更大的新內存,將現有的棧內存copy過去,就像std::vector那樣擴展內存。

在某些語言上是可以實現這樣的機制,但C++ 是有指針的,棧內存的Copy會導致指向其內存地址的指針失效;又因為其指針的靈活性(可以加減運算),修改對應的指針成為了一種幾乎不可能實現的事情(參照c++ 為什麼沒辦法實現gc原理,詳見《C++11新特性解析與應用》第5章 5.2.4節)。

共享棧(Shared Stack)

申請一塊大內存作為共享棧(比如:8MB),每次開始運行協程之前,先把協程棧的內存copy到共享棧中,運行結束後再計算協程棧真正使用的內存,copy出來保存起來,這樣每次只需保存真正使用到的棧內存量即可。

這種方案極大程度上避免了內存的浪費,做到了用多少佔多少,同等內存條件下,可以啟動的協程數量更多,libco使用這種方案單機啟動了上千萬協程。

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

1.協程切換慢:每次協程切換,都需要2次Copy協程棧內存,這個內存量基本上都在1KB以上,通常是幾十kb甚至幾百kb,這樣的2次Copy要花費很長的時間。

2.棧上引用失效導致隱蔽的bug:例如下面的代碼

bar這個協程函數裡面,啟動了一個新的協程,然後bar等待新協程結束後再退出;當切換到新協程時,由於bar協程的棧已經被copy到了其他位置,棧上分配的變數a已經失效,此時調用a.foo就會出現難以預料的結果。

這樣的場景在開發中數不勝數,比如:某個處理流程需要聚合多個後端的結果、父協程對子協程做一些計數類的操作等等等等

有人說我可以把變數a分配到堆上,這樣的改法確實可以解決這個已經發現的bug;那其他沒發現的怎麼辦呢,難道每個變數都放到堆上以提前規避這個坑?這顯然是不切實際的。

早期的libgo也使用過共享棧的方式,也正是因為作者在實際開發中遇到了這樣的問題,才放棄了共享棧的方式。

虛擬內存棧(Virtual Memory Stack)

既然前面提到的4種協程棧都有這樣那樣的弊端,那麼有沒有一種方案能夠相對完美的解決這個問題?答案就是虛擬內存棧。

Linux、Windows、MacOS三大主流操作系統都有這樣一個虛擬內存機制:進程申請的內存並不會立即被映射成物理內存,而是僅管理於虛擬內存中,真正對其讀寫時會觸發缺頁中斷,此時才會映射為物理內存。

比如:我在進程中malloc了1MB的內存,但是不做讀寫,那麼物理內存佔用是不會增加的;當我讀寫這塊內存的第一個位元組時,系統才會將這1MB內存中的第一頁(默認頁大小4KB)映射為物理內存,此時物理內存的佔用會增加4KB,以此類推,可以做到用多少佔多少,冗餘不超過一個內存頁大小。

基於這樣一個機制,libgo為每個協程malloc 1MB的虛擬內存作為協程棧(這個值是可以定製化的);不做讀寫操作就不會佔用物理內存,協程棧使用了多少才會佔用多少物理內存,實現了與共享棧近似的內存使用率,並且不存在共享棧的兩大弊端。

典型代表:libgo

第3節.協程調度

像操作系統的進程調度一樣,協程調度也有多種方案可選,也有公平調度和不公平調度之分。

棧式調度

棧式調度是典型的不公平調度:協程隊列是一個棧式的結構,每次創建的協程都置於棧頂,並且會立即暫停當前協程並切換至子協程中運行,子協程運行結束(或其他原因導致切換出來)後,繼續切換回來執行父協程;越是處於棧底部的協程(越早創建的協程),被調度到的機會越少;

甚至某些場景下會產生隱晦的死循環導致永遠在棧頂的兩個協程間切來切去,其他協程全部無法執行。

典型代表:libco

星切調度(非對稱協程調度)

調度線程 -> 協程A -> 調度線程 -> 協程B -> 調度線程 -> …

調度線程居中,協程畫在周圍,調度順序圖看起來就像是星星一樣,因此戲稱為星切。

將當前可調度的協程組織成先進先出的隊列(runnable list),順序pop出來做調度;新創建的協程排入隊尾,調度一次後如果狀態依然是可調度(runnable)的協程則排入隊尾,調度一次後如果狀態變為阻塞,那阻塞事件觸發後也一樣排入隊尾,是為公平調度。

典型代表:libgo

環切調度(對稱協程調度)

調度線程 -> 協程A -> 協程B -> 協程C -> 協程D -> 調度線程 -> …

調度線程居中,協程畫在周圍,調度順序圖看起來呈環狀,因此戲稱為環切。

從調度順序上可以發現,環切的切換次數僅為星切的一半,可以帶來更高的整體切換速度;但是多線程調度、WorkSteal方面會帶來一定的挑戰。

這種方案也是libgo後續優化的一個方向

多線程調度、負載均衡與WorkSteal

本節的內容其實不是協程庫的必選項,互聯網服務端開發領域現在主流方案都是微服務,單線程多進程的模型不會有額外的負擔。

但是某些場景下多進程會有很昂貴的額外成本(比如:開發一個資料庫),只能用多線程來解決,libgo為了有更廣闊的適用性,實現了多線程調度和Worksteal。同時也突破了傳統協程庫僅用來處理網路io密集型業務的局限,也能適用於cpu密集型業務,充當並行編程庫來使用。

libgo的多線程調度採用N:M模型,調度線程數量可以動態增加,但不能減少; 每個調度線程持有一個Processer(後文簡稱: P),每個P持有3個runnable協程隊列(普通隊列、IO觸發隊列、親緣性隊列),其中普通隊列保存的是可以被偷取的協程;當某個P空閑時,會去其他P的隊列尾部偷取一些協程過來執行,以此實現負載均衡。

為了IO方面降低線程競爭,libgo會為每個調度線程在必要的時候單獨創建一個epoll;

關於每個epoll的使用,會在後面的本章第4節.HOOK-網路io中展開詳細論述;其他關於多線程的設計會貫穿全文的逐個介紹。

第4節.HOOK

是否有HOOK是一個協程庫定位到玩具級和工業級之間的重要分水嶺; HOOK的底層實現是否遵從HOOK的基本守則;決定著用戶是如履薄冰的使用一個漏洞百出的協程庫?還是可以揮灑自如的使用一個穩定健壯的協程庫?

基本守則:HOOK介面表現出來的行為與被HOOK的介面保持100%一致

HOOK是一個精細活,需要繁瑣的邊界條件測試,不但要保證返回值與原函數一致,相應的errno也要一致,做的與原函數越像,能夠支持的三方庫就越多; 但只要不做到100%,使用時就總是要提心弔膽的,因為你無法辨識哪些三方庫的哪些邏輯分支會遇到BUG!

比如我們在試用libco的時候就遇到這樣一個問題:

眾所周知,新建的socket默認都是阻塞式的,isNonBlock應該為false。但是當這段代碼執行於libco的協程中時,被hook後的結果isNonBlock居然是true!

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

如果第三方庫有表情的話,此時一定是一臉懵逼的。。。

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

網路io

libgo的HOOK設計與實現嚴格的遵守著HOOK的基本守則,在linux系統上hook的socket函數列表如下:

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

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

協程掛起:

如果協程對一個或多個socket的IO阻塞操作(read/write/poll/select)無法立即完成,那麼協程會被設置為io-block狀態並保存到io-wait隊列中,將當期協程的sentry保存在socket的等待隊列中,然後將這一個或多個socket添加到當前線程所屬的epoll中;

協程喚醒:

如果這一個或多個socket被epoll監聽到協程關心的事件觸發了,對應的協程就會被喚醒(設置成runnable狀態),並追加到所屬P的IO觸發隊列尾部,等待再次被調度。

喚醒後的清理:

協程被喚醒後的首次調度,會從socket的等待隊列中清除當期協程的sentry,如果socket讀寫事件對應的等待隊列被清空且沒有設置為ET模式,則會調用epoll_ctl清理epoll對socket的對應監聽事件。

顯而易見,調用void set_et_mode(int fd);介面將頻繁讀寫的socket設置成et模式可以減少epoll相關的系統調用,提升性能;libgonet就做了這樣的優化。

關於阻塞、非阻塞的問題,libgo是這樣解決的:

為了實現協程的掛起,socket是必須被轉換成非阻塞模式的,libgo在其上封裝了一個狀態:user_nonblock,表示用戶是否主動設置過nonblock,並hook相關函數,屏蔽掉socket真實的阻塞狀態,對用戶呈現user_nonblock。

如果用戶設置過nonblock,即user_nonblock == true,則對用戶呈現一個非阻塞socket的所有特質(調用讀寫函數都不會阻塞,而是立即返回)。

如果用戶沒有設置過nonblock,即socket的真實狀態是非阻塞的,但是user_nonblock == false,此時對用戶呈現一個阻塞式socket的所有特質(調用讀寫函數不能立即完成就阻塞等待,並且阻塞時間等同於RCVTIMEO或SNDTIMEO)。

為了可以正確維護user_nonblock狀態,就必須把dup、dup2、dup3這幾個複製fd的函數給hook了,另外fcntl也是可以複製fd的,也要做出類似的處理。

libgo的HOOK不但可以100%模擬原生syscall的行為,還可以做一些原生syscall沒能實現的功能,比如:帶超時設置的connect。

在libgo的協程中調用connect之前,可以先調用void set_connect_timeout(int milliseconds);介面設置connect的超時時長。

DNS

libgo在linux系統上hook的dns函數列表如下:

gethostbyname

gethostbyname2

gethostbyname_r

gethostbyname2_r

gethostbyaddr

gethostbyaddr_r

其中,形如getXXbyYY的三個函數是其對應的getXXbyYY_r函數外層封裝了一個TLS緩衝區的實現;

HOOK後的實現中,libgo使用CLS替代了原生syscall里的TLS的功能。

通過觀察glibc源碼發現,形如getXXbyYY_r的三個函數內部還使用了一個存在struct thread_info結構體中的TLS變數緩存調用遠程dns伺服器使用的socket,實測中發現libco提供的HOOK __res_state函數的方案是無效的,getXXbyYY_r會並發亂序的讀寫同一個socket,導致混亂的結果或長久的阻塞。

libgo針對這個問題HOOK了getXXbyYY_r系列函數,在函數入口使用了一個線程私有的協程鎖,解決了同一個線程的getXXbyYY_r亂序讀寫同一個socket的問題;又由於P中的IO觸發隊列的存在,getXXbyYY_r由於內部的__poll掛起再重新喚醒後,保證了會在原線程完成後續代碼的執行。

signal

linux上的signal是有著不可重入屬性的,在signal處理函數中處理複雜的操作極易出現死鎖,libgo提供了解決這個問題的編譯參數:

其他會導致阻塞的syscall

libgo還HOOK了三個sleep函數:sleep、usleep、nanosleep

在協程中直接使用這三個sleep函數,可以讓當前協程掛起相應的時間。

第5節.完整生態

依照golang近10年的實踐經驗來看,我們很容易發現協程是核心功能,但只有協程是遠遠不夠的。 我們還需要很多周邊生態來輔助協程更好地完成並發任務。

Channel

和線程一樣,協程間也是需要交換數據。

很多時候我們需要一個能夠屏蔽協程同步、多線程調度等各種底層細節的,簡單的,保證數據有序傳遞的通訊方式,golang中channel的設計就剛好滿足了我們的需求。

libgo仿照golang製作了Channel功能,通過如下代碼:

即創建了一個不帶額外緩衝區的、傳遞int的channel,重載了操作符>,使用

向其寫入一個整數1,正如golang中channel的行為一樣,此時如果沒有另一個協程使用

嘗試讀取,當前協程會被掛起等待。

如果使用

則表示從channel中讀取一個元素,但是不再使用它。 channel的這種掛起協程等待的特性,也通常用於父協程等待子協程處理完成後再向下執行。

也可以使用

創建一個帶有長度為10的緩衝區的channel,正如golang中channel的行為一樣,對這樣的channel進行寫操作,緩衝區寫滿之前協程不會掛起。

這適用於有大批量數據需要傳遞的場景。

協程鎖、協程讀寫鎖

在任何C++協程庫的使用中,都應該慎重使用或禁用線程鎖,比如下面的代碼

協程A首先被調度,加鎖後調用sleep導致當前協程掛起,注意此時mtx已然是被鎖定的。

然後協程B被調度,要等待mtx被解鎖才能繼續執行下去,由於mtx是線程鎖,會阻塞調度線程,協程A再也不會有機會被調度,從而形成死鎖。

這是一個典型的邊角問題,因為我們無法阻止C++程序員在使用協程庫的同時再使用線程同步機制。

其實我們可以提供一個協程鎖來解決這一問題,比如下面的代碼

代碼與前一個例子幾乎一樣,唯一的區別是mtx的鎖類型從線程鎖變成了libgo提供的協程鎖。

協程A首先被調度,加鎖後調用sleep導致當前協程掛起,注意此時mtx已然是被鎖定的。

然後協程B被調度,要等待mtx被解鎖才能繼續執行下去,由於mtx是協程鎖,協程鎖在等待時會掛起當前協程而不是阻塞線程,協程A在sleep時間結束後會被喚醒並被調度,協程A退出foo函數時會解鎖,解鎖的行為又會喚醒協程B,協程B被調度時再次鎖定mtx,然後順利完成整個邏輯。

libgo還提供了協程讀寫鎖:co_rwmutex

另外,即便開發者有意識的規避第一個例子那樣的場景,也很容易踩到另外一個線程鎖導致的坑,比如在使用zookeeper-client這樣會啟動後台線程來call回調函數的第三方庫時:

看起來好像沒什麼問題,但其實routine裡面的線程鎖會阻塞整個調度線程,使得其他協程都無法被及時調度。

針對這種情況最優雅的處理方式就是使用Channel,因為libgo提供的Channel不僅可以用於協程間交換數據,也可以用於協程與線程間交換數據,可以說是專門針對zk這類起後台線程的第三方庫設計的。

定時器

libgo框架的主調度器提供了一個基於紅黑樹的定時器,會在調度線程的主循環中被執行,這樣的設計可以與epoll更好地協同工作,無論是定時器還是epoll監聽的fd都可以最及時的觸發。

使用co_timer_add介面可以添加一個定時任務,co_timer_add介面接受兩個參數,第一個參數是可以是std::chrono::system_clock::time_point,也可以是std::chrono::steady_clock::time_point,還可以是std::chrono庫里的一個duration。第二個參數接受一個回調函數,可以是函數指針、仿函數、lambda等等;

當第一個參數使用system_clock::time_point時,表示定時任務跟隨系統時間的變化而變化,可以通過調整操作系統的時間設置提前或延緩定時任務的執行。

當第一個參數使用另外兩種類型時,定時任務不隨系統時間的變化而變化。

co_timer_add介面返回一個co::TimerId類型的定時任務id,可以用來取消定時任務。

取消定時任務有種方式:co_timer_cancel和co_timer_block_cancel,均會返回一個bool類型表示是否取消成功。

使用co_timer_cancel,會立即返回,即使定時任務正在被執行。

使用co_timer_block_cancel,如果定時任務正在被執行,則會阻塞地等待任務完成後返回false;否則會立即返回;

需要注意的是co_timer_block_cancel的阻塞行為是使用自旋鎖實現的,如果定時任務耗時較長,co_timer_block_cancel的阻塞行為不但會阻塞當前調度線程,還會產生高昂的cpu開銷;這個介面是設計用來在libgo內部使用的,請用戶謹慎使用!

CLS(Coroutine Local Storage)(協程本地存儲)

CLS類似於TLS(Thread Local Storage);

這個功能是HOOK DNS函數族的基石,沒有CLS的協程庫是無法HOOK DNS函數族的。

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

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

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

線程池

除了前文提到的各種邊角問題之外,還有一個非常常見的邊角問題:文件IO 筆者曾經努力嘗試過HOOK文件IO操作,但很不幸linux系統中,文件fd是無法使用poll、select、epoll正確監聽可讀可寫狀態的;linux提供的非同步文件IO系統調用nio又不支持操作系統的文件緩存,不適合用來實現HOOK(這會導致用戶的所有文件IO都不經過系統緩存而直接操作硬碟,這是一種不恰當的做法)。

除此之外也還會有其他不能HOOK或未被HOOK的阻塞syscall,因此需要一個線程池機制來解決這種阻塞行為對協程調度的干擾。

libgo提供了一個宏:co_await,來輔助用戶完成線程池與協程的交互。

在協程中使用

可以把func投遞到線程池中,並且掛起當前協程,直到func完成後協程會被喚醒,繼續執行下去。 也可以使用

等待bar在線程池中完成,並將bar的返回值寫入變數a中。 co_await也同樣可以在協程之外被調用。

另外,為了用戶更靈活的定製線程數量,也為了libgo不偷起後台線程的操守;線程池並不會自行啟動,需要用戶自行啟動一個或多個線程執行co_sched.GetThreadPool().RunLoop();

調試

libgo作為框架級的協程庫,調試機制是必不可少的。

1.可以設置co_sched.GetOptions().debug列印一些log,具體flag見config.h

2.可以設置一個協程事件監聽器,詳見tutorial文件夾下的sample12_listener.cpp教程代碼

3.編譯時添加cmake參數:-DENABLE_DEBUGGER=ON 開啟debug信息收集後,可以使用co::CoDebugger類獲取一些調試信息,詳見debugger.h的注釋

4.後續還會提供更多調試手段

協程之外(運行在線程上的代碼)

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

跨平台

libgo支持三大主流系統:linux、windows、mac-os

linux是主打平台,也是libgo運行性能最好的平台,master分支永遠支持linux

win分支支持windows系統,會不定期的將master分支的新功能合入其中

mac的情況同windows

(個人開發者精力有限,還請見諒!)

上層封裝

筆者另有一個開源庫:libgonet,是基於libgo封裝的linux協程網路庫,使用起來極為方便。

如果你要開發一個網路服務或rpc框架,更推薦從libgonet寫起,畢竟即使有協程,socket相關的處理也並不輕鬆。

未來的發展方向

1.目前是使用go、go_stack、go_dispatch三個不同的宏來設置協程的屬性,這種方式不夠靈活,後續要改成: go stack(1024 * 1024) dispatch(::co::egod_robin) func; 這樣的語法形式,可以更靈活的定製協程屬性。

2.基於(1)的新語法,實現「協程親緣性」功能,將協程綁定到指定線程上,並防止被steal。

3.優化協程切換速度:

A)使用環切調度替代現在的星切調度(CoYeild時選擇下一個切換目標),必要時才切換回線程處理epoll、定時器、sleep等邏輯,同時協調好多線程調度

B)調度器的Run函數裡面做了很多協程切換之外的事情,盡量降低這部分在非必要時的cpu消耗,比如:有任務加入定時器是設置一個tls標記為true,只有標記為true時才去處理定時器相關邏輯。

C)調度器中的runnable隊列使用了自旋鎖,沒有競爭時對原子變數的操作也是比較昂貴的,runnable隊列可以優化成多寫一讀,僅在寫入端加鎖的隊列。

4.協程對象Task內存布局調優,tls池化,每個池使用多寫一讀鏈表隊列,申請時僅在當前線程的池中申請,可以免鎖,釋放時均衡每個線程的池水水位,可以塞入其他線程的池中。

5.libgo之外,會進一步尋找和當前已經比較成熟的非協程的開發框架的結合方案,讓還未能用上協程的用戶低成本的用上協程。

libgo開源地址:https://github.com/yyzybb537/libgo

●編號320,輸入編號直達本文

●輸入m獲取文章目錄

推薦↓↓↓

演算法與數據結構

更多推薦18個技術類微信公眾號

涵蓋:程序人生、演算法與數據結構、黑客技術與網路安全、大數據技術、前端開發、Java、Python、Web開發、安卓開發、iOS開發、C/C++、.NET、Linux、資料庫、運維等。


喜歡這篇文章嗎?立刻分享出去讓更多人知道吧!

本站內容充實豐富,博大精深,小編精選每日熱門資訊,隨時更新,點擊「搶先收到最新資訊」瀏覽吧!


請您繼續閱讀更多來自 CPlusPlus 的精彩文章:

如果這輩子只能精通一門語言,那一定是……

TAG:CPlusPlus |