計算機存儲與I/O系統基礎原理筆記

一、存儲器層次結構

1. CPU中的寄存器(Register)與其說是存儲器,其實更像是CPU本身的一部分,只能存放極其有限的信息,但是速度非常快,和CPU同步。而CPU Cache(CPU高速緩存)用的是一種叫作SRAM(Static Random-Access Memory,靜態隨機存取存儲器)的芯片。SRAM之所以被稱爲“靜態”存儲器,是因爲只要處在通電狀態,裏面的數據就可以保持存在。而一旦斷電,裏面的數據就會丟失了。在SRAM裏面一個比特的數據需要6~8個晶體管。所以SRAM的存儲密度不高,同樣的物理空間下,能夠存儲的數據有限。不過,因爲SRAM的電路簡單,所以訪問速度非常快

在CPU裏通常會有L1、L2、L3這樣三層高速緩存。每個CPU核心都有一塊屬於自己的L1 高速緩存,通常分成指令緩存和數據緩存,分開存放CPU使用的指令和數據,這裏的指令緩存和數據緩存其實就是來自於哈佛架構。L1的Cache往往就嵌在CPU核心的內部。L2的Cache同樣是每個CPU核心都有的,不過它往往不在CPU核心內部,所以L2 Cache的訪問速度會比L1稍微慢一些。而L3 Cache則通常是多個CPU核心共用的,尺寸會更大一些,訪問速度自然也就更慢一些。可以把L1 Cache理解爲大腦短期記憶,把L2/L3 Cache理解成長期記憶,把內存當成擁有的書架或者書桌。數據從內存中加載到CPU的寄存器和Cache 中,然後通過CPU進行處理和運算

2. 內存用的芯片和Cache有所不同,它用的是一種叫作DRAM(Dynamic Random Access Memory,動態隨機存取存儲器)的芯片,比起SRAM來說它的密度更高,有更大的容量,而且也比SRAM芯片便宜不少。DRAM被稱爲“動態”存儲器,是因爲DRAM需要靠不斷地“刷新”,才能保持數據被存儲起來。DRAM的一個比特,只需要一個晶體管和一個電容就能存儲。所以DRAM在同樣的物理空間下,能夠存儲的數據也就更多,也就是存儲的“密度”更大。但是,因爲數據是存儲在電容裏的,電容會不斷漏電,所以需要定時刷新充電,才能保持數據不丟失。DRAM的數據訪問電路和刷新電路都比SRAM更復雜,所以訪問延時也就更長。各個存儲介質的關係圖如下所示:

CPU並不是直接和每一種存儲器設備打交道,而是每一種存儲器設備只和它相鄰的存儲設備打交道。比如CPU Cache是從內存里加載而來的,或者需要寫回內存,並不會直接寫回數據到硬盤,也不會直接從硬盤加載數據到CPU Cache中,而是先加載到內存,再從內存加載到Cache

二、局部性原理與緩存

3. 進行服務端軟件開發的時候,通常會把數據存儲在數據庫裏。而服務端系統遇到的第一個性能瓶頸,往往就發生在訪問數據庫的時候。這個時候往往會通過Redis或者Memcache這樣的開源軟件,在數據庫前面提供一層緩存的數據,來緩解數據庫面臨的壓力,提升服務端的程序性能。

儘管CPU緩存、內存、硬盤之間的容量、價格與速度差距巨大,用戶依然希望既享受CPU Cache的速度,又享受內存、硬盤巨大的容量和低廉的價格。想要同時享受到這幾點,前輩們探索出的答案就是存儲器中數據的局部性原理(Principle of Locality)。可以利用這個局部性原理來制定管理和訪問數據的策略,包括時間局部性(temporal locality)和空間局部性(spatial locality)這兩種策略。

(1)時間局部性。指如果一個數據被訪問了,那麼它在短時間內還會被再次訪問。比如在一個電商App中,用戶看到了首屏,可以推斷他應該很快還會再次訪問購物的其他內容或者頁面,就將這個用戶的個人信息,從存儲在硬盤的數據庫讀取到內存的緩存中來。

(2)空間局部性。指如果一個數據被訪問了,那麼和它相鄰的數據也很快會被訪問。例如一個程序在訪問了數組的首項之後,多半會循環訪問它的下一項。因爲在存儲數據的時候,數組內的多項數據會存儲在相鄰的位置。

因此有了時間局部性和空間局部性的特性,就不用爲了追求速度把所有數據都放在內存裏,而是把訪問次數多的數據,放在貴但是快一點的存儲器裏,把訪問次數少的數據,放在慢但是大一點的存儲器裏。這樣組合使用內存、SSD以及HDD,可以用最低的成本提供實際所需要的數據存儲、管理和訪問的需求。

例如要提供一個電商網站。假設裏面有6億件商品,如果每件商品需要4MB的存儲空間,那麼一共需要2400TB的數據存儲。如果把數據都放在內存裏面,那就需要約3600萬美元。但是這6億件商品中,不是每一件商品都會被經常訪問。如果只在內存裏放前1%的熱門商品,而把剩下的商品放在機械式的HDD硬盤上,那麼需要的存儲成本就下降到約45.6萬美元,是原來成本的1.3%左右。

這裏用的就是時間局部性。把有用戶訪問過的數據加載到內存中,一旦內存裏面放不下了,就把最長時間沒有在內存中被訪問過的數據從內存中移走,這個其實就是常用的LRU(Least Recently Used)緩存算法。熱門商品被訪問得多,就會始終被保留在內存裏,而冷門商品被訪問得少,就只存放在HDD硬盤上,數據的讀取也都是直接訪問硬盤,即使加載到內存中,也會很快被移除。越是熱門的商品,越容易在內存中找到,也就更好地利用了內存的隨機訪問性能

但是內容的響應速度和LRU的緩存命中率(Hit Rate/Hit Ratio)有關,也就是訪問的數據中,可以在設置的內存緩存中找到的,佔有多大比例。內存的隨機訪問請求一次需要約100ns。這也就意味着,在極限情況下內存可以支持 每秒1000萬次隨機訪問。如果數據沒有命中內存,那麼對應的數據請求就要訪問到HDD磁盤了,而HDD只能支撐每秒約100次的隨機訪問。

因此局部性原理是計算機各類優化的基石,小到cpu cache,大到cdn。而且不僅僅是存儲,java的jit也是利於局部性優化性能。任何東西只要不是均勻分佈的,就有優化空間

4. 關於CPU緩存,先來看下面的代碼例子:

int[] arr = new int[64 * 1024 * 1024];

// 循環1
for (int i = 0; i < arr.length; i++) arr[i] *= 3;

// 循環2
for (int i = 0; i < arr.length; i += 16) arr[i] *= 3

在循環1裏,遍歷整個數組將數組中每一項的值變成了原來的3倍;在循環2裏,每隔16個索引訪問一個數組元素,將這一項的值變成了原來的3倍。按道理來說,循環2只訪問循環1中1/16的數組元素,只進行了循環1中1/16的乘法計算,那循環2花費的時間應該是循環1的1/16左右。但實際上循環1在電腦上運行花了50毫秒,循環2卻花了46毫秒,這兩個循環花費時間之差在15%之內。這和CPU Cache有關。

按照摩爾定律,CPU的訪問速度每18個月便會翻一番,相當於每年增長60%。內存的訪問速度雖然也在不斷增長,卻遠沒有這麼快,每年只增長7%左右。而這兩個增長速度的差異,使得CPU性能和內存訪問性能的差距不斷拉大。今天一次內存的訪問,大約需要120個 CPU Cycle,所以CPU和內存的訪問速度已經有了120倍的差距。爲了彌補兩者之間的性能差異,把CPU的性能提升用起來,而不是讓它在那兒空轉,現代CPU中引入了高速緩存

自從CPU Cache被加入到CPU裏後,內存中的指令、數據,會被加載到L1-L3 Cache中,而不是直接由CPU訪問內存。在95%的情況下CPU都只需要訪問L1-L3 Cache從裏面讀取指令和數據,而無需訪問內存。這裏的CPU Cache不是一個單純的、概念上的緩存(比如拿內存作爲硬盤的緩存),而是指特定的SRAM組成的物理芯片

因此在上面的程序中,運行程序的時間主要花在了將對應的數據從內存中讀取出來,加載到CPU Cache。CPU從內存中讀取數據到CPU Cache的過程中,是一小塊一小塊來讀取數據的,而不是按照單個數組元素來讀取數據的。這樣一小塊一小塊的數據,在CPU Cache裏面叫作Cache Line(緩存塊)。在Intel服務器或者PC裏,Cache Line的大小通常是64字節。而在上面的循環2裏面每隔16個整型數計算一次,16個整型數正好是64個字節。於是循環1和循環2,需要把同樣數量的Cache Line數據從內存中讀取到CPU Cache中,最終兩個程序花費的時間就差別不大了。

5. 現代CPU進行數據讀取的時候,無論數據是否已經存儲在Cache中,CPU始終會首先訪問Cache。只有當CPU在Cache中找不到數據的時候,纔會去訪問內存,並將讀取到的數據寫入Cache之中。當時間局部性原理起作用後,這個最近剛剛被訪問的數據,會很快再次被訪問。而Cache的訪問速度遠快於內存,這樣CPU花在等待內存訪問上的時間就大大變短了,如下所示:

那CPU如何知道要訪問的內存數據存儲在Cache的哪個位置呢?這要從直接映射Cache(Direct Mapped Cache)說起。因爲CPU訪問內存數據是一小塊一小塊數據來讀取的。對於讀取內存中的數據,首先拿到的是數據所在的內存塊(Block)地址。而直接映射Cache採用的策略,就是確保任何一個內存塊的地址,始終映射到一個固定的CPU Cache地址(Cache Line)。而這個映射關係,通常用mod運算(求餘運算)來實現

例如主內存被分成0~31號這樣32個塊。一共有8個緩存塊,用戶想要訪問第21號內存塊。如果21號內存塊內容在緩存塊中的話,它一定在5號緩存塊(21 mod 8 = 5)中,如下所示:

實際計算中爲了取巧,通常會把緩存塊的數量設置成2的N次方。這樣在計算取模的時候,可以直接取地址的低N位,也就是二進制裏面的後幾位。比如這裏的8個緩存塊,就是2的3次方。那麼在對21取模的時候,可以對21的二進制表示10101取地址的低三位,也就是101對應十進制的5,就是對應的緩存塊地址。取Block地址的低位,就能得到對應的Cache Line地址,除了21號內存塊外,13號、5號等很多內存塊的數據都對應着5號緩存塊中。如下所示:

既然多個block地址在5號緩存塊裏,怎麼知道讀取時裏面的數據是不是21號對應的數據呢?這時候在對應的CPU緩存塊中,會存儲一個組標記(Tag),這個組標記會記錄當前緩存塊內存儲的數據對應的內存塊,而緩存塊本身的地址表示內存訪問地址的低N位。例如21的低3位101,緩存塊本身的地址已經涵蓋了對應的信息、對應的組標記,只需要記錄21剩餘的高2位的信息,也就是10就可以了。

除了組標記信息之外,緩存塊中還有兩個數據。一個自然是從主內存中加載來的實際存放的數據,另一個是有效位(valid bit)。它其實就是用來標記,對應的緩存塊中的數據是否是有效的,確保不是機器剛剛啓動時候的空數據如果有效位是0,無論其中的組標記和Cache Line裏的數據內容是什麼,CPU都不會管這些數據,會直接訪問內存重新加載數據。CPU在讀取數據的時候,並不是要讀取一整個Block,而是讀取一個需要的整數。這樣的數據叫作CPU裏的一個(Word)。具體是哪個字,就用這個字在整個Block裏面的位置來決定,這個位置叫作偏移量(Offset)。因此,一個內存的訪問地址,最終在CPU Cache中的映射包括高位代表的組標記、低位代表的索引,以及在對應Data Block中定位對應字的位置偏移量。如下所示:

如果內存中的數據已經在CPU Cache裏了,那一個內存地址的訪問就會經歷這樣4個步驟:

(1)根據內存地址的低位,計算在Cache中的索引;

(2)判斷有效位,確認Cache中的數據是有效的;

(3)對比內存訪問地址的高位,和Cache中的組標記,確認Cache中的數據就是要訪問的內存數據,從Cache Line中讀取到對應的數據塊(Data Block);

(4)根據內存地址的Offset位,從Data Block中讀取希望讀取到的字。

如果在中間兩個步驟中,CPU發現Cache中的數據並不是要訪問的內存地址的數據,那CPU就會訪問內存並把對應的Block Data更新到Cache Line中,同時更新對應的有效位和組標記的數據。當然,現代CPU已經很少使用直接映射Cache的方法了,通常用的是組相連Cache(set associative cache)。一般二維數組在內存中存放是按行來優先存放的,所以在加載數據時候就會把一行數據加載進Cache裏,這樣Cache的命中率就大大提高。如果按列迭代cache就很難命中,從而CPU就要經常從內存中讀數據。

6. 對於Java中的volatile關鍵字,有以下的2種誤解:

// 一種錯誤的理解,是把volatile關鍵詞,當成是一個鎖,可以把long/double這樣的數的操作自動加鎖
private volatile long synchronizedValue = 0;

// 另一種錯誤的理解,是把volatile關鍵詞,當成可以讓整數自增的操作也變成原子性的
private volatile int atomicInt = 0;
amoticInt++;

實際上,volatile關鍵字的核心知識點,要關係到Java內存模型(JMM,Java Memory Model)。雖然JMM只是Java虛擬機這個進程級虛擬機裏的一個內存模型,但是這個內存模型,和計算機組成原理中的CPU、高速緩存和主內存組合在一起的硬件體系非常相似。理解了JMM可以很容易理解計算機組成裏CPU、高速緩存和主內存之間的關係。以下代碼例子可以說明volatile真正的作用:

public class VolatileTest {
    private static volatile int COUNTER = 0;

    public static void main(String[] args) {
        new ChangeListener().start();
        new ChangeMaker().start();
    }

    static class ChangeListener extends Thread {
        @Override
        public void run() {
            int threadValue = COUNTER;
            while ( threadValue < 5){
                if( threadValue!= COUNTER){
                    System.out.println("Got Change for COUNTER : " + COUNTER + "");
                    threadValue= COUNTER;
                }
            }
        }
    }

    static class ChangeMaker extends Thread{
        @Override
        public void run() {
            int threadValue = COUNTER;
            while (COUNTER <5){
                System.out.println("Incrementing COUNTER to : " + (threadValue+1) + "");
                COUNTER = ++threadValue;
                try {
                    Thread.sleep(500);
                } catch (InterruptedException e) { e.printStackTrace(); }
            }
        }
    }
}

可以看到分別啓動了兩個單獨的線程ChangeListener和ChangeMaker。ChangeListener線程運行時先取COUNTER當前的值,然後一直監聽着這個COUNTER的值。一旦COUNTER的值發生了變化,就把新的值打印出來,直到COUNTER的值達到5爲止。這個監聽的過程,通過一個永不停歇的while循環等待來實現。ChangeMaker線程運行時同樣是取到COUNTER的值,在COUNTER小於5時每隔500毫秒,就讓COUNTER自增1。在自增之前把自增後的值打印出來。運行結果如下所示:

Incrementing COUNTER to : 1
Got Change for COUNTER : 1
Incrementing COUNTER to : 2
Got Change for COUNTER : 2
Incrementing COUNTER to : 3
Got Change for COUNTER : 3
Incrementing COUNTER to : 4
Got Change for COUNTER : 4
Incrementing COUNTER to : 5
Got Change for COUNTER : 5

此時如果把上面的程序修改一行代碼,把定義COUNTER這個變量時設置的volatile關鍵字給去掉:

private static int COUNTER = 0;

運行結果會完全不同,ChangeMaker還是能正常工作,每隔500ms仍然能夠對COUNTER自增1。但是ChangeListener不再工作了,它似乎一直覺得COUNTER的值還是一開始的0,如下所示:

Incrementing COUNTER to : 1
Incrementing COUNTER to : 2
Incrementing COUNTER to : 3
Incrementing COUNTER to : 4
Incrementing COUNTER to : 5

如果再對程序做一些修改,不再讓ChangeListener進行完全的忙等待,而是在while循環裏面等待上5毫秒,看看會發生什麼情況:

static class ChangeListener extends Thread {
    @Override
    public void run() {
        int threadValue = COUNTER;
        while ( threadValue < 5){
            if( threadValue!= COUNTER){
                System.out.println("Sleep 5ms, Got Change for COUNTER : " + COUNTER + "");
                threadValue= COUNTER;
            }
            try {
                Thread.sleep(5);
            } catch (InterruptedException e) { e.printStackTrace(); }
        }
    }
}

有了沉睡5ms的邏輯後,雖然COUNTER變量仍然沒有設置volatile這個關鍵字,但是ChangeListener又能夠正常取到 COUNTER 的值了,運行結果如下:

Incrementing COUNTER to : 1
Sleep 5ms, Got Change for COUNTER : 1
Incrementing COUNTER to : 2
Sleep 5ms, Got Change for COUNTER : 2
Incrementing COUNTER to : 3
Sleep 5ms, Got Change for COUNTER : 3
Incrementing COUNTER to : 4
Sleep 5ms, Got Change for COUNTER : 4
Incrementing COUNTER to : 5
Sleep 5ms, Got Change for COUNTER : 5

這些有趣現象,其實來自於Java內存模型以及關鍵字volatile的含義。volatile關鍵字會確保對於這個變量的讀取和寫入,都一定會同步到主內存裏,而不是從Cache裏面讀取。一開始第一個使用了volatile關鍵字的例子裏,因爲所有數據的讀和寫都來自主內存,那麼自然ChangeMaker和ChangeListener之間,看到的COUNTER值就是一樣的。

到了第二個例子去掉volatile的時候,ChangeListener又是一個忙等待的循環,它嘗試不停地獲取COUNTER的值,這樣就會從當前線程的“Cache”裏面獲取。於是,這個線程就沒有時間從主內存裏面同步更新後的COUNTER。這樣,它就一直卡死在COUNTER=0的死循環上了。

而到了再次修改的第三段代碼裏面,雖然還是沒有使用volatile關鍵字,但是短短5ms的Thead.Sleep給了這個線程喘息之機。既然這個線程沒有這麼忙了,它就有機會把最新的數據從主內存同步到自己的高速緩存裏面了。於是ChangeListener在下一次查看COUNTER值的時候,就能看到ChangeMaker造成的變化了。

7. volatile關鍵字在用C語言編寫嵌入式軟件裏面用得很多,不使用volatile關鍵字的代碼比使用volatile關鍵字的代碼效率要高一些,但就無法保證數據的一致性。volatile的本意是告訴編譯器,此變量的值是易變的,每次讀寫該變量的值時務必從該變量的內存地址中讀取或寫入,不能爲了效率使用對一個“臨時”變量的讀寫來代替對該變量的直接讀寫。

編譯器看到了volatile關鍵字,就一定會生成內存訪問指令,每次讀寫該變量就一定會執行內存訪問指令直接讀寫該變量。若是沒有volatile關鍵字,編譯器爲了效率,只會在循環開始前使用讀內存指令將該變量讀到寄存器中,之後在循環內都是用寄存器訪問指令來操作這個“臨時”變量,在循環結束後再使用內存寫指令將這個寄存器中的“臨時”變量寫回內存。在這個過程中,如果內存中的這個變量被別的因素(其他線程、中斷函數、信號處理函數、DMA控制器、其他硬件設備)所改變了,就產生數據不一致的問題

另外,寄存器訪問指令的速度要比內存訪問指令的速度快,這裏說的內存也包括緩存,也就是說內存訪問指令實際上也有可能訪問的是緩存裏的數據,但即便如此,還是不如訪問寄存器快的緩存對於編譯器也是透明的,編譯器使用內存讀寫指令時只會認爲是在讀寫內存,內存和緩存間的數據同步由CPU保證。雖然Java內存模型是一個隔離了硬件實現的虛擬機內的抽象模型,但是它給了一個很好的“緩存同步”問題的示例。也就是說如果數據在不同的線程或者CPU核裏面去更新,因爲不同的線程或CPU核有着自己各自的緩存,很有可能在A線程的更新,到B線程裏面是看不見的

8. 有了上面的例子,事實上可以把Java內存模型和計算機組成裏的CPU結構對照起來看。現代Intel CPU通常都是多核的,每一個CPU核裏面都有獨立屬於自己的L1、L2的Cache,還有多個CPU核共用的L3 Cache和主內存。因爲CPU Cache的訪問速度要比主內存快很多,而在CPU Cache裏L1/L2的Cache也要比L3的Cache快,所以CPU始終都是儘可能地從CPU Cache中獲取數據,而不是每一次都要從主內存裏面去讀取數據,如下所示:

這個層級結構,就好像在Java內存模型裏面,每一個線程都有屬於自己的線程棧。在沒有volatile關鍵字時,線程在讀取COUNTER的數據時,其實是從本地線程棧的Cache副本里面讀取數據,而不是從主內存裏面讀取數據。如果對於數據僅僅只是讀問題還不大,因爲Cache Line的存在會從內存裏面把對應的數據加載到Cache裏。

9. 但是對於數據,不光要讀還要去寫入修改。這個時候有這樣的問題:寫入Cache的性能比寫入主內存要快,那寫入的數據到底應該寫到Cache裏還是主內存呢?如果直接寫入到主內存裏,Cache裏的數據是否會失效呢?

爲了解決這個問題,最簡單的一種寫入策略叫作寫直達(Write-Through)。在這個策略裏,每一次數據都要寫入到主內存裏面。寫入前會先去判斷數據是否已經在Cache裏面,如果數據已經在Cache裏了,就先把數據寫入更新到Cache裏面,再寫入到主內存裏面;如果數據不在Cache裏,就只更新主內存。寫直達的這個策略很直觀,但是問題也很明顯,那就是這個策略很慢。無論數據是不是在Cache裏面,都需要把數據寫到主內存裏面。這個方式就有點兒像上面的volatile關鍵字,始終都要把數據同步到主內存裏面

但是既然去讀數據也是默認從Cache裏面加載,能否不用把所有的寫入都同步到主內存裏,只寫入CPU Cache裏呢?當然是可以的。在CPU Cache的寫入策略裏,還有一種就叫作寫回(Write-Back)。這個策略裏不再是每次都把數據寫入到主內存,而是隻寫到CPU Cache裏,只有當CPU Cache裏面的數據要被“替換”的時候,才把數據寫入到主內存裏面去。如下所示:

(1)如果發現要寫入的數據就在CPU Cache裏面,那麼就只是更新CPU Cache裏面的數據,同時會標記CPU Cache裏的這個Block是髒(Dirty)的,所謂髒的就是指這個時候CPU Cache裏面的這個Block的數據,和主內存是不一致的。

(2)如果發現要寫入的數據所對應的Cache Block裏,放的是別的內存地址的數據,那麼就要看一看那個Cache Block裏的數據有沒有被標記成髒的,如果是髒的要先把這個Cache Block裏面的數據寫入到主內存裏面,然後再把當前要寫入的數據寫入到Cache裏,同時把Cache Block標記成髒的。

(3)如果Block裏面的數據沒有被標記成髒的,那麼直接把數據寫入到Cache裏面,然後再把Cache Block標記成髒的就好了。

用了寫回這個策略之後,在加載內存數據到Cache裏面的時候,也要多出一步同步髒Cache的動作如果加載內存裏的數據到Cache時,發現Cache Bloc裏面有髒標記,我們要先把Cache Block裏的數據寫回到主內存,才能加載數據覆蓋掉Cache可以看到,如果大量的操作都能夠命中緩存。那麼大部分時間裏都不需要讀寫主內存,自然性能會比寫直達的效果好很多

10. 然而,無論是寫回還是寫直達,其實都還沒有解決上面volatile程序示例中遇到的問題,也就是多個線程或者是多個CPU核的緩存一致性的問題。這也就是在寫入修改緩存後,需要解決的另一個問題。CPU Cache解決的是內存訪問速度和CPU的速度差距太大的問題。而多核CPU提供的是在主頻難以提升的時候,通過增加CPU核心來提升CPU吞吐率的辦法。把多核和CPU Cache兩者一結合,就帶來了一個新的挑戰:因爲CPU的每個核各有各的緩存,互相之間的操作又是各自獨立的,就會帶來緩存一致性(Cache Coherence)的問題。

那什麼是緩存一致性問題呢?比如說一個有兩個核心的 CPU:

比方說iPhone降價了,要把iPhone最新的價格更新到內存裏。爲了性能問題它採用了寫回策略,先把數據寫入到L2 Cache裏面,然後把Cache Block標記成髒的。這個時候,數據其實並沒有被同步到L3 Cache或者主內存裏。1號核心希望在這個Cache Block要被交換出去的時候,數據才寫入到主內存裏。這個時候,2號核心嘗試從內存裏面去讀取iPhone的價格,結果讀到的是一個錯誤的價格。這是因爲iPhone的價格剛剛被1號核心更新過。但是這個更新的信息,只出現在1號核心的L2 Cache裏,而沒有出現在2號核心的L2 Cache或者主內存裏面。這個問題就是所謂的緩存一致性問題,1號核心和2號核心的緩存在這個時候是不一致的

爲了解決這個緩存不一致的問題,就需要有一種機制來同步兩個不同核心裏面的緩存數據。這樣的機制能夠做到下面兩點就是合理的:

(1)寫傳播(Write Propagation)。在一個CPU核心裏,Cache數據更新必須能夠傳播到其他的對應節點的Cache Line裏。

(2)事務的串行化(Transaction Serialization)。在一個CPU核心裏面的讀取和寫入,在其他的節點看起來順序是一樣的。關於事務串行化,例如一個有4個核心的CPU,1號核心先把iPhone的價格改成了5000塊。差不多在同時,2號核心把iPhone的價格改成了6000塊,這裏兩個修改都會傳播到3號核心和4號核心,如下所示:

然而這裏有個問題,3號核心先收到了2號核心的寫傳播,再收到1號核心的寫傳播。所以3號核心看到iPhone價格是先變成了6000塊,再變成了5000塊。而4號核心是反過來的,先看到變成了5000塊再變成6000塊。雖然寫傳播是做到了,但是各個Cache裏面的數據是不一致的。事實上需要的是,從1號到4號核心,都能看到相同順序的數據變化。比如都是先變成了5000塊再變成了6000塊,這樣才能稱之爲實現了事務的串行化。

三、MESI協議

11. 事務的串行化不僅僅是緩存一致性中所必須的。比如平時所用到的系統當中,最需要保障事務串行化的就是數據庫。多個不同的連接去訪問數據庫的時候,必須保障事務的串行化,做不到事務的串行化的數據庫根本沒法作爲可靠的商業數據庫來使用。而在CPU Cache裏做到事務串行化,需要做到兩點:

(1)一個CPU核心對於數據的操作,需要同步通信給到其他CPU核心

(2)如果兩個CPU核心裏有同一個數據的Cache,那麼對於這個Cache數據的更新需要有一個“鎖”的概念。只有拿到了對應Cache Block的“鎖”之後,才能進行對應的數據更新

要解決緩存一致性問題,首先要解決的是多個CPU核心之間的數據傳播問題。最常見的一種解決方案叫作總線嗅探(Bus Snooping)。這個策略本質上就是把所有的讀寫請求都通過總線(Bus)廣播給所有的CPU核心,然後讓各個核心去“嗅探”這些請求,再根據本地的情況進行響應。總線本身就是一個特別適合廣播進行數據傳輸的機制,所以總線嗅探這個辦法也是Intel CPU進行緩存一致性處理的解決方案。基於總線嗅探機制,其實還可以分成很多種不同的緩存一致性協議。不過其中最常用的叫作MESI協議。這是一個維護緩存一致性協議,不僅可以用在CPU Cache之間,也可以廣泛用於各種需要使用緩存,同時緩存之間需要同步的場景下

MESI協議是一種叫作寫失效(Write Invalidate)的協議。在寫失效協議裏只有一個CPU核心負責寫入數據,其他的核心只是同步讀取到這個寫入。在這個CPU核心寫入Cache之後,它會去廣播一個“失效”請求告訴所有其他CPU核心。其他的CPU核心只是去判斷自己是否也有一個“失效”版本的Cache Block,然後把這個block也標記成失效的就好了。寫失效的協議的好處是,不需要在總線上傳輸數據內容,而只需要傳輸操作信號和地址信號就好了,不會那麼佔總線帶寬。

相對於寫失效協議,還有一種叫作寫廣播(Write Broadcast)的協議。在這個協議裏,一個寫入請求廣播到所有的CPU核心,同時更新各個核心裏的Cache。寫廣播在實現上很簡單,但是需要佔用更多的總線帶寬。而寫失效只需要告訴其他CPU核心,哪一個內存地址的緩存失效了,但是寫廣播還需要把對應的數據傳輸給其他CPU核心。如下所示:

12. MESI協議的名字來自於對Cache Line的四個不同的標記,分別是:

(1)M:代表已修改(Modified)。

(2)E:代表獨佔(Exclusive)。E狀態的緩存和主內存是一樣的。

(3)S:代表共享(Shared)。

(4)I:代表已失效(Invalidated)。

“已修改”和“已失效”這兩個狀態比較容易理解。所謂的“已修改”就是“髒”的Cache Block,即Cache Block裏面的內容已經更新過了,但是還沒有寫回到主內存裏面。而所謂的“已失效“,自然是這個Cache Block裏面的數據已經失效了,不可以相信這個Cache Block裏面的數據。

而“獨佔”和“共享”這兩個狀態就是MESI協議的精華所在。無論是獨佔狀態還是共享狀態,緩存裏面的數據都是“乾淨”的也就是說這個時候Cache Block裏面的數據和主內存裏面的數據是一致的。在獨佔狀態下,對應的Cache Line只加載到了當前CPU核所擁有的Cache裏,其他的CPU核並沒有加載對應的數據到自己的Cache。這時如果要向獨佔的Cache Block寫入數據,就可以自由地寫入數據,而不需要告知其他CPU核。在獨佔狀態下的數據,如果收到了一個來自於總線的讀取對應緩存的請求,它就會變成共享狀態。這個共享狀態是因爲,此時另外一個CPU核心也把對應的Cache Block,從內存裏面加載到了自己的Cache裏來

而在共享狀態下,因爲同樣的數據在多個CPU核心的Cache裏都有。所以當想要更新Cache裏面的數據時,不能直接修改,而是要先向所有的其他CPU核心廣播一個請求,要求先把其他CPU核心裏面的Cache都變成無效的狀態,然後再更新當前Cache裏面的數據。這個廣播操作,一般叫作RFO(Request For Ownership),也就是獲取當前對應Cache Block數據的所有權。這個操作有點兒像在多線程裏面用到的讀寫鎖,在共享狀態下各線程都可以並行去讀對應的數據。但是如果要寫,就需要通過一個鎖獲取當前寫入位置的所有權。

對於不同狀態觸發的事件操作,可能來自於當前CPU核心,也可能來自總線裏其他CPU核心廣播出來的信號。獨佔和共享狀態,就好像在多線程應用開發裏面的讀寫鎖機制,確保了緩存一致性。而整個MESI的狀態變更,則是根據來自自己CPU核心的請求,以及來自其他CPU核心通過總線傳輸過來的操作信號和地址信息,進行狀態流轉的一個有限狀態機。如下所示:

13. 關於上述的緩存一致性,再回到Java中的volatile變量,它修飾的共享變量在進行寫操作時候會多出一行彙編:

```
    0x01a3de1d:movb $0×0,0×1104800(%esi);0x01a3de24:lock addl $0×0,(%esp);
    ```

lock前綴的指令在多核處理器下會:

(1)將當前CPU核緩存的數據寫回到系統內存。

(2)這個寫回內存的操作會使其他CPU核裏緩存了該內存地址的數據無效。

對於多核CPU的總線嗅探來說,爲了提高處理速度,處理器不直接和內存進行通信,而是先將系統內存的數據讀到內部緩存後再進行操作,但寫回操作不知道這個更改何時回寫到內存。對變量使用volatile進行寫操作時,JVM就會向處理器發送一條lock前綴的指令,將這個變量所在的cache line的數據寫回到系統內存

在多核處理器中,爲了保證各個核的緩存一致性,每個核通過嗅探在總線上傳播的數據類型來檢查自己的緩存值是否過期了,如果某個核發現自己cache line對應的內存地址被修改,就會將當前核的cache line設置爲無效狀態,就相當於寫回時發現狀態標識爲0失效,當這個覈對數據進行修改操作時,會重新從系統內存中讀取數據到CPU緩存中

四、內存

14. 計算機有五大組成部分,分別是:運算器、控制器、存儲器、輸入設備和輸出設備。如果說計算機最重要的組件,是承擔了運算器和控制器作用的CPU,那內存就是第二重要的組件了。內存是五大組成部分裏面的存儲器,指令和數據都需要先加載到內存裏面,纔會被CPU拿去執行。根據程序裝載到內存的過程可以知道,在日常使用的Linux或者Windows操作系統下,程序並不能直接訪問物理內存內存需要被分成固定大小的頁(Page),然後再通過虛擬內存地址(Virtual Address)到物理內存地址(Physical Address)的地址轉換(Address Translation),才能到達實際存放數據的物理內存位置。而程序看到的內存地址,都是虛擬內存地址。

那麼虛擬內存地址是怎麼轉換成物理內存地址的呢?最直觀的辦法就是建一張映射表。這個映射表能夠實現虛擬內存裏的頁,到物理內存裏的頁的一一映射。這個映射表在計算機裏就叫作頁表(Page Table)。頁表會把一個虛擬內存地址分成頁號(Directory)和偏移量(Offset)兩個部分。以一個32位的內存地址爲例,其實前面的高位就是內存地址的頁號,後面的低位就是內存地址裏面的偏移量。做地址轉換的頁表,只需要保留虛擬內存地址的頁號和物理內存地址的頁號之間的映射關係就可以了。同一個頁裏面的內存,在物理層面是連續的。以一個頁的大小是4K字節(4KB)爲例,就需要20位的高位和12位的低位。如下所示:

因此,對於一個內存地址轉換,其實就是這樣三個步驟:(1)把虛擬內存地址,切分成頁號和偏移量的組合;(2)從頁表裏面,查詢出虛擬頁號,對應的物理頁號;(3)直接拿物理頁號,加上前面的偏移量,就得到了物理內存地址。如下所示

當然只是這樣簡單會有頁表佔用空間的問題。以32位的內存地址空間爲例,頁表一共需要記錄2^20個到物理頁號的映射關係,就好比一個2^20大小的數組。一個頁號是完整的32位的4字節(Byte),這樣一個頁表就需要4MB的空間(因爲32位計算機系統的物理頁一頁保存信息是4K比特,如果需要找到第4K比特上面的數據,2^12 = 4096,最多需要12位才能找到第4K比特上的那個比特值0或者1,所以偏移量需要12位,剩下的高位爲32-12 =20位,所以數據長度爲2^20=1048576,每個數組中只需要保存物理頁號信息就行了,虛擬頁號高位是從全0到全1的,每個頁號保存的也是32位=4個字節(1字節8位),所以總共大小爲1048576 * 4字節 = 4194304字節 = 4194KB = 4M)。不過,這個空間不是隻佔用一份,每一個進程都有屬於自己獨立的虛擬內存地址空間。這也就意味着,每一個進程都需要這樣一個頁表。對於計算機衆多的進程來說,這樣簡單實現的頁表佔用的內存就太大了。

15. 然而,計算機其實沒有必要存下這2^20個物理頁表的映射,大部分進程所佔用的內存是有限的,需要的頁也自然是很有限的,只需要去存那些用到的頁之間的映射關係就好了。在實踐中,採用的是一種叫作多級頁表(Multi-Level Page Table)的解決方案。一個進程的內存地址空間分配,通常是“兩頭實、中間空”。在程序運行的時候,內存地址從頂部往下,不斷分配佔用的棧的空間。而堆的空間內存地址則是從底部往上不斷分配佔用的。所以,在一個實際的程序進程裏面,虛擬內存佔用的地址空間通常是兩段連續的空間,而不是完全散落的隨機內存地址。而多級頁表,就特別適合這樣的內存地址分佈。

以一個4級的多級頁表爲例,同樣一個虛擬內存地址,偏移量的部分和上面簡單頁表一樣不變,但是原先的頁號部分把它拆成四段,從高到低分成4級到1級這樣4個頁表索引,如下所示:

對應的,一個進程會有一個4級頁表。先通過4級頁表索引找到4級頁表裏面對應的條目(Entry)。這個條目裏存放的是一張3級頁表所在的位置。4級頁面裏面的每一個條目都對應着一張3級頁表,所以可能有多張3級頁表。找到對應這張3級頁表之後,用3級索引去找到對應的3級索引條目。3級索引的條目再會指向一個2級頁表。同樣2級頁表裏可以用2級索引指向一個1級頁表。而最後一層的1級頁表裏面的條目,對應的數據內容就是物理頁號了。在拿到物理頁號之後,同樣可以用“頁號 + 偏移量”的方式,來獲取最終的物理內存地址。

因爲進程中實際的虛擬內存空間通常是連續的,很可能只需要很少的2級頁表,甚至只需要1張3級頁表就夠了。多級頁表就像一個多叉樹的數據結構,所以常常稱它爲頁表樹(Page Table Tree)。因爲虛擬內存地址分佈的連續性,樹的第一層節點的指針很多就是空的,也就不需要有對應的子樹了。所謂不需要子樹,其實就是不需要對應的2級、3級的頁表。因爲棧的虛擬內存地址是從上往下存儲,堆是內存地址是從下往上存儲,例如棧佔用512KB,堆佔用另外512KB,所以不太可能在同一個3級索引表中,索引表中沒有子節點的就不用創建他的子節點索引表了,只有存在纔會創建,所以每一級索引表不一定都是32位,部分位置上是可以空出來的。找到最終的物理頁號,就好像通過一個特定的訪問路徑,走到樹最底層的葉子節點。如下所示:

以這樣分成4級的多級頁表來看,每一級索引表的地址如果都用5個比特表示,那麼每一張某一級的頁表只需要2^5=32個條目。如果每個索引條目地址還是4個字節(32位),那麼一張索引表一共需要128個字節,而一個1級索引表對應32個4KB的頁也就是能索引128KB的內存頁。一個填滿的2級索引表,對應的就是32個1級索引表,也就是能索引4MB大小的內存頁。一個進程如果佔用了8MB的內存空間,分成了2個4MB的連續空間(棧和堆)。那麼,它一共需要2個獨立的、填滿的2級索引表,也就意味着64個1級索引表,2個獨立的3級索引表(棧和堆的存儲地址不同基本上不在同一個索引表裏),1個4級索引表。一共需要69個索引表,每個128字節,四級索引表總共大概佔9KB的空間。比起上面簡單實現的4MB單級頁表來說,只有差不多1/500。

16. 在優化頁表的過程中,可以看到數組這樣的緊湊的數據結構,以及樹這樣稀疏的數據結構,在時間複雜度和空間複雜度的差異,軟件的數據結構和硬件的設計也是高度相關的。不過,多級頁表雖然節約了存儲空間,卻帶來了時間上的開銷,所以它其實是一個“以時間換空間”的策略。原本進行一次地址轉換隻需要訪問一次內存就能找到物理頁號,算出物理內存地址。但是用了4級頁表,就需要訪問4次內存才能找到物理頁號了。內存訪問其實比Cache要慢很多,本來只是要做一個簡單的地址轉換,反而是一下子要多訪問好多次內存。

機器指令裏面的內存地址都是虛擬內存地址,程序裏面的每一個進程,都有一個屬於自己的虛擬內存地址空間,可以通過地址轉換來獲得最終的實際物理地址,每一個指令和數據都存放在內存裏面。因此,“地址轉換”是一個非常高頻的動作,它的性能就變得至關重要了。因爲指令、數據都存放在內存裏面,這裏就會遇到內存安全問題。如果被人修改了內存裏面的內容,CPU就可能會去執行計劃之外的指令。這個指令可能是破壞服務器裏面的數據,也可能是被人獲取到服務器裏面的敏感信息。

爲了解決訪問多次內存的性能問題,可以用加個緩存的思路來解決。因爲程序所需要使用的指令,都順序存放在虛擬內存裏面。執行的指令也是一條條順序執行下去的,也就是說對於指令地址的訪問,存在前面所說的“空間局部性”和“時間局部性”,而需要訪問的數據也是一樣的。假如連續執行5條指令。因爲內存地址都是連續的,所以這5條指令通常都在同一個“虛擬頁”裏。因此,這連續5次的內存地址轉換,其實都來自於同一個虛擬頁號,轉換的結果自然也就是同一個物理頁號。那就可以把之前的內存轉換地址(物理內存地址)緩存下來,使得不需要反覆去訪問內存來進行內存地址轉換。如下所示:

於是,工程師們專門在CPU裏放了一塊緩存芯片,稱之爲TLB,全稱是地址變換高速緩衝(Translation-Lookaside Buffer),存放了之前已經進行過地址轉換的查詢結果。這樣,當同樣的虛擬地址需要進行地址轉換的時候,可以直接在TLB裏面查詢結果,而不需要多次訪問內存來完成一次轉換。TLB和CPU的高速緩存類似,可以分成指令的TLB和數據的TLB,也就是ITLB和DTLB。同樣的,也可以根據大小對它進行分級,變成L1、L2這樣多層的TLB。除此之外,還有一點和CPU裏的高速緩存也是一樣的,需要用髒標記這樣的標記位,來實現“寫回”這樣緩存管理策略。爲了性能,整個內存轉換過程也要由硬件來執行,在CPU芯片裏封裝了內存管理單元(MMU,Memory Management Unit)芯片,用來完成地址轉換。和TLB的訪問和交互,都是由這個 MMU 控制的。如下所示:

17. 關於地址轉換產生的內存安全問題中,實際程序指令的執行,是通過程序計數器裏面的地址去讀取內存裏的內容,然後運行對應的指令使用相應的數據。雖然現代操作系統和CPU已經做了各種權限的管控,正常情況下已經通過虛擬內存地址和物理內存地址的區分,隔離了各個進程。但是,無論是CPU還是操作系統都太複雜了,難免還是會被黑客們找到各種各樣的漏洞。在內存管理方面,計算機也有一些最底層的安全保護機制。這些機制統稱爲內存保護(Memory Protection)。主要有以下2種保護措施:

(1)可執行空間保護(Executable Space Protection)。即對於一個進程使用的內存,只把其中的指令部分設置成“可執行”的,對於其他比如數據部分,不給予“可執行”的權限。因爲無論是指令還是數據,在CPU看來都是二進制的數據。把數據部分拿給CPU後,如果這些數據解碼後也能變成一條合理的指令,其實就是可執行的。

這個時候黑客們想到了一些搞破壞的辦法,即在程序的數據區裏,放入一些要執行的指令編碼後的數據,然後找到一個辦法讓CPU去把它們當成指令去加載,那CPU就能執行黑客想要執行的指令了。現在對於進程裏內存空間的執行權限進行控制,可以使得CPU只能執行指令區域的代碼。對於數據區域的內容,即使找到了其他漏洞想要加載成指令來執行,也會因爲沒有權限而被阻擋掉

其實在實際的應用開發中,類似的策略也很常見。比如說,在用PHP進行Web開發的時候,通常會禁止PHP有eval函數的執行權限。這個其實就是害怕外部的用戶沒有把數據提交到服務器,而是把一段想要執行的腳本提交到服務器。服務器裏在拼裝字符串執行命令的時候,可能就會執行到預計之外被“注入”的破壞性腳本。例如如下代碼可以刪除服務器上的數據:

script.php?param1=xxx   //PHP接受一個傳入的參數,這個參數希望提供計算功能
$code = eval($_GET["param1"]);   // 直接通過eval計算出來對應的參數公式的計算結果
script.php?param1=";%20echo%20exec('rm -rf ~/');%20//   // 用戶傳入的參數裏面藏了一個命令
$code = ""; echo exec('rm -rf ~/'); //";   // 執行的結果就變成了刪除服務器上的數據

還有一個例子就是SQL注入攻擊。如果服務端執行的SQL腳本是通過字符串拼裝出來的,那麼在Web請求裏面傳輸的參數就可以藏下一些黑客想要執行的SQL,讓服務器執行一些管理員沒有想到過的SQL語句。這樣的結果就是可能破壞了數據庫裏的數據,或者被人拖庫泄露了數據。

(2)地址空間佈局隨機化(Address Space Layout Randomization)。內存層面的安全保護核心策略,是在可能有漏洞的情況下進行安全預防,上面的可執行空間保護就是一個例子。但是,內存層面的漏洞還有其他的可能性,例如其他人、進程、程序會去修改掉特定進程的指令、數據,然後,讓當前進程去執行這些指令和數據,造成破壞。要想修改這些指令和數據,需要知道這些指令和數據所在的位置纔行

以前一個進程的內存佈局空間是固定的,所以任何第三方很容易就能知道指令在哪裏,程序棧在哪裏,數據在哪裏,堆又在哪裏。這個其實爲想要搞破壞的人創造了很大的便利。而地址空間佈局隨機化這個機制,就是讓這些區域的位置不再固定,在內存空間隨機去分配這些進程裏不同部分所在的內存空間地址,讓破壞者猜不出來,自然就沒法找到想要修改的內容的位置。如果只是隨便做點修改,程序只會crash掉,而不會去執行計劃之外的代碼。如下所示:

這樣的“隨機化”策略,其實也是日常應用開發中一個常見的策略。例如密碼登陸功能,在服務器端會把用戶名和密碼保存下來,用戶密碼當然不能明文存儲在數據庫裏,否則意味着能拿到數據庫訪問權限的人,都能看到用戶的明文密碼。於是,大家會在數據庫裏存儲密碼的哈希值,比如用現在常用的SHA256,生成一個驗證的密碼哈希值。但是這個往往還不夠,因爲同樣的密碼對應的哈希值都是相同的,大部分用戶的密碼又常常比較簡單。於是,拖庫成功的黑客可以通過彩虹表的方式,來推測出用戶的密碼。

這個時候“隨機化策略”就可以用上了。可以在數據庫裏給每一個用戶名生成一個隨機的、使用了各種特殊字符的鹽值(Salt)。這樣哈希值就不再是僅僅使用密碼來生成的了,而是密碼和鹽值放在一起生成的對應哈希值。哈希值的生成中,包括了一些類似於“亂碼”的隨機字符串,所以通過彩虹表碰撞來猜出密碼的辦法就用不了了。例如下面的代碼:

$password = "goodmorning12345";
// 密碼是明文存儲的

$hashed_password = hash('sha256', password);
// 對應的hash值是 054df97ac847f831f81b439415b2bad05694d16822635999880d7561ee1b77ac
// 但是這個hash值裏可以用彩虹表直接“猜出來”原始的密碼就是goodmorning12345

$salt = "#21Pb$Hs&Xi923^)?";
$salt_password = $salt.$password;
$hashed_salt_password = hash('sha256', salt_password);
// 這個hash後的salt因爲有部分隨機的字符串,不會在彩虹表裏面出現。
// 261e42d94063b884701149e46eeb42c489c6a6b3d95312e25eee0d008706035f

可以看到,通過加入“隨機”因素有了一道最後防線。即使在出現安全漏洞的時候,也有了更多的時間和機會去補救這些問題。

五、總線

18. CPU所代表的控制器和運算器,要和存儲器也就是主內存,以及輸入和輸出設備進行通信。那麼計算機是用什麼樣的方式來完成它們的通信呢?如果各個設備間的通信,都是互相之間單獨進行的。如果有N個不同的設備,它們之間需要各自單獨連接,那麼系統複雜度就會變成N^2。每一個設備或者功能電路模塊,都要和其他N−1個設備去通信。爲了簡化系統的複雜度就引入了總線(Bus),把這個N^2的複雜度變成一個N的複雜度,即與其讓各個設備之間互相單獨通信,不如去設計一個公用的線路。CPU想要和什麼設備通信都發送到這個線路上;設備要向CPU發送什麼信息也發送到這個線路上。這個線路就好像一個高速公路,各個設備和其他設備之間不需要單獨建公路,只建一條小路通向這條高速公路就好了,如下所示:

總線其實就是一組線路。CPU、內存以及輸入和輸出設備,都是通過這組線路進行相互間通信的。各個接入設備要想向一個設備傳輸數據,只要把數據放上“公交車”,在對應的車站下車就可以了。對應的設計思路,在軟件開發中也是非常常見的。在做大型系統開發的過程中,經常會用到一種叫作事件總線(Event Bus)的設計模式,大規模應用系統中的各個組件之間也需要相互通信,模塊之間如果是兩兩單獨去定義協議,這個軟件系統一樣會遇到一個複雜度變成了N^2的問題。所以解決方案就是事件總線這個設計模式,各個模塊觸發對應的事件,並把事件對象發送到總線上。也就是說每個模塊都是一個發佈者(Publisher)。各個模塊也會把自己註冊到總線上,去監聽總線上的事件,並根據事件的對象類型或者是對象內容,來決定自己是否要進行特定的處理或者響應。如下所示:

這樣的設計下,註冊在總線上的各個模塊就是松耦合的。模塊互相之間並沒有依賴關係。無論代碼的維護,還是未來的擴展,都會很方便

19. 現代Intel CPU的體系結構裏面,通常有好幾條總線。首先CPU和內存以及高速緩存通信的總線通常有兩種,稱之爲雙獨立總線(Dual Independent Bus,縮寫爲 DIB)。CPU裏有一個快速的本地總線(Local Bus),以及一個速度相對較慢的前端總線(Front-side Bus)。這裏的高速本地總線就是CPU用來和高速緩存通信的,而前端總線則是用來和主內存以及輸入輸出設備通信的。有時候也會把本地總線也叫作後端總線(Back-side Bus),而前端總線也有很多其他名字,比如處理器總線(Processor Bus)、內存總線(Memory Bus)。2008年之後,Intel CPU已經沒有前端總線,發明了快速通道互聯(Intel Quick Path Interconnect,簡稱爲 QPI)技術替代了傳統的前端總線。總線連接各設備的示意圖如下所示:

CPU裏面的北橋芯片,把上面的前端總線一分爲二變成了三個總線,前端總線其實就是系統總線。CPU裏面的內存接口直接和系統總線通信,然後系統總線再接入一個I/O橋接器(I/O Bridge)。這個I/O橋接器一邊接入了內存總線,使得CPU和內存通信;另一邊又接入了一個I/O總線用來連接I/O設備。事實上真實的計算機裏,這個總線層面拆分得更細。根據不同的設備還會分成獨立的PCI總線、ISA總線等等,如下所示:

在物理層面,其實完全可以把總線看作一組“電線”。不過這些電線之間也是有分工的,通常有三類線路:

(1)數據線(Data Bus),用來傳輸實際的數據信息,也就是實際上了公交車的“人”。

(2)地址線(Address Bus),用來確定到底把數據傳輸到哪裏去,是內存的某個位置還是某一個I/O設備,相當於拿了個紙條寫下了上面的人要下車的站點。

(3)控制線(Control Bus),用來控制對於總線的訪問。

雖然把總線比喻成了一輛公交車。那麼有人想要做公交車的時候,需要告訴公交車司機,這個就是控制信號。儘管總線減少了設備之間的耦合,也降低了系統設計的複雜度,但同時也帶來了一個新問題,那就是總線不能同時給多個設備提供通信功能。線是很多個設備公用的,那多個設備都想要用總線就需要有一個機制,去決定這種情況下,到底把總線給哪一個設備用。這個機制就叫作總線裁決(Bus Arbitraction)。

六、IO設備與IO性能

20. 實際上輸入輸出設備並不只是一個設備。大部分的輸入輸出設備都有兩個組成部分,一個是它的接口(Interface),第二個纔是實際的I/O設備(Actual I/O Device)。硬件設備並不是直接接入到總線上和CPU通信的,而是通過接口連接到總線上,再通過總線和CPU通信。接口本身就是一塊電路板,CPU其實不是和實際的硬件設備打交道,而是和這個接口電路板打交道。設備裏面的三類寄存器都在這個設備的接口電路上,而不在實際的設備上,它們分別是狀態寄存器(Status Register)、 命令寄存器(Command Register)以及數據寄存器(Data Register)。

Windows系統中可以打開設備管理器,裏面有各種的Devices(設備)、Controllers(控制器)、Adaptors(適配器)。這些其實都是對於輸入輸出設備不同角度的描述。被叫作Devices看重的是實際的I/O設備本身,被叫作Controllers看重的是輸入輸出設備接口裏面的控制電路,被叫作Adaptors則是看重接口作爲一個適配器後面可以插上不同的實際設備。如下所示:

21. 無論是內置在主板上的接口,還是集成在設備上的接口,除了上面三類寄存器之外,還有對應的控制電路。正是通過這個控制電路,CPU才能通過向這個接口電路板傳輸信號來控制實際的硬件。關於硬件設備上這些寄存器的作用,可以舉個下面的打印機例子:

(1)首先是數據寄存器(Data Register),CPU向I/O設備寫入需要傳輸的數據。

(2)然後是命令寄存器(Command Register)。CPU發送一個命令,告訴打印機要進行打印工作。這個時候打印機裏面的控制電路會做兩個動作。第一是去設置狀態寄存器裏面的狀態,把狀態設置成not-ready。第二就是實際操作打印機進行打印。

(3)狀態寄存器(Status Register),就是告訴了CPU現在設備已經在工作了,所以這個時候CPU再發送數據或者命令過來都是沒有用的。直到前面的動作已經完成,狀態寄存器重新變成了ready狀態,CPU才能發送下一個字符和命令。

當然,在實際情況中,打印機裏通常不只有數據寄存器,還會有數據緩衝區,CPU也是一次性把整個文檔傳輸到打印機的內存或者數據緩衝區裏面一起打印的。

22. CPU和I/O設備的通信,一樣是通過CPU支持的機器指令來執行的。而例如MIPS的機器指令分類中,並沒有一種專門的和I/O設備通信的指令類型。那麼MIPS的CPU到底是通過什麼樣的指令來和I/O設備來通信呢?答案就是和訪問主內存一樣,使用“內存地址”。爲了讓已經很複雜的CPU儘可能簡單,計算機會把I/O設備的各個寄存器以及I/O設備內部的內存地址,都映射到主內存地址空間裏來。主內存的地址空間裏,會給不同的I/O設備預留一段段的內存地址。CPU想要和這些I/O設備通信的時候就往這些地址發送數據。這些地址信息就是通過上面說過的地址線來發送的,而對應的數據信息是通過數據線來發送的。I/O設備會監控地址線,並且在CPU往自己地址發送數據的時候,把對應數據線裏面傳輸過來的數據,接入到對應設備裏的寄存器和內存裏面來。CPU無論是向I/O設備發送命令、查詢狀態還是傳輸數據,都可以通過這樣的方式。這種方式叫作內存映射IO(Memory-Mapped I/O,簡稱 MMIO),如下所示:

當然MMIO並不是唯一一種CPU和設備通信的方式。精簡指令集MIPS的CPU特別簡單,所以只有MMIO。而有2000多個指令的Intel X86架構計算機,自然可以設計專門和I/O設備通信的指令,也就是in和out指令。Intel CPU雖然也支持MMIO,不過它還可以通過特定的指令來支持端口映射I/O(Port-Mapped I/O,簡稱 PMIO)或者也叫獨立輸入輸出(Isolated I/O)。

其實PMIO的通信方式和MMIO差不多,核心區別在於PMIO裏面訪問的設備地址,不再是在內存地址空間裏面,而是一個專門的端口(Port)。這個端口並不是指一個硬件上的插口,而是和CPU通信的一個抽象概念。無論是PMIO還是MMIO,CPU都會傳送一條二進制的數據給到I/O設備的對應地址。設備自己本身的接口電路,再去解碼這個數據。解碼之後的數據就會變成設備支持的一條指令,再去通過控制電路去操作實際的硬件設備。對於CPU來說並不需要關心設備本身能夠支持哪些操作,它要做的只是在總線上傳輸一條條數據就好了。這其實也有點像設計模式裏面的Command模式,在總線上傳輸的是一個個數據對象,然後各個接受這些對象的設備,再去根據對象內容,進行實際的解碼和命令執行。

例如下面的顯卡,在設備管理器裏面的資源(Resource)信息可以看到,裏面既有Memory Range,就是設備對應映射到的內存地址,也就是上面所說的MMIO的訪問方式,同樣裏面還I/O Range,這個就是上面所說的PMIO,也就是通過端口來訪問I/O設備的地址。最後裏面還有一個IRQ,也就是來自於這個設備的中斷信號了:

23. 因此,CPU並不是發送一個特定的操作指令來操作不同的I/O設備。因爲如果那樣的話,隨着新I/O設備的發明,就要去擴展CPU的指令集了。在計算機系統裏面,CPU和I/O設備之間的通信是這麼來解決的。首先I/O設備這一側,把I/O設備拆分成能和CPU通信的接口電路,以及實際的I/O設備本身。接口電路里面有對應的狀態寄存器、命令寄存器、數據寄存器、數據緩衝區和設備內存等等。接口電路通過總線和CPU通信,接收來自CPU的指令和數據。

而接口電路中的控制電路,再解碼接收到的指令,實際去操作對應的硬件設備。在CPU這一側,它看到的並不是一個個特定的設備,而是一個個內存地址或者端口地址,CPU只是向這些地址傳輸數據或者讀取數據。所需要的指令和操作內存地址的指令其實沒有什麼本質差別。通過軟件層面對於傳輸的命令數據的定義,而不是提供特殊的新的指令,來實際操作對應的I/O硬件。

24. HDD硬盤接收一個來自CPU的請求能夠在幾毫秒時間返回,傳輸數據的速度也有200MB/s左右。平時往數據庫裏寫入一條記錄,也就是1KB左右的大小,拿200MB去除以1KB差不多每秒鐘可以插入20萬條數據,但是這個計算出來的數字和日常的經驗不符合,答案就來自於硬盤的讀寫。在順序讀寫和隨機讀寫的情況下,硬盤的性能是完全不同的。比如AS SSD的性能指標裏面有一個“4K”的指標,其實就是程序去隨機讀取磁盤上某一個4KB大小的數據,一秒之內可以讀取到多少數據。

在4K隨機訪問這個指標上,使用SATA 3.0接口的硬盤和PCI Express接口的硬盤,性能差異變得很小。這是因爲在這個時候,接口本身的速度已經不是硬盤訪問速度的瓶頸了。即使用PCI Express的接口,在隨機讀寫的時候數據傳輸率也只能到40MB/s左右,是順序讀寫情況下的幾十分之一。拿這個40MB/s和一次讀取4KB的數據相除,也就是說一秒之內這塊SSD硬盤可以隨機讀取1萬次的4KB的數據。如果是寫入的話會更多一些,90MB /4KB差不多是2萬多次(隨機讀性能弱於隨機寫)。這個4K隨機每秒讀寫的次數稱之爲IOPS,即每秒輸入輸出操作的次數。事實上比起硬盤響應時間,一般更關注IOPS這個性能指標。IOPS和DTR(Data Transfer Rate,數據傳輸率)纔是輸入輸出性能的核心指標。因此,HDD硬盤的IOPS通常也就在100左右,而不是上面順序讀取的20萬次。

25. 即使是用上了PCI Express接口的SSD硬盤,IOPS也就是在2萬左右,而CPU的主頻通常在2GHz以上,也就是每秒可以做20億次操作。即使CPU向硬盤發起一條讀寫指令需要很多個時鐘週期,一秒鐘CPU能夠執行的指令數,和硬盤能夠進行的操作數相比也有好幾個數量級的差異。這也是爲什麼,在應用開發的時候往往會說“性能瓶頸在I/O上”因爲很多時候,CPU指令發出去之後,不得不去“等”I/O操作完成,才能進行下一步的操作

在實際遇到服務端程序的性能問題時,爲了知道問題是不是來自於CPU在等待I/O來完成操作,可以通過top和iostat命令查看,如下所示:

$ top
top - 06:56:02 up 3 days, 19:34,  2 users,  load average: 5.99, 1.82, 0.63
Tasks:  88 total,   3 running,  85 sleeping,   0 stopped,   0 zombie
%Cpu(s):  3.0 us, 29.9 sy,  0.0 ni,  0.0 id, 67.2 wa,  0.0 hi,  0.0 si,  0.0 st
KiB Mem :  1741304 total,  1004404 free,   307152 used,   429748 buff/cache
KiB Swap:        0 total,        0 free,        0 used.  1245700 avail Mem

在top命令的輸出結果裏,有一行是以%CPU開頭的。這一行裏有一個叫wa的指標,代表着iowait也就是CPU等待IO完成操作花費的時間佔CPU的百分比。當服務器遇到性能瓶頸load很大的時候,就可以通過top看一看這個指標。知道了iowait很大,那麼就要去看一看實際的I/O操作情況是什麼樣的。這個時候就可以去用iostat命令了,可以看到實際的硬盤讀寫情況

$ iostat
avg-cpu:  %user   %nice %system %iowait  %steal   %idle
           5.03    0.00   67.92   27.04    0.00    0.00
Device:            tps    kB_read/s    kB_wrtn/s    kB_read    kB_wrtn
sda           39762.26         0.00         0.00          0          0

這個命令裏不僅有iowait這個CPU等待時間的百分比,還有一些更加具體的指標,並且它還是按照機器上安裝的多塊不同硬盤劃分的。這裏的tps指標其實就對應着硬盤的IOPS性能,而kB_read/s和kB_wrtn/s指標就對應着數據傳輸率的指標。知道實際硬盤讀寫的tps、kB_read/s和kb_wrtn/s的指標,基本上可以判斷出機器的性能是不是卡在I/O上了

那麼接下來就是要找出到底是哪一個進程是這些I/O讀寫的來源了。這個時候需要iotop命令,如下所示:

$ iotop
Total DISK READ :       0.00 B/s | Total DISK WRITE :       0.00 B/s
Actual DISK READ:       0.00 B/s | Actual DISK WRITE:       0.00 B/s
  TID  PRIO  USER     DISK READ  DISK WRITE  SWAPIN     IO>    COMMAND                                             
29161 be/4 xuwenhao    0.00 B/s    0.00 B/s  0.00 % 56.71 % stress -i 2
29162 be/4 xuwenhao    0.00 B/s    0.00 B/s  0.00 % 46.89 % stress -i 2
1 be/4 root        0.00 B/s    0.00 B/s  0.00 %  0.00 % init

通過iotop這個命令,可以看到具體是哪一個進程實際佔用了大量I/O,那就可以去優化對應的程序了。

七、機械硬盤(HDD)

26. 機械硬盤的IOPS大概只能做到每秒100次左右。這個次數和機械硬盤的物理構造有關,如下所示:

(1)首先是盤面(Disk Platter),就在實際存儲數據的盤片上。盤面上有一層磁性的塗層,數據就存儲在這個磁性塗層上。盤面中間有一個受電機控制的轉軸,會控制盤面去旋轉。硬盤轉速有5400、7200轉等,單位也叫RPM即每分鐘的旋轉圈數(Rotations Per Minute)。

(2)接着是磁頭(Drive Head)。數據並不能直接從盤面傳輸到總線上,而是通過磁頭從盤面上讀取到,然後再通過電路信號傳輸給控制電路、接口,再到總線上的。通常一個盤面上會有兩個磁頭,分別在盤面的正反面,盤面在正反兩面都有對應的磁性塗層來存儲數據,而且一塊硬盤也不只一個盤面,而是上下堆疊了很多個盤面,各個盤面之間是平行的,每個盤面正反兩面都有對應的磁頭。

(3)最後是懸臂(Actutor Arm)。懸臂與磁頭相連,並且在一定範圍內會把磁頭定位到盤面的某個特定磁道(Track)上。一個盤面由很多個同心圓組成,就好像是一個個大小不一樣的“甜甜圈”嵌套在一起。每一個“甜甜圈”都是一個磁道,每個磁道都有自己的編號。懸臂其實只是控制到底是讀最裏面那個“甜甜圈”的數據,還是最外面“甜甜圈”的數據。

一個磁道會分成一個一個扇區(Sector)。上下平行的一個個盤面的相同扇區叫作一個柱面(Cylinder)。如下所示:

讀取數據有兩個步驟:

(1)把盤面旋轉到某一個位置,在這個位置上懸臂可以定位到整個盤面的某一個子區間。這個子區間的形狀有點像一塊披薩餅,一般把這個區間叫作幾何扇區(Geometrical Sector),意思是在“幾何位置上”所有這些扇區都可以被懸臂訪問到。

(2)把懸臂移動到特定磁道的特定扇區,也就是在這個“幾何扇區”裏面找到實際的扇區。找到之後磁頭會落下,就可以讀取到正對着扇區的數據。

因此,進行一次硬盤上的隨機訪問,需要的時間由兩個部分組成:

(1)平均延時(Average Latency)。這個時間其實就是把盤面旋轉,把幾何扇區對準懸臂位置的時間。它其實就和機械硬盤的轉速相關。隨機情況下平均找到一個幾何扇區需要旋轉半圈盤面,例如7200轉的硬盤一秒裏面就可以旋轉240個半圈(1秒120整圈),那麼這個平均延時就是1s / 240 = 4.17ms。

(2)平均尋道時間(Average Seek Time)。就是在盤面旋轉之後,懸臂定位到扇區的的時間。HDD硬盤的平均尋道時間一般在4-10ms,這樣就能夠算出,如果隨機在整個硬盤上找一個數據需要8~14ms。由於硬盤是機械結構的,只有一個電機轉軸也只有一個懸臂,所以沒有辦法並行地去定位或者讀取數據。因此一塊7200轉的硬盤一秒鐘隨機的IO訪問次數,也就是1s / 8 ms = 125 IOPS或者1s / 14ms = 70 IOPS。

如果不是去進行隨機的數據訪問,而是進行順序的數據讀寫,爲了最大化讀取效率可以選擇把順序存放的數據儘可能地存放在同一個柱面上,這樣只需要旋轉一次盤面進行一次尋道,就可以去寫入或者讀取同一個垂直空間上的多個盤面的數據。如果一個柱面上的數據不夠也不用去動懸臂,而是通過電機轉動盤面,這樣就可以順序讀完一個磁道上的所有數據。所以其實對於HDD硬盤的順序數據讀寫,吞吐率還是很不錯的,可以達到200MB/s左右。

27. 爲了進一步提高機械硬盤的IOPS,目前有個方法叫做Partial Stroking或者Short Stroking(縮短行程)技術。這個技術優化的思路是既然訪問一次數據的時間是“平均延時 + 尋道時間”,那麼只要能縮短這兩個之一就可以提升IOPS。一般情況下,硬盤的尋道時間都比平均延時要長,縮短平均尋道時間最極端的辦法就是不需要尋道,也就是說把所有數據都放在一個磁道上,這樣尋道時間就基本爲0,訪問時間就只有平均延時了。那樣IOPS就變成了1s / 4ms = 250 IOPS。

不過只用一個磁道能存的數據就比較有限了,所以實踐當中可以只用最外1/4或1/2的磁道。這樣硬盤可以使用的容量可能變成了1/2或者1/4,但是尋道時間也變成了1/4或者1/2,因爲懸臂需要移動的“行程”也變成了原來的1/2或者1/4,IOPS就能夠大幅度提升了。

比如說一塊7200轉的硬盤,正常情況下平均延時是4.17ms,而尋道時間是9ms,那麼它原本的IOPS就是1s / (4.17ms + 9ms) = 75.9 IOPS。如果只用其中1/4的磁道,那麼它的IOPS就變成了1s / (4.17ms + 9ms/4) = 155.8 IOPS。這樣IOPS提升了一倍,和一塊15000轉硬盤的性能差不多了,而15000轉硬盤的價格遠不止7200轉硬盤的四倍,這樣通過軟件去格式化硬盤,只保留部分磁道讓系統可用的情況,可以大大提升硬件的性價比。多年前的谷歌在SSD不像如今普遍的情況下,就考慮了類似的解決方案。

由於機械硬盤分區是由外到內的,C盤往往在最外側磁道,所以機械硬盤裏面C盤的性能是所有分區裏最好的。要想只用最外側1/4的磁道,只需要簡單地把C盤分成整個硬盤1/4的容量,剩下的容量棄而不用就可以達到相對較高的IOPS效果了。

八、固態硬盤(SSD)

28. 無論是用10000轉的企業級機械硬盤,還是用Short Stroking這樣的方式進一步提升IOPS,HDD硬盤的速度漸漸已經滿足不了需求了,上面這些優化措施無非就是把IOPS從100提升到300、500也就到頭了。而一塊普通的SSD硬盤,可以輕鬆支撐10000乃至20000的IOPS。因爲SSD沒有像機械硬盤那樣的尋道過程,所以它的隨機讀寫都更快。HDD與SSD的對比如下所示:

在速度上HDD遠不如SSD,但是HDD的耐用性更好,如果需要頻繁地重複寫入刪除數據,那麼機械硬盤要比SSD性價比高很多。關於SSD耐用性相對更差的原因,需要先理解SSD硬盤的存儲和讀寫原理。CPU Cache用的SRAM是用一個電容來存放一個比特的數據。對於SSD硬盤也可以先簡單地認爲它由一個電容加上一個電壓計組合在一起,記錄了一個或者多個比特。能夠記錄一個比特很容易理解。給電容裏充上電有電壓的時候就是1,給電容放電裏面沒有電就是0。採用這樣方式存儲數據的SSD硬盤,一般稱之爲使用了SLC(Single-Level Cell)的顆粒,也就是一個存儲單元中只有一位數據。如下所示:

但是這樣的方式會遇到和CPU Cache類似的問題,那就是同樣的面積下,能夠存放下的元器件是有限的。如果只用SLC,就會遇到存儲容量上不去,並且價格下不來的問題。於是硬件工程師們就陸續發明了MLC(Multi-Level Cell)、TLC(Triple-Level Cell)以及QLC(Quad-Level Cell),也就是能在一個電容裏面存下2個、3個乃至4個比特。如下所示:

只有一個電容,爲了能夠表示更多的比特,就需要有一個電壓計。4個比特一共可以從0000-1111表示16個不同的數。那麼如果能往電容裏面充電的時候,充上15個不同的電壓,並且電壓計能夠區分出這15個不同的電壓,加上電容被放空代表的0,就能夠代表從0000-1111這樣4個比特了。不過要想表示15個不同的電壓,充電和讀取的時候對於精度的要求就會更高。這會導致充電和讀取的時候都更慢,所以QLC的SSD的讀寫速度要比SLC的慢上好幾倍

29. SSD硬盤的硬件構造大致是自頂向下的幾個部分構成:

(1)對應的接口和控制電路。現在SSD硬盤用的是SATA或者PCI Express接口。在控制電路里有一個很重要的模塊叫作FTL(Flash-Translation Layer),也就是閃存轉換層。這個可以說是SSD硬盤的一個核心模塊,SSD硬盤性能的好壞,很大程度上也取決於FTL的算法好不好

(2)實際I/O設備。它其實和機械硬盤很像。現在新的大容量SSD硬盤都是3D封裝的了,也就是說是由很多個裸片(Die)疊在一起的,就好像機械硬盤把很多個盤面(Platter)疊放再一起一樣,這樣可以在同樣的空間下放下更多的容量。

(3)一張裸片上可以放多個平面(Plane),一般一個平面上的存儲容量大概在GB級別。一個平面上面會劃分成很多個(Block),一般一個塊(Block)的存儲大小通常幾百KB到幾MB大小。一個塊裏面還會區分很多個(Page),就和內存裏面的頁一樣,一個頁的大小通常是4KB。

在這一層一層的結構裏,處在最下面的兩層塊和頁非常重要。對於SSD硬盤來說,數據的寫入叫作Program,寫入不能像機械硬盤一樣通過覆寫(Overwrite)來進行,而是要先去擦除(Erase)然後再寫入。SSD的讀取和寫入的基本單位,不是一個比特(bit)或者一個字節(byte),而是一個頁(Page)。SSD的擦除單位就更誇張了,必須按照塊來擦除

SSD的使用壽命,其實是每一個塊(Block)的擦除的次數。可以把SSD硬盤的一個平面看成是一張白紙。在上面寫入數就好像用鉛筆在白紙上寫字。如果想要把已經寫過字的地方寫入新的數據,我們要用橡皮把已經寫好的字擦掉。但是如果頻繁擦同一個地方,那這個地方就會破掉,之後就沒有辦法再寫字了。SLC的芯片可以擦除的次數大概在10萬次,MLC就在1萬次左右,而TLC和QLC就只在幾千次了

30. 對於SSD的日常使用運行,可以舉一個例子。用三種顏色分別來表示SSD硬盤裏的頁的不同狀態,白色代表這個頁從來沒有寫入過數據,綠色代表裏面寫入的是有效的數據,紅色代表裏面的數據在操作系統看來已經是刪除的了。如下所示:

一開始所有塊的每一個頁都是白色的。隨着開始往裏面寫數據,裏面的有些頁就變成了綠色。然後因爲刪除了硬盤上的一些文件,所以有些頁變成了紅色。但是這些紅色的頁並不能再次寫入數據,因爲SSD硬盤不能單獨擦除一個頁,必須一次性擦除整個塊,所以新的數據只能往後面的白色的頁裏面寫。這些散落在各個綠色空間裏面的紅色空洞就好像磁盤碎片。如果有哪一個塊的數據一次性全部被標紅了,那就可以把整個塊進行擦除,它就又會變成白色,可以重新一頁一頁往裏面寫數據。這種情況其實也會經常發生。畢竟一個塊不也就在幾百 K到幾 MB。刪除一個幾MB的文件,數據又是連續存儲的,自然會導致整個塊可以被擦除。

隨着硬盤裏面的數據越來越多,紅色空洞佔的地方也會越來越多,於是漸漸就要沒有白色的空頁去寫入數據了。這個時候要做一次類似於Windows“磁盤碎片整理”或者Java“內存垃圾回收”工作,找一個紅色空洞最多的塊,把裏面的綠色數據挪到另一個塊裏面去,然後把整個塊擦除變成白色,可以重新寫入數據。不過這個“磁盤碎片整理”或者“內存垃圾回收”的工作不能太主動、太頻繁地去做,因爲SSD的擦除次數是有限的。如果動不動就搞個磁盤碎片整理,那麼SSD硬盤很快就會報廢了。

因此一般SSD的空間無法完全用滿,因爲總會遇到一些紅色空洞。生產SSD硬盤的廠商其實是預留了一部分空間,專門用來做這個“磁盤碎片整理”工作。一塊標成240G的SSD硬盤,往往實際256G 的硬盤空間。SSD硬盤通過控制芯片電路把多出來的硬盤空間,用來進行各種數據的轉移和塊擦除。這個劃出來的16G空間叫作預留空間(Over Provisioning),一般SSD的硬盤的預留空間都在7%-15%左右,如下所示:

31. SSD硬盤特別適合讀多寫少的應用,在日常應用裏系統盤適合用SSD。但是,如果用SSD做專門的下載盤以及刻盤備份就不太好了。在數據中心裏面,SSD的應用場景也是適合讀多寫少的場景。SSD硬盤用來做數據庫,存放電商網站的商品信息很合適。但是,如果用來作爲Hadoop這樣的MapReduce應用的數據盤就不行了,因爲MapReduce任務會大量在任務中間向硬盤寫入中間數據再刪除掉,這樣用不了多久SSD硬盤的壽命就會到了。對於日誌系統,寫入量大而且有些還會清除老舊的日誌,反而讀日誌卻不多,所以日誌系統完全不適合存放在SSD硬盤上,應該用HDD硬盤

日常使用PC進行軟件開發的時候,會先在硬盤上裝上操作系統和常用軟件。這些軟件所在的塊,寫入一次之後就不太會擦除了,所以就只有讀的需求。一旦開始代碼開發就會不斷添加新的代碼文件,還會不斷修改已經有的代碼文件。因爲SSD硬盤沒有覆寫(Override)的功能,所以這個過程中其實是在反覆地寫入新的文件,然後再把原來的文件標記成邏輯上刪除的狀態。等SSD裏面空的塊少了,就會用“垃圾回收”的方式進行擦除。這樣擦除會反覆發生在這些用來存放數據的地方。如下所示:

有一天這些塊的擦除次數到了,變成了壞塊,但是安裝操作系統和軟件的地方還沒有壞,而這塊硬盤的可以用的容量卻變小了。

32. 爲了不讓這些壞塊那麼早就出現,優化的思路是考慮勻出一些存放操作系統的塊的擦寫次數,給到這些存放數據的地方也就是讓SSD硬盤各個塊的擦除次數,均勻分攤到各個塊上,這個策略就叫作磨損均衡(Wear-Leveling)。實現這個技術的核心辦法就和虛擬內存一樣,就是添加一個間接層,這個間接層就是上面說過的FTL即閃存轉換層。如下所示:

就像在管理內存的時候,通過一個頁表映射虛擬內存頁和物理頁一樣,在FTL裏面存放了邏輯塊地址(Logical Block Address,簡稱 LBA)到物理塊地址(Physical Block Address,簡稱 PBA)的映射。操作系統訪問的硬盤地址其實都是邏輯地址,只有通過FTL轉換之後纔會變成實際的物理地址,找到對應的塊進行訪問。操作系統本身不需要去考慮塊的磨損程度,只要和操作機械硬盤一樣來讀寫數據就好了。

操作系統所有對於SSD硬盤的讀寫請求都要經過FTL,FTL裏面又有邏輯塊對應的物理塊,所以FTL能夠記錄下來每個物理塊被擦寫的次數。如果一個物理塊被擦寫的次數多了,FTL就可以將這個物理塊挪到一個擦寫次數少的物理塊上,但是邏輯塊不用變,操作系統也不需要知道這個變化,這也是在設計大型系統中的一個典型思路,也就是各層之間是隔離的,操作系統不需要考慮底層的硬件是什麼,完全交由硬件控制電路里面的FTL,來管理對於實際物理硬件的寫入。

33. 不過操作系統不去關心實際底層的硬件是什麼,在SSD硬盤的使用上也會帶來一個問題,就是操作系統的邏輯層和SSD的邏輯層裏的塊狀態是不匹配的。在操作系統裏面去刪除一個文件,其實並沒有真的在物理層面去刪除這個文件,只是在文件系統裏面把對應的inode裏面的元信息清理掉,這代表這個inode還可以繼續使用,可以寫入新的數據。這個時候實際物理層面的對應的存儲空間,在操作系統裏面被標記成可以寫入了。

所以其實日常的文件刪除,都只是一個操作系統層面的邏輯刪除。這也是爲什麼很多時候不小心刪除了對應的文件,還可以通過各種恢復軟件把數據找回來,因爲物理數據還在只需要恢復元信息。同樣的這也是爲什麼如果想要刪除乾淨數據,需要用各種“文件粉碎”的功能纔行。這個刪除的邏輯在機械硬盤層面沒有問題,因爲文件被標記成可以寫入,後續的寫入可以直接覆寫這個位置。但是在SSD硬盤上就不一樣了,如下所示:

當在操作系統裏面刪除掉一個剛剛下載的文件,比如標記成黃色openjdk.exe這樣的安裝文件,在操作系統裏對應的inode裏面,就沒有了文件的元信息。但是這個時候SSD的邏輯塊層面其實並不知道這個事情,所以在邏輯塊層面openjdk.exe仍然是佔用了對應的空間,對應的物理頁也仍然被認爲是被佔用了的。這個時候如果需要對SSD進行垃圾回收操作,openjdk.exe對應的物理頁仍然要在這個過程中,被搬運到其他的Block裏面去。只有當操作系統再在剛纔的inode裏面寫入數據的時候,SSD纔會知道原來黃色的頁其實都已經沒有用了,纔會把它標記成廢棄掉

所以在使用SSD的情況下,操作系統對於文件的刪除SSD硬盤其實並不知道。這就導致爲了磨損均衡,很多時候都在搬運很多已經刪除了的數據,這就會產生很多不必要的數據讀寫和擦除,既消耗了SSD的性能,也縮短了SSD的使用壽命。爲了解決這個問題,現在的操作系統和SSD的主控芯片都支持TRIM命令,這個命令可以在文件被刪除的時候,讓操作系統去通知SSD硬盤,對應的邏輯塊已經標記成已刪除了,現在的SSD硬盤都已經支持了TRIM命令。

34. 其實TRIM命令的發明也反應了一個使用SSD硬盤的問題,那就是SSD硬盤容易越用越慢。當SSD硬盤的存儲空間被佔用得越來越多,每一次寫入新數據都可能沒有足夠的空白,不得不去進行垃圾回收,合併一些塊裏面的頁然後再擦除掉一些頁,才能勻出一些空間來。這個時候從應用層或者操作系統層面來看,可能只是寫入了一個4KB或者4MB的數據,但是實際通過FTL之後,可能要去搬運8MB、16MB甚至更多的數據。

通過“實際的閃存寫入的數據量 / 系統通過FTL寫入的數據量 = 寫入放大”,可以得到寫入放大的倍數越多,意味着實際的SSD性能也就越差,會遠遠比不上實際SSD硬盤標稱的指標。而解決寫入放大,需要在後臺定時進行垃圾回收,在硬盤比較空閒的時候就把搬運數據、擦除數據、留出空白的塊的工作做完,而不是等實際數據寫入的時候再進行這樣的操作。

35. 因此,想要把SSD硬盤用好,其實沒有那麼簡單。如果只簡單地拿一塊SSD硬盤替換掉原來的HDD硬盤,而不是從應用層面考慮任何SSD硬盤特性的話,多半還是沒法獲得想要的性能提升。例如AeroSpike這個專門針對SSD硬盤特性設計的Key-Value 數據庫,很好利用了SSD的物理特性:

(1)AeroSpike操作SSD硬盤,並沒有通過操作系統的文件系統而是直接操作SSD裏面的塊和頁,因爲操作系統裏面的文件系統對於KV數據庫來說,只是多了一層間接層,只會降低性能沒有什麼實際的作用。

(2)AeroSpike在讀寫數據的時候,做了兩個優化。在寫入數據的時候,AeroSpike儘可能去寫一個較大的數據塊,而不是頻繁地去寫很多小的數據塊。這樣硬盤就不太容易頻繁出現磁盤碎片。並且一次性寫入一個大的數據塊,也更容易利用好順序寫入的性能優勢。AeroSpike寫入的一個數據塊是128KB,遠比一個頁的4KB要大得多。另外在讀取數據的時候,AeroSpike可以讀取512字節(Bytes)這樣的小數據,因爲SSD的隨機讀取性能很好,也不像寫入數據那樣有擦除壽命問題。而且很多時候讀取的數據是鍵值對裏面的值的數據,這些數據要在網絡上傳輸。如果一次性必須讀出比較大的數據,就會導致網絡帶寬不夠用。

因爲AeroSpike是一個對於響應時間要求很高的實時KV數據庫,如果出現了嚴重的寫放大效應,會導致寫入數據的響應時間大幅度變長。所以AeroSpike做了這樣幾個動作:

(1)持續地進行磁盤碎片整理。AeroSpike用了所謂的高水位(High Watermark)算法,就是一旦一個物理塊裏面的數據碎片超過50%,就把這個物理塊搬運壓縮,然後進行數據擦除,確保磁盤始終有足夠的空間可以寫入。

(2)爲了保障數據庫的性能,開發者建議只用到SSD硬盤標定容量的一半。也就是說人爲地給SSD硬盤預留了50%的預留空間,以確保SSD硬盤的寫放大效應儘可能小,不會影響數據庫的訪問性能。

正是因爲做了種種的優化,在NoSQL數據庫剛剛興起的時候,AeroSpike的性能把Cassandra、MongoDB這些數據庫遠遠甩在身後,和這些數據庫之間的性能差距有時候會到達一個數量級,這也讓AeroSpike成爲了當時高性能KV數據庫的標杆。

九、DMA:Kafka速度快的原因之一

36. 無論I/O速度如何提升,比起CPU總還是太慢。SSD硬盤的IOPS可以到2萬和4萬,但是目前CPU的主頻在2GHz以上,也就意味着每秒會有20億次的操作。如果對於I/O的操作都是由CPU發出對應的指令,然後等待I/O設備完成操作之後返回,那CPU有大量的時間其實都是在等待I/O設備完成操作。但是這個CPU的等待,在很多時候其實並沒有太多的意義。對於I/O設備的大量操作,其實都只是把內存裏面的數據傳輸到I/O設備而已。特別是當傳輸的數據量比較大的時候,比如進行大文件複製,如果所有數據都要經過CPU,實在是太浪費時間了。因此計算機工程師們就發明了DMA技術,也就是直接內存訪問(Direct Memory Access)技術,來減少CPU等待的時間。

本質上DMA技術就是在主板上放一塊獨立的芯片。在進行內存和I/O設備的數據傳輸時,不再通過CPU來控制數據傳輸,而直接通過DMA控制器(DMA Controller,簡稱 DMAC),這塊芯片可以認爲它其實就是一個協處理器(Co-Processor)。DMAC最有價值的地方體現在當要傳輸的數據特別大、速度特別快,或者傳輸的數據特別小、速度特別慢的時候。比如說用千兆網卡或者硬盤傳輸大量數據的時候,如果都用CPU來控制搬運肯定忙不過來,所以可以選擇讓DMAC處理。而當數據傳輸很慢的時候,DMAC可以等數據到齊了,再發送信號給到CPU去處理,而不是讓CPU在那裏空轉忙等待

DMAC是一塊“協處理器芯片”,這裏的“協”字是指“協助”CPU完成對應的數據傳輸工作,因此在DMAC控制數據傳輸的過程中,依然還是需要CPU。除此之外DMAC其實也是一個特殊的I/O設備,它和CPU以及其他I/O設備一樣,通過連接到總線來進行實際的數據傳輸。總線上的設備其實有兩種類型。一種稱之爲主設備(Master),另外一種稱之爲從設備(Slave)。

想要主動發起數據傳輸,必須要是一個主設備纔可以,CPU就是主設備,而從設備(比如硬盤)只能接受數據傳輸。所以如果通過CPU來傳輸數據,要麼是CPU從I/O設備讀數據,要麼是CPU向I/O設備寫數據。如果是I/O設備向主設備發起請求,發送的不是數據內容,而是控制信號,I/O設備可以告訴CPU這裏有數據要傳輸給它,但是實際數據是CPU拉走的,而不是I/O設備推給CPU。如下所示:

不過DMAC很有意思,它既是一個主設備,又是一個從設備。對於CPU來說它是一個從設備;對於硬盤這樣的IO設備來說它又變成了一個主設備。使用DMAC進行數據傳輸的過程如下:

(1)首先CPU作爲一個主設備向DMA 設備發起請求。這個請求其實就是在DMAC裏面修改配置寄存器。

(2)CPU修改DMAC配置的時候,會告訴DMAC這樣幾個信息:

a.首先是源地址的初始值以及傳輸時候的地址增減方式。所謂源地址就是數據要從哪裏傳輸過來。如果要從內存裏面寫入數據到硬盤上,那麼就是要讀取的數據在內存裏面的地址;如果是從硬盤讀取數據到內存裏,那就是硬盤的I/O接口的地址。I/O地址可以是一個內存地址,也可以是一個端口地址。而地址的增減方式就是數據是從大的地址向小的地址傳輸,還是從小的地址往大的地址傳輸。

b.其次是目標地址初始值和傳輸時候的地址增減方式。目標地址自然就是和源地址對應的設備,也就是數據傳輸的目的地。

c.第三個是要傳輸的數據長度,也就是一共要傳輸多少數據。

(3)設置完這些信息之後,DMAC就會進入一個空閒狀態(Idle)。

(4)如果要從硬盤上往內存里加載數據,這個時候硬盤就會向DMAC發起一個數據傳輸請求。這個請求並不是通過總線,而是通過一個額外的連線。

(5)DMAC需要再通過一個額外的連線響應這個申請。

(6)DMAC向硬盤的接口發起要總線讀的傳輸請求。數據就從硬盤裏讀到了DMAC的控制器裏面。

(7)DMAC再向內存發起總線寫的數據傳輸請求,把數據寫入到內存裏面。

(8)DMAC會反覆進行上面第6、7步的操作,直到DMAC的寄存器裏設置的數據長度傳輸完成。

(9)數據傳輸完成之後,DMAC重新回到第3步的空閒狀態。

所以整個數據傳輸的過程中,不是通過CPU來搬運數據,而是由DMAC來搬運數據。但是CPU在這個過程中也是必不可少的,因爲傳輸什麼數據,從哪裏傳輸到哪裏,其實還是由CPU來設置的,這也是爲什麼DMAC被叫作“協處理器”。最早計算機裏是沒有DMAC的,所有數據都由CPU來搬運。隨着人們對於數據傳輸的需求越來越多,先是出現了主板上獨立的DMAC控制器。到了今天各種I/O設備越來越多,數據傳輸的需求越來越複雜,使用的場景各不相同,而且顯示器、網卡、硬盤對於數據傳輸的需求都不一樣,所以各個設備裏面都有自己的DMAC芯片了。如下所示:

37. 有一個開源項目很好地利用了DMA的數據傳輸方式,通過DMA實現了非常大的性能提升,這個項目就是大數據領域的Kafka。Kafka是一個用來處理實時數據的管道,常常用它來做一個消息隊列,或者用來收集和落地海量的日誌。作爲一個處理實時數據和日誌的管道,瓶頸自然也在I/O層面。Kafka裏面會有兩種常見的海量數據傳輸的情況,一種是從網絡中接收上游的數據,然後需要落地到本地的磁盤上,確保數據不丟失;另一種情況則是從本地磁盤上讀取出來,通過網絡發送出去。

例如後一種情況從磁盤讀數據發送到網絡上去,如果寫一個簡單的程序,最直觀的辦法自然是用一個文件讀操作,從磁盤上把數據讀到內存裏面來,然後再用一個Socket把這些數據發送到網絡上去。例如下面來自IBM的僞代碼:

File.read(fileDesc, buf, len);

Socket.send(socket, buf, len);

在這個過程中,數據一共發生了四次傳輸的過程。其中兩次是DMA的傳輸,另外兩次則是通過CPU控制的傳輸。具體的過程如下:

(1)第一次傳輸是從硬盤上,讀到操作系統內核的緩衝區裏。這個傳輸是通過DMA搬運的。

(2)第二次傳輸需要從內核緩衝區裏面的數據,複製到應用分配的內存裏面。這個傳輸是通過CPU搬運的。

(3)第三次傳輸,要從應用的內存裏面再寫到操作系統的Socket緩衝區裏面去。這個傳輸還是由CPU搬運的。

(4)最後一次傳輸,需要再從Socket的緩衝區裏面,寫到網卡的緩衝區裏面去。這個傳輸又是通過DMA搬運的。

然而,真正的需求只是想要“搬運”一份數據,結果卻整整搬運了四次。操作系統的內核緩衝區其實也在內存的某一個地方(只是用戶訪問不到),第二步複製到應用程序內存中,事實上就是從內存的某一個地方複製到內存另一個地方,使應用程序可以操作。所以從內核的讀緩衝區傳輸到應用的內存裏,再從應用的內存裏傳輸到Socket的緩衝區裏,其實都是把同一份數據在內存裏面搬運來搬運去,特別沒有效率。像Kafka這樣的應用場景,其實大部分最終利用到的硬件資源,又都是在幹這個搬運數據的事,所以就需要儘可能地減少數據搬運的需求。Kafka做的事情,就是把這個數據搬運的次數,從上面的四次變成了兩次,並且只有DMA來進行數據搬運,而不需要CPU。如下面的代碼所示:

@Override
public long transferFrom(FileChannel fileChannel, long position, long count) throws IOException {
    return fileChannel.transferTo(position, count, socketChannel);
}

Kafka的代碼調用了Java NIO庫,具體是FileChannel裏面的transferTo方法。數據並沒有讀到中間的應用內存裏面,而是直接通過Channel寫入到對應的網絡設備裏。並且對於Socket的操作,也不是寫入到 Socket的Buffer裏面,而是直接根據描述符(Descriptor)直接寫入到網卡的緩衝區裏面於是在這個過程之中,Kafka只進行了兩次數據傳輸。如下所示:

第一次傳輸,是通過DMA從硬盤直接讀到操作系統內核的讀緩衝區裏面。第二次則是根據Socket描述符信息,直接從讀緩衝區裏面,寫入到網卡的緩衝區裏面。通過砍掉兩次內核空間和用戶空間的數據拷貝,以及內核態和用戶態的切換成本來優化,這樣同一份數據傳輸的次數從四次變成了兩次,並且沒有通過CPU來進行數據搬運,所有的數據都是通過DMA來進行傳輸的。在這個方法裏面沒有在內存層面去“複製(Copy)”數據,所以這個方法也被稱之爲零拷貝(Zero-Copy。IBM Developer Works裏有一篇文章專門寫過程序來測試過,在同樣的硬件下使用零拷貝能夠帶來的性能提升。結論是無論傳輸數據量的大小,傳輸同樣的數據,使用了零拷貝能夠縮短65%的時間,大幅度提升了機器傳輸數據的吞吐量。

十、數據完整性:檢錯與糾錯

38. 如果在分佈式計算中沒有使用ECC內存(Error-Correcting Code memory,糾錯內存。顧名思義就是在內存裏出現錯誤的時候,能夠自己糾正過來),可能就會出現硬件上的單比特翻轉(Single-Bit Flip)錯誤。比如“34+23”,結果應該是“57”,但是卻變成了一個美元符號“$”。這個符號出現可能是由於內存中的一個整數字符,遇到了一次單比特翻轉轉化而來的。4的ASCII碼二進制表示是0010 0100,所以“$”完全可能來自0011 0100遇到一次在第4個比特的單比特翻轉,也就是從整數“4”變過來的。如下所示:

其實內存裏的單比特翻轉或者錯誤,並不是一個特別罕見的現象。無論是因爲內存的製造質量造成的漏電,還是外部的射線,都有一定的概率會造成單比特錯誤。而內存層面的數據出錯軟件工程師並不知道,而且這個出錯很有可能是隨機的,所以必須要有一個辦法避免這個問題。在ECC內存發明之前,工程師們已經開始通過奇偶校驗的方式來發現這些錯誤。奇偶校驗的思路很簡單,把內存裏面N 位比特當成是一組,比如8位就是一個字節,然後用額外的一位去記錄這8個比特里面有奇數個1還是偶數個1,如果是奇數個1那額外的一位就記錄爲1;如果是偶數個1那額外的一位就記錄成0,那額外的一位就稱之爲校驗碼位。如下所示:

如果在這個字節裏面發生了單比特翻轉,那麼數據位計算得到的校驗碼,就和實際校驗位裏面的數據不一樣,內存就知道出錯了。校驗位有一個很大的優點,就是計算非常快,往往只需要遍歷一遍需要校驗的數據,通過一個O(N)的時間複雜度的算法,就能把校驗結果計算出來。校驗碼的思路在很多地方都會用到。比如下載一些軟件會看到有對應的MD5這樣的哈希值或者循環冗餘編碼(CRC)校驗文件。這樣把軟件下載下來之後,可以計算一下對應軟件的校驗碼,和官方提供的校驗碼是不是一樣,如果不一樣就不能輕易去安裝這個軟件了,因爲有可能這個軟件包是壞的,或者被人篡改過。

不過使用奇偶校驗,還是有兩個比較大的缺陷:

(1)奇偶校驗只能解決遇到單個位的錯誤,或者說奇數個位的錯誤。如果出現2個位進行了翻轉,那麼這個字節的校驗位計算結果其實沒有變,校驗位自然也就不能發現這個錯誤。

(2)只能發現錯誤,但是不能糾正錯誤。所以即使在內存裏面發現數據錯誤了,也只能中止程序而不能讓程序繼續正常地運行下去。對於龐大的分佈式計算任務,無法糾錯而選擇從頭重跑任務,肯定會讓人崩潰。

因此不僅需要能捕捉到錯誤,還要能夠糾正發生的錯誤,這個策略通常叫作糾錯碼(Error Correcting Code)。它還有一個升級版本叫作糾刪碼(Erasure Code),不僅能夠糾正錯誤,還能夠在錯誤不能糾正的時候,直接把數據刪除。無論是ECC內存還是網絡傳輸,乃至硬盤的RAID,其實都利用了糾錯碼和糾刪碼的相關技術。

39. 無論是奇偶校驗碼,還是CRC這樣的循環校驗碼,都只能知道數據出錯了。所以校驗碼也被稱爲檢錯碼(Error Detecting Code)。但是,具體錯在哪裏校驗碼是回答不了的。這就導致處理方式只有一種,那就是當成“哪兒都錯了”。如果是下載一個文件發現校驗碼不匹配,只能重新去下載;如果是程序計算後放到內存裏面的數據,只能再重新算一遍,這樣的效率實在是太低了,所以需要有一個辦法不僅能檢錯還能糾錯。於是計算機科學家們就發明了糾錯碼,糾錯碼需要更多的冗餘信息,通過這些冗餘信息不僅可以知道哪裏的數據錯了,還能直接把數據給改對。

最知名的糾錯碼就是海明碼(Hamming Code)。直到今天ECC內存也還在使用海明碼來糾錯。最基礎的海明碼叫7-4海明碼,這裏的“7”指的是實際有效的數據一共是7位(Bit)。而這裏的“4”指的是額外存儲了4位數據用來糾錯。糾錯碼的糾錯能力是有限的,事實上在7-4海明碼裏面只能糾正某1位的錯誤。4位的校驗碼,一共可以表示 2^4 = 16個不同的數,根據各數據位計算出來的校驗值一定是確定的。所以如果數據位出錯了,計算出來的校驗碼一定和確定的那個校驗碼不同,那計算出的值就是在2^4 - 1 = 15那剩下的15個可能的校驗值當中。

15個可能的校驗值其實可以對應15個可能出錯的位。既然數據位只有7位,那爲什麼要用4位的校驗碼呢,用3位不就夠了嗎?因爲單比特翻轉的錯誤,不僅可能出現在數據位,也有可能出現在校驗位。所以7位數據位和3位校驗位,如果只有單比特出錯,可能出錯的位數就是10位,2^3 - 1 = 7種情況是不能找到具體是哪一位出錯的。事實上如果數據位有K位,校驗位有N位,那麼需要滿足下面這個不等式,才能確保能夠對單比特翻轉的數據糾錯

K + N + 1 ≤ 2^N

在有7位數據位,也就是K=7的情況下,N的最小值就是4。4位校驗位其實最多可以支持到11位數據位。數據位數和校驗位數的對照表如下所示:

40. 爲了解釋海明碼的糾錯原理,選取計算較爲簡單的4-3海明碼(4位數據位,3位校驗位)。把4位數據位分別記作d1、d2、d3、d4,這裏的d取的是數據位data bits的首字母;把位校驗位,分別記作p1、p2、p3,這裏的p取的是校驗位parity bits的首字母。從4位的數據位裏面,拿走1位然後計算出一個對應的校驗位。這個校驗位的計算用奇偶校驗就可以了,比如用d1、d2、d4來計算出一個校驗位p1;用d1、d3、d4計算出一個校驗位p2;用d2、d3、d4計算出一個校驗位p3。如下所示:

如果d1這一位的數據出錯了,會發現p1和p2的校驗位計算結果與應有值不一樣(因爲d1參與了計算)。發現d2出錯了是因爲p1和p3的校驗位計算結果與應有值不一致;發現d3出錯了則是因爲p2和p3;如果d4出錯了,則是p1、p2、p3都不一致。這樣當數據碼出錯的時候,至少會有2位校驗碼的計算是與應有值不一致的如果是p1的校驗碼出錯了,這個時候只有p1的校驗結果出錯。p2和p3出錯的結果也是一樣的,即只有一個校驗碼的計算是不一致的。所以校驗碼不一致,一共有 2^3-1=7 種情況,正好對應了 7 個不同的位數的錯誤,如下所示:

生成海明碼的步驟如下:

(1)首先要確定編碼後要傳輸的數據是多少位。比如說7-4海明碼就是一共11位。

(2)然後給這11位數據從左到右進行編號,並且也把它們的二進制(四位)表示寫出來。

(3)接着先把這11個數據中的二進制的整數次冪找出來。在7-4海明碼裏面就是1、2、4、8,這些數就是校驗碼位,把它們記錄作p1~p4。如果從二進制的角度看,它們是這11個數當中唯四的,在4個比特里面只有一個比特是1的數值(例如0001)。那麼剩下的7個數,就是d1-d7的二進制碼位了。

(4)對於校驗碼位還是用奇偶校驗碼,但是每一個校驗碼位不是用所有的7位數據來計算校驗碼,而是p1用3、5、7、9、11來計算,也就是在二進制表示下從右往左數的第一位比特是1的情況下的幾個數據位(不包含1是因爲1代表校驗位p1);p2用3、6、7、10、11來計算校驗碼,也就是在二進制表示下從右往左數的第二位比特是1的幾個數據位。那麼p3自然是用從右往左數第三位比特是1的情況下的數據位d2到d4計算,而p4則是用第四位比特是1的情況下的數據位d5到d7計算。如下所示:

這個時候會發現,任何一個數據碼出錯了,就至少會有對應的兩個或者三個校驗碼對不上,這樣就能反過來找到是哪一個數據碼出錯了。如果校驗碼出錯了,那麼只有校驗碼這一位對不上,就知道是這個校驗碼出錯了。

41. 對於兩個二進制表示的數據,他們之間有差異的位數稱之爲海明距離。比如1001和0001的海明距離是1,因爲他們只有最左側的第一位是不同的。而1001和0000的海明距離是2,因爲他們最左側和最右側有兩位是不同的。因此所謂的進行一位糾錯,也就是所有和要傳輸數據的海明距離爲1的數,都能被糾正回來。而任何兩個實際想要傳輸的數據,海明距離都至少要是3。因爲如果是2的話,就會有一個出錯的數,到兩個正確數據的海明距離都爲1,當看到這個出錯的數的時候,就不知道究竟應該糾正到哪一個數了。

在引入了海明距離之後,就可以更形象地理解糾錯碼了。在沒有糾錯功能的情況下,看到的數據就好像是空間裏面的一個個點。這個時候可以讓數據之間的距離很緊湊,但是如果這些點的座標稍稍有錯,就很難搞清楚是哪一個點。在有了1位糾錯功能之後,就好像把一個點變成了以這個點爲中心,半徑爲1的球。只要座標在這個球的範圍之內,都能知道實際要的數據就是球心的座標。而各個數據球不能距離太近,不同的數據球之間要有3個單位的距離。如下所示:

十一、分佈式計算與IO

42. 一臺計算機在數據中心裏是不夠的。因爲如果只有一臺計算機,會遇到三個核心問題,即垂直擴展和水平擴展的選擇問題、如何保持高可用性(High Availability)和一致性問題(Consistency)。當服務器資源不夠用時有2個選擇,第一個選擇是升級現在這臺服務器的硬件,這樣的選擇稱之爲垂直擴展(Scale Up)。第二個選擇則是再加一臺和之前一樣的服務器,這樣的選擇稱之爲水平擴展(Scale Out)。在分佈式計算中,水平擴展需要引入負載均衡(Load Balancer)這樣的組件來進行流量分配。同時需要拆分應用服務器和數據庫服務器,來進行垂直功能的切分,而且也需要不同的應用之間通過消息隊列,來進行異步任務的執行。如下所示:

所有這些軟件層面的改造,其實都是在做分佈式計算的一個核心工作,就是通過消息傳遞(Message Passing)而不是共享內存(Shared Memory)的方式,讓多臺不同的計算機協作起來共同完成任務。如果採用了水平擴展,即便有一臺服務器的CP壞了,還有另外一臺服務器仍然能夠提供服務。負載均衡能夠通過健康檢測(Health Check)發現壞掉的服務器沒有響應了,就可以自動把所有的流量切換到第2臺服務器上,這個操作就叫作故障轉移(Failover),系統仍然是可用的。系統的可用性(Avaiability)指的就是系統可以正常服務的時間佔比。無論是因爲軟硬件故障,還是需要對系統進行停機升級,都會損失系統的可用性。可用性通常是用一個百分比的數字來表示,比如系統每個月的可用性要保障在99.99%,也就是意味着一個月裏,服務宕機的時間不能超過4.32分鐘。

例如有一個三臺服務器組成的小系統,一臺部署了Nginx來作爲負載均衡和反向代理,一臺跑了PHP-FPM作爲Web應用服務器,一臺用來作MySQL 數據庫服務器。每臺服務器的可用性都是99.99%。那麼整個系統的可用性是99.99%  × 99.99%  × 99.99% = 99.97%。如下所示:

如果任何一臺服務器出錯了,整個系統就沒法用了,這個問題就叫作單點故障問題(Single Point of Failure,SPOF)。要解決單點故障問題,首先就是要移除單點,例如讓兩臺服務器提供相同的功能,然後通過負載均衡把流量分發到兩臺不同的服務器去,即使一臺服務器掛了,還有一臺服務器可以正常提供服務。不過光用兩臺服務器是不夠的,單點故障其實在數據中心裏面無處不在。如果是雲上的兩臺虛擬機。如果這兩臺虛擬機是託管在同一臺物理機上的,那這臺物理機本身又成爲了一個單點,那就需要把這兩臺虛擬機分到兩臺不同的物理機上。

不過這樣還是不夠,如果這兩臺物理機在同一個機架(Rack)上,那機架上的交換機(Switch)就成了一個單點。即使放到不同的機架上,還是有可能出現整個數據中心遭遇意外故障的情況。如果遇到一個數據中心內全部掛掉的情況,就需要設計進行異地多活的系統設計和部署。

只是能夠去除單點,其實可用性問題還沒有解決。比如上面用負載均衡把流量均勻地分發到2臺服務器上,當一臺應用服務器掛掉的時候,的確還有一臺服務器在提供服務。但是負載均衡會把一半的流量發到已經掛掉的服務器上,所以這個時候只能算作一半可用。想要讓整個服務完全可用,就需要有一套故障轉移(Failover)機制。想要進行故障轉移,就首先要能發現故障。例如負載均衡通常會定時去請求一個Web應用提供的健康檢測(Health Check地址。這個時間間隔可能是5秒鐘,如果連續2~3次發現健康檢測失敗,負載均衡就會自動將這臺服務器的流量切換到其他服務器上。

故障轉移的自動化在大型系統裏是很重要的,因爲服務器越多,出現故障基本就是個必然發生的事情。而自動化的故障轉移既能夠減少運維的人手需求,也能夠縮短從故障發現到問題解決的時間週期提高可用性。

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