概述
現代多核CPU的cache模型基本都跟下圖1所示一樣,L1 L2 cache是每個核獨占的,只有L3是共享的,當多個cpu讀、寫同一個變量時,就需要在多個cpu的cache之間同步數據,跟分布式系統一樣,必然涉及到一致性的問題,只不過兩者之間共享內容的方式不一樣而已,一個通過共享內存來共享內容,另一個通過網絡消息傳遞來共享內容。就像wiki所提及的:
Interestingly enough, a shared-memory multiprocessor system really is a message-passing computer under the covers. This means that clusters of SMP machines that use distributed shared memory are using message passing to implement shared memory at two different levels of the system architecture.
圖1、現代cpu多級cache
多核一致性與原子操作
多核一致性最典型的應用場景是多線程的原子操作,其在多線程開發中經常用到,比如在計數器的生成,這類情況下數據有並發的危險,但是用鎖去保護又顯得有些浪費,所以原子類型操作十分的方便。
原子操作雖然用起來簡單,但是其背景遠比我們想象的要復雜。其主要在於現代計算系統過於的復雜:多處理器、多核處理器、處理器又有核心獨有以及核心共享的多級緩存,在這種情況下,一個核心修改了某個變量,其他核心什么時候可見是一個十分嚴肅的問題。同時在極致最求性能的時代,處理器和編譯器往往表現的很智能,進行極度的優化,比如什么亂序執行、指令重排等,雖然可以在當前上下文中做到很好的優化,但是放在多核環境下常常會引出新的問題來,這時候就必須提示編譯器和處理器某種提示,告訴某些代碼的執行順序不能被優化。今天我們重點看一下處理器在多線程原子操作上的背景原理以及具體應用。
CPU Cache與內存屏障
考慮下面典型的代碼:
-Thread 1- void foo(void) { a = 1; b = 1; } -Thread 2- void bar(void) { while (b == 0) continue; assert(a == 1); }
由於cpu cache的存在,thread 2在斷言處可能會失敗。具體的,由於各個CPU的cache是獨立的,所以變量在他們各自的cache里面的順序可能跟代碼的順序是不一致的,也就是說執行thread2的cpu可能會先看到變量b的變化,然后再看到變量a的變化,導致斷言失敗。就是我們常見的program order與process order的不一致的工程現象,這里就涉及到了memory consistency model的問題(類似於分布式系統的一致性)。
上述的代碼如果要正確執行,則變量a、b之間需要有‘happen before’的語義來約束(這里就可以聯想到分布式系統中因果一致性的概念)。但是對於這個語義上的需求,硬件設計者也愛莫能助,因為CPU無法知道變量之間的關聯關系。所以硬件設計者提供了memory barrier指令,讓軟件可以通過這些指令來告訴CPU這類關系,實現program order與process order的順序一致。類似於下面的代碼:
-Thread 1- void foo(void) { a = 1; memory_barrier(); b = 1; }
增加memory barrier之后,就可以保證在執行b=1的時候,cpu已經處理過'a=1'的操作了。也就是說通過硬件提供的memory barrier語義,使得軟件能夠保證其之前的內存訪問操作先於其后的完成。memory barrier 常用的地方包括:實現內核的鎖機制、應用層編寫無鎖代碼、原子變量等。下面我們一起看下,c++11是怎樣使用內存屏障來實現原子操作的。
C++11的原子操作
在C++11標准出來之前,C++標准沒有一個明確的內存模型,各個C++編譯器實現者各自為政,隨着多線程開發的普及解決這個問題變得越來越迫切。在標准出來之前,GCC的實現是根據Intel的開發手冊搞出的一系列的__sync原子操作函數集合,具體如下:
type __sync_fetch_and_OP (type *ptr, type value, ...) type __sync_OP_and_fetch (type *ptr, type value, ...) bool__sync_bool_compare_and_swap (type *ptr, type oldval, type newval, ...) type __sync_val_compare_and_swap (type *ptr, type oldval, type newval, ...) __sync_synchronize (...)
在C++11新標准中規定的內存模型(memory model)顆粒要比上述的內存模型細化很多,所以軟件開發者就有很多的操作空間了,如果熟悉這些內存模型,在保證業務正確的同時可以將對性能的影響減弱到最低,在硬件資源吃緊的地方,這是我們優化程序的一個重要方向。
我們以c++11的原子變量的保證來展開這些內存模型。原子變量的通用接口使用store()和load()方式進行存取,可以額外接受一個額外的memory order參數,這個參數就是對應了c++11的內存模型,根據執行線程之間對變量的同步需求強度,新標准下的內存模型可以分成如下幾類:
Sequentially Consistent
該模型是最強的同步模式,參數表示為std::memory_order_seq_cst,同時也是默認的模型。
-Thread 1- y = 1 x.store (2); -Thread2- if(x.load() ==2) assert (y ==1)
對於上面的例子,即使x和y是不相關的,通常情況下處理器或者編譯器可能會對其訪問進行重排,但是在seq_cst模式下,x.store(2)之前的所有memory accesses都發生在store操作之前。同時,x.load()之后的所有memory accesses都發生在load()操作之后,也就是說seq_cst模式下,內存的限制是雙向的。
Acquire/Release Consistent
std::atomic<int> a{0}; intb =0;
-Thread 1- b = 1; a.store(1, memory_order_release); -Thread 2- while(a.load(memory_order_acquire) !=1)/*waiting*/; std::cout<< b <<'\n';
毫無疑問,如果是memory_order_seq_cst內存模型,那么上面的操作一定是成功的(打印變量b顯示為1)。
1. memory_order_release保證在這個操作之前的memory accesses不會重排到這個操作之后去,但是這個操作之后的memory accesses可能會重排到這個操作之前去。通常這個主要是用於之前准備某些資源后,通過store+memory_order_release的方式”Release”給別的線程;
2. memory_order_acquire保證在這個操作之后的memory accesses不會重排到這個操作之前去,但是這個操作之前的memory accesses可能會重排到這個操作之后去。通常通過load+memory_order_acquire判斷或者等待某個資源,一旦滿足某個條件后就可以安全的“Acquire”消費這些資源了。
這個就是類似於分布式系統的因果一致性的概念。
Relaxed Consistent
這個是最寬松的模式,memory_order_relaxed沒有happens-before的約束,編譯器和處理器可以對memory access做任何的re-order,因此另外的線程不能對其做任何的假設,這種模式下能做的唯一保證,就是一旦線程讀到了變量var的最新值,那么這個線程將再也見不到var修改之前的值了(這個類似於分布式系統單調讀保證的概念)。
這種情況通常是在需要原子變量,但是不在線程間同步共享數據的時候會用,同時當relaxed存一個數據的時候,另外的線程將需要一個時間才能relaxed讀到該值(也就是最終如果變量不再更改的話,所有的線程還是可以讀取到變量最終的值的),在非緩存一致性的構架上需要刷新緩存。在開發的時候,如果你的上下文沒有共享的變量需要在線程間同步,選用Relaxed就可以了。
這一點類似於分布式系統的最終一致性概念了。
總結
上述的過程體現的是強一致性、因果一致性、最終一致性等概念在c++11原子操作的使用,以及當前技術圈非常熱門的話題分布式系統開發中分布式一致性概念的思考與遷移。從中我們可以看出技術在發展,但是很多概念其實是一脈相承的,只有深刻理解了概念背后的原理以及相關技術發展的背景,才能勉強跟上技術的發展浪潮。