如今,微信擁有月活躍使用者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層的請求,事件注冊與回調實質上是協程的讓出與恢複執行。
相比線程,選擇協程意味着?
比起線程,對于很多人而言,協程的應用并不是那麼輕車熟路。
線程和協程的相同點是什麼?
我們可以簡單認為協程是一種使用者态線程,它與線程一樣擁有獨立的寄存器上下文以及運作棧,對程式員最直覺的效果就是,代碼可以在協程裡面正常的運作,就像線上程裡面一樣。但是線程和協程還是有差別的,我們需要重點關注是運作棧管理模式與協程排程政策。關于這兩點的具體執行,在本文後續部分會談及。
那兩者的不同點呢?
協程的建立與排程相比線程要輕量得多,而且協程間的通信與同步是可以無鎖的,任一時刻都可以保證隻有本協程在操作線程内的資源。
我們的方案是使用協程,但這意味着面臨以下挑戰:
- 業界協程在C/C++環境下沒有大規模應用的經驗;
- 如何處理同步風格的API調用,如Socket、mysqlclient等;
- 如何控制協程排程;
- 如何處理已有全局變量、線程私有變量的使用;
下面我們來探讨如何攻克這四個挑戰。
挑戰之一:前所未有的大規模應用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%,并且,在基于協程的同步模型下,我們很簡單的就實作了批量請求的功能。