導讀:對於工程經驗比較豐富的同窗,併發應該也並非陌生的概念了,可是每一個人所理解的併發問題,卻又每每並不統一,本文系統梳理了百度C++工程師在進行併發優化時所做的工做。前端
全文15706字,預計閱讀時間24分鐘。算法
1、背景
簡單回顧一下,一個程序的性能構成要件大概有三個,即算法複雜度、IO開銷和併發能力。因爲現代計算機體系結構複雜化,形成不少時候,工程師的性能優化會更集中在算法複雜度以外的另外兩個方向上,即IO和併發,在以前的《百度C++工程師的那些極限優化(內存篇)》中,咱們介紹了百度C++工程師工程師爲了優化性能,從內存IO角度出發所作的一些優化案例。編程
此次咱們就再來聊一聊另一個性能優化的方向,也就是所謂的併發優化。和IO方向相似,對於工程經驗比較豐富的同窗,併發應該也並非陌生的概念了,可是每一個人所理解的併發問題,卻又每每並不統一。因此下面咱們先回到一個更根本的問題,從新梳理一下所謂的併發優化。json
2、爲何咱們須要併發?
是的,這個問題可能有些跳躍,可是在天然地進展到如何處理各類併發問題以前,咱們確實須要先停下來,回想一下爲何咱們須要併發?後端
這時第一個會冒出來的概念可能會是大規模,例如咱們要設計大規模互聯網應用,大規模機器學習系統。但是咱們仔細思考一下,不管使用了那種程度的併發設計,這樣的規模化系統背後,都須要成百上千的實例來支撐。也就是,若是一個設計(尤爲是無狀態計算服務設計)已經能夠支持某種小規模業務。那麼當規模擴大時,極可能手段並非提高某個業務單元的處理能力,而是增長更多業務單元,並解決可能遇到的分佈式問題。設計模式
其實真正讓併發編程變得有價值的背景,更可能是業務單元自己的處理能力沒法知足需求,例如一次請求處理時間太久,業務精細化致使複雜度積累提高等等問題。那麼又是什麼致使了近些年來,業務單元處理能力問題不足的問題呈現更加突出的趨勢?數組
可能下面這個統計會很說明問題:緩存
(https://www.karlrupp.net/2015/06/40-years-of-microprocessor-trend-data/)安全
上圖從一個長線角度,統計了CPU的核心指標參數趨勢。從其中的晶體管數目趨勢能夠看出,雖然可能逐漸艱難,可是摩爾定律依然尚能維持。然而近十多年,出於控制功耗等因素的考慮,CPU的主頻增加基本已經停滯,持續增長的晶體管轉而用來構建了更多的核心。性能優化
從CPU廠商角度來看,單片處理器所能提供的性能仍是保持了持續提高的,可是單線程的性能增加已經顯著放緩。從工程師角度來看,最大的變化是硬件紅利再也不能透明地轉化成程序的性能提高了。隨時代進步,更精準的算法,更復雜的計算需求,都在對的計算性能提出持續提高的要求。早些年,這些算力的增加需求大部分還能夠經過處理器更新換代來天然解決,但是隨着主頻增加停滯,若是沒法利用多核心來加速,程序的處理性能就會隨主頻一同面臨增加停滯的問題。所以近些年來,是否可以充分利用多核心計算,也愈來愈成爲高性能程序的一個標籤,也只有具有了充分的多核心利用能力,才能隨新型硬件演進,繼續表現出指數級的性能提高。而伴隨多核心多線程程序設計的普及,如何處理好程序的併發也逐漸成了工程師的一項必要技能。
上圖描述了併發加速的基本原理,首先是對原始算法的單一執行塊拆分紅多個可以同時運行的子任務,並設計好子任務間的協同。以後利用底層的並行執行部件能力,將多個子任務在時間上真正重疊起來,達到真正提高處理速度的目的。
須要注意的是還有一條從下而上的反向剪頭,主要表達了,爲了正確高效地利用並行執行部件,每每會反向指導上層的併發設計,例如正確地數據對齊,合理的臨界區實現等。雖然加速看似徹底是由底層並行執行部件的能力所帶來的,程序設計上只須要作到子任務拆分便可。可是現階段,執行部件對上層還沒法達到透明的程度,致使這條反向依賴對於最終的正確性和性能依然相當重要。既瞭解算法,又理解底層設計,並結合起來實現合理的併發改造,也就成爲了工程師的一項重要技能。
3、單線程中的並行執行
提到並行執行部件,你們的第一個印象每每時多核心多線程技術。不過在進入到多線程以前,咱們先來看看,即便是單線程的程序設計中,依然須要關注的那些並行執行能力。回過頭再仔細看前文的處理器趨勢圖其實能夠發現,雖然近年主頻再也不增加,甚至穩中有降,可是單線程處理性能其實仍是有細微的提高的。這其實意味着,在單位時鐘週期上,單核心的計算能力依然在提高,而這種提高,很大程度上就得益於單核心單線程內的細粒度並行執行能力。
3.1 SIMD
其中一個重要的細粒度並行能力就是SIMD(Single Instruction Multiple Data),也就是多個執行單元,同時對多個數據應用相同指令進行計算的模式。在經典分類上,通常單核心CPU被納入SISD(Single Instruction Single Data),而多核心CPU被納入MIMD(Mingle Instruction Multiple D ata),而GPU才被納入SIMD的範疇。可是現代CPU上,除了多核心的MIMD基礎模型,也同時附帶了細粒度SIMD計算能力。
上圖是Intel關於SIMD指令的一個示意圖,經過增長更大位寬的寄存器實如今一個寄存器中,「壓縮」保存多個較小位寬數據的能力。再經過增長特殊的運算指令,對寄存器中的每一個小位寬的數據元素,批量完成某種相同的計算操做,例如圖示中最典型的對位相加運算。以這個對位相加操做爲例,CPU只須要增大寄存器,內存傳輸和計算部件位寬,針對這個特殊的應用場景,就提高到了8倍的計算性能。相比將核心數通用地提高到8倍大小,這種方式付出的成本是很是少的,指令流水線系統,緩存系統都作到了複用。
從CPU發展的視角來看,爲了可以在單位週期內處理更多數據,增長核心數的MIMD強化是最直觀的實現路徑。可是增長一套核心,就意味增長一套 完整的指令部件、流水線部件和緩存部件,並且實際應用時,還要考慮額外的核心間數據分散和聚合的傳輸和同步開銷。一方面高昂的部件需求, 致使完整的核心擴展成本太高,另外一方面,多核心間傳輸和同步的開銷針對小數據集場景額外消耗過大,還會進一步限制應用範圍。爲了最大限度利用好有限的晶體管,現代CPU在塑造更多核心的同時,也在另外一個維度上擴展單核心的處理和計算位寬,從而實現提高理論計算性能(核心數 * 數據寬度)的目的。
不過提起CPU上的SIMD指令支持,有一個繞不開的話題就是和GPU的對比。CPU上早期SIMD指令集(MMX)的誕生背景,和GPU的功能定位就十分相似,專一於加速圖像相關算法,近些年又隨着神經網絡計算的興起,轉向通用矩陣類計算加速。可是因爲GPU在設計基礎上就以面向密集可重複計算負載設計,指令部件、流水線部件和緩存部件等能夠遠比CPU簡潔,也所以更容易在量級上進行擴展。這就致使,當計算密度足夠大,數據的傳輸和同步開銷被足夠沖淡的狀況下(這也是典型神經網絡計算的的特性),CPU僅做爲控制流進行指揮,而數據批量傳輸到GPU協同執行反而 會更簡單高效。
因爲Intel自身對SIMD指令集的宣傳,也集中圍繞神經網絡類計算來展開,而在當前工程實踐經驗上,主流的密集計算又以GPU實現爲主。這就致使了很多CPU上SIMD指令集無用論應運而生,尤爲是近兩年Intel在AVX512初代型號上的降頻事件,進一步強化了『CPU就應該作好CPU該作的事情』這一論調。可是單單從這一的視角來認識CPU上的SIMD指令又未免有些片面,容易忽視掉一些真正有意義的CPU上SIMD應用場景。
對於一段程序來說,若是將每讀取單位數據,對應的純計算複雜度大小定義爲計算密度,而將算法在不一樣數據單元上執行的計算流的相同程度定義爲模式重複度,那麼能夠以此將程序劃分爲4個象限。在大密度可重複的計算負載(典型的重型神經網絡計算),和顯著小密度和非重複計算負載(例如HTML樹狀解析)場景下,業界在CPU和GPU的選取上實際上是有相對明確「最優解」的。不過對於過渡地帶,計算的重複特徵沒有那麼強, 或者運算密度沒有那麼大的場景下,雙方的弱點都會被進一步放大。即使是規整可重複的計算負載,隨着計算自己強度減少,傳輸和啓動成本逐漸顯著。另外一方面,即使是不太規整可重複的計算負載,隨着計算負荷加大,核心數不足也會逐漸成爲瓶頸。這時候,引入SIMD的CPU和引入SIMT 的GPU間如何選擇和使用,就造成了沒有那麼明確,見仁見智的權衡空間。
即便排除了重型神經網絡,從程序的通常特性而言,具備必定規模的重複特性也是一種廣泛現象。例如從概念上講,程序中的循環段落,都或多或少意味着批量/重複的計算負載。儘管由於摻雜着分支控制,致使重複得沒有那麼純粹,但這種必定規模的細粒度重複,正是CPU上SIMD發揮獨特價值的地方。例如最多見的SIMD優化其實就是memcpy,現代的memcpy實現會探測CPU所能支持的SIMD指令位寬,並盡力使用來加速內存傳輸。另外一方面現代編譯器也會利用SIMD指令來是優化對象拷貝,進行簡單循環向量化等方式來進行加速。相似這樣的一類優化方法偏『自動透明』,也是默默支撐着主頻不變狀況下,性能稍有上升的重要推手。
惋惜這類簡單的自動優化能作到的事情還至關有限,爲了可以充分利用CPU上的SIMD加速,現階段還很是依賴程序層進行主動算法適應性改造,有 目的地使用,換言之,就是主動實施這種單線程內的併發改造。一個沒法自動優化的例子就是《內存篇》中提到的字符串切分的優化,現階段經過編譯器分析還很難從循環 + 判斷分支提取出數據並行pattern並轉換成SIMD化的match&mask動做。而更爲顯著的是近年來一批針對SIMD指令從新設計的算法,例如Swiss Table哈希表,simdjson解析庫,base64編解碼庫等,在各自的領域都帶來了倍數級的提高,而這一類算法適應性改造,就已經徹底脫離了自動透明所能觸及的範圍。能夠預知近些年,尤爲隨着先進工藝下AVX512降頻問題的逐漸解決,還會/也須要涌現出更多的傳統基礎算法的SIMD改造。而熟練運用SIMD指令優化技術,也將成爲C++工程師的一項必要技能。
3.2 OoOE
另外一個重要的單線程內並行能力就是亂序執行OoOE(Out of Order Execution)。經典教科書上的CPU流水線機制通常描述以下(經典5級RISC流水線)。
指令簡化表達爲取指/譯碼/計算/訪存/寫回環節,當執行環節遇到數據依賴,以及緩存未命中等場景,就會致使總體停頓的產生。其中MEM環節的影響尤爲顯著,主要也是由於緩存層次的深化和多核心的共享現象,帶來單次訪存所需週期數良莠不齊的現象愈來愈嚴重。上圖中的流水線在多層緩存下的表現,可能更像下圖所示:
爲了減輕停頓的影響,現代面向性能優化的CPU通常引入了亂序執行結合超標量的技術。也就是一方面,對於重點執行部件,好比計算部件,訪存部件等,增長多份來支持並行。另外一方面,在執行部件前引入緩衝池/隊列機制,通用更長的預測執行來儘量打滿每一個部件。最終從流水線模式,轉向了更相似『多線程』的設計模式:
亂序執行系統中,通常會將經過預測維護一個較長的指令序列,並構建一個指令池,經過解析指令池內的依賴關係,造成一張DAG(有向無環圖) 組織的網狀結構。經過對DAG關係的計算,其中依賴就緒的指令,就能夠進入執行態,被提交到實際的執行部件中處理。執行部件相似多線程模型中的工做線程,根據特性細分爲計算和訪存兩類。計算類通常有相對固定可預期的執行週期,而訪存類因爲指令週期差別較大,採用了異步回調的模型,經過Load/Store Buffer支持同時發起數十個訪存操做。
亂序執行系統和傳統流水線模式的區別主要體如今,當一條訪存指令由於Cache Miss而沒法當即完成時,其後無依賴關係的指令能夠插隊執行(相似於多線程模型中某個線程阻塞後,OS將其掛起並調度其餘線程)。插隊的計算類指令能夠填補空窗充分利用計算能力,而插隊的訪存指令經過更早啓動傳輸,讓訪存停頓期儘可能重疊來減少總體的停頓。所以亂序執行系統的效率,很大程度上會受到窗口內指令DAG的『扁平』程度的影響,依賴深度較淺的DAG能夠提供更高的指令級併發能力,進而提供更高的執行部件利用率,以及更少的停頓週期。另外一方面,因爲Load/Store Buffer也有最大的容量限制,處理較大區域的內存訪問負載時,將可能帶來更深層穿透的訪存指令儘可能靠近排布,來提升訪存停頓的重疊,也可以有效減小總體的停頓。
雖然理論比較清晰,但是在實踐中,僅僅從外部指標觀測到的性能表現,每每難以定位亂序執行系統內部的熱點。最直白的CPU利用率其實只能表達線程未受阻塞,真實在使用CPU的時間週期,可是其實並不能體現CPU內部部件真正的利用效率如何。稍微進階一些的IPC(Instruction Per Cyc le),能夠相對深刻地反應一些利用效能,可是影響IPC的因素又多種多樣。是指令並行度不足?仍是長週期ALU計算負載大?又或者是訪存停頓太久?甚至多是分支預測失敗率太高?真實程序中,這幾項問題每每是並存的,並且單一地統計每每又難以統一比較,例如10次訪存停頓/20次ALU 未打滿/30個週期的頁表遍歷,到底意味着瓶頸在哪裏?這個問題單一的指標每每就難以回答了。
3.3 TMAM
TMAM(Top-down Microarchitecture Analysis Method)是一種利用CPU內部PMU(Performance Monitoring Unit)計數器來從上至下分解定位部件瓶頸的手段。例如在最頂層,首先以標定最大指令完成速率爲標準(例如Skylake上爲單週期4條微指令),若是沒法達到標定,則認爲瓶頸在於未能充分利用部件。進一步細分以指令池爲分界線,若是指令池未滿,可是取指部件又沒法滿負荷輸出微指令,就代表『前端』存在瓶頸。另外一種沒法達到最大指令速率的因素,是『前端』雖然在發射指令到指令池,可是由於錯誤的預測,最終沒有產出有效結果,這類損耗則被納入『錯誤預測』。除此之外的問題就是由於指令池調度執行能力不足產生的反壓停頓,這一類被歸爲『後端』瓶頸。進一步例如『後端』瓶頸還能夠根 據,停頓發生時,是否伴隨了ALU利用不充分,是否伴隨了Load/Store Buffer滿負荷等因素,繼續進行分解細化,造成了一套總體的分析方法。例如針對Intel,這一過程能夠經過pmu-tools來被自動完成,對於指導精細化的程序瓶頸分析和優化每每有很大幫助。
int array\[1024\]; for (size\_t i = 0; i < 1024; i += 2) { int a = array\[i\]; int b = array\[i + 1\]; for (size\_t j = 0; j < 1024; ++j) { a = a + b; b = a + b;} array\[i\] = a; array\[i + 1\] = b; }
例如這是裏演示一個多輪計算斐波那契數列的過程,由於計算特徵中深層循環有強指令依賴,且內層循環長度遠大於常規亂序執行的指令池深度, 存在較大的計算依賴瓶頸,從工具分析也能夠印證這一點。
程序的IPC只有1,內部瓶頸也顯示集中在『後端』內部的部件利用效率(大多時間只利用了一個port),此時亂序執行並無發揮做用。
int array\[1024\]; for (size\_t i = 0; i < 1024; i += 4) { int a = array\[i\]; int b = array\[i + 1\]; int c = array\[i + 2\]; int d = array\[i + 3\]; for (size\_t j = 0; j < 1024; ++j) { a = a + b; b = a + b; c = c + d; d = c + d; } array\[i\] = a; array\[i + 1\] = b; array\[i + 2\] = c; array\[i + 3\] = d; }
這裏演示了典型的的循環展開方法,經過在指令窗口內同時進行兩路無依賴計算,提升了指令並行度,經過工具分析也能夠確認到效果。
不過實踐中,可以在寄存器上反覆迭代的運算並不常見,大多狀況下比較輕的計算負載,搭配比較多的訪存動做會更常常遇到,像下面的這個例子:
struct Line { char data\[64\]; }; Line\* lines\[1024\]; // 其中亂序存放多個緩存行 for (size\_t i = 0; i < 1024; ++i) { Line\* line = lines\[i\]; for (size\_t j = 0; j < 64; ++j) { line->data\[j\] += j; } }
這是一個非連續內存上進行累加計算的例子,隨外層迭代會跳躍式緩存行訪問,內層循環在連續緩存行上進行無依賴的計算和訪存操做。
能夠看到,這一次的瓶頸到了穿透緩存後的內存訪存延遲上,但同時內存訪問的帶寬並無被充分利用。這是由於指令窗口內雖然併發度不低,不過由於緩存層次系統的特性,內層循環中的多個訪存指令,其實最終都是等待同一行被從內存加載到緩存。致使真正觸發的底層訪存壓力並不足以打滿傳輸帶寬,可是程序卻表現出了較大的停頓。
for (size\_t i = 0; i < 1024; i += 2) { Line\* line1 = lines\[i\]; Line\* line2 = lines\[i + 1\]; ... for (size\_t j = 0; j < 64; ++j) { line1->data\[j\] += j; line2->data\[j\] += j; ... } }
經過調整循環結構,在每一輪內層循環中一次性計算多行數據,能夠在儘可能在停頓到來的指令窗口內,讓更多行出於同時從內存系統進行傳輸。從統計指標上也能夠看出,瓶頸重心開始從穿透訪存的延遲,逐步轉化向訪存帶寬,而實際的緩存傳輸部件Fill Buffer也開始出現了滿負荷運做的狀況。
3.4 總結一下單線程併發
現代CPU在遇到主頻瓶頸後,除了改成增長核心數,也在單核心內逐步強化並行能力。若是說多進程多線程技術的普及,讓多核心的利用技術多少不那麼罕見和困難,那麼單核心內的並行加速技術,由於更加黑盒(多級緩存加亂序執行),規範性不足(SIMD),相對普及度和利用率都會更差一些。雖然硬件更多的細節嚮應用層暴露讓程序的實現更加困難,不過困難和機會每每也是伴隨出現的,既然客觀發展上這種複雜性增長已經無可避免,那麼是否能善加利用也成了工程師進行性能優化時的一項利器。隨着體系結構的進一步複雜化,可見的將來一段時間裏,可否利用一些體系結構的原理和工具來進行優化,也會不可避免地成爲服務端工程師的一項重要技能。
4、多線程併發中的臨界區保護
相比單線程中的併發設計,多線程併發應該是更爲工程師所熟悉的概念。現在,將計算劃分到多線程執行的應用技術自己已經相對成熟了,相信各個服務端工程師都有各自熟悉的隊列+線程池的小工具箱。在不作其餘額外考慮的狀況下,單純的大任務分段拆分,提交線程池並回收結果可能也僅僅是幾行代碼就能夠解決的事情了。真正的難點,其實每每不在於『拆』,而在於『合』的部分,也就是任務拆分中沒法避免掉的共享數據操做環節。若是說更高的分佈式層面,還能夠儘量地利用Share Nothing思想,在計算髮生以前,就先儘可能經過任務劃分來作到儘量充分地隔離資源。可是深刻到具體的計算節點內部,若是再進行一些細粒度的拆分加速時,共享每每就難以完全避免了。如何正確高效地處理這些沒法避免的共享問題,就涉及到併發編程中的一項重要技術,臨界區保護。
4.1 什麼是臨界區
算法併發改造中,通常會產生兩類段落,一類是多個線程間無需交互就能夠獨立執行的部分,這一部分隨着核心增多,能夠順利地水平擴展。而另外一類是須要經過操做共享的數據來完成執行,這部分操做爲了可以正確執行,沒法被多個核心同時執行,只能每一個線程排隊經過。所以臨界區內的代碼,也就沒法隨着核心增多來擴展,每每會成爲多線程程序的瓶頸點。也是由於這個特性,臨界區的效率就變得相當重要,而如何保證各個線程安全地經過臨界區的方法,就是臨界區保護技術。
4.1.1 Mutual Exclusion
最基本的臨界區保護方法,就是互斥技術。這是一種典型的悲觀鎖算法,也就是假設臨界區高几率存在競爭,所以須要先利用底層提供的機制進行仲裁,成功得到全部權以後,才進入臨界區運行。這種互斥算法,有一個典型的全局阻塞問題,也就是上圖中,當臨界區內的線程發生阻塞,或被操做系統換出時,會出現一個全局執行空窗。這個執行空窗內,不只自身沒法繼續操做,未得到鎖的線程也只能一同等待,形成了阻塞放大的現象。可是對於並行區,單一線程的阻塞只會影響自身,一樣位於在上圖中的第二次阻塞就是如此。
因爲真實發生在臨界區內的阻塞每每又是不可預期的,例如發生了缺頁中斷,或者爲了申請一塊內存而要先進行一次比較複雜的內存整理。這就會讓阻塞擴散的問題更加嚴重,極可能改成讓另外一個線程先進入臨界區,反而能夠更快順利完成,可是如今必須全部併發參與者,都一塊兒等待臨界區持有者來完成一些並無那麼『關鍵』的操做。由於存在全局阻塞的可能性,採用互斥技術進行臨界區保護的算法有着最低的阻塞容忍能力,通常在『非阻塞算法』領域做爲典型的反面教材存在。
4.1.2 Lock Free
針對互斥技術中的阻塞問題,一個改良型的臨界區保護算法是無鎖技術。雖然叫作無鎖,不過主要是取自非阻塞算法等級中的一種分類術語,本質上是一種樂觀鎖算法。也就是首先假設臨界區不存在競爭,所以直接開始臨界區的執行,可是經過良好的設計,讓這段預先的執行是無衝突可回滾的。可是最終設計一個須要同步的提交操做,通常基於原子變量CAS(Compare And Swap),或者版本校驗等機制完成。在提交階段若是發生衝突,那麼被仲裁爲失敗的各方須要對臨界區預執行進行回滾,並從新發起一輪嘗試。
無鎖技術和互斥技術最大的區別是,臨界區核心的執行段落是能夠相似並行段落同樣獨立進行,不過又不一樣於真正的並行段落,同時執行的臨界區中,只有一個是真正有效的,其他最終將被仲裁爲無效並回滾。可是引入了冗餘的執行操做後,當臨界區內再次發生阻塞時,不會像互斥算法那樣在參與線程之間進行傳播,轉而讓一個次優的線程成功提交。雖然從每一個併發算法參與線程的角度,存在沒有執行『實質有效』計算的段落,可是這種浪費計算的段落,必定對應着另外一個參與線程執行了『有效』的計算。因此從整個算法層面,可以保證不會全局停頓,老是有一些有效的計算在運行。
4.1.3 Wait-Free
無鎖技術主要解決了臨界區內的阻塞傳播問題,可是本質上,多個線程依然是排隊順序通過臨界區。形象來講,有些相似交通中的三叉路口匯合, 不管是互斥仍是無鎖,最終都是把兩條車道匯聚成了一條單車道,區別只是指揮是否高明能保證沒有斷流出現。但是不管如何,臨界區內全局吞吐下降成串行這點是共同的缺陷。
而Wait Free級別和無鎖的主要區別也就體如今這個吞吐的問題上,在無全局停頓的基礎上,Wait Free進一步保障了任意算法參與線程,都應該在有限的步驟內完成。這就和無鎖技術產生了區別,不僅是總體算法時時刻刻存在有效計算,每一個線程視角依然是須要持續進行有效計算。這就要求了多線程在臨界區內不能被細粒度地串行起來,而必須是同時都能進行有效計算。回到上面三叉路口匯聚的例子,就覺得着在Wait Free級別下,最終匯聚的道路依舊須要是多車道的,以保證能夠同時都可以有進展。
雖然理論角度存在很多有Wait Free級別的算法,不過大多爲概念探索,並不具有工業使用價值。主要是因爲Wait Free限制了同時有進展,可是並無描述這個進展有多快。所以進一步又提出了細分子類,以比較有實際意義的Wait-Free Population Oblivious級別來講,額外限制了每一個參與線程必需要在預先可給出的明確執行週期內完成,且這個週期不能和與參與線程數相關。這一點明確拒絕了一些相似線程間協做的方案(這些方案每每引發較大的緩存競爭),以及一些須要很長很長的有限步來完成的設計。
上圖實例了一個典型的Wait Free Population Oblivious思路。進行臨界區操做前,經過一個協同操做爲參與線程分配獨立的ticket,以後每一個參與線程能夠經過獲取到的ticket做爲標識,操做一塊獨立的互不干擾的工做區,並在其中完成操做。工業可用的Wait Free算法通常較難設計,例如ticket機制要求在協調動做中原子完成工做區分配,而不少數據結構是不容易作到這樣的拆分的。時至今日各類數據結構上工業可用的Wait Free算法依舊是一項持續探索中的領域。
4.2 無鎖不是萬能的
從非阻塞編程的角度看,上面的幾類臨界區處理方案優劣有着顯著的偏序關係,即Wait Free > Lock Free > Mutual Exclusion。這主要是從阻塞適應性角度進行的衡量,原理上並不能直接對應到性能緯度。可是依然很容易給工程師形成一個普適印象,也就是『鎖是很邪惡的東西,不使用鎖來實現算法能夠顯著提升性能』,再結合廣爲流傳的鎖操做自身開銷很重的認知,不少工程師在實踐中會有對鎖敬而遠之的傾向。那麼,這個指導思想是不是徹底正確的?
讓咱們先來一組實驗:
// 在一個cache line上進行指定步長的斐波那契計算來模擬臨界區計算負載 uint64\_t calc(uint64\_t\* sequence, size\_t size) { size\_t i; for (i = 0; i < size; ++i) { sequence\[(i + 1) & 7\] += sequence\[i & 7\]; } return sequence\[i & 7\]; } { // Mutual Exclusion ::std::lock\_guard<::std::mutex> lock(mutex); sum += calc(sequence, workload); } { // Lock Free / Atomic CAS auto current = atomic\_sum.load(::std::memory\_order\_relaxed); auto next = current; do { next = current + calc(sequence, workload); } while (!atomic\_sum.compare\_exchange\_weak( current, next, ::std::memory\_order\_relaxed)); } { // Wait Free / Atomic Modify atomic\_sum.fetch\_add(calc(sequence, workload), ::std::memory\_order\_relaxed); }
這裏採用多線程累加做爲案例,分別採用上鎖後累加,累加後CAS提交,以及累加後FAA(Fetch And Add)提交三種方法對全局累加結果作臨界區保護。針對不一樣的併發數量,以及不一樣的臨界區負載,能夠造成以下的三維曲線圖。
其中Latency項除以臨界區規模進行了歸一,便於形象展現臨界區負載變化下的臨界區保護開銷趨勢,所以跨不一樣負載等級下不具有橫向可比性。Cycles項表示多線程協同完成總量爲一樣次數的累加,用到的CPU週期總和,整體隨臨界區負載變化有少許自然傾斜。100/1600兩個截面圖將3中算法疊加在一塊兒展現,便於直觀對比。
從上面的數據中能夠分析出這樣一些信息
一、基於FAA的Wait Free模式各方面都顯著賽過其餘方法;
二、無鎖算法相比互斥算法在平均吞吐上有必定優點,可是並無達到數量級水平;
三、無鎖算法隨競爭提高(臨界區大小增大,或者線程增多),cpu消耗顯著上升;
基於這些信息來分析,會發現一個和以前提到的『鎖性能』的常規認知相悖的點。性能的分水嶺並無出如今基於鎖的互斥算法和無鎖算法中間, 而是出如今同爲『未使用鎖』的Lock Free和Wait Free算法中間。並且從CPU消耗角度來看,對臨界區比較複雜,競爭強度高的場景,甚至Lock Free由於『無效預測執行』過多反而引發了過多的消耗。這代表了鎖操做自己的開銷雖然稍重於原子操做,但其實也並不是洪水猛獸,而真正影響性能的,是臨界區被迫串行執行所帶來的並行能力折損。
所以當咱們遇到臨界區保護的問題時,能夠先思考一下,是否能夠採用Wait Free的方法來完成保護動做,若是能夠的話,在性能上可以接近徹底消除了臨界區的效果。而在多數狀況下,每每仍是要採用互斥或Lock Free來進行臨界區的保護。此時臨界區的串行不可避免,因此充分縮減臨界區的佔比是共性的第一要務,而是否進一步採用Lock Free技術來減小臨界區保護開銷,討論的前提也是臨界區已經顯著很短,不會引發過多的無效預 測。除此之外,因爲Lock Free算法通常對臨界區須要設計成兩階段提交,以便支持回滾撤銷,所以每每須要比對應的互斥保護算法更復雜,局部性也可能更差(例如某些場景必須引入鏈表來替換數組)。綜合來看,通常若是沒法作到Wait Free,那麼無需對Lock Free過分執着,充分優化臨界區的互斥方法每每也足以提供和Lock Free至關的性能表現了。
4.3 併發計數器優化案例
從上文針對臨界區保護的多種方法所作的實驗,還能夠發現一個現象。隨着臨界區逐漸減少,保護措施開銷隨線程數量增長而提高的趨勢都預發顯著,即使是設計上效率和參與線程數本應無關的Wait Free級別也是同樣。這對於臨界區極小的併發計數器場景,依舊會是一個顯著的問題。那麼咱們就先從鎖和原子操做的實現角度,看看這些損耗是如何致使的。
首先給出一個典型的鎖實現,左側是鎖的fast path,也就是若是在外層的原子變量操做中未發現競爭,那麼其實上鎖和解鎖其實就只經歷了一組原子變量操做。當fast path檢測到可能出現衝突時,纔會進入內核,嘗試進行排隊等待。fast path的存在大幅優化了低衝突場景下的鎖表現,並且現代操做系統內核爲了優化鎖的內存開銷,都提供了『Wait On Address』的功能,也就是爲了支持這套排隊機制,每一個鎖常態只須要一個整數的存儲開銷便可,只有在嘗試等待時,纔會建立和佔用額外的輔助結構。
所以實際設計中,鎖能夠建立不少,甚至很是多,只要可以達到足夠細粒度拆解衝突的效果。這其中最典型的就是brpc中計數器框架bvar的設計。
這是bvar中基礎統計框架的設計,局部計數和全局匯聚時都經過每一個tls附加的鎖來進行臨界區保護。由於採集週期很長,衝突能夠忽略不記,所以雖然默認使用了大量的鎖(統計量 * 線程數),可是並無很大的內存消耗,並且運行開銷其實很低,可以用來支持任意的匯聚操做。這個例子也能進一步體現,鎖自己的消耗其實並不顯著,競爭帶來的軟件或硬件上的串行化纔是開銷的核心。
不過即便競爭很低,鎖也仍是會由一組原子操做實現,而當咱們本身查看原子操做時,實際是由cache鎖操做保護的原子指令構成,並且這個指令會在亂序執行中起到內存屏障的效果下降訪存重疊的可能性。所以針對很是經常使用的簡單計數器,在百度內部咱們進行了進一步去除局部鎖的改造,來試圖進一步下降統計開銷。
例如對於須要同時記錄次數和總和的IntRecorder,由於須要兩個64位加法,曾經只能依賴鎖來保證原子更新。但隨着新x86機型的不斷普及,在比較新的X86和ARM服務端機型上已經能夠作到128bit的原子load/store,所以能夠利用相應的高位寬指令和正確對齊來實現鎖的去除。
另外一個例子是Percentile分位值統計,因爲抽樣數據是一個多元素容器,並且分位值統計須要週期清空重算,所以常規也是採用了互斥保護的方法。不過若是引入版本號機制,將清空操做轉交給計數線程本身完成,將sample區域的讀寫徹底分離。在這個基礎上,就能夠比較簡單的作到線程安全,並且也不用引入原子修改。嚴格意義上,異步清空存在邊界樣本收集丟失的可能性,不過由於核心的蓄水池抽樣算髮自己也具備隨機性,在監控指標統計領域已經擁有足夠精度。
除了運行時操做,線程局部變量的組織方式原先採用鎖保護的鏈表進行管理,採用分段數據結合線程編號的方法替換後,作到空間連續化。最終總體進一步改善了計數器的性能。
4.4 併發隊列優化案例
另外一個在多線程編程中常常出現的數據結構就是隊列,爲了保證能夠安全地處理併發的入隊和出隊操做,最基礎的算法是整個隊列用鎖來保護起來。
這個方法的缺點是顯而易見的,由於隊列每每做爲多線程驅動的數據中樞位置,大量的競爭下,隊列操做被串行很容易影響總體計算的並行度。所以一個天然的改進點是,將隊列頭尾分開保護,先將生產者和消費者解耦開,只追加必要的同步操做來保證不會過分入隊和出隊。這也是Jave中LinkedBlockingQueue所使用的作法。
在頭尾分離以後,進一步的優化進入了兩個方向。首先是由於單節點的操做具有了Lock Free化的可能,所以產生了對應的Michael & Scott無鎖隊列算法。業界的典型實現有Java的ConcurrentLinkedQueue,以及boost中的boost::lockfree::queue。
而另外一個方向是隊列分片,即將隊列拆解成多個子隊列,經過領取token的方式選擇子隊列,而子隊列內部使用傳統隊列算法,例如tbb:: concurrent_queue就是分片隊列的典型實現。
對兩種方式進行對比,能夠發現,在強競爭下,分片隊列的效果其實顯著賽過單純的無鎖處理,這也是前文對於無鎖技術真實效果分析的一個體現。
除了這類通用隊列,還有一個強化競爭發佈,串行消費的隊列也就是bthread::ExecutionQueue,它在是brpc中主要用於解決多線程競爭fd寫入的問題。利用一些有趣的技巧,對多線程生產側作到了Wait Free級別。
整個隊列只持有隊尾,而無隊頭。在生產側,第一步直接將新節點和當前尾指針進行原子交換,以後再將以前的隊尾銜接到新節點以後。由於不管是否存在競爭,入隊操做都能經過固定的兩步完成,所以入隊算法是Wait Free的。不過這給消費側帶來的麻煩,消費一樣從一個原子交換開始,將隊尾置換成nullptr,以後的消費動做就是遍歷取到的單鏈表。可是由於生產操做分了兩部完成,此時可能發現部分節點尚處於『斷鏈』狀態,因爲消費者無從知曉後續節點信息,只能輪詢等待生產者最終完成第二步。因此理論上,生產/消費算法其實甚至不是Lock Free的,由於若是生產者在兩階段中間被換出,那麼消費者會被這個阻塞傳播影響,整個消費也只能先阻塞住。可是在排隊寫入fd的場景下,專項優化生產併發是合理,也所以能夠得到更好的執行效率。
不過爲了能利用原子操做完成算法,bthread::ExecutionQueue引入了鏈表做爲數據組織方式,而鏈表自然存在訪存跳躍的問題。那麼是否能夠用數組來一樣實現Wait Free的生產甚至消費併發呢?
這就是babylon::ConcurrentBoundedQueue所但願解決的問題了。
不過介紹這個隊列併發原理以前,先插入一個勘誤信息。其實這個隊列在《內存篇》最後也簡單提到過,不過當時粗略的評測顯示了acquire- release等級下,即便不作cache line隔離性能也能夠保障。文章發表後收到業界同好反饋,討論發現當時的測試用例命中了Intel Write Combining 優化技術,即當僅存在惟一一個處於等待加載的緩存行時,只寫動做能夠無阻塞提早完成,等緩存行真實加載完畢後,再統一提交生效。可是因爲內存序問題,一旦觸發了第二個待加載的緩存行後,對於第一個緩存行的Write Combine就沒法繼續生效,只能等待第二個緩存行的寫完成後,才能繼續提交。原理上,Write Combine技術確實緩解了只寫場景下的False Sharing,可是隻能等待一個緩存行的限制在真實場景下想要針對性利用起來限制至關大。例如在隊列這個典型場景下,每每會同時兩路操做數據和完成標記,極可能同時處於穿透加載中,此時是沒法應用Write Combine技術的。此外,可以在緩存行加載週期內,有如此充分的同行寫入,可能也只有並沒有真實意義的評測程序才能作到。因此從結論上講,一般意義上的多線程cache line隔離仍是頗有必要的。
回到babylon::ConcurrentBoundedQueue的設計思路上,實際上是將子隊列拆分作到極致,將同步量粒度下降到每一個數據槽位上。每一個入隊和出隊 請求,首先利用原子自增領取一個遞增的序號,以後利用循環數組的存儲方式,就能夠映射到一個具體的數據槽位上。根據操做是入隊仍是出隊, 在循環數組上發生了多少次摺疊,就能夠在一個數據槽位上造成一個連續的版本序列。例如1號入隊和5號出隊都對應了1號數據槽位,而1號入隊預期的版本轉移是0到1,而5號出隊的版本轉移是2到3。這樣針對同一個槽位的入隊和出隊也能夠造成一個連續的版本變動序列,一個領到序號的具體操做,只須要明確檢測版本便可確認本身當前是否能夠開始操做,並經過本身的版本變動和後續的操做進行同步。
經過同步量下放到每一個元素的方式,入隊和出隊操做在能夠除了最開始的序號領取存在原子操做級別的同步,後續均可以無干擾並行開展。而更連續的數據組織,也解決了鏈表存儲的訪存跳躍問題。生產消費雙端可併發的特色,也提供了更強的泛用性,實際在MPMC(Multiple Producer Mult iple Consumer)和MPSC(Multiple Producer Single Consumer)場景下都有不錯的性能表現,在具有必定小批量處理的場景下尤爲顯著。
招聘信息
歡迎出色的C++ 工程師加入百度,與大神一塊兒成長。關注同名公衆號百度Geek說,輸入內推便可,咱們期待你的加入!
推薦閱讀
---------- END ----------
百度Geek說
百度官方技術公衆號上線啦!
技術乾貨 · 行業資訊 · 線上沙龍 · 行業大會
招聘信息 · 內推信息 · 技術書籍 · 百度周邊
歡迎各位同窗關注