故事從一個變量講起。好比初學編程的時候,咱們都是從這樣的代碼學起的。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 的呢?
帶着這個疑問,我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。
既然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掛了,整臺機器就異常了是能夠接受的。延遲是真正的大問題。若是sfence的延遲這麼高,可能把全部的併發節省下來的時間都浪費掉了。
要下降這個寫操做的延遲,須要知道延遲產生在哪裏:
就像一個餐館生意太好了,須要叫號是同樣的。
CPU的設計者是怎麼解決cache太忙了,不能及時更新的問題的呢?排隊吧
CPU0發起的一個寫入。須要排兩個隊,一個是在本地的store buffer裏,一個是在其餘cpu的invalidate queue裏。排隊的好處是不須要等cache真的更新了。只要排上了,就能夠認爲已經寫入了。讀取的時候,cpu會去查這個隊列,以及cache自己,綜合決定值是什麼。也就是把成本部分分攤到了讀取的時候去作。
表面上 a =1,print(a+1) 這樣的簡單內存地址的寫入的讀取,其實在多核cpu層面是用消息傳遞,全部參與者遵照一樣的MESI協議(和你的http handler其實沒啥區別,就是一個響應消息的業務邏輯代碼),來保證內存的一致性。你能夠理解爲多核CPU之間架了一個RPC網絡,給你提供了一個內存地址能夠線性讀取的抽象。
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 級別的細粒度的悲觀鎖。
按順序來講,就須要這麼幾個消息往來
因此有意思的地方是linux操做系統提供的悲觀鎖,實際上底層是由樂觀鎖實現的(CAS保護的processor當前排在runqueue裏的線程)。而CPU的樂觀鎖,最終仍是由system bus的address級別的鎖實現的。
基於這樣的實現,多核CPU就提供了線性一致的寫操做的能力。
經過前面的 going down rabbit hole的過程,咱們瞭解了在單機多核非持久化的狀況下,實現線性一致的讀取(sfence/mfence),核線性一致的寫入(CAS),是很是不容易的。
那麼把問題擴展到分佈式場景下。若是有一個變量,能夠多機併發的讀寫,要保證線性一致的讀寫就更困難了。最簡單的一個想法,直接把多核CPU的那一套拿過來不就好了麼?有幾個問題:
前三點都好說。無非就是把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,剛好也是兩步(這不是意外):
可是沒有System Bus硬件實現的Address Lock,軟件的共識算法如何保證Prepare和Accept中間沒有人插入進行執行呢?
我發現最好的理解分佈式共識算法的視角是創建一個正確的世界觀。那就是,state is illusion,log is reality。
給你一個變量a。這個變量a只是一個抽象,所謂a有狀態只是一個幻覺。a背後的變動日誌纔是事實。
這樣的一個日誌,有兩個變動的記錄。這個時候a的值就是2。
在2的基礎上,再-3。那麼a的值就變成了-1。
有了這樣的一個日誌,背後就至關於咱們有了一個邏輯上的時間的概念。也就是log的每一個entry的offset。
有了這樣的一個世界觀以後。那麼就擁有了一個絕對的時間的概念。咱們知道,a這個變量的狀態就是從左往右,一個個apply新的變動獲得的。offset越小,表明邏輯時間越小。offset越大表明邏輯時間越大。
回到咱們的問題。CAS要實現的兩個目標:
這個要求,落到日誌的模型上。就是去爭搶一個offset。好比
假設你要去寫offset5。這個時候是無法寫入的。由於副本1和副本3在offset4這個位置上仍是空洞。也就是你的歷史仍是處在變化中的。在offset5這個位置的值,取決於這兩個洞填上什麼值。怎樣才能讓offset5得到一個靜止的歷史,從而拿到所謂的讀鎖(當你的歷史都肯定了,至關於上鎖了):
當offset5以前的offset的全部副本的空洞都填上了。那麼能夠確保offset5能夠放心的去讀取歷史,根據歷史作判斷,而後寫入offset5這個位置的改動。
其實把「全部副本的空洞」都填上是一個過強的要求了。一個更形象的說法是」把offset5以前的offset的寫入都給攪和黃」,讓這些offset都寫不成功就行。如何才能讓這些offset寫不成功?
因此一次安全的CAS寫入過程是:
這個過程就是 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%不丟,單行數據,或者說單個變量的線性一致性讀和寫都是能夠作到的,也不是很難。