C/C++協程庫libco:微信怎樣漂亮地完成異步化改造

xiaorui 8年前發布 | 40K 次閱讀 C/C++開發 C/C++

如今,微信擁有月活躍用戶8億。

不可否認,當今的微信后臺擁有著強大的并發能力。

不過, 正如羅馬并非一日建成;微信的技術也曾經略顯稚嫩。

微信誕生于2011年1月,當年用戶規模為0.1億左右;2013年11月,微信月活躍用戶數達到3.55億,一躍成為亞洲地區擁有最大用戶群體的移動終端即時通訊軟件。

面對如此體量的提升,微信后臺也曾遭遇棘手的窘境;令人贊嘆的是技術人及時地做出了漂亮的應對。

這背后有著怎樣的技術故事?

此時此刻,你在微信手機端發出的請求,是怎樣被后臺消化和處理的?

這次,InfoQ聚焦微信后臺解決方案之協程庫libco。

該項目在保留后臺敏捷的同步風格同時,提高了系統的并發能力,節省了大量的服務器成本;自2013年起穩定運行于微信的數萬臺機器之上。

本文源自InfoQ對Leiffy的采訪和《揭秘:微信如何用libco支撐8億用戶》的整理。

微信后端遇到了問題

早期微信后臺因為業務需求復雜多變、產品要求快速迭代等需求,大部分模塊都采用了半同步半異步模型。接入層為異步模型,業務邏輯層則是同步的多進程或多線程模型,業務邏輯的 并發能力只有 幾十到幾百。

隨著微信業務的增長,直到2013年中,微信后臺機器規模已達到1萬多臺,涉及數百個后臺模塊,RPC調用每分鐘數十億。在如此龐大復雜的系統規模下,每個模塊很容易受到后端服務或者網絡抖動的影響。因此我們急需對微信后臺進行異步化的改造。

異步化改造方案的考量

當時我們有兩種選擇:

  • A 線程異步化:把所有服務改造成異步模型,等同于從框架到業務邏輯代碼的徹底改造
  • B 協程異步化:對業務邏輯非侵入的異步化改造,即只修該少量框架代碼

兩者相比,工作量和風險系數的差異顯而易見。雖然A方案 服務器端多線程異步處理 是常見做法,對提高并發能力這個原始目標非常奏效;但是對于微信后臺如此復雜的系統,這過于耗時耗力且風險巨大。

無論是異步模型還是同步模型,都需要保存異步狀態。所以兩者在技術細節的相同點是,兩個方案,都是需要維護當前請求的狀態。在A異步模型中方案,當請求需要被異步執行時,需要主動把請求相關數據保存起來,再等待狀態機的下一次調度執行;而在B協程模型方案中,異步狀態的保存與恢復是自動的,協程恢復執行的時候就是上一次退出時的上下文。

因此,B協程方案不需要顯式地維護異步狀態:一方面在編程上可以更簡單和直接;另一方面協程中只需要保存少量的寄存器。 因此在復雜系統上,協程服務的性能可能比純異步模型更優。

綜合以上考慮,最終我們選擇了B方案,通過協程的方式對微信后臺上百個模塊進行了異步化改造。

接管歷史遺留的同步風格API

方案敲定之后,接下來做的就是實現異步化的同時盡可能地少做代碼修改。

通常而言,一個常規的網絡后臺服務需要connect、write、read等系列步驟,如果使用同步風格的API對網絡進行調用,整個服務線程會因為等待網絡交互而掛起,這就會造成等待并占用資源。原來的這種情況很明顯地影響到了系統的并發性能,但是當初這樣的選擇是因為對應的 同步編程風格具有其獨特的優勢:代碼邏輯清晰、易于編寫并且支持業務快速迭代敏捷開發。

我們的改造方案需要消除同步風格API的缺點,但是同時還希望保持同步編程的優點。

最后在不修改線上已有的業務邏輯代碼的情況下,我們的libco框架創新地接管了網絡調用接口(Hook)。 把協程的讓出與恢復作為異步網絡IO中的一次事件注冊與回調。 當業務處理遇到同步網絡請求的時候,libco層會把本次網絡請求 注冊為異步事件 ,當前的協程讓出CPU占用,CPU交給其它協程執行。在網絡事件發生或者超時的時候,libco會自動的恢復協程執行。

libco的架構

libco架構從設計的時候就已經確立下來了,最近的在GitHub上一次較大更新主要是功能上的更新。

libco框架有三層:分別是協程接口層、系統函數Hook層以及事件驅動層。

協程接口層實現了協程的基本源語。co_create、co_resume等簡單接口負責協程創建于恢復。co_cond_signal類接口可以在協程間創建一個協程信號量,可用于協程間的同步通信。

系統函數Hook層負責主要負責系統中同步API到異步執行的轉換。對于常用的同步網絡接口,Hook層會把本次網絡請求注冊為異步事件,然后等待事件驅動層的喚醒執行。

事件驅動層實現了一個簡單高效的異步網路框架,里面包含了異步網絡框架所需要的事件與超時回調。對于來源于同步系統函數Hook層的請求,事件注冊與回調實質上是協程的讓出與恢復執行。

相比線程,選擇協程意味著?

比起線程,對于很多人而言,協程的應用并不是那么輕車熟路。

線程和協程的相同點是什么?

我們可以簡單認為協程是一種用戶態線程,它與線程一樣擁有獨立的寄存器上下文以及運行棧,對程序員最直觀的效果就是,代碼可以在協程里面正常的運作,就像在線程里面一樣。但是線程和協程還是有區別的,我們需要重點關注是運行棧管理模式與協程調度策略。關于這兩點的具體執行,在本文后續部分會談及。

那兩者的不同點呢?

協程的創建與調度相比線程要輕量得多,而且協程間的通信與同步是可以無鎖的,任一時刻都可以保證只有本協程在操作線程內的資源。

我們的方案是使用協程,但這意味著面臨以下挑戰:

  1. 業界協程在C/C++環境下沒有大規模應用的經驗;
  2. 如何處理同步風格的API調用,如Socket、mysqlclient等; 
  3. 如何控制協程調度;
  4. 如何處理已有全局變量、線程私有變量的使用;

下面我們來探討如何攻克這四個挑戰。

挑戰之一:前所未有的大規模應用C/C++協程

實際上,協程這個概念的確很早就提出來了,但是確是因為最近幾年在某些語言中(如lua、go等)被廣泛的應用而逐漸的被大家所熟知。但是真正用于C/C++語言的、并且是大規模生產的著實不多。

而這個libco框架中,除了協程切換時寄存器保存與恢復使用了匯編代碼,其它代碼實現都是用C/C++語言編寫的。

那為什么我們選擇了C/C++語言?

當前微信后臺絕大部分服務都基于C++,原因是微信最早的后臺開發團隊從郵箱延續而來,郵箱團隊一直使用C++作為后臺主流開發語言,而且C++能滿足微信后臺對性能和穩定性的要求。

我們的C++后臺服務框架增加了協程支持之后,高并發和快速開發的矛盾解決了。開發者絕大部分情況下只需要關注并發數的配置,不需要關注協程本身。其他語言我們也會在一些工具里面嘗試,但是對于整個微信后臺而言,C++仍是我們未來長期的主流語言。

挑戰之二:保留同步風格的API

這里的做法我們在上文中提到了處理同步風格的API的思路方法:大部分同步風格的API我們都通過Hook的方法來接管了,libco會在恰當的時機調度協程恢復執行。

怎樣防止協程庫調度器被阻塞?

libco的系統函數Hook層主要處理同步API到異步執行的轉換,我們當前的hook層只處理了主要的同步網絡接口,對于這些接口,同步調用會被異步執行,不會導致系統的線程阻塞。當然,我們還有少量未Hook的同步接口,這些接口的調用可能會導致協程調度器阻塞等待。

與線程類似,當我們操作跨線程數據的時候,需要使用線程安全級別的函數。而在協程環境下,也是有協程安全的代碼約束。在微信后臺,我們約束了不能使用導致協程阻塞的函數,比如pthread_mutex、sleep類函數(可以用 poll(NULL, 0, timeout) 代替)等。而對于已有系統的改造,就需要審核已有代碼是否符合協程安全規范。

挑戰之三:調度千萬級協程

調度策略方面,我們可以看下Linux的進程調度,從早期的O(1)到目前CFS完全公平調度,經過了很復雜的演進過程,而協程調度事實上也是可以參考進程調度方法的,比如說你可以定義一種調度策略,使得協程在不同的線程間切換,但是這樣做會帶來昂貴的切換代價。在進程/線程上面,后臺服務通常已經做了足夠多的工作,使得多核資源得到充分使用,所以協程的定位應該是在這個基礎上發揮最大的性能。

libco的協程調度策略很簡潔,單個協程限定在固定的線程內部,僅在網絡IO阻塞等待時候切出,在網絡IO事件觸發時候切回,也就是說在這個層面上面可以認為協程就是有限狀態機,在事件驅動的線程里面工作,相信后臺開發的同學會一下子就明白了。

那怎么實現千萬級別呢?

libco默認是每一個協程獨享一個運行棧,在協程創建的時候,從堆內存分配一個固定大小的內存作為該協程的運行棧。如果我們用一個協程處理前端的一個接入連接,那對于一個海量接入服務來說,我們的服務的并發上限就很容易受限于內存。

所以量級的問題就轉換成了怎樣高效使用內存的問題。

為了解決這個問題,libco采用的是共享棧模式。(傳統運行棧管理有stackfull和stackless兩種模式) 簡單來講,是若干個協程共享同一個運行棧。

同一個共享棧下的協程間切換的時候,需要把當前的運行棧內容拷貝到協程的私有內存中。為了減少這種內存拷貝次數,共享棧的內存拷貝只發生在不同協程間的切換。當共享棧的占用者一直沒有改變的時候,則不需要拷貝運行棧。

再具體一點講講共享棧的原理:libco默認模式(stackfull) 滿足大部分的業務場景,每個協程獨占128k棧空間,只需1G內存就可以支持萬級協程。 而共享棧是libco新增的一個特性,可以支持單機千萬協程,應對海量連接特殊場景。實現原理上,共享棧模式在傳統的stackfull和stackless兩種模式之間做了個微創新,用戶可以自定義分配若干個共享棧內存,協程創建時指定使用哪一個共享棧。

不同協程之間的切換、 如何主動退出一個正在執行的協程?我們把共享同一塊棧內存的多個協程稱為協程組,協程組內不同協程之間切換需要把棧內存拷貝到協程的私有空間,而協程組內同一個協程的讓出與恢復執行則不需要拷貝棧內存,可以認為共享棧的棧內存是“寫時拷貝”的。

共享棧下的協程切換與退出,與普通協程模式的API一致,co_yield與co_resume,libco底層會實現共享棧的模式下的按需拷貝棧內存。

挑戰之四:全局變量 VS私有變量

在stackfull模式下面,局部變量的地址是一直不變的;而stackless模式下面,只要協程被切出,那么局部變量的地址就失效了,這是開發者需要注意的地方。

libco默認的棧模式是每一個協程獨享運行棧的,在這個模式下,開發者需要注意棧內存的使用,盡量避免 char buf[128 * 1024] 這種超大棧變量的申請,當棧使用大小超過本協程棧大小的時候,就可能導致棧溢出的core。

而在共享棧模式下,雖然在協程創建的時候可以映射到一個比較大的棧內存上面,但是當本協程需要讓出給其它協程執行的時候,已使用棧的拷貝保存開銷也是有的,因此最好也是盡量減少大的局部變量使用。更多的,共享棧模式下,因為是多個協程共享了同一個棧空間,因此,用戶需要注意協程內的局部棧變量地址不可以跨協程傳遞。

協程私有變量的使用場景與線程私有變量類似,協程私有變量是全局可見的,不同的協程會對同一個協程變量保存自己的副本。開發者可以通過我們的API宏聲明協程私有變量,在使用上無特別需要注意的地方。

多進程程序改造為多線程程序時候,我們可以用__thread來對全局變量進行快速修改,而在協程環境下,我們創造了協程變量ROUTINE_VAR,極大簡化了協程的改造工作量。

關于協程私有變量,因為協程實質上是線程內串行執行的,所以當我們定義了一個線程私有變量的時候,可能會有重入的問題。比如我們定義了一個__thread的線程私有變量,原本是希望每一個執行邏輯獨享這個變量的。但當我們的執行環境遷移到協程了之后,同一個線程私有變量,可能會有多個協程會操作它,這就導致了變量沖入的問題。為此,我們在做libco異步化改造的時候,把大部分的線程私有變量改成了協程級私有變量。協程私有變量具有這樣的特性:當代碼運行在多線程非協程環境下時,該變量是線程私有的;當代碼運行在協程環境的時候,此變量是協程私有的。底層的協程私有變量會自動完成運行環境的判斷并正確返回所需的值。

協程私有變量對于現有環境同步到異步化改造起了舉足輕重的作用,同時我們定義了一個非常簡單方便的方法定義協程私有變量,簡單到只需一行聲明代碼即可。

簡而言之

一句話總結libco庫的原理,在協程里面用同步風格編寫代碼,實際運作是事件驅動的有限狀態機,由上層的進程/線程負責多核資源的使用。

最終效果,大功告成

我們曾把一個狀態機驅動的純異步代理服務改成了基于libco協程的服務,在性能上比之前提升了10%到20%,并且,在基于協程的同步模型下,我們很簡單的就實現了批量請求的功能。

正如當時所愿,我們使用libco對微信后臺上百個模塊進行了協程異步化改造,在整個的改造過程中,業務邏輯代碼基本沒有改變,修改只是在框架層代碼。我們所做的是把原先在線程內執行的業務邏輯轉到了協程上執行。改造的工作主要是復核系統中線程私有變量、全局變量、線程鎖的使用,確保在協程切換的時候不會數據錯亂或者重入。

至今,微信后臺絕大部分服務都已是多進程或多線程協程模型,并發能力相比之前有了質的提升,而在這過程中應運而生的libco也成為了微信后臺框架的基石。

 

 

 本文由用戶 xiaorui 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!