掃二維碼與項目經(jīng)理溝通
我們在微信上24小時期待你的聲音
解答本文疑問/技術(shù)咨詢/運營咨詢/技術(shù)建議/互聯(lián)網(wǎng)交流

接上篇《??關(guān)于多線程同步的一切:偽共享??》
原子,意味著不可切分的最小單元,程序中的原子操作指任務(wù)不可切分到更小的步驟。
原子性(atomic)是一個可見性的概念:
當(dāng)我們稱一個操作是atomic的,實際上隱含了一個對什么atomic的上下文。
注意:我們說的是從線程視角觀察不到完成一半的狀態(tài),而并非不存在物理上的進度狀態(tài),它取決于你的觀察視角。
比如說一個線程中被互斥鎖保護的區(qū)域,對另一個線程是atomic的,因為從另一個線程視角來看,它沒法進入臨界區(qū)讀到數(shù)據(jù)中間狀態(tài),但是對kernel而言卻不是atomic的。
從線程視角只能觀察到未做和已做兩種狀態(tài),觀察不到完成一半的狀態(tài),任務(wù)執(zhí)行不會被中斷,也不會穿插進其他操作。
原子性對多線程操作是一個非常重要的屬性,因為它不可切分,所以,一個線程沒法在另一個線程執(zhí)行原子操作的時候穿插進去。
比如一個線程原子的寫入共享數(shù)據(jù),那么其他線程沒有辦法讀到“半修改的數(shù)據(jù)”;同樣,如果一個線程原子讀取共享數(shù)據(jù),那么它讀取的是共享變量在那個瞬間的值,因此原子的讀和寫沒有數(shù)據(jù)競爭(Data Race)。
原子操作常用于與順序無關(guān)的場景。
原子指令指單一的不可再分的不可中斷的被硬件直接執(zhí)行的機器指令,原子指令是無鎖編程的基石。
原子指令常被分成兩類:
通常,一條簡單的store/load機器指令是原子的,比如數(shù)據(jù)復(fù)制指令(mov)可以把內(nèi)存位置的數(shù)據(jù)讀取到CPU寄存器,相當(dāng)于Load數(shù)據(jù)。
x86架構(gòu)讀/寫“按數(shù)據(jù)類型對齊要求對齊的長度不大于機器字長的數(shù)據(jù)”是原子的。
那什么是數(shù)據(jù)類型對齊要求呢?
比如在x86_64架構(gòu)LLP64系統(tǒng)上(LLP64指long、long long和pointer類型是64位的),只要int32類型數(shù)據(jù)滿足放置在起始地址除4為0,int64/long類型數(shù)據(jù)滿足起始地址除8為0,則該數(shù)據(jù)就是滿足類型對齊要求,那么對它的讀和寫,都是原子的。
一字節(jié)的數(shù)據(jù)讀寫一定是原子的。
其實,Intel新CPU架構(gòu)確保讀寫放置在一個Cache Line的數(shù)據(jù)(不大于機器字長),跨Cache Line的數(shù)據(jù)訪問無法保證原子性。
C/C++編程中,變量和結(jié)構(gòu)體會自動滿足對齊要求,比如:
int i;
void f() {
long y;
}
struct Foo {
int x;
short s;
void* ptr;
} foo;
全局變量i會被放置在起始地址可以被4整除的內(nèi)存位置,局部變量y會被放置在起始地址可以被8整除的內(nèi)存位置,而結(jié)構(gòu)體內(nèi)的x成員會被放置在起始地址可以被4整除的內(nèi)存位置。
為了把ptr安置在起始地址可以被8整除的內(nèi)存位置,編譯器會在s后加入填充,從而使得ptr也滿足對齊要求。
通過C malloc()接口動態(tài)分配的內(nèi)存,其返回值一般也會對齊到8/16字節(jié),如果有更高的內(nèi)存對齊要求,可以通過aligned_alloc(alignment, size)接口。C++中的alignas關(guān)鍵字用于設(shè)置結(jié)構(gòu)或變量的對齊要求。
對一個滿足對齊要求的不大于機器字長的類型變量賦值是原子的,不會出現(xiàn)半完成(即只完成一半字節(jié)的賦值),讀的情況亦如此。
注意:對長度大于機器字長的數(shù)據(jù)讀寫,是不符合原子操作特征的,比如在x86_64系統(tǒng)上,對下面結(jié)構(gòu)體變量的讀寫都是非原子的:
struct Foo {
int a;
int b;
int c;
} foo1;
void set_foo1(const Foo& f) {
foo1 = f;
}foo1包含3個int成員共12字節(jié),大于機器字長8字節(jié),所以對`foo1 = f`不是原子的。
基于以上知識,我們便知道,一些getter/setter接口,即使在多線程環(huán)境下,也可以不用加鎖,比如:
struct Foo {
size_t get_x() const { // OK
return x;
}
void set_y(float y) { // OK
this->y = y;
}
size_t x;
float y;
};
int main() {
char buf[8];
Foo* f = (Foo*)buf;
f->set(3.14); // dang
}但是,如果你把一塊buf,強轉(zhuǎn)成Foo,然后調(diào)用它的getter/setter,則是危險的,有可能破壞前述的對齊要求。
如果你把一個int變量編碼進一個buf,則最好使用memcpy,而不是強轉(zhuǎn)+賦值。
但有時候,我們需要更復(fù)雜的操作指令,而不僅僅是單獨的讀或?qū)?,它需要把幾個動作組合在一起完成某項任務(wù)。
比如語句`++count`對應(yīng)到“讀+修改+寫”三個操作,但這3個操作不是一個原子操作。所以,多線程程序中使用`++count`,多個執(zhí)行流會交錯執(zhí)行,會導(dǎo)致計數(shù)錯誤(通常結(jié)果比預(yù)期數(shù)值?。?。
考慮另一個情況:讀+判斷,來我們看一下經(jīng)典單件實現(xiàn):
class Singleton {
static Singleton* instance;
public:
static Singleton* get_instance() {
if (instance == nullptr) {
instance = new Singleton;
}
return instance;
}
};
因為對instance的判斷和`instance = new Singleton`不是原子的,所以,我們需要加鎖:
class Singleton {
static Singleton* instance;
static std::mutex mutex;
public:
static Singleton* get_instance() {
mutex.lock();
if (instance == nullptr)
instance = new Singleton;
mutex.unlock();
return instance;
}
};
但為了性能,更好的方案是加雙檢,代碼變成下面這樣:
static Singleton* get_instance() {
if (instance == nullptr) {
mutex.lock();
if (instance == nullptr) { // 雙檢
instance = new Singleton;
}
mutex.unlock();
return instance;
}
return instance;
}第一個檢查,如果instance不為空,那么直接返回instance,大多數(shù)時候命中這個情況,因為instance一旦被創(chuàng)建,就不再為空。
如果instance為空,那么再加鎖、然后第二次檢查instance是否為空,為什么要雙檢呢?因為前面的檢查通過后,有可能其他線程創(chuàng)建了實例,導(dǎo)致instance不再為空。
看起來一切都挺好的,高效又縝密。
但雙檢真的安全嗎?這其實是一個非常經(jīng)典的問題。它有2個風(fēng)險:
邏輯上,需要幾個操作是一個密不可分的整體,現(xiàn)代CPU通常都直接提供這類原子指令的支持,這類RMW原子指令通常包括:
以上所有操作都是在一個內(nèi)存位置執(zhí)行多個動作,但這些操作都是原子單步的,它不會被中斷,也不會穿插進其他操作,這個重要屬性使得RMW指令非常適合用來實現(xiàn)無鎖編程。
雖然CPU在執(zhí)行機器指令的時候,會把它分成更小粒度的微指令(micro-operations),但程序員應(yīng)把關(guān)注點放在微指令上層的原子指令上。
前面講的原子指令是硬件層面,不同架構(gòu)甚至不同型號CPU有不同的原子指令,它是CPU層面的東西,跨平臺特性差,用它編寫的代碼不可移植,所以應(yīng)該盡量避免直接使用原子指令。
回到軟件層面,軟件層面的原子操作包括三個層次:
(1) 操作系統(tǒng)層面,linux操作系統(tǒng)提供atomic這種原子類型,配合相關(guān)的編程接口使用,大多數(shù)它只是對原子指令的簡單封裝,但它屏蔽了硬件差異,比原子指令更易用?:
atomic_read(atomic_t *v)
atomic_set(atomic_t *v, int i)
atomic_inc(atomic_t *v)
atomic_dec(atomic_t *v)
atomic_add(int i, atomic_t *v)
atomic_sub(int i, atomic_t *v)
atomic_inc_and_test(atomic_t *v)
atomic_dec_and_test(atomic_t *v);
atomic_sub_and_test(int i, atomic_t *v)
(2) 編譯器層面,gcc提供原子操作build-in函數(shù),使用gcc編譯c/c++代碼,可以直接使用它們?:
//其中type對應(yīng)8/16/32/64位整數(shù)
type __sync_fetch_and_add (type *ptr, type value, ...)
type __sync_fetch_and_sub (type *ptr, type value, ...)
type __sync_fetch_and_or (type *ptr, type value, ...)
type __sync_fetch_and_and (type *ptr, type value, ...)
type __sync_fetch_and_xor (type *ptr, type value, ...)
type __sync_fetch_and_nand (type *ptr, type value, ...)
type __sync_add_and_fetch (type *ptr, type value, ...)
type __sync_sub_and_fetch (type *ptr, type value, ...)
type __sync_or_and_fetch (type *ptr, type value, ...)
type __sync_and_and_fetch (type *ptr, type value, ...)
type __sync_xor_and_fetch (type *ptr, type value, ...)
type __sync_nand_and_fetch (type *ptr, type value, ...)
gcc在實現(xiàn)C++11之后,新的原子接口,以__atomic為前綴,推薦使用下面這些接口:
type __atomic_add_fetch(type *ptr, type val, int memorder)
type __atomic_sub_fetch(type *ptr, type val, int memorder)
type __atomic_and_fetch(type *ptr, type val, int memorder)
type __atomic_xor_fetch(type *ptr, type val, int memorder)
type __atomic_or_fetch(type *ptr, type val, int memorder)
type __atomic_nand_fetch(type *ptr, type val, int memorder)
type __atomic_fetch_add(type *ptr, type val, int memorder)
type __atomic_fetch_sub(type *ptr, type val, int memorder)
type __atomic_fetch_and(type *ptr, type val, int memorder)
type __atomic_fetch_xor(type *ptr, type val, int memorder)
type __atomic_fetch_or(type *ptr, type val, int memorder)
type __atomic_fetch_nand(type *ptr, type val, int memorder)
(3) 編程語言層面,也通常提供原子操作類型和接口,這也是使用原子操作的推薦方式,它有良好的跨平臺性和可移植性,程序員應(yīng)優(yōu)先使用它們:

我們在微信上24小時期待你的聲音
解答本文疑問/技術(shù)咨詢/運營咨詢/技術(shù)建議/互聯(lián)網(wǎng)交流