新聞中心

接上篇《??關(guān)于多線程同步的一切:偽共享??》
原子,意味著不可切分的最小單元,程序中的原子操作指任務(wù)不可切分到更小的步驟。
原子性(atomic)是一個可見性的概念:
當我們稱一個操作是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
- read-modify-write(RMW)
Store/Load指令
- store:存儲數(shù)據(jù)到內(nèi)存,對應(yīng)變量寫(賦值)
- load:從內(nèi)存加載數(shù)據(jù),對應(yīng)變量讀
通常,一條簡單的store/load機器指令是原子的,比如數(shù)據(jù)復(fù)制指令(mov)可以把內(nèi)存位置的數(shù)據(jù)讀取到CPU寄存器,相當于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)+賦值。
Read-Modify-Write指令
但有時候,我們需要更復(fù)雜的操作指令,而不僅僅是單獨的讀或?qū)懀枰褞讉€動作組合在一起完成某項任務(wù)。
比如語句`++count`對應(yīng)到“讀+修改+寫”三個操作,但這3個操作不是一個原子操作。所以,多線程程序中使用`++count`,多個執(zhí)行流會交錯執(zhí)行,會導(dǎo)致計數(shù)錯誤(通常結(jié)果比預(yù)期數(shù)值?。?/p>
考慮另一個情況:讀+判斷,來我們看一下經(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個風險:
- 首先,編寫者沒有告訴編譯器,必須假設(shè)instance可能被其他線程修改,所以,編譯器可能認為2個if保留一個就可以了,當然,它也可能不做這個優(yōu)化,取決于編譯器的策略,因此,instance必須改為volatile,告訴編譯器,兩次讀都必須從內(nèi)存里加載,避免雙檢被優(yōu)化掉。
- 就是前面講的原子性,instance指針不能保證一定在8字節(jié)對齊的地方保存,所以,需要用std::atomic
代替。
邏輯上,需要幾個操作是一個密不可分的整體,現(xiàn)代CPU通常都直接提供這類原子指令的支持,這類RMW原子指令通常包括:
- test-and-set(TAS),把1寫入某個內(nèi)存位置并返回舊值;如果原來內(nèi)存位置是1,則返回1,否則原子的寫入1并返回0;只能標識0和1兩種情況
- fetch_and_add,增加某個內(nèi)存位置的值,并返回舊值;可用來做atomic的后自增
- compare-and-swap(CAS),比較內(nèi)存位置的值和指定的值,如果相等,則將新值寫入內(nèi)存位置,如果不等,什么也不做;比tas更強
以上所有操作都是在一個內(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)先使用它們:
- C11新增了原子操作庫,通過stdatomic.h頭文件提供atomic_fetch_add/atomic_compare_exchange_weak等接口。
- C++11也新增了原子操作庫,提供一種類型為std::atomic
的類模板,它提供++/--/+=/-=/fetch_sub/fetch_add等原子操作接口。 - 原子操作常用于與順序無關(guān)的場景,比如計數(shù)錯誤的場景,用原子變量改寫后,則會輸出符合預(yù)期的值。
- 原子操作是編寫Lock-free多線程程序的基礎(chǔ),原子操作只保證原子性,不保證操作順序。
- 在Lock-free多線程程序中,光有原子操作是不夠的,需要將原子操作和Memory Barrier結(jié)合起來,才能實現(xiàn)免鎖。
文章標題:關(guān)于多線程的一切:原子操作
網(wǎng)址分享:http://www.dlmjj.cn/article/dhddses.html


咨詢
建站咨詢
