計算機組成原理現實應用筆記

一、大型DMP系統設計思路

1. DMP系統的全稱叫作數據管理平臺(Data Management Platform),目前廣泛應用在互聯網的廣告定向(Ad Targeting)、個性化推薦(Recommendation)等領域。通常來說DMP系統會通過處理海量的互聯網訪問數據以及機器學習算法,給一個用戶標註上各種各樣的標籤。然後在做個性化推薦和廣告投放的時候,再利用這些這些標籤去做實際的廣告排序、推薦等工作。無論是Google的搜索廣告、淘寶裏千人千面的商品信息,還是抖音裏面的信息流推薦,背後都會有一個DMP系統。如下所示:

在一個DMP系統的搭建中,對於外部使用DMP的系統或者用戶來說,可以簡單地把DMP看成是一個鍵-值對(Key-Value)數據庫。廣告系統或者推薦系統可以通過一個客戶端輸入用戶的唯一標識(ID),然後拿到這個用戶的各種信息。這些信息中有些是用戶的人口屬性信息(Demographic),比如性別、年齡;有些是具體的行爲(Behavior),比如用戶最近看過的商品是什麼,用戶的手機型號是什麼;有一些是通過算法系統計算出來的興趣(Interests),比如用戶喜歡健身、聽音樂;還有一些則是完全通過機器學習算法得出的用戶向量,給後面的推薦算法或者廣告算法作爲數據輸入。

基於上面的特性,對於這個KV數據庫,性能期望也很清楚,那就是:低響應時間(Low Response Time)、高可用性(High Availability)、高併發(High Concurrency)、海量數據(Big Data),同時需要付得起對應的成本(Affordable Cost)。如果用數字來衡量這些指標,那麼這些期望就會具體化成下面這些要求:

(1)低響應時間:一般的廣告系統留給整個廣告投放決策的時間也就是10ms左右,所以對於訪問DMP獲取用戶數據,預期的響應時間都在1ms之內。

(2)高可用性:DMP常常用在廣告系統裏面,DMP系統出問題往往就意味着整個的廣告收入在不可用的時間就沒了,所以對於可用性的追求可謂是沒有上限的。

(3)高併發:以廣告系統爲例,如果每天需要響應100億次的廣告請求,那麼每秒的併發請求數就在100億/ (86400) ~= 12K次左右,所以DMP需要支持高併發。

(4)數據量:如果產品針對中國市場,那麼需要有10億個Key,對應的假設每個用戶有500個標籤,標籤有對應的分數。標籤和分數都用一個4字節(Bytes)的整數來表示,那麼一共需要10億×500×(4 + 4) Bytes = 400 TB的數據了。

(5)低成本:廣告系統的收入通常用CPM(Cost Per Mille),也就是千次曝光來統計。如果千次曝光的利潤是0.10,那麼每天100億次的曝光就是100萬美元的利潤。這個利潤聽起來非常高了,但是反過來算一下會發現,DMP每1000次請求的成本不能超過0.10。最好只有$0.01甚至更低,才能儘可能多賺到一點廣告利潤。

雖然從外部看起來,DMP 特別簡單,就是一個 KV 數據庫,但是生成這個數據庫需要做的事情更多。爲了能夠生成這個KV數據庫,需要有一個在客戶端或者Web端的數據採集模塊,不斷採集用戶的行爲,向後端的服務器發送數據。服務器端接收到數據,就要把這份數據放到一個數據管道(Data Pipeline)裏面。數據管道的下游,需要實際將數據落地到數據倉庫(Data Warehouse),把所有的這些數據結構化地存儲起來。後續就可以通過程序去分析這部分日誌,生成報表或者或者利用數據運行各種機器學習算法。除了數據倉庫之外,還會有一個實時數據處理模塊(Realtime Data Processing),也放在數據管道的下游。它同樣會讀取數據管道里面的數據,去進行各種實時計算,然後把需要的結果寫入到DMP的KV數據庫裏面去。如下所示:

2. 對於KV數據庫、數據管道以及數據倉庫這三個不同的數據存儲的需求,技術方案並非可以全用MongoDB這一種數據庫。它的優點雖然很多,例如不需要預先數據Schema,訪問速度很快,能夠無限水平擴展等,似乎作爲KV數據庫可以把MongoDB當作DMP裏面的KV數據庫;除此之外MongoDB 還能水平擴展跑MQL,可以把它當作數據倉庫至於數據管道,只要能夠不斷往MongoDB裏面插入新的數據就好了。從運維的角度來說,只需要維護一種數據庫,技術棧也變得簡單了。

看起來,MongoDB這個選擇非常完美。但是,所有的軟件系統都有它的適用場景,想通過一種解決方案適用三個差異非常大的應用場景,顯然既不合理又不現實。對於數據管道來說需要的是高吞吐量,它的併發量雖然和KV數據庫差不多,但是在響應時間上要求就沒有那麼嚴格,1-2秒甚至再多幾秒的延時都是可以接受的。而且和KV數據庫不太一樣,數據管道的數據讀寫都是順序讀寫,沒有大量的隨機讀寫的需求

數據倉庫就更不一樣了,數據倉庫的數據讀取的量要比管道大得多。管道的數據讀取就是當時寫入的數據,例如一天有10TB日誌數據,管道只會寫入10TB。但是數據倉庫的數據分析任務要讀取的數據量就大多了。一方面可能要分析一週、一個月乃至一個季度的數據,這樣一次分析要讀取的數據可不只10TB,而是100TB乃至1PB。平臺一天在數據倉庫上跑的分析任務也不是1個,而是成千上萬個,所以數據的讀取量是巨大的。另一方面,存儲在數據倉庫裏面的數據,也不像數據管道一樣存放幾個小時、最多一天的數據,而是往往要存上3個月甚至是1年的數據。所以,數據倉庫需要的是1PB乃至5PB這樣的存儲空間

KV 數據庫、數據管道和數據倉庫的應用場景比較如下所示,可以看到這三種場景都用MongoDB是不行的:

在KV數據庫的場景下,需要支持高併發,那麼MongoDB需要把更多的數據放在內存裏面,但是這樣存儲成本就會特別高了。在數據管道的場景下,需要的是大量的順序讀寫,而MongoDB則是一個文檔數據庫系統,並沒有爲順序寫入和吞吐量做過優化,看起來也不太適用。而在數據倉庫的場景下,主要的數據讀取是順序讀取,並且需要海量的存儲,MongoDB這樣的文檔式數據庫也沒有爲海量的順序讀做過優化,仍然不是一個最佳的解決方案,而且文檔數據庫裏總是會有很多冗餘的字段的元數據,還會浪費更多的存儲空間。

因此,對於KV數據庫,最佳的選擇方案自然是使用SSD硬盤,選擇AeroSpike或Cassandra這樣的KV數據庫。高併發的隨機訪問並不適合HDD的機械硬盤,而400TB的數據如果用內存的話,成本又會顯得太高。

對於數據管道,最佳選擇自然是Kafka。因爲追求的是吞吐率,採用了Zero-Copy和DMA機制的Kafka最大化了作爲數據管道的吞吐率。而且數據管道的讀寫都是順序讀寫,所以也不需要對隨機讀寫提供支持,用上HDD硬盤就好了

到了數據倉庫,存放的數據量更大了,在硬件層面使用HDD硬盤成了一個必選項,否則存儲成本就會差上10倍。這麼大量的數據在存儲上需要定義清楚Schema,使得每個字段都不需要額外存儲元數據,能夠通過Avro/Thrift/ProtoBuffer這樣的二進制序列化的方式存儲下來,或者直接使用Hive這樣明確了字段定義的數據倉庫產品。很明顯MongoDB那樣不限制Schema的數據結構,在這個情況下並不好用。

3. 對於DMP系統中的數據庫,如果現在自己寫一個最簡單的關係型數據庫,最簡單最直觀的想法是用CSV文件格式,一個文件就是一個數據表,文件裏面的每一行就是這個表裏面的一條記錄。如果要修改數據庫裏面的某一條記錄,那麼要先找到這一行,然後直接去修改這一行的數據,讀取數據也是一樣,要找到這樣數據最笨的辦法自然是一行行讀,也就是遍歷整個CSV文件。

不過這樣的話,相當於隨便讀取任何一條數據都要掃描全表,太浪費硬盤的吞吐量了,可以試試給這個CSV文件加一個索引,比如給數據的行號加一個索引,在數據庫原理或者算法和數據結構中,通過B+樹多半是可以來建立這樣一個索引的。索引裏面沒有一整行的數據,只有一個映射關係,這個映射關係可以讓行號直接從硬盤的某個位置去讀。所以索引比起數據小很多,可以把索引加載到內存裏面,即使不在內存裏面,要找數據的時候快速遍歷一下整個索引,也不需要讀太多的數據

加了索引之後要讀取特定的數據,就不用去掃描整個數據表文件了,直接從特定的硬盤位置就可以讀到想要的行。索引不僅可以索引行號,還可以索引某個字段,可以創建很多個不同的獨立的索引,寫SQL的時候where子句後面的查詢條件可以用到這些索引。不過這樣的話,寫入數據的時候就會麻煩一些,不僅要在數據表裏面寫入數據,對於所有的索引也都需要進行更新。這個時候,寫入一條數據就要觸發好幾個隨機寫入的更新。如下所示:

在這樣一個數據模型下,查詢操作很靈活。無論是根據哪個字段查詢,只要有索引就可以通過一次隨機讀,很快地讀到對應的數據。但是這個靈活性也帶來了一個很大的問題,那就是無論乾點什麼,都有大量的隨機讀寫請求。而隨機讀寫請求,如果請求最終是要落到HDD硬盤的話,就很難做到高併發了,畢竟HDD硬盤只有100左右的QPS。而這個隨時添加索引,可以根據任意字段進行查詢,這樣的靈活性又是DMP系統裏面不太需要的。DMP的KV數據庫主要的應用場景,是根據主鍵的隨機查詢,不需要根據其他字段進行篩選查詢。

數據管道的需求是隻要不斷追加寫入和順序讀取就好了。即使進行數據分析的數據倉庫,通常也不是根據字段進行數據篩選,而是全量掃描數據進行分析彙總。後面的兩個場景大不讓程序去掃描全表或者追加寫入,但是在KV數據庫上,上面這個最簡單的關係型數據庫的設計,就會面臨大量的隨機寫入和隨機讀取的挑戰。所以在實際的大型系統中,大家都會使用專門的分佈式KV數據庫來滿足這個需求。

4. 作爲一個分佈式的KV數據庫,Cassandra的鍵一般被稱爲Row Key。其實就是一個16到36個字節的字符串。每一個Row Key對應的值其實是一個哈希表,裏面可以用鍵值對再存入很多需要的數據。Cassandra本身不像關係型數據庫那樣有嚴格的Schema,在數據庫創建的一開始就定義好了有哪些列(Column)。但是它設計了一個叫作列族(Column Family,HBase中也有)的概念,把需要經常放在一起使用的字段放在同一個列族裏面。比如DMP裏面的人口屬性信息,可以把它當成是一個列族;用戶的興趣信息可以是另外一個列族。

這樣既保持了不需要嚴格的Schema這樣的靈活性,也保留了可以把常常一起使用的數據存放在一起的空間局部性。往Cassandra的裏面讀寫數據其實特別簡單,就好像是在一個巨大的分佈式的哈希表裏面寫數據。指定一個Row Key,然後插入或者更新這個Row Key的數據就好了。

Cassandra解決隨機寫入數據的解決方案,簡單來說叫作“不隨機寫,只順序寫”。對Cassandra 數據庫的寫操作通常包含兩個動作:

(1)往磁盤上寫入一條提交日誌(Commit Log)。

(2)直接在內存的數據結構上去更新數據。後面這個在內存的數據結構裏面的數據更新,只有在提交日誌寫成功之後纔會進行。寫入提交日誌都是順序寫(Sequential Write),而不是隨機寫(Random Write),這樣最大化了寫入的吞吐量。如下所示:

內存的空間比較有限,一旦內存裏面的數據量或者條目超過一定的限額,Cassandra就會把內存裏面的數據結構dump到硬盤上這個Dump的操作也是順序寫而不是隨機寫。除了Dump的數據結構文件,Cassandra還會根據row key來生成一個索引文件,方便後續基於索引來進行快速查詢。隨着硬盤上的Dump出來的文件越來越多,Cassandra會在後臺進行文件的對比合並,在很多別的KV數據庫系統裏面也有類似這種的合併動作,比如AeroSpike或者Google的BigTable,這些操作一般稱之爲Compaction。合併動作同樣是順序讀取多個文件,在內存裏面合併完成,再Dump出一個新的文件,整個操作過程中在硬盤層面仍然是順序讀寫

當要從Cassandra讀數據的時候,會從內存裏面找數據再從硬盤讀數據,然後把兩部分的數據合併成最終結果這些硬盤上的文件,在內存裏面會有對應的Cache,只有在Cache裏面找不到,纔會去請求硬盤裏面的數據。如果不得不訪問硬盤,因爲硬盤裏面可能Dump了很多個不同時間點的內存數據的快照,所以找數據的時候也是按照時間從新的往舊的裏面找

這就帶來另外一個問題,可能要查詢很多個Dump文件,才能找到想要的數據。所以Cassandra在這一點上又做了一個優化,那就是它會爲每一個Dump的文件裏面所有Row Key生成一個BloomFilter,然後把這個BloomFilter放在內存裏面。這樣如果想要查詢的Row Key在數據文件裏面不存在,那麼99%以上的情況下它會被內存裏的BloomFilter過濾掉,而不需要訪問硬盤。這樣只有當數據在內存裏面沒有,並且在硬盤的某個特定文件上的時候,纔會觸發一次對於硬盤的讀請求。如下所示:

5. Cassandra的讀寫設計充分考慮了硬件本身的特性。在寫入數據進行持久化上,Cassandra沒有任何的隨機寫請求,無論是Commit Log還是Dump全部都是順序寫。在數據讀的請求上,最新寫入的數據都會更新到內存,如果要讀取這些數據會優先從內存讀到,這相當於是使用了LRU的緩存機制。只有在萬般無奈的情況下,纔會有對於硬盤的隨機讀請求。即使在這樣的情況下,Cassandra也在文件之前加了一層BloomFilter,把本來因爲Dump文件帶來的需要多次讀硬盤的問題,簡化成多次內存讀和一次硬盤讀

這些設計使得Cassandra即使是在HDD硬盤上,也能有不錯的訪問性能。因爲所有的寫入都是順序寫或者寫入到內存,所以寫入可以做到高併發。HDD硬盤的吞吐率還是不錯的,每秒可以寫入100MB以上的數據,如果一條數據只有1KB,那麼10萬的WPS(Writes per seconds)也是能夠做到的。這足夠支撐DMP期望的寫入壓力了。而對於數據的讀就有一些挑戰了。如果數據讀請求有很強的局部性,那內存就能搞定DMP需要的訪問量,但是問題就出在局部性上,DMP的數據訪問分佈其實是缺少局部性的。DMP裏面的Row Key都是用戶的唯一標識符,普通用戶的上網時刻和時長並沒有局部性,不能把某些用戶的數據放在內存裏面,其他用戶的不放。

因爲缺少了時間局部性,內存的緩存能夠起到的作用就很小了,大部分請求最終還是要落到HDD硬盤的隨機讀上。但是HDD硬盤的隨機讀性能太差了,也就是100QPS左右,而如果全都放內存那就太貴了,成本在HDD硬盤100倍以上。而SSD的出現緩解了這個問題,它的價格在HDD硬盤的10倍,但是隨機讀的訪問能力在HDD硬盤的百倍以上。同樣的價格的SSD硬盤容量則是內存的幾十倍,能夠用較低的成本存下互聯網用戶信息。不誇張地說,過去十年的大數據、高併發、千人千面,有一半的功勞應該歸在讓SSD容量不斷上升、價格不斷下降的硬盤產業上

而Cassandra的寫入機制完美匹配了SSD硬盤的優缺點。在數據寫入層面,Cassandra的數據寫入都是Commit Log的順序寫入,也就是不斷地在硬盤上往後追加內容,而不是去修改現有的文件內容。一旦內存裏面的數據超過一定的閾值,Cassandra又會完整地Dump一個新文件到文件系統上,這同樣是一個追加寫入。數據的對比和緊湊化(Compaction),同樣是讀取現有的多個文件然後寫一個新的文件出來。寫入操作只追加不修改的特性,正好天然地符合SSD硬盤只能按塊進行擦除寫入的操作。在這樣的寫入模式下,Cassandra用到的SSD硬盤,不需要頻繁地進行後臺的Compaction,能夠最大化SSD硬盤的使用壽命。這也是爲什麼Cassandra在SSD硬盤普及之後,能夠獲得進一步快速發展。

二、Disruptor高性能思想

6. 最在意極限性能的並不是互聯網公司,而是高頻交易公司。Disruptor就是由一家專門做高頻交易的公司LMAX開源出來的。有意思的是Disruptor的開發語言並不是很多人心目中最容易做到性能極限的C/C++,而是性能受限於JVM的Java。其實只要通曉硬件層面的原理,即使是像Java這樣的高級語言,也能夠把CPU的性能發揮到極限。例如下面的代碼,Disruptor在RingBufferPad這個類裏面定義了p1,p2一直到p7這樣7個long類型的變量:

abstract class RingBufferPad
{
    protected long p1, p2, p3, p4, p5, p6, p7;
}

在看到這段代碼的第一反應是,變量名取得不規範,p1-p7這樣的變量名沒有明確的意義。但其實這些變量名取得恰如其分,因爲這些變量就是沒有實際意義,只是幫助進行緩存行填充(Padding Cache Line),使得能夠儘可能地用上CPU高速緩存(CPU Cache)。內存的訪問速度其實是遠遠慢於CPU的,想要追求極限性能,需要儘可能地多從CPU Cache裏面拿數據,而不是從內存裏面拿數據

CPU Cache裝載內存裏面的數不是一個個字段加載的,而是加載一整個緩存行。例如如果定義了一個長度爲64的long類型的數組,那麼數據從內存加載到CPU Cache裏面的時候,不是一個個數組元素加載的,而是一次性加載固定長度的一個緩存行。現在64位Intel CPU的計算機緩存行通常是64個字節(Bytes)。一個long類型的數據需要8個字節,所以一下子會加載8個long類型的數據也就是說一次加載數組裏面連續的8個數值,這樣的加載方式使得遍歷數組元素的時候會很快,因爲後面連續7次的數據訪問都會命中緩存,不需要重新從內存裏面去讀取數據。

但是,在不使用數組而是使用單獨的變量的時候,這裏就會出現問題了。在Disruptor的RingBuffer(環形緩衝區)的代碼裏面,定義了一個RingBufferFields類,裏面有indexMask和其他幾個變量,用來存放RingBuffer的內部狀態信息。如下所示:

......

abstract class RingBufferPad
{
    protected long p1, p2, p3, p4, p5, p6, p7;
}
  

abstract class RingBufferFields<E> extends RingBufferPad
{
    ......    
    private final long indexMask;
  private final Object[] entries;
  protected final int bufferSize;
  protected final Sequencer sequencer;
    ......    
}

public final class RingBuffer<E> extends RingBufferFields<E> implements Cursored, EventSequencer<E>, EventSink<E>
{
    ......    
    protected long p1, p2, p3, p4, p5, p6, p7;
    ......
}

CPU在加載數據的時候,自然也會把這個數據從內存加載到高速緩存裏面來。不過這個時候,高速緩存裏面除了這個數據,還會加載這個數據前後定義的其他變量,這個時候問題就來了,Disruptor是一個多線程的服務器框架,在這個數據前後定義的其他變量可能會被多個不同的線程去更新數據、讀取數據,這些寫入以及讀取的請求會來自於不同的CPU Core。於是爲了保證數據的同步更新,不得不把CPU Cache裏面的數據重新寫回到內存裏面去,或者重新從內存裏面加載數據。如下所示:

這些CPU Cache的寫回和加載都不是以一個變量作爲單位的,這些動作都是以整個Cache Line作爲單位的。所以,當INITIAL_CURSOR_VALUE前後的那些變量被寫回到內存的時候,這個字段自己也寫回到了內存,這個常量的緩存也就失效了。當要再次讀取這個值的時候,要再重新從內存讀取,這也就意味着讀取速度大大變慢了

面臨這樣一個情況,Disruptor裏發明了一個神奇的代碼技巧,就是緩存行填充。Disruptor在RingBufferFields裏面定義的變量的前後,分別定義了7個long類型的變量。前面的7個來自繼承的RingBufferPad類,後面的7個則是直接定義在RingBuffer類裏面。14個變量沒有任何實際的用途,既不會去讀他們,也不會去寫他們。而RingBufferFields裏面定義的這些變量都是final的,第一次寫入之後不會再進行修改。所以一旦它被加載到CPU Cache之後,只要被頻繁地讀取訪問,就不會再被換出Cache。這也就意味着,對於這個值的讀取速度,會一直是CPU Cache的訪問速度,而不是內存的訪問速度。如下所示:

本來對於類裏面定義的單獨變量,不容易享受到CPU Cache的速度,因爲這些字段雖然在內存層面會分配到一起,但是實際應用的時候往往沒有什麼關聯。於是就會出現多個CPU Core訪問的情況下,數據頻繁在CPU Cache和內存裏面來來回回的情況。而Disruptor很取巧地在需要頻繁高速訪問的變量,也就是RingBufferFields裏的indexMask這些字段前後,各定義了7個沒有任何作用和讀寫請求的long類型的變量。這樣無論在內存的什麼位置上,這些變量所在的Cache Line都不會有任何寫更新的請求,就可以始終在Cache Line裏面讀到indexMark等字段的值,而不需要從內存裏面去讀取數據,也就大大加速了Disruptor的性能。

7.  有點類似於Kafka,Disruptor整個框架其實就是一個高速的生產者-消費者模型(Producer-Consumer)下的隊列。生產者不停地往隊列裏面生產新的需要處理的任務,而消費者不停地從隊列裏面處理掉這些任務。如下所示:

如果要實現一個隊列,最合適的數據結構應該是鏈表。只要維護好鏈表的頭和尾,就能很容易實現一個隊列。生產者只要不斷地往鏈表的尾部不斷插入新的節點,而消費者只需要不斷從頭部取出最老的節點進行處理就好了。實際上,Java自己的基礎庫裏面就有LinkedBlockingQueue這樣的隊列庫,可以直接用在生產者-消費者模式上。如下所示:

不過,Disruptor裏面並沒有用LinkedBlockingQueue,而是使用了一個RingBuffer這樣的數據結構,這個RingBuffer的底層實現則是一個固定長度的數組。比起鏈表形式的實現,數組的數據在內存裏面會存在空間局部性,數組的連續多個元素會一併加載到CPU Cache裏面來,所以訪問遍歷的速度會更快。而鏈表裏面各個節點的數據,多半不會出現在相鄰的內存空間,自然也就享受不到整個Cache Line加載後數據連續從高速緩存裏面被訪問到的優勢。除此之外,數據的遍歷訪問還有一個很大的優勢,就是CPU層面的分支預測會很準確。這可以更有效利用CPU裏的多級流水線,程序就會跑得更快。

8. 利用CPU高速緩存只是Disruptor快的一個因素,另一個因素就是“無鎖”,即儘可能發揮CPU本身的高速處理性能。Disruptor作爲一個高性能的生產者-消費者隊列系統,一個核心的設計就是通過RingBuffer實現一個無鎖隊列。Java裏面的基礎庫裏有像LinkedBlockingQueue這樣的隊列庫,但是這個隊列庫比起Disruptor裏用的RingBuffer要慢上很多。慢的第一個原因是因爲鏈表的數據在內存裏面的佈局對於高速緩存並不友好,而RingBuffer所使用的數組則不然。如下所示:

LinkedBlockingQueue慢,有另外一個重要的因素,那就是它對於鎖的依賴。在生產者-消費者模式裏,可能有多個消費者與多個生產者。多個生產者都要往隊列的尾指針裏面添加新的任務,就會產生多個線程的競爭,於是生產者就需要拿到對於隊列尾部的鎖。同樣在多個消費者去消費隊列頭的時候,也會產生競爭,同樣消費者也要拿到鎖。只有一個生產者或者一個消費者,也依然還是有鎖競爭的問題。

一般來說,在生產者-消費者模式下,消費者要比生產者快,不然的話隊列會產生積壓,隊列裏面的任務會越堆越多。一方面越來越多的任務沒有能夠及時完成;另一方面內存也會放不下。雖然生產者-消費者模型下都有一個隊列來作爲緩衝區,但是大部分情況下這個緩衝區裏面是空的。也就是說即使只有一個生產者和一個消費者,這個生產者指向的隊列尾和消費者指向的隊列頭是同一個節點,於是這兩個生產者和消費者之間一樣會產生鎖競爭。

在LinkedBlockingQueue上,這個鎖機制是通過ReentrantLock這個Java基礎庫來實現的。這個鎖是一個用Java在JVM上直接實現的加鎖機制,這個鎖機制需要由JVM來進行裁決。這個鎖的爭奪,會把沒有拿到鎖的線程掛起等待,也就需要經過一次上下文切換(Context Switch),這裏上下文切換要做的和CPU異常和中斷裏的是一樣的。上下文切換的過程,需要把當前執行線程的寄存器等信息保存到線程棧裏面,而這個過程也意味着,已經加載到高速緩存裏的指令或數據又回到了主內存裏面,會進一步拖慢性能。例如下面的代碼,把一個long類型的counter從0自增到5億,一種方式是沒有任何鎖,另外一個方式是每次自增的時候都要去取一個鎖。如下所示:

import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;


public class LockBenchmark{

    public static void runIncrement()
    {
        long counter = 0;
        long max = 500000000L;
        long start = System.currentTimeMillis();
        while (counter < max) {
            counter++;
        }
        long end = System.currentTimeMillis();
        System.out.println("Time spent is " + (end-start) + "ms without lock");
    }

    public static void runIncrementWithLock()
    {
        Lock lock = new ReentrantLock();
        long counter = 0;
        long max = 500000000L;
        long start = System.currentTimeMillis();
        while (counter < max) {
            if (lock.tryLock()){
                counter++;
                lock.unlock();
            }
        }
        long end = System.currentTimeMillis();
        System.out.println("Time spent is " + (end-start) + "ms with lock");
    }

    public static void main(String[] args) {
        runIncrement();
        runIncrementWithLock();
	}
}

兩個方式執行所需要的時間分別是207毫秒和9603毫秒,性能差出了將近50倍。結果如下所示:

Time spent is 207ms without lock
Time spent is 9603ms with lock

9. 加鎖很慢,所以Disruptor的解決方案就是“無鎖”,這個“無鎖”指的是沒有操作系統層面的鎖。實際上Disruptor還是利用了一個CPU硬件支持的指令稱之爲CAS(Compare And Swap,比較和交換),在Intel CPU裏這個對應的指令就是cmpxchg。Disruptor中的RingBuffer實現和直接在鏈表的頭和尾加鎖不同,它創建了一個Sequence對象,用來指向當前的RingBuffer的頭和尾。這個頭和尾的標識不是通過一個指針來實現的,而是通過一個序號,這也是爲什麼對應源碼裏面的類名叫Sequence。如下所示:

在這個RingBuffer當中,進行生產者和消費者之間的資源協調,採用的是對比序號的方式。當生產者想要往隊列里加入新數據的時候,它會把當前的生產者的Sequence序號,加上需要加入的新數據的數量,然後和實際的消費者所在的位置進行對比,看看隊列裏是不是有足夠的空間加入這些數據,而不會覆蓋掉消費者還沒有處理完的數據。在Sequence的代碼裏面,就是通過compareAndSet這個方法,並且最終調用到了UNSAFE.compareAndSwapLong,也就是直接使用了CAS指令。如下所示:

public boolean compareAndSet(final long expectedValue, final long newValue)
      {
          return UNSAFE.compareAndSwapLong(this, VALUE_OFFSET, expectedValue, newValue);
      }

public long addAndGet(final long increment)
    {
        long currentValue;
        long newValue;

        do
        {
            currentValue = get();
            newValue = currentValue + increment;
        }
        while (!compareAndSet(currentValue, newValue));

        return newValue;
    }

這個CAS指令,也就是比較和交換的操作,並不是基礎庫裏的一個函數,也不是操作系統裏面實現的一個系統調用,而是一個CPU硬件支持的機器指令,就是Intel CPU上的cmpxchg這個指令。如下所示:

compxchg [ax] (隱式參數,EAX累加器), [bx] (源操作數地址), [cx] (目標操作數地址)

cmpxchg指令一共有三個操作數,第一個操作數不在指令裏面出現,是一個隱式的操作數,也就是EAX累加寄存器裏面的值。第二個操作數就是源操作數,並且指令會對比這個操作數和上面的累加寄存器裏面的值,如果值是相同的,那CPU會把ZF(也就是條件碼寄存器裏面零標誌位的值)設置爲1,然後再把第三個操作數(也就是目標操作數)設置到源操作數的地址上;如果不相等的話,就會把源操作數裏面的值設置到累加器寄存器裏面。這個過程的僞代碼如下所示:

IF [ax]< == [bx] THEN [ZF] = 1, [bx] = [cx]
                 ELSE [ZF] = 0, [ax] = [bx]

單個指令是原子的,這也就意味着在使用CAS操作的時候不再需要單獨進行加鎖,直接調用就可以了。沒有了鎖CPU這部高速跑車就像在賽道上行駛,不會遇到需要上下文切換這樣的紅燈而停下來,雖然會遇到像CAS這樣複雜的機器指令,就好像賽道上會有U型彎一樣,不過不用完全停下來等待,CPU運行起來仍然會快很多。證明CAS操作速度快的代碼如下所示:

import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class LockBenchmark {

    public static void runIncrementAtomic()
    {
        AtomicLong counter = new AtomicLong(0);
        long max = 500000000L;
        long start = System.currentTimeMillis();
        while (counter.incrementAndGet() < max) {
        }
        long end = System.currentTimeMillis();
        System.out.println("Time spent is " + (end-start) + "ms with cas");
    }

    public static void main(String[] args) {
        runIncrementAtomic();
    }
}

運行結果如下所示:

Time spent is 3867ms with cas

和上面其他代碼的counter自增一樣,只不過這一次自增採用了AtomicLong這個Java類,裏面的incrementAndGet最終到了CPU指令層面,在實現的時候用的就是CAS操作。可以看到它所花費的時間,雖然要比沒有任何鎖的操作慢上一個數量級,但是比起使用ReentrantLock這樣的操作系統鎖的機制,還是減少了一半以上的時間。通過CAS這樣的操作,去進行序號的自增和對比,使CPU不需要獲取操作系統的鎖,而是能夠繼續順序地執行CPU指令。沒有上下文切換、沒有操作系統鎖,自然程序就跑得快了。不過因爲採用了CAS這樣忙等待(Busy-Wait)的方式,會使得CPU始終滿負荷運轉

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章