從SPserver到BRPC
公眾號轉載自:汽車之家技術委員會
1.背景
性能優化是后端服務優化的一個重要課題。尤其在廣告業務中,服務超時不但會引發廣告客戶的預算消耗顧慮,更會直接影響C端用戶的瀏覽體驗。而一個服務程序的性能往往是覆蓋了編程語言特性、業務需求邏輯,甚至是操作系統底層原理等多方面因素的綜合性外在表現。面對超時問題,不論是對其進行量化分解、問題復現,還是異常監控,以及后續的超時優化,對后端開發同學而言都極富挑戰。如果變換思路,轉而升級后端服務框架或許會成為最為徹底的解決方案。
本文是之家廣告引擎團隊結合工作中遇到的超時現象及問題分析過程,對服務框架升級背后的思考做出的一個總結與沉淀。
2.問題回顧
之家廣告檢索服務從建立之初采用的一直是SPserver服務框架。Spserver是一個C++實現的半同步異步的網絡框架,名氣雖然不大,但在當年并發編程尤其是協程概念尚不廣泛的年代,相比較代碼風格各異、封裝獨立性參差不齊的諸多自研網絡框架,它確實不失為一個比較好的選擇。特別是在之家廣告系統搭建之初,其以良好的性能、穩定性和簡便的使用方式,在引擎內部曾得到廣泛應用,可以說在推進廣告業務發展上,SPserver立下了汗馬功勞。
隨著需求的不斷迭代和升級,廣告系統運行中碰到的問題也越來越凸顯,包括:內存消耗、并發、超時、生態融合等等,其中又以超時問題為重。
說起超時,恐怕會讓人抓狂,因為它來的太突然而且幾乎不留任何痕跡。沒錯,監控可以抓到它,但問題是通過分析全鏈路日志你可能不會有任何收獲,因為你所劃分的各個業務邏輯階段的耗時并沒有超過預先設定的超時閾值,而且進程的CPU利用率也不高(可以說很低),讓人詭異的是上游請求方的超時卻是實實在在發生了的。
3.問題分析
應用服務不是孤立的,它所發生的問題也應該是有關聯的。
(圖1:來自網絡)
通過圖1知道,一個業務應用在處理網絡請求之前以及發送網絡應答之后,數據會流經網絡、系統內核。那么,超時會不會是由它們引起的呢?為此,我們通過壓測環境復現了超時發生的系統上下文場景:
(圖2)
可以發現,業務應用與其上下游之間經由多個tcp連接通信,而部分tcp連接在對應socket的接收緩沖區和發送緩沖區卻存在著數據積壓的現象,且兩個隊列的積壓數據量分別固定在4xx和2xx。
不同于grpc倡導的單通道通信模式,spserver原生支持多tcp連接服務,這點無可厚非。基于tcp協議的擁塞控制機制,網絡數據進入應用層前,會在操作系統內核態的fd緩沖區稍作停留,因此短時間內存在一定量的數據積壓也是正常的;既然是緩沖區,自然有大小之分。那么,會不會是緩沖區太小或者網絡擁堵導致的超時呢?又該如何判斷呢?
(圖3)
可見,系統對socket收發緩沖區的默認值都在80k以上,遠大于圖2中的431字節。而且spserver源代碼中也沒有通過setsockopt系統調用對socket的收發緩沖區重新設值。所以,圖2中緩沖區大小是合理的,而其中接收隊列中的數據量431則可能是有問題的(實際上,431是壓測場景中單次請求的數據長度;在生產環境中,這個隊列長度會隨著C端請求的不同而呈現不同的值,毫無規律可言)。
連續刷新netstat,對比目標tcp連接的發送緩沖隊列或接收隊列長度,發現此場景下收發隊列的長度并不能立即消失,也沒有減小,而是持續了1秒左右才有所變化。由此可以推測2種可能:1)網絡擁堵;2)服務端cpu繁忙,不能及時將數據從緩沖區讀走。第一種猜測通過網絡檢測工具很容易驗證,是否定的;第二種猜測,也通過查看系統以及進程cpu的利用率也很容易排除掉。分析排查到此,似乎走到了死胡同。真的是這樣嗎?
有一個細節似乎被忽略了:作為多線程服務,進程的cpu利用率≈線程cpu利用率之和,但進程cpu利用率低并不意味著某些或某個線程cpu利用率也低。換句話說,個別線程所在cpu的高利用率同樣會使上面第二種猜測成立。
(圖4)
如圖4,反復施加不同量級的請求壓力,會發現除了18194號線程的cpu使用率保持在99%以上,其他線程的cpu使用率最大都不過40%左右。面對高壓力,服務不能平攤cpu壓力,這就是問題!
4.根本原因
上帝總是吝嗇造就一個十全十美的東西,對待SPserver自然也不例外。其實排查走到線程的cpu利用率時,已經接近真實原因,但還不是真相。為此,擼了一遍spserver的源碼,得出如下線程模型示意圖。
(圖5)
不出所料,SPserver采用的是單線程reactor網絡模型,即單線程負責事件監聽、socket讀寫,多線程負責業務邏輯處理。單線程io的優劣顯而易見,可以很好的利用thread local加速,也沒有cpu cache bounding的問題;但問題是一旦某個socket上待讀取或待發送的數據量較大時,就會阻塞其他socket上數據的收發,這就比較致命。退一步講,即便每個socket fd上的數據量大小均勻,在上述單線程的cpu吃滿時,整個框架的數據收發效率同樣會成為所在服務的性能瓶頸。
真相大白了,原來SPserver框架的設計機制決定了它不適合高并發、高吞吐業務場景的事實。但如果是在業務初期又或是業務流量比較小,個人覺得它仍可視為一個不錯的選擇(SPserver框架的c++代碼風格還是很不錯的,很簡潔,封裝性也很好,值得借鑒)。
5.選擇BRPC
如今rpc框架林立,我們的選擇是百度研發的brpc框架,主要是基于以下考慮:
1)高并發高性能
個人理解后面會著重介紹一下。這里我只貼一個brpc和其他rpc框架性能的對比圖:
(圖6:來自brpc官網)
是的,你沒有看錯,在跨機多client請求單server的場景下,brpc框架的性能已經絕對領先國外知名的rpc框架,尤其是grpc,用碾壓一詞來形容也不為過。
2)文檔資料豐富
brpc有著豐富的中英文文檔,豐富程度讓人有點難以置信,曾一度有人認為是百度內部的技術資料無意中被公開了,呵呵。
3)apache 的頂級項目,目前有數千個企業級應用
說直白點,已經在生產中經歷過千錘百煉了,質量有保證。
當然,brpc還有諸多特性,這里不再贅述。詳見brpc官網或移步到github incubator-brpc項目。
6.性能初探
經歷了spserver框架,免不了要有一番對比。在介紹brpc的線程模型前,先了解一個bprc中的概念:bthread。我們看一下官方的解釋:
“bthread是brpc使用的M:N線程庫,目的是在提高程序的并發度的同時,降低編碼難度,并在核數日益增多的CPU上提供更好的scalability和cache locality。”M:N“是指M個bthread會映射至N個pthread,一般M遠大于N。由于linux當下的pthread實現[NPTL]是1:1的,M個bthread也相當于映射至N個[LWP]。bthread的前身是Distributed Process(DP)中的fiber,一個N:1的合作式線程庫,等價于event-loop庫,但寫的是同步代碼。”
個人理解bthread其實是一個運行在系統pthread之上的可以低成本、靈活調度的任務(隊列),而這個任務載有自身運行時的上下文信息(比如:棧、寄存器、signal等),使它能夠隨意切換在不同的pthread上運行。其低成本體現在幾個方面:1)bthread實現了多種同步原語,可以和系統線程實現相互等待,我們可以像使用pthread一樣使用bthread;2)納秒級建立bthread,耗時遠低于pthread;3)幾乎沒有上下文切換,且將cpu cache locality和thread local發揮到了極致,這部分后面會有實踐說明。
下面再來看看它的線程模型示意圖:
(圖7)
由圖7可見:1)在brpc框中系統級線程池pthread是高效運行的基礎設施,不再與具體的業務邏輯直接綁定,取而代之的是bthread;2)bthread按不同責任分成不同類型,且不同類型的bthread有著不同的數量。比如:網絡事件的監聽和驅動由一個bthread專職處理,當然也可以通過命令行啟動服務時配置,或者服務啟動后通過web入口更新;而處理具體請求的bthread數量則是動態計算的;3)brpc支持不忙的線程“偷”繁忙線程的bthread任務來提升系統整體效能。
那么問題來了,某一個socket fd上大量數據的接收或發送,會發生類似spserver那樣的阻塞嗎?答案是不會。首先,每個fd有兩個bthread,分別負責接收和發送,這就能保證收發互不影響;其次,bthread作為被調度的任務,會被分派給不同系統線程(也就是pthread),而一個系統線程同一時刻只能執行一個bthread任務,加上bthread 支持的stealing機制,就保證了進程中所有線程都是有事可做的,不會存在空閑的pthread(除非請求量非常小,不足以給pthread平分)。因此,基本上是不可能發生“一處阻塞處處阻塞”的情況的。
使用更大量的壓測請求對brpc服務發壓,得到各pthread的cpu消耗如下:
(圖8)
由圖8可見,系統的多核cpu得到充分的調動,且隨著壓力的增大cpu的擴展性表現良好;再來看一下網絡隊列:
(圖9)
可見,(同機grpc單通道壓測,僅有一個tcp連接)tcp fd的收發緩沖區得到了充分的利用,且隊列長度很快能減少至0。
Socket緩沖區不再是擺設,整個系統活了起來!
另外,值得一提的是線程間的上下文切換。因為過多的上下文切換,會把cpu時間消耗在寄存器、線程棧的保存和恢復上,從而降低服務的整體性能。brpc框架的m:n線程庫,在這方面做的比較好,它使用固定的系統線程調度運行大量用戶態的bthread,將所有的切換基本上都限制在了用戶態,這就避免了內核態和用戶態的數據交換(用戶態之間切換耗時在100~200ns,而內核態和用戶態切換則在微秒級)。通過命令也可以證明這一點:
(圖10)
如圖10,我們的服務在使用brpc后上下文切換頻次基本保持在每秒1次。再回頭來看看spserver框架,由于沒有用戶態任務的概念,只是單純依賴系統級的線程池,就不可避免的使cpu不斷地游離于多線程調度和任務執行上,內核態和用戶態間的上下文切換開銷肯定少不了:
(圖11)
用戶態線程切換的另一個好處是,可以將內核態線程與cpu核心很好的綁定到了一起,這就能夠盡量避免cpu不同核心間cacheline的數據同步,從而提升性能,這也是brpc框架高性能的一個原因。
7.應用實踐
brpc的編譯安裝及基本使用,在官方文檔都有較為詳細的說明,比較簡單。這里再分享一下我們在brpc應用過程中遇到過的幾個值得注意的地方:
1)thread local.
它是多線程程序常用的加速手段。比如tcmalloc就充分利用了這個技術,通過在線程內部設置局部緩存來加速小額空間的申請效率。之家廣告引擎服務毫無例外的也使用了這項技術。但需要注意的是,在引入brpc框架后,原有的pthread id可能將不再有效,如果你執意為之,就可能會在程序運行期間遇到莫名其妙的段錯誤。這是因為我們的業務代碼是托管在bthread中的,而bthread是在系統pthread之間隨機游走的,使用pthread1的標識信息到pthreadN的線程棧中讀取緩存數據自然是讀不到的。我們的臨時解決辦法是,暫時剝離掉thread local緩存,在控制鎖粒度的前提下改為全局cache,暴力、簡單、有效。
2)cpu profiler
顧名思義,你的程序可能會因此得以優化并加速運行。但事實并非完全如此。如果你們業務程序的CMakeList來自某個demo或網絡程序,則最好要注意這個編譯選項。它的原理是通過調用對應的庫函數采集活躍線程中的線程函數信息,并根據棧體現的函數調用關系生成調用圖,進而進行調用優化。所以,它會加速但不是立即,因為它要先采集數據、再分析,最后才能優化運行。我們實踐初期,肉眼可見其性能相比較不加此編譯選項要低10%以上,所以,對待cpu profiler要慎用。
3)關于grpc
前面提到過,gprc默認是基于單通道的通信方式,這是既是google官方的建議也是微軟的實踐建議。下面截圖來自微軟的” Performance best practices with gRPC”一文:
(圖12:截自微軟官網)
不能總是人云亦云。結合具體業務場景,我們的實踐結論是:多通道數據傳輸效率要優于單通道。受限于tcp速率限制,單通道(連接)情況下,一旦遇到高吞吐的數據傳輸業務場景,會明顯阻塞此網絡連接。特別對于廣告業務來說,我們允許有大數據塊傳輸,但不允許大數據塊的傳輸影響其他正常的廣告數據響應。
因此,我們的建議是:要么使用多通道grpc或者其他協議方式,要么就放棄grpc吧(事實上,grpc在生產中還有其他問題)。
4)并發
早在spserver時期,我們曾在其中實現了并發線程庫(確切說是一個并發線程類),但效果并未達到預期,因為它一定程度上加重了多線程調度成本。而如今的brpc則直接提供了較為簡單的并發線程 api,我們可以直接使用,無需造輪。
然而,會面臨新的選擇:用bthread_start_background,還是用bthread_start_urgent。使用后者啟動bthread后會在當前pthread立即執行任務,而前者則會讓新生成的bthread任務排隊等待調度。在我們的廣告檢索過濾場景中,適合使用后者;而在執行http請求時,則更適合使用前者。建議brpc開發者,一定要根據自己業務的實際情況再做決定。
8.最后
通過從SPserver框架升級到BRPC框架,在相同的業務場景下,之家廣告服務qps從5w+提升到了10w左右,服務實例數也因此下降了一半以上,收益明顯。
另外,Brpc提供了相對豐富的內置服務,這里貼兩個具有代表性功能的web界面,都比較實用,推薦大家嘗試。
圖13:我們可以看到服務的qps、latency分布等數據,方便把握服務的運行時信息。
(圖13)
而從下圖,可以看到服務運行期間在等待鎖上所消耗的時間及發生等待的函數,從而支持我們有針對性的開展性能優化。
(圖14)
note
限于作者水平,難免會有理解和描述上的疏漏或者錯誤,歡迎共同交流、指正。文章供于學習交流,轉載注明出處,謝謝!
作者簡介
汽車之家
主機廠事業部-技術部
楊明哲
2018年加入汽車之家,目前任職于主機廠事業部-技術部-廣告技術及系統團隊,負責之家廣告引擎架構的設計與研發等相關工作。