Disruptor官方技術(shù)論文

LMAX Disruptor: High performance alternative to bounded queues for exchanging data between concurrent threads

《LMAX Disruptor:用于并發(fā)線程間交換數(shù)據(jù)的有界隊(duì)列的高性能替代方案》

作者 Martin Thompson

     Dave Farley

     Michael Barker

     Patricia Gee

     Andrew Stewart

版本 Version 4.0.0-SNAPSHOT,May 2011

摘要

LMAX的成立旨在是要創(chuàng)建一個非常高性能的線上金融交易所。而作為我們實(shí)現(xiàn)這一目標(biāo)的工作的一部分,我們評估了設(shè)計(jì)這樣一個系統(tǒng)的幾種方法,但是當(dāng)我們開始評估和測試這些方法時,我們遇到了一些傳統(tǒng)方法的基本限制和瓶頸。

許多應(yīng)用系統(tǒng)要依賴隊(duì)列在處理階段之間交換數(shù)據(jù)。我們的性能測試表明,以這種方式使用隊(duì)列時,延遲成本與磁盤IO操作(基于RAID或SSD的磁盤系統(tǒng))的成本基本同一個數(shù)量級——非常慢。如果在一個端到端操作中有多個隊(duì)列,這將使總延遲增加數(shù)百微秒。這顯然是還有優(yōu)化空間的。

進(jìn)一步的研究和對計(jì)算機(jī)科學(xué)原理的關(guān)注使我們認(rèn)識到,傳統(tǒng)方法(如隊(duì)列和處理節(jié)點(diǎn))中固有的因素的共同作用( the conflation of concerns inherent in conventional approaches)會導(dǎo)致多線程實(shí)現(xiàn)中的爭用,這表明可能有更好的方法。

考慮到現(xiàn)代cpu是如何工作的,我們喜歡稱之為“機(jī)械共鳴”,軟件與底層硬件的機(jī)制呼應(yīng)上了,使用良好的設(shè)計(jì)實(shí)踐,并將重點(diǎn)放在解決導(dǎo)致多線程爭用的因素上(a strong focus on teasing apart the concerns),我們提出了一種新的數(shù)據(jù)結(jié)構(gòu)和設(shè)計(jì)模式,這就是我們的Disruptor框架。

測試表明,對于一個有3個階段的業(yè)務(wù)處理流程pipeline,使用Disruptor方案的平均延遲比對應(yīng)的基于隊(duì)列的方案低3個數(shù)量級。此外,對于相同的配置,Disruptor處理的吞吐量大約是原來的8倍。

這些性能改進(jìn)代表了并發(fā)編程思想的一個階段性變化。這種新模式是任何需要高吞吐量和低延遲的異步事件處理架構(gòu)的理想基礎(chǔ)。

在LMAX,我們已經(jīng)構(gòu)建了一個訂單匹配引擎、實(shí)時風(fēng)險管理和一個高度可用的內(nèi)存事務(wù)處理系統(tǒng),所有這些都基于了這種模式并取得了巨大的成功。這些系統(tǒng)中的每一個都制定了新的性能標(biāo)準(zhǔn),而據(jù)我們所知,這些標(biāo)準(zhǔn)是無與倫比的。

我們的基于Disruptor的方案并不是一個僅與金融行業(yè)相關(guān)的特定專業(yè)領(lǐng)域內(nèi)的解決方案。Disruptor是一種通用機(jī)制,它以一種性能最大化的方式解決并發(fā)編程中的復(fù)雜問題,并且易于實(shí)現(xiàn)。使用的過程中,盡管這個框架的一些個設(shè)計(jì)上的概念、可能一開始理解起來不習(xí)慣,但根據(jù)我們的經(jīng)驗(yàn),一旦熟悉、按照這種模式構(gòu)建的系統(tǒng)要比類似的機(jī)制簡單得多。

與同類方案相比,Disruptor具有顯著的更少的寫爭用、更低的并發(fā)開銷、對CPU的緩存機(jī)制更友好,所有這些都使得其在更低的延遲下產(chǎn)生更大的吞吐量和更少性能上的抖動。在中等時鐘頻率的處理器上,我們已經(jīng)看到每秒處理超過2500萬條消息,延遲低于50納秒。與我們所看到的任何其他實(shí)現(xiàn)相比,此性能是一個顯著的改進(jìn)。這已經(jīng)非常接近現(xiàn)代處理器在內(nèi)核之間交換數(shù)據(jù)的硬件上的理論極限了。

1. 概述

Disruptor是我們努力在LMAX公司建立世界上性能最高的在線金融交易所這一工作目標(biāo)下的成果。早期的設(shè)計(jì)側(cè)重于從SEDA[1]和Actors[2]派生的架構(gòu),它們使用pipeline模型來實(shí)現(xiàn)吞吐量。在分析了各種實(shí)現(xiàn)之后,很明顯,pipeline模型中各階段之間的事件排隊(duì)是主要的成本。我們發(fā)現(xiàn)隊(duì)列還引入了延遲和很高的性能抖動(性能很不穩(wěn)定、每次測試結(jié)果差異很大)。我們花了大量精力開發(fā)性能更好的新隊(duì)列實(shí)現(xiàn)。然而,顯而易見的是,隊(duì)列作為一種基本的數(shù)據(jù)結(jié)構(gòu),由于生產(chǎn)者、消費(fèi)者和他們的數(shù)據(jù)存儲的設(shè)計(jì)關(guān)注點(diǎn)的合并(the conflation of design concerns)而受到限制。Disruptor是我們構(gòu)建一個干凈地分離這些關(guān)注點(diǎn)的并發(fā)數(shù)據(jù)結(jié)構(gòu)的成果。

2. 并發(fā)的復(fù)雜性

在本文以及在一般的計(jì)算機(jī)科學(xué)理論中,并發(fā)不僅意味著兩個以上任務(wù)同時并行發(fā)生,而且意味著它們在訪問資源時相互競爭。爭用的資源可以是數(shù)據(jù)庫、文件、socket,甚至是內(nèi)存中的一個位置。

代碼的并發(fā)執(zhí)行涉及兩件事:互斥和內(nèi)存可見性?;コ馐顷P(guān)于如何管理保證某些資源的獨(dú)占式使用。內(nèi)存可見性是關(guān)于控制內(nèi)存更改何時對其他線程可見。如果你可以避免多線程競爭的去更新共享資源,那么就可以避免互斥。如果您的算法可以保證任何給定的資源只被一個線程修改,那么互斥是不必要的。讀寫操作要求所有更改對其他線程可見。但是,只有爭用的寫操作需要對更改進(jìn)行互斥。

在任何并發(fā)環(huán)境中,最昂貴的操作是爭用寫訪問。要讓多個線程寫入同一資源,需要復(fù)雜而昂貴的協(xié)調(diào)。通常,這是通過采用某種鎖策略來實(shí)現(xiàn)的。

2.1 鎖的開銷

鎖可以提供互斥,并確保以有序的方式發(fā)生更改的可見性。但鎖的開銷是難以置信的昂貴,因?yàn)殒i爭用時需要仲裁。這種仲裁是通過操作系統(tǒng)內(nèi)核的上下文切換來實(shí)現(xiàn)的,這將掛起等待鎖的所有線程,直到鎖被釋放。在這樣的上下文切換期間,以及釋放對操作系統(tǒng)的控制(操作系統(tǒng)可能決定在有控制權(quán)的情況下執(zhí)行其他內(nèi)部管理任務(wù)),執(zhí)行上下文可能會丟失以前緩存的數(shù)據(jù)和指令。這會對現(xiàn)代處理器的性能產(chǎn)生嚴(yán)重影響??梢允褂每焖俚挠脩魬B(tài)的鎖,但這些鎖只有在不爭用時才有真正的好處。

我們將通過一個簡單的演示來說明鎖的成本。這個實(shí)驗(yàn)的重點(diǎn)是調(diào)用一個函數(shù),該函數(shù)在一個循環(huán)中使一個64位計(jì)數(shù)器遞增5億次。如果是用Java編寫的,這可以由2.4Ghz Intel Westmere EP上的單個線程在300毫秒內(nèi)執(zhí)行。使用何種語言對于這個實(shí)驗(yàn)來說并不重要,對于所有具有相同基本原語的語言,結(jié)果都是相似的。

一旦引入一個鎖來提供互斥,即使鎖還沒有被爭用,成本也會顯著增加。當(dāng)兩個或多個線程開始爭用時,成本又會增加幾個數(shù)量級。這個簡單實(shí)驗(yàn)的結(jié)果如下表所示:

Method Time (ms)
Single thread 單線程不用鎖 300
Single thread with lock 單線程加鎖 10,000
Two threads with lock 2線程加鎖 224,000
Single thread with CAS 單線程用CAS 5,700
Two threads with CAS 2線程用CAS 30,000
Single thread with volatile write 單線程寫volatile變量 4,700

2.2 CAS的開銷

當(dāng)更新的目標(biāo)是單個內(nèi)存變量時,可以采用比使用鎖更有效的替代方法來更新內(nèi)存。這些替代方案基于現(xiàn)代處理器中實(shí)現(xiàn)的原子指令或互鎖指令。這些操作通常稱為CAS(比較和交換)操作,例如x86上的“l(fā)ock cmpxchg”。CAS操作是一種特殊的機(jī)器代碼指令,它允許將內(nèi)存中的字有條件地設(shè)置為原子操作。比如對于前面的“遞增計(jì)數(shù)器實(shí)驗(yàn)”例子,每個線程都可以在一個循環(huán)中自旋,讀取計(jì)數(shù)器,然后嘗試以原子方式將其設(shè)置為新的遞增值。舊值和新值作為本指令的參數(shù)提供。如果在執(zhí)行操作時,計(jì)數(shù)器的值與提供的預(yù)期值匹配,則計(jì)數(shù)器將用新值更新。另一方面,如果該值不符合預(yù)期,則CAS操作將失敗。然后由嘗試執(zhí)行更改計(jì)數(shù)器的線程重試,重新讀取從該值遞增的計(jì)數(shù)器,依此類推,直到更改成功。這種CAS方法比鎖更有效,因?yàn)樗恍枰舷挛那袚Q到內(nèi)核態(tài)進(jìn)行仲裁。但是CAS操作也并不是完全沒有開銷的。處理器必須鎖定其指令管道以確保原子性,并使用內(nèi)存屏障使更改對其他線程可見。通過使用Java.util.concurrent.Atomic*類,可以在Java中使用CAS操作。

筆者注:CAS無需線程進(jìn)行上下文切換到內(nèi)核態(tài)去執(zhí)行,在用戶態(tài)執(zhí)行了CPU的原語指令cmpxchg,這里可能有疑問,指令分為特權(quán)指令和非特權(quán)指令,用戶態(tài)是可以執(zhí)行非特權(quán)指令的。所以這里糾正一個錯誤理解就是并不是發(fā)生了系統(tǒng)調(diào)用就一定是內(nèi)核態(tài),內(nèi)核態(tài)和用戶態(tài)是用CPU中的程序狀態(tài)字寄存器PSW,當(dāng)該寄存器值為0時表示CPU處于用戶態(tài)、為1時表示處于核心態(tài)(此刻執(zhí)行的線程處于內(nèi)核態(tài))。

https://blog.csdn.net/qq_19018277/article/details/98225102 《非特權(quán)指令、特權(quán)指令,用戶態(tài)、核心態(tài)》

如果程序的關(guān)鍵部分比計(jì)數(shù)器的簡單增量更復(fù)雜,則可能需要使用多個CAS操作的復(fù)雜狀態(tài)機(jī)來編排爭用。使用鎖開發(fā)并發(fā)程序是困難的;而使用CAS操作和內(nèi)存屏障開發(fā)無鎖算法要更加復(fù)雜多倍,而且難于測試和證明正確性。

理想的算法是只有一個線程負(fù)責(zé)對單個資源的所有寫入操作,而其他線程則讀取結(jié)果。而說到在多處理器環(huán)境中讀取結(jié)果,需要內(nèi)存屏障,以確保在其他處理器上運(yùn)行的線程可以立即看到更改。

2.3 內(nèi)存屏障

出于提升性能的原因,現(xiàn)代處理器執(zhí)行指令、以及內(nèi)存和執(zhí)行單元之間數(shù)據(jù)的加載和存儲都是不保證順序的。不管實(shí)際的執(zhí)行順序如何,處理器只需保證與程序邏輯的順序產(chǎn)生相同的結(jié)果即可。這在單線程的程序中不是一個問題。但是,當(dāng)線程共享狀態(tài)時,為了確保數(shù)據(jù)交換的成功與正確,在需要的時候、內(nèi)存的改變能夠以正確的順序顯式是非常重要的。處理器使用內(nèi)存屏障來指示內(nèi)存更新順序很重要的代碼部分。它們是在線程之間實(shí)現(xiàn)硬件排序和更改可見性的方法。編譯器可以設(shè)置免費(fèi)的軟件屏障,以確保編譯代碼的順序,也就是除了處理器本身使用的硬件屏障之外,還存在這樣的軟件內(nèi)存屏障。

現(xiàn)代的CPU現(xiàn)在比當(dāng)前一代的內(nèi)存系統(tǒng)快得多。為了彌合這一鴻溝,CPU使用復(fù)雜的高速緩存系統(tǒng),這些系統(tǒng)是有效的快速硬件哈希表,無需鏈接。這些緩存通過消息傳遞協(xié)議與其他處理器緩存系統(tǒng)保持一致。此外,處理器還具有“存儲緩沖區(qū)”(store buffer/load buffer,比L1緩存更靠近CPU,跟寄存器同一個級別,用來當(dāng)作CPU與高速緩存之間的緩沖。畢竟高速緩存由于一致性的問題也會阻塞)來緩沖對這些緩存的寫入,以及作為“失效隊(duì)列”,以便緩存一致性協(xié)議能夠在即將發(fā)生寫入時快速確認(rèn)失效消息,以提高效率。

這對數(shù)據(jù)意味著,任何值的最新版本在被寫入后的任何階段都可以位于寄存器、存儲緩沖區(qū)、L1/L2/L3緩存之一或主內(nèi)存中。如果線程要共享此值,則需要以有序的方式使其可見,這是通過協(xié)調(diào)緩存一致性消息的交換來實(shí)現(xiàn)的。這些信息的及時產(chǎn)生可以通過內(nèi)存屏障來控制。

  • A read memory barrier orders load instructions on the CPU that executes it by marking a point in the invalidate queue for changes coming into its cache. This gives it a consistent view of the world for write operations ordered before the read barrier.

  • A write barrier orders store instructions on the CPU that executes it by marking a point in the store buffer, thus flushing writes out via its cache. This barrier gives an ordered view to the world of what store operations happen before the write barrier.

  • A full memory barrier orders both loads and stores but only on the CPU that executes it.

除了這三個原語之外,有些CPU還有更多的變體,但這三個原語足以理解所涉及內(nèi)容的復(fù)雜性。在Java內(nèi)存模型中,volatile字段的讀寫分別實(shí)現(xiàn)了讀寫屏障。這在Java內(nèi)存模型中是明確的[3]正如Java5發(fā)行版所定義的那樣。

理解這節(jié)需要了解內(nèi)存屏障的底層實(shí)現(xiàn)原理和CPU緩存一致性協(xié)議相關(guān)的知識。https://blog.csdn.net/m0_37561834/article/details/78457078

2.4 緩存行

高速緩存在現(xiàn)代處理器中的使用方式對于成功的高性能操作具有極其重要的意義。這樣的處理器在處理高速緩存中的數(shù)據(jù)和指令時非常高效,但在發(fā)生高速緩存丟失時,效率相對非常低。

我們的硬件不會以byte或word(字,1字=2byte)的形式移動內(nèi)存。為了提高效率,緩存被組織成大小通常為32-256字節(jié)的緩存行cacheline,最常見的cacheline是64字節(jié)。這是緩存一致性協(xié)議MESI操作的粒度級別。這意味著,如果兩個變量位于同一個cacheline中,并且它們是由不同的線程寫入的,那么它們將出現(xiàn)與單個變量相同的寫爭用問題。這是一個被稱為“偽共享”的概念。為了獲得高性能、盡量最小化爭用,確保獨(dú)立但同時寫入的變量不共享同一cacheline是非常重要的。

當(dāng)以可預(yù)測的方式訪問內(nèi)存時,cpu能夠通過預(yù)測下一個可能訪問的內(nèi)存并在后臺將其預(yù)取到緩存中來減少下一次訪問主內(nèi)存的延遲成本。只有當(dāng)處理器能夠預(yù)測一個固定內(nèi)存訪問的模式、比如以可預(yù)測的“步幅”遍歷一塊內(nèi)存,這種方法才有效。當(dāng)?shù)鷶?shù)組的內(nèi)容時,步長是可預(yù)測的,因此內(nèi)存將被預(yù)讀到cacheline,從而最大限度地提高訪問效率。步長通常必須小于2048字節(jié),處理器才能注意到。然而,像鏈表和樹這樣的數(shù)據(jù)結(jié)構(gòu)往往有節(jié)點(diǎn),這些節(jié)點(diǎn)在內(nèi)存中分布更廣,沒有可預(yù)測的訪問步長。內(nèi)存中缺少一致的模式限制了系統(tǒng)預(yù)取cacheline的能力,從而導(dǎo)致只能直接訪問主存——其性能較高速緩存要降低2個數(shù)量級以上。

筆者注:其實(shí)第2節(jié)都是在講理論儲備,2.4概括來說就是一句話,為了有效利用CPU的高速緩存機(jī)制,我們應(yīng)該盡量用順序存儲結(jié)構(gòu),比如數(shù)組

2.5 隊(duì)列帶來的問題

隊(duì)列通常使用鏈表或數(shù)組作為元素的底層存儲。如果允許內(nèi)存中的隊(duì)列是無界的,那么對于許多類的問題,它可以不受約束地增長,直到耗盡內(nèi)存而達(dá)到災(zāi)難性的后果,當(dāng)生產(chǎn)者超過消費(fèi)者時就會發(fā)生這種情況。無界隊(duì)列在可以在生產(chǎn)者可以保證不超過消費(fèi)者的系統(tǒng)中使用,因?yàn)閮?nèi)存是一種寶貴的資源,但是如果這種假設(shè)不成立,而隊(duì)列增長沒有限制,那么總是有風(fēng)險的。為了避免這種災(zāi)難性的結(jié)果,隊(duì)列的大小通常要受到限制(有界)。要使隊(duì)列保持有界,就需要對其底層選擇數(shù)組結(jié)構(gòu)或主動跟蹤其大小。

隊(duì)列的實(shí)現(xiàn)往往要在head、tail和size變量上有寫爭用。在使用時,由于消費(fèi)者和生產(chǎn)者之間的速度差異,隊(duì)列通??偸墙咏跐M或接近于空。它們很少在生產(chǎn)和消費(fèi)速率均衡的中間地帶運(yùn)作。這種總是滿的或總是空的傾向會導(dǎo)致高級別的爭用、和/或昂貴的緩存一致性。問題在于,即使head和tail使用不同的并發(fā)對象(如鎖或CAS變量)來進(jìn)行讀寫鎖分離,它們通常也占用相同的cacheline。

管理生產(chǎn)者申請隊(duì)列的head,消費(fèi)者申請隊(duì)列的tail,以及中間節(jié)點(diǎn)的存儲,這些問題使得并發(fā)實(shí)現(xiàn)的設(shè)計(jì)非常復(fù)雜,除了在隊(duì)列上使用一個粗粒度的鎖之外,還難以管理。對于put和take操作,使用整個隊(duì)列上的粗粒度鎖實(shí)現(xiàn)起來很簡單,但對吞吐量來說是一個很大的瓶頸。如果并發(fā)關(guān)注點(diǎn)在隊(duì)列的語義中被分離開來,那么對于除單個生產(chǎn)者-單個消費(fèi)者之外的任何場景,實(shí)現(xiàn)都變得非常復(fù)雜。

在Java中,隊(duì)列的使用還有一個問題,因?yàn)樗鼈儠a(chǎn)生較多的垃圾對象。因?yàn)?,必須在?duì)列中分配和放置對象。其次,如果支持鏈表,則必須分配表示鏈表節(jié)點(diǎn)的對象。當(dāng)不再被引用時,需要重新聲明為支持隊(duì)列實(shí)現(xiàn)而分配的所有的這些對象。

筆者注:這節(jié)大意是推薦使用有界隊(duì)列以避免內(nèi)存耗盡問題,隊(duì)列的head、tail、size等變量通常會占用相同的cacheline從而引發(fā)偽共享問題,這樣即便是嘗試使用讀寫鎖分離的策略,由于無法有效利用高速緩存機(jī)制,性能也很難達(dá)到最優(yōu)。隊(duì)列中鎖的粒度比較粗,帶來吞吐量上的性能影響。Java實(shí)現(xiàn)的隊(duì)列容易產(chǎn)生較多垃圾對象。

2.6 Pipelines and Graphs

對于許多類型的問題,都可以將幾個處理階段連接到一個管道pipeline中。有時候幾個這樣的管道又具有并行路徑,可以被組織成圖形拓?fù)錉畹膱?zhí)行流程。而每個階段之間的連接通常由隊(duì)列實(shí)現(xiàn),每個階段都有自己的處理線程。

上述所說的這種方案的開銷并不小——在每一個階段,我們都必須承擔(dān)工作單元入隊(duì)和出隊(duì)的成本。當(dāng)路徑必須分叉fork時,目標(biāo)的數(shù)量將此成本相乘,當(dāng)路徑必須在分叉之后重新連接時,將不可避免地產(chǎn)生爭用成本。

如果我們的流程模型可以即表示stage之間的依賴關(guān)系圖,而不需要在階段之間放置隊(duì)列,這將是非常理想的方案。

3. LMAX Disruptor的設(shè)計(jì)

在試圖解決上述問題的同時,通過嚴(yán)格分離在隊(duì)列中被合并的關(guān)注點(diǎn),出現(xiàn)了一種設(shè)計(jì)。這種方法結(jié)合了一個重點(diǎn),即確保任何數(shù)據(jù)只能由一個線程擁有以進(jìn)行寫訪問,從而消除寫爭用。這種設(shè)計(jì)被稱為“Disruptor”。它之所以這樣命名,是因?yàn)樗谔幚砼cJava7中的“Phasers”[4]概念的圖狀依賴關(guān)系時具有相似的點(diǎn),而Java7是為了支持Fork-Join而引入的。

LMAX Disruptor旨在解決上述所有問題,試圖最大限度地提高內(nèi)存分配效率,并以緩存友好的方式運(yùn)行,以便在現(xiàn)代硬件上以最佳方式運(yùn)行。Disruptor機(jī)制的核心是一個以環(huán)形緩沖區(qū)的形式預(yù)先分配的有界數(shù)據(jù)結(jié)構(gòu)。數(shù)據(jù)通過一個或多個生產(chǎn)者添加到環(huán)形緩沖區(qū),并由一個或多個消費(fèi)者進(jìn)行處理。

3.1 內(nèi)存分配

啟動時,將預(yù)先分配環(huán)形緩沖區(qū)的所有內(nèi)存。環(huán)形緩沖區(qū)可以存儲指向entry的指針數(shù)組,也可以存儲表示entry的結(jié)構(gòu)數(shù)組。Java語言的局限性意味著entry作為指向?qū)ο蟮闹羔樑c環(huán)形緩沖區(qū)相關(guān)聯(lián)。這些entry中的每一個通常不是傳遞的數(shù)據(jù)本身,而是它的容器。這種entry的預(yù)分配消除了支持垃圾回收的語言中的問題,因?yàn)閑ntry將被重用,并在整個Disruptor實(shí)例存活期間都有效。這些entry的內(nèi)存是同時分配的,很可能會在主內(nèi)存中連續(xù)布局,因此支持高速緩存預(yù)讀。John Rose提議在Java語言中引入“值類型”[5],這將允許元組數(shù)組,就像C等其他語言一樣,從而確保連續(xù)分配內(nèi)存并避免指針間接尋址。

在像Java這樣的托管運(yùn)行時環(huán)境中開發(fā)低延遲系統(tǒng)時,垃圾收集機(jī)制可能會帶來問題。分配的內(nèi)存越多,給垃圾收集器帶來的負(fù)擔(dān)就越大。當(dāng)對象的壽命很短或?qū)嶋H上是常駐的時候,垃圾收集器工作得最好。在環(huán)形緩沖區(qū)中預(yù)先分配entry意味著它對于垃圾收集器來說是常駐內(nèi)存的,因此帶來很少的負(fù)擔(dān)。

在重負(fù)載情況下,基于隊(duì)列的系統(tǒng)會發(fā)生堵塞,這會導(dǎo)致處理速度的降低,并導(dǎo)致分配的對象比原本正常情況下存活的時間更長,因此會被通過分代垃圾收集器提升到年輕一代之外。這有兩個含義:第一,對象必須在幾代之間復(fù)制,這會導(dǎo)致延遲抖動;第二,這些對象必須從老年代收集,這通常是一個更昂貴的操作,并且增加了“STW”暫停的可能性,當(dāng)碎片化的內(nèi)存空間需要壓縮時,就會出現(xiàn)這種暫停。在大內(nèi)存堆中,這可能會導(dǎo)致每GB的暫停時間長達(dá)數(shù)秒。

3.2 Teasing Apart the Concerns

我們看到,在所有隊(duì)列實(shí)現(xiàn)中,以下問題被合并在一起,以至于這些不同行為的集合傾向于定義隊(duì)列實(shí)現(xiàn)的接口:

  1. 隊(duì)列中用于交換的元素的存儲

  2. 協(xié)調(diào)生產(chǎn)者獲得下一個序列號用于入隊(duì)

  3. 協(xié)調(diào)通知消費(fèi)者有新元素可用

當(dāng)用使用Java這種帶垃圾收集機(jī)制的語言開發(fā)在線金融交易所系統(tǒng)時,過多的內(nèi)存分配可能是有問題的。因此,正如我們所描述的,用基于鏈表的隊(duì)列做數(shù)據(jù)結(jié)構(gòu)不是一個好方法。如果可以預(yù)先分配用于各個處理階段stage之間做數(shù)據(jù)交換的整個存儲,則可以最小化垃圾收集的開銷。此外,如果這個分配可以在一個統(tǒng)一的內(nèi)存塊中執(zhí)行,那么數(shù)據(jù)的遍歷將以一種對現(xiàn)代處理器所采用的高速緩存策略非常友好的方式來完成。滿足此要求的數(shù)據(jù)結(jié)構(gòu)是一個預(yù)先填充了所有slot的數(shù)組。在創(chuàng)建RingBuffer時,Disruptor使用抽象工廠模式來預(yù)分配entry。這樣生產(chǎn)者在數(shù)據(jù)入隊(duì)而索要entry時,可以將其數(shù)據(jù)復(fù)制到預(yù)先分配的結(jié)構(gòu)中。

在大多數(shù)處理器上,序列號的余數(shù)計(jì)算成本非常高,而序列號%ringBufferSize得到的余數(shù)=元素在環(huán)中的插槽的下標(biāo)。通過使環(huán)的大小為2的冪,可以大大降低成本??梢允褂么笮樨?fù)1的位掩碼來有效地執(zhí)行余數(shù)運(yùn)算。

正如我們前面所描述的,有界隊(duì)列在隊(duì)列的head和tail都會發(fā)生爭用。而環(huán)形緩沖區(qū)數(shù)據(jù)結(jié)構(gòu)不受這種爭用和并發(fā)原語的影響,因?yàn)檫@些問題已經(jīng)被梳理成生產(chǎn)者和消費(fèi)者的屏障barrier,必須通過這些barrier來訪問環(huán)形緩沖區(qū)。這些barrier的邏輯如下所述。

在Disruptor最常見的用法中,通常只有一個生產(chǎn)者。典型的生產(chǎn)者是file reader或網(wǎng)絡(luò)listener。在只有一個生產(chǎn)者的情況下,equence/entry的分配沒有爭用。在有多個生產(chǎn)者的更不尋常的用法中,生產(chǎn)者將互相競爭以獲得環(huán)形緩沖區(qū)中的下一個entry。索要下一個可用entry的爭用可以通過對該slot的sequence number執(zhí)行簡單的CAS操作來管理。

一旦生產(chǎn)者將相關(guān)數(shù)據(jù)復(fù)制到申請到的entry中,它就可以通過提交序列將其公開給消費(fèi)者。這可以在沒有CAS的情況下通過一個簡單的忙旋轉(zhuǎn)來完成,直到其他生產(chǎn)者在他們自己的提交中達(dá)到這個序列。然后,該生產(chǎn)者可以前進(jìn)光標(biāo),指示下一個可用的消費(fèi)條目。生產(chǎn)者可以通過跟蹤消費(fèi)者序列來避免覆蓋環(huán)(避免覆蓋了還未讀取的slot),這是在其寫入環(huán)緩沖區(qū)之前的要做的一個簡單的讀取操作。

消費(fèi)者在讀取entry之前,等待entry的序列在環(huán)形緩沖區(qū)中變?yōu)榭捎?。等待時可以采用各種策略。如果CPU資源非常寶貴,那么它們可以在等待一個鎖中的Condition.awaite(),等待由生產(chǎn)者對Condition.signal()。這顯然是一個爭用點(diǎn),僅在CPU資源比延遲或吞吐量更重要時使用。消費(fèi)者還可以循環(huán)的檢查環(huán)形緩沖區(qū)中代表當(dāng)前可用序列號的游標(biāo)。這可以在有或沒有線程yield的情況下完成,以CPU資源換取低延遲。如果不使用lock和condition變量,這將有很好的伸縮性,因?yàn)槲覀円呀?jīng)打破了生產(chǎn)者和消費(fèi)者之間的競爭依賴關(guān)系。無鎖多生產(chǎn)者-多消費(fèi)者隊(duì)列確實(shí)存在,但它們需要在head、tail、size等計(jì)數(shù)器上執(zhí)行多個CAS操作。Disruptor避免了這種CAS爭用。

這節(jié)介紹了大量的使用RingBuffer這個環(huán)形數(shù)組實(shí)現(xiàn)無鎖隊(duì)列的精巧算法和設(shè)計(jì),由于翻譯過程中的信息不對稱和表達(dá)不到位,還是要配合源碼才能理解很多細(xì)節(jié)。此外,這篇論文是按照當(dāng)時早期的Disruptor版本寫的,跟最新的Disruptor會有一些不同,比如RingBuffer的ReadBarrier和WriteBarrier我記得后來的版本中好像就去掉了,我們注意理解論文中的思想就好。 可以參考美團(tuán)技術(shù)團(tuán)隊(duì)的blog,https://tech.meituan.com/2016/11/18/disruptor.html

3.3 Sequencing

順序序列是Disruptor中如何管理并發(fā)的核心概念。每個生產(chǎn)者和消費(fèi)者都有一個嚴(yán)格的序列概念,用于如何與RingBuffer交互。生產(chǎn)者在環(huán)中申請entry時,按順序申請下一個slot。在只有一個生產(chǎn)者的情況下、下一個可用slot的序列下標(biāo)可以是一個簡單計(jì)數(shù)器,或者在多個生產(chǎn)者的情況下,可以是使用CAS操作更新的原子計(jì)數(shù)器。一旦序列值被申請了,環(huán)形緩沖區(qū)中的這個entry現(xiàn)在就可以被申請它的生產(chǎn)者寫入。當(dāng)生產(chǎn)者完成更新entry時,它可以通過更新一個單獨(dú)的計(jì)數(shù)器來提交更改,該計(jì)數(shù)器表示環(huán)形緩沖區(qū)上的游標(biāo),以獲取消費(fèi)者可用的最新entry。環(huán)形緩沖區(qū)游標(biāo)可以由生產(chǎn)者使用內(nèi)存屏障在繁忙的旋轉(zhuǎn)中讀寫,而不需要CAS操作。

long expectedSequence = claimedSequence – 1;
while (cursor != expectedSequence)
{
  // busy spin
}

cursor = claimedSequence;

消費(fèi)者通過使用內(nèi)存屏障來讀取游標(biāo),等待給定的序列下標(biāo)對應(yīng)的entry變?yōu)榭捎?。一旦游?biāo)被更新,內(nèi)存屏障確保等待游標(biāo)前進(jìn)的消費(fèi)者可以立刻看到環(huán)形緩沖區(qū)中entry的變化。

每個消費(fèi)者都包含自己的序列sequence,在處理來自環(huán)形緩沖區(qū)的entry時更新這些序列。這些消費(fèi)者序列允許生產(chǎn)者跟蹤消費(fèi)者,以防止消費(fèi)者在環(huán)形緩沖上被生產(chǎn)者套圈、這樣生產(chǎn)者寫入就覆蓋了還未被消費(fèi)者讀取的數(shù)據(jù)了。消費(fèi)者序列還允許消費(fèi)者以有序的方式協(xié)調(diào)同一entry上的工作

在只有一個生產(chǎn)者的情況下,不管消費(fèi)者的復(fù)雜性如何,都不需要鎖或CAS操作。在所討論的序列上,只需設(shè)置內(nèi)存障礙,就可以實(shí)現(xiàn)整個并發(fā)協(xié)調(diào)。

3.4 Batching Effect

當(dāng)消費(fèi)者在環(huán)形緩沖區(qū)中等待前進(jìn)的游標(biāo)序列時,一個在隊(duì)列中不可能出現(xiàn)有趣的現(xiàn)象出現(xiàn)了。如果消費(fèi)者發(fā)現(xiàn)環(huán)形緩沖區(qū)游標(biāo)自上次檢查以來已前進(jìn)了許多步,則它可以處理該序列,而無需涉及并發(fā)機(jī)制。這導(dǎo)致落后的消費(fèi)者很快就追趕上了生產(chǎn)者的步伐,從而平衡系統(tǒng)。這種類型的批處理可以提高吞吐量,同時減少和平滑延遲。根據(jù)我們的觀察,這種效應(yīng)可使得延遲時間在任何負(fù)載下接近恒定,直到內(nèi)存子系統(tǒng)飽和,延遲曲線是線性的,遵循利特爾定律[6]。這與我們以往觀察到的隊(duì)列在負(fù)載增加時的延遲的“J”形曲線效果非常不同。

環(huán)形數(shù)組中生產(chǎn)者指針如果已經(jīng)領(lǐng)先很多,消費(fèi)者是可以不用任何鎖等并發(fā)機(jī)制直接把自己指針與生產(chǎn)者指針之間的數(shù)據(jù)都拿出來的,與之相對的是隊(duì)列則是要一個一個的從head里取出數(shù)據(jù)、且這中間還要用到鎖、產(chǎn)生了延遲,很容易想到當(dāng)負(fù)載增加、隊(duì)列中出現(xiàn)了堆積,這種延遲會成倍的增加。從由數(shù)據(jù)結(jié)構(gòu)本身產(chǎn)生的延遲的角度來看,無疑是RingBuffer要比一般的隊(duì)列要少的多。

3.5. Dependency Graphs

隊(duì)列表示生產(chǎn)者和消費(fèi)者之間簡單的一步管道依賴關(guān)系。如果消費(fèi)者形成了一個鏈?zhǔn)交蝾愃朴趫D的依賴結(jié)構(gòu),那么在圖的每個階段之間都需要隊(duì)列。在相依階段圖中,這會導(dǎo)致多次隊(duì)列的固定成本。在設(shè)計(jì)lmax在線金融交易所系統(tǒng)時,我們的觀測與分析表明,如采用基于隊(duì)列的方案,在最終總的整體的事務(wù)處理執(zhí)行的成本中,隊(duì)列成本占比最高。

因?yàn)樯a(chǎn)者和消費(fèi)者的關(guān)注點(diǎn)(concerns)是用Disruptor模型分開的,所以可以表示消費(fèi)者之間的復(fù)雜依賴關(guān)系圖,而只需在核心部分使用一個環(huán)形緩沖區(qū)。這將大大降低執(zhí)行的固定成本,從而在減少延遲的同時提高吞吐量。

可以使用單個環(huán)形緩沖區(qū)來存儲具有復(fù)雜結(jié)構(gòu)的entry,在一個內(nèi)聚的地方的表示整個工作流。在設(shè)計(jì)這種結(jié)構(gòu)時必須小心,要確保彼此獨(dú)立的幾個消費(fèi)者寫狀態(tài)不會導(dǎo)致cacheline的偽共享。

3.6. Disruptor的類結(jié)構(gòu)圖

classdiagram.png

Disruptor框架中的核心類如圖。此圖省略了可用于簡化編程模型的工具類。在建立了依賴圖之后,編程模型變得簡單。生產(chǎn)者通過ProducerBarrier按順序申請entry,將其更改寫入申請到的entry,然后通過ProducerBarrier提交該entry,使其可供消費(fèi)。作為消費(fèi)者,我們所需要做的就是提供一個BatchHandler實(shí)現(xiàn),當(dāng)一個新entry可用時接收回調(diào)。整個編程模型是基于事件驅(qū)動的,與Actor模型有很多相似之處。

分離通常在隊(duì)列實(shí)現(xiàn)中合并的關(guān)注點(diǎn)可以實(shí)現(xiàn)更靈活的設(shè)計(jì)。Disruptor框架的核心是一個RingBuffer,它為數(shù)據(jù)交換提供存儲,而不存在爭用。對于與RingBuffer交互的生產(chǎn)者和消費(fèi)者,并發(fā)關(guān)注點(diǎn)被分離出來。ProducerBarrier管理與在環(huán)緩沖區(qū)中索要slot相關(guān)的任何并發(fā)問題,同時跟蹤相關(guān)的消費(fèi)者以防止覆蓋未讀的slot。當(dāng)新entry可用時,ConsumerBarrier通知消費(fèi)者,消費(fèi)者可以被構(gòu)造成表示處理管道中多個階段的依賴關(guān)系圖狀流程。

3.7. 代碼例子

下面的代碼是單個生產(chǎn)者、以及單個的使用convenience接口BatchHandler實(shí)現(xiàn)的消費(fèi)者的示例。消費(fèi)者在一個單獨(dú)的線程上運(yùn)行,接收可用的entry。

// Callback handler which can be implemented by consumers
final BatchHandler<ValueEntry> batchHandler = new BatchHandler<ValueEntry>()
{
public void onAvailable(final ValueEntry entry) throws Exception
{
// process a new entry as it becomes available.
}

    public void onEndOfBatch() throws Exception
    {
        // useful for flushing results to an IO device if necessary.
    }

    public void onCompletion()
    {
        // do any necessary clean up before shutdown
    }
};

RingBuffer<ValueEntry> ringBuffer =
    new RingBuffer<ValueEntry>(ValueEntry.ENTRY_FACTORY, SIZE,
                               ClaimStrategy.Option.SINGLE_THREADED,
                               WaitStrategy.Option.YIELDING);
ConsumerBarrier<ValueEntry> consumerBarrier = ringBuffer.createConsumerBarrier();
BatchConsumer<ValueEntry> batchConsumer =
    new BatchConsumer<ValueEntry>(consumerBarrier, batchHandler);
ProducerBarrier<ValueEntry> producerBarrier = ringBuffer.createProducerBarrier(batchConsumer);

// Each consumer can run on a separate thread
EXECUTOR.submit(batchConsumer);

// Producers claim entries in sequence
ValueEntry entry = producerBarrier.nextEntry();

// copy data into the entry container

// make the entry available to consumers
producerBarrier.commit(entry);

4. Throughput Performance Testing

5. Latency Performance Testing

6. 總結(jié)

在提高吞吐量、減少并發(fā)執(zhí)行上下文之間的延遲和確??深A(yù)測的延遲方面,Disruptor是向前邁出的重要一步,這在許多應(yīng)用程序中是一個重要的考慮因素。我們的測試表明,它在線程間交換數(shù)據(jù)方面的性能超過了類似的方案。我們相信這是這種數(shù)據(jù)交換的最高性能機(jī)制。通過將跨線程數(shù)據(jù)交換中涉及的關(guān)注點(diǎn)完全分離,消除寫爭用,最小化讀爭用,并確保代碼與現(xiàn)代處理器使用的Cache特性良好配合,我們創(chuàng)建了一種高效的機(jī)制,可用于在任何應(yīng)用中的線程之間交換數(shù)據(jù)。

批處理效應(yīng)允許消費(fèi)者在沒有任何爭用的情況下處理高達(dá)給定閾值的entry,這在高性能系統(tǒng)中引入了一個新特性。對于大多數(shù)系統(tǒng),隨著負(fù)載和爭用的增加,延遲呈指數(shù)增長,即特征“J”曲線。而當(dāng)Disruptor上的負(fù)載增加時,在內(nèi)存子系統(tǒng)達(dá)到飽和之前,延遲幾乎保持不變。

我們相信Disruptor為高性能計(jì)算建立了一個新的基準(zhǔn),并且它的設(shè)計(jì)非常能夠繼續(xù)利用當(dāng)前趨勢下處理器和計(jì)算機(jī)設(shè)計(jì)的優(yōu)勢。

后記

查看百度空間殘留的個人日記得知,筆者早在2012年2月1日就動了研究Disruptor原理的念頭,并打算在后端開發(fā)中使用它,前后花了1個月左右時間嘗試翻譯和理解它的官方論文和指南、以及開發(fā)者的各篇blog,到當(dāng)年3月20日認(rèn)識到自己知識儲備不足功力不夠,遂暫時放棄。2016年差不多到了能夠基本的簡單進(jìn)行使用的程度。2020年疫情期間,著手把Disruptor應(yīng)用在了筆者負(fù)責(zé)的一個實(shí)時競價系統(tǒng)上去,用來取代隊(duì)列。只可惜公司拉跨,沒有機(jī)會把這個成果推到線上并在高負(fù)載場景下進(jìn)行驗(yàn)證。2021年的今天,念念不忘,再次翻譯理解Disruptor的官方論文,經(jīng)過了將近十年的理論認(rèn)知與實(shí)踐經(jīng)驗(yàn)的逐漸提高,理解起來變得容易了許多。筆者愚鈍,且又經(jīng)歷種種生活不易,光陰十載、仍未成神。但古人云,朝聞道、夕死可矣。每個人都有著他自己的朝圣路,不是么。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容