1. 程式人生 > >C/C++協程庫libco:微信怎樣漂亮地完成非同步化改造

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

如今,微信擁有月活躍使用者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為開源專案,原始碼同步更新,可移步:https://github.com/tencent/libco)。

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也成為了微信後臺框架的基石。

作者簡介

李方源, 微信高階工程師,目前負責微信後臺基礎框架及優化,致力於高效能、高可用的大規模分散式系統設計及研發,先後參與微信後臺協程化改造專案、微信後臺框架重構等專案。