不要騙我了,這能有多難嘛!

個人要求不高,只是一個變量而已

故事從一個變量講起。好比初學編程的時候,咱們都是從這樣的代碼學起的。html

a = 1
print(a+1)
複製代碼

這能有多難?變量就是一個原子的容器,裏面「以前」放了什麼,「以後」取出來就是什麼。映射到內存上就是一個內存地址而已。java

可是這實際上是很難的事情。咱們這些被慣壞了程序員是沒法理解的。好比,你有這樣的SQLlinux

UPDATE order SET order_status="arrived" WHERE order_id=10234
複製代碼

執行完了以後,訂單狀態應該就是arrived了的。程序員

SELECT order_status FROM order WHERE order_id=10234
複製代碼

若是沒有其餘的寫入操做。以後執行SELECT語句,取出來的訂單狀態就應該是arrived。當底層的數據庫不是這樣的行爲的時候,咱們會憤怒,咱們會抓狂。好比我下一個接口取到的訂單的時候會「校驗」一下這個訂單的當前狀態是否是對的。讀不到以前寫入的訂單狀態,這個校驗就過不去。golang

另一個對變量的理所固然的假設是,它應該支持我去作帶前提條件的更新。好比面試

if (amount >= 4) {
   amount = amount - 4
   return SUCCESS
} else {
   return FAILED
}
複製代碼

這個例子,我是要給帳戶餘額扣掉4塊錢。若是可以保證扣完了以後帳戶的餘額不爲負數,也就是當前餘額不小於4塊,就扣掉。若是不能保證,就返回失敗。這是一個理所固然應該支持的所謂compare and swap操做嘛,也就是read modify write的操做嘛。算法

對應成 SQL,咱們的指望其實很是低。也不要求什麼多行記錄的事務。要求僅僅是操做一行的時候,這個操做能是原子的。好比這樣的SQL數據庫

UPDATE account SET amount = amount - 4 WHERE amount >= 4
複製代碼

對數據庫有這樣的要求高麼?一點都不高對不對。這個事情難不難?很是難!可是若是底層的數據不能提供這樣的基本保障,咱們這些寫業務代碼的程序員會憤怒,會抓狂。編程

抽象一下問題

剝離掉具體的業務需求。咱們來看本質。第一個需求是要求寫入和讀取要能按照牆上時間進行排序。全部在讀取以前發生的(happens before)寫入都要可以在以後被讀取到。這個是對讀操做的線性一致性的保證緩存

第二個需求是要求以前的寫入在CAS操做的時候均可見,並且要求寫入的時候要保證這個時候變量的值仍然是讀取到的那個。這個對寫操做的線性一致性的保證

總結來講,前面咱們給數據庫提的需求就是,咱們須要的是linearizable的需求。這個需求過度不過度。參見:Strong consistency models

這個圖的右邊,是講多個變量的問題的,左邊是單變量的問題。Linearizable的讀寫,是咱們能給一個「變量」提的最高級別的一致性要求了。你說過度不過度。

我讀書少,你不要騙我

Wait a minute。作爲一個變量這麼一點基本的操守都沒有麼。好比我作爲一個 Java 程序員都知道

volatile int a;
a = 1; // thread 1
System.out.println(a+1); // thread 2
複製代碼

什麼Linearizability,名詞一套套的。把我都說蒙圈了。大家這些基礎架構的高T們,是否是就會寫PPT啊。

這難道不是java面試題麼。經過設置 volatile,能夠保證在 thread 1 寫入了 a,在這個時間以後,從任意其餘線程去讀 a 均可以讀到以前寫入的值。

並且這個volatile也不是什麼複雜的東西嘛。聽說就是把volatile變量的寫入以後加了一條sfence的彙編指令。在讀取以前加了mfence的彙編指令(參見:https://mechanical-sympathy.blogspot.com/2011/07/memory-barriersfences.html)。有什麼難的嘛,兩條CPU指令而已。

至於 CAS,不就是 java.util.concurrent.atomics.AtomicInteger.compareAndSet 麼。全部的條件寫入均可以化簡爲調用這個函數啊。

聽說底層調用的是 lock cmpxchg 這條彙編指令。有什麼難的嘛,一條CPU指令而已。

可是……CPU指令又是如何實現 sfence,mfence 和 lock cmpxchg 的呢?

Welcome to the rabbit hole

帶着這個疑問,我google了半天。找到了兩篇講得比較清楚的介紹:

Holy shit。咱們作業務邏輯開發的程序員真是太naive了。a = 1,而後 print(a+1) 真的不是那麼容易實現的事情。

首先來看一段最簡單的業務邏輯代碼

doX() {
  a = 1
  a = a + 1
  b = 2
  b = b + 1
}
複製代碼

很簡單吧。這個就是沒有任何分支和循環的最簡單的串行邏輯。咱們稱這樣的串行的業務邏輯爲非concurrent的。

可是業務邏輯落到具體的機制上進行執行,這個執行的過程未必就是「串行」的。也多是parallel的。也就是 "not concurrent" != "not parallel"。

爲何?由於CPU的日子也很差過啊。你這要求它作1作2作3作4的,還要求人家特別快,確實很難。CPU找到的討巧的辦法是把 not concurrent 的業務邏輯,parallel 的執行。怎麼作?

a = 1
a = a + 1
複製代碼

這一串,和下面的

b = 2
b = b + 1
複製代碼

貌似沒有什麼聯繫嘛。一個CPU核內部有多個能夠parallel運算的單元。那麼就獨立去算好了。這個就是所謂的CPU亂序執行

另外再來看一段代碼

a = 0
doX() {
  if (a == 0) { 
    a = a + 1 
  }
}
doY() {
  if (a == 0) {
     a = a + 2;
  }
}
複製代碼

這段代碼是concurrent的,由於doX和doY是兩個獨立的運算流程,或者叫兩段獨立的業務邏輯。可是 "concurrent" != "parallel"。(參見 https://blog.golang.org/concurrency-is-not-parallelism)concurrency是業務邏輯上的並行。可是具體到實際的執行過程,未必是並行的。好比這樣的一個執行順序

a = 0
doX() {
  if (a == 0) { // 第一步
    a = a + 1 // 第三步
  }
}
doY() {
  if (a == 0) { // 第二步
     a = a + 2; // 第四步
  }
}
複製代碼

按照上面的這樣一個執行順序,即使只有一個CPU核,也能夠「同時」執行兩段業務邏輯。可是第一步執行完doX的第一行以後,爲何可以跳到doY這個函數裏去呢。難道一個函數不是「獨佔」一個cpu核的麼?

獨佔cpu核,用parallel去實現concurrent是一種執行方案,但不是惟一的執行方案。編程語言徹底能夠再doX的第一行執行完以後把狀態保存起來,而後切換到doY執行,而後再切換回來,直接跳到doX的第二行去繼續執行。也就是所謂的「協程」。

按照剛纔的執行順序,雖然只有一個CPU核在用非parallel的方式去運算。可是語義上是同時concurrent地跑着兩段業務邏輯。這種concurrency就引發了concurrent object的問題。由於a的狀態同時被doX和doY進行了讀寫。按照給定的執行順序,計算出來的結果是3,既不是1也不是2。也就是所謂的代碼出了併發的bug。

concurrent object 問題不是多核 cpu「搞出來的問題」。根本上是有兩段獨立的業務邏輯共享了狀態引發的。concurrent object 問題本質上是業務邏輯是否完備的問題。即使是單核cpu串行去執行,只要有協程存在,就仍然會有出bug的可能。對於 concurrent object,安全寫入的辦法就是作 CAS。全部寫入帶上一個前提條件,而不是無條件的write。由於concurrency是由多段獨立的業務邏輯引發的,CAS是作爲協調手段,自己也是業務邏輯。不用CAS,只有在寫入方只有一個的時候,纔是安全的。

對於存粹的 parallelism,好比CPU亂序執行。Intel x86 的 CPU是很是負責任的。基本上你能夠認爲你的一段串行的業務邏輯,真的就是一行接着一行執行的。大部分狀況下作爲寫業務代碼的同窗,徹底不用在乎CPU背後搞的這些小動做。可是 arm 之類的 CPU 就沒有那麼負責任,會由於追求parallel執行,破壞掉單個變量的 linearizability 和多個變量的 serializability。

Concurrent Object 的線性一致性讀

既然CPU能夠聰明到用亂序執行,自做主張的引入parallelism。而後還能夠保證亂序執行不會引發內存可見性方面的問題。既然已經這麼牛b了,爲何不「順手」把多核執行concurrent的業務邏輯,這種parallelism引發的內存可見性問題也解決了?讓全部的變量都可以線性一致性讀呢?

a = 0
doX() {
  a = 1;
}
doY() {
  print(a);
}
複製代碼

對於doX和doY這兩個concurrent的執行流程,只要保證doX「真的」把1寫入到了a的內存地址,doY的時候「真的」從a的內存地址去讀取了值。「線性一致的讀取」不就實現了麼?

sfence 和 mfence 乾的事情就是這個。可是爲何不把全部的內存讀寫都加上 sfence 和 mfence?

緣由很簡單。由於多個CPU核去讀寫內存的時候,共享的總線是很慢的。若是全部的寫入都要當即回寫到內存裏,全部的讀取都要真的從內存裏走總線調上來,這個速度是很慢的。因此每一個CPU核都引入了本身的本地緩存來加速內存的讀寫。

假設每一個cpu都有本身的緩存了。那麼咱們如何保證讀操做的線性一致性?就是一個變量被更新了以後,全部其餘的cpu再去讀這個變量都要讀到新的值。最簡單的實現就是要求更新操做去刷新全部其餘CPU的緩存。等全部的CPU緩存都刷新完了以後,才結束此次寫操做。開始執行下一條指令。

按照使用數據庫的經驗,這個實現問題很大啊:

  • 可用性:全部的CPU都寫入成功。要是有一個CPU掛了,不是就寫不成功了麼。
  • 延遲:等全部的CPU更新完本身的緩存。這寫一個變量該多慢啊。sfence的代價也過高了吧。

好在是CPU,而不是數據庫。可用性不是問題。一個CPU掛了,整臺機器就異常了是能夠接受的。延遲是真正的大問題。若是sfence的延遲這麼高,可能把全部的併發節省下來的時間都浪費掉了。

要下降這個寫操做的延遲,須要知道延遲產生在哪裏:

  • 本地cpu cache的爭搶:cache太忙了,致使來不及更新,須要等cache
  • 其餘cpu cache的爭搶:別的cpu的cache太忙了,不能當即更新,須要等待

就像一個餐館生意太好了,須要叫號是同樣的。

CPU的設計者是怎麼解決cache太忙了,不能及時更新的問題的呢?排隊吧

CPU0發起的一個寫入。須要排兩個隊,一個是在本地的store buffer裏,一個是在其餘cpu的invalidate queue裏。排隊的好處是不須要等cache真的更新了。只要排上了,就能夠認爲已經寫入了。讀取的時候,cpu會去查這個隊列,以及cache自己,綜合決定值是什麼。也就是把成本部分分攤到了讀取的時候去作。

表面上 a =1,print(a+1) 這樣的簡單內存地址的寫入的讀取,其實在多核cpu層面是用消息傳遞,全部參與者遵照一樣的MESI協議(和你的http handler其實沒啥區別,就是一個響應消息的業務邏輯代碼),來保證內存的一致性。你能夠理解爲多核CPU之間架了一個RPC網絡,給你提供了一個內存地址能夠線性讀取的抽象。

Concurrent Object 的線性一致性寫

sfence和mfence聽起來也不是那麼糟糕。就是拿緩存作了點同步的工做而已嘛。lock cmpxchg指令又是如何工做的呢?CAS作爲線性一致寫入的基石,這個是如何實現的?

根據 Computer Science Handbook, 2nd Ed 第19章,Bus。這個過程實際上是這樣的。

最簡單的實現辦法是把lock cmpxchg作爲一個原子來執行。由於system bus保證了只有一個cpu核能夠同時拿到執行權。cpu核能夠把整個system bus鎖上,而後執行lock cmpxchg,而後再解鎖。能夠說就是用一個很是大粒度的悲觀鎖來實現的。可是這樣會形成system bus長時間沒法服務於其餘的任務。

既然鎖粒度太大,那麼就把鎖的粒度搞小一點。把CAS操做分解爲 read & lock,以及 write & unlock 兩個步驟來執行。在system bus上實現address 級別的細粒度的悲觀鎖。

按順序來講,就須要這麼幾個消息往來

  1. 對 system bus進行 address lock
  2. 對每一個其餘cpu進行invalidate,等ack,獲得最新的值
  3. 比對是否等於預期的值,若是符合預期,則作更新
  4. 作寫入操做,同時寫入到全部cpu的cache,等ack
  5. 對system bus進行 address unlock

因此有意思的地方是linux操做系統提供的悲觀鎖,實際上底層是由樂觀鎖實現的(CAS保護的processor當前排在runqueue裏的線程)。而CPU的樂觀鎖,最終仍是由system bus的address級別的鎖實現的。

基於這樣的實現,多核CPU就提供了線性一致的寫操做的能力。

擴展到分佈式的 Concurrent Object

經過前面的 going down rabbit hole的過程,咱們瞭解了在單機多核非持久化的狀況下,實現線性一致的讀取(sfence/mfence),核線性一致的寫入(CAS),是很是不容易的。

那麼把問題擴展到分佈式場景下。若是有一個變量,能夠多機併發的讀寫,要保證線性一致的讀寫就更困難了。最簡單的一個想法,直接把多核CPU的那一套拿過來不就好了麼?有幾個問題:

  • 可用性:要求全體都在線,致使整體的可用性是個體的可用性的乘積
  • 延遲:延遲取決於最慢的那一次複製交互。
  • 持久化:CPU的內存一致性模式不用考慮內存掛了,再恢復的問題。而分佈式存儲的要求是不把雞蛋放在一個籃子裏。
  • 沒有System Bus:CAS指令最終是依賴總線實現的Address Lock的。分佈式系統下沒有這樣一個共享的硬件來作這個事情。

前三點都好說。無非就是把CPU的內存一致性算法裏的要求全部的CPU都Ack,改爲要求多數派應答就能夠了。不管是延遲,仍是可用性,仍是持久化經過拷貝多份,均可以經過這個多數派來解決。並且CPU的內存一致性算法隨着CPU核數的變多,也開始有了一些變化,好比:http://people.csail.mit.edu/yxy/pubs/tardis.pdf 。在覈數愈來愈多的背景下,下降一致性引發的延遲也變成了有收益的事情。

Paxos和CPU一致性算法在實現線性一致性讀的區別在於,CPU要求全體ack,而Paxos不要求。這個決定背後的隱藏約束是,CPU的環境下,高可用不是問題,並且讀取的低延遲很是重要。而Paxos的環境下,須要對硬件故障進行容錯,可是讀取的延遲是能夠放鬆(若是要求讀到最新的版本,要讀多個節點)。二者只是根據本身的硬件基礎,作了一些取捨而已。

真正要命的挑戰是第四點。沒有共享的System Bus來實現Address Lock,這個CAS操作如何實現呢?沒有CAS,壓根就沒有辦法對Concurrent Object實現安全的寫入(安全的好意是符合業務邏輯的意思)。回憶一下CPU的CAS的實現,它實際是拆分紅兩步來實現的:

  • 先讀出來當前是不是有值的,而且同時上鎖
  • 若是沒有值,則寫入值,同時釋放鎖

而號稱惟一正確的分佈式共識算法Paxos,剛好也是兩步(這不是意外):

  • Prepare & Promise:就是把值出來,同時上鎖
  • Propose & Accept:寫入值

可是沒有System Bus硬件實現的Address Lock,軟件的共識算法如何保證Prepare和Accept中間沒有人插入進行執行呢?

State is illusion, Log is reality

我發現最好的理解分佈式共識算法的視角是創建一個正確的世界觀。那就是,state is illusion,log is reality。

給你一個變量a。這個變量a只是一個抽象,所謂a有狀態只是一個幻覺。a背後的變動日誌纔是事實。

這樣的一個日誌,有兩個變動的記錄。這個時候a的值就是2。

在2的基礎上,再-3。那麼a的值就變成了-1。

有了這樣的一個日誌,背後就至關於咱們有了一個邏輯上的時間的概念。也就是log的每一個entry的offset。

有了這樣的一個世界觀以後。那麼就擁有了一個絕對的時間的概念。咱們知道,a這個變量的狀態就是從左往右,一個個apply新的變動獲得的。offset越小,表明邏輯時間越小。offset越大表明邏輯時間越大。

回到咱們的問題。CAS要實現的兩個目標:

  • Read & Lock:讀到一個肯定性,靜止的歷史。
  • Write:在這個歷史的基礎上,寫入個人值。這個寫入要得到足夠的節點的支持纔算成功。

這個要求,落到日誌的模型上。就是去爭搶一個offset。好比

假設你要去寫offset5。這個時候是無法寫入的。由於副本1和副本3在offset4這個位置上仍是空洞。也就是你的歷史仍是處在變化中的。在offset5這個位置的值,取決於這兩個洞填上什麼值。怎樣才能讓offset5得到一個靜止的歷史,從而拿到所謂的讀鎖(當你的歷史都肯定了,至關於上鎖了):

  • 等一段時間,等別的寫入者把offset4都給寫完
  • 一直等下去也不是辦法,我把offset4填上一個「做廢」的標籤吧。

當offset5以前的offset的全部副本的空洞都填上了。那麼能夠確保offset5能夠放心的去讀取歷史,根據歷史作判斷,而後寫入offset5這個位置的改動。

其實把「全部副本的空洞」都填上是一個過強的要求了。一個更形象的說法是」把offset5以前的offset的寫入都給攪和黃」,讓這些offset都寫不成功就行。如何才能讓這些offset寫不成功?

  • 每一個副本的每一個offset表明一個洞,這個位置只能寫一次
  • 假設總共10個副本,寫入3個就算成功的話
  • 那麼把一個offset攪和黃,永遠寫不成功。就是要把8個副本的對應offset的洞填上做廢。這樣就永遠不會有3個洞填上值了。3+8=11 大於10

因此一次安全的CAS寫入過程是:

  • 假設總共N份副本
  • 要寫入offset I,把I以前全部offset的位置 [0, I-1],填上R份的做廢。確保過去的歷史呈靜止狀。
  • 讀取過去的歷史日誌。判斷當前的offset應該寫什麼值進去。
  • 把當前offset I的值,寫入W份副本,纔算寫入成功,返回給調用方。
  • 要求 R + W > N

這個過程就是 corfu 的連續offset模型。而paxos的區別在於,proposal number(至關於這裏的日誌offset)不必定是連續的。因此paxos實現了一個更有效率的標記」做廢「的過程。就是每一個副本有一個當前最大offset的標記。小於這個標記的offset都作爲」做廢「來處理。

但願把 Paxos 講清楚了。

總結

若是要求數據100%不丟。要實現線性一致性讀取(SQL提交了,接下來用SQL查詢就能夠查到),要實現線性一致性寫入(當行SQL既讀又寫),是很是困難和苛刻的要求的。由於要永遠不lost update,數據庫必須用multi-paxos或者raft,才能實現單行數據的線性一致性(且不說多行數據變動的ACID問題)。

可是若是不要求100%不丟,容許lost update,這個問題就變得很是容易了。只要把寫入都發給一個master,讓master異步複製日誌就行了。一旦master掛掉,複製過程當中的日誌對應的寫入就丟失了。

絕大多數業務都不要求數據100%不丟,單行數據,或者說單個變量的線性一致性讀和寫都是能夠作到的,也不是很難。

相關文章
相關標籤/搜索