新聞中心

接上篇《??關(guān)于多線(xiàn)程同步的一切:偽共享??》
原子,意味著不可切分的最小單元,程序中的原子操作指任務(wù)不可切分到更小的步驟。
原子性(atomic)是一個(gè)可見(jiàn)性的概念:
當(dāng)我們稱(chēng)一個(gè)操作是atomic的,實(shí)際上隱含了一個(gè)對(duì)什么atomic的上下文。
注意:我們說(shuō)的是從線(xiàn)程視角觀(guān)察不到完成一半的狀態(tài),而并非不存在物理上的進(jìn)度狀態(tài),它取決于你的觀(guān)察視角。
比如說(shuō)一個(gè)線(xiàn)程中被互斥鎖保護(hù)的區(qū)域,對(duì)另一個(gè)線(xiàn)程是atomic的,因?yàn)閺牧硪粋€(gè)線(xiàn)程視角來(lái)看,它沒(méi)法進(jìn)入臨界區(qū)讀到數(shù)據(jù)中間狀態(tài),但是對(duì)kernel而言卻不是atomic的。
從線(xiàn)程視角只能觀(guān)察到未做和已做兩種狀態(tài),觀(guān)察不到完成一半的狀態(tài),任務(wù)執(zhí)行不會(huì)被中斷,也不會(huì)穿插進(jìn)其他操作。
原子性對(duì)多線(xiàn)程操作是一個(gè)非常重要的屬性,因?yàn)樗豢汕蟹郑?,一個(gè)線(xiàn)程沒(méi)法在另一個(gè)線(xiàn)程執(zhí)行原子操作的時(shí)候穿插進(jìn)去。
比如一個(gè)線(xiàn)程原子的寫(xiě)入共享數(shù)據(jù),那么其他線(xiàn)程沒(méi)有辦法讀到“半修改的數(shù)據(jù)”;同樣,如果一個(gè)線(xiàn)程原子讀取共享數(shù)據(jù),那么它讀取的是共享變量在那個(gè)瞬間的值,因此原子的讀和寫(xiě)沒(méi)有數(shù)據(jù)競(jìng)爭(zhēng)(Data Race)。
原子操作常用于與順序無(wú)關(guān)的場(chǎng)景。
原子指令
原子指令指單一的不可再分的不可中斷的被硬件直接執(zhí)行的機(jī)器指令,原子指令是無(wú)鎖編程的基石。
原子指令常被分成兩類(lèi):
- store/load
- read-modify-write(RMW)
Store/Load指令
- store:存儲(chǔ)數(shù)據(jù)到內(nèi)存,對(duì)應(yīng)變量寫(xiě)(賦值)
- load:從內(nèi)存加載數(shù)據(jù),對(duì)應(yīng)變量讀
通常,一條簡(jiǎn)單的store/load機(jī)器指令是原子的,比如數(shù)據(jù)復(fù)制指令(mov)可以把內(nèi)存位置的數(shù)據(jù)讀取到CPU寄存器,相當(dāng)于Load數(shù)據(jù)。
x86架構(gòu)讀/寫(xiě)“按數(shù)據(jù)類(lèi)型對(duì)齊要求對(duì)齊的長(zhǎng)度不大于機(jī)器字長(zhǎng)的數(shù)據(jù)”是原子的。
那什么是數(shù)據(jù)類(lèi)型對(duì)齊要求呢?
比如在x86_64架構(gòu)LLP64系統(tǒng)上(LLP64指long、long long和pointer類(lèi)型是64位的),只要int32類(lèi)型數(shù)據(jù)滿(mǎn)足放置在起始地址除4為0,int64/long類(lèi)型數(shù)據(jù)滿(mǎn)足起始地址除8為0,則該數(shù)據(jù)就是滿(mǎn)足類(lèi)型對(duì)齊要求,那么對(duì)它的讀和寫(xiě),都是原子的。
一字節(jié)的數(shù)據(jù)讀寫(xiě)一定是原子的。
其實(shí),Intel新CPU架構(gòu)確保讀寫(xiě)放置在一個(gè)Cache Line的數(shù)據(jù)(不大于機(jī)器字長(zhǎng)),跨Cache Line的數(shù)據(jù)訪(fǎng)問(wèn)無(wú)法保證原子性。
C/C++編程中,變量和結(jié)構(gòu)體會(huì)自動(dòng)滿(mǎn)足對(duì)齊要求,比如:
int i;
void f() {
long y;
}
struct Foo {
int x;
short s;
void* ptr;
} foo;
全局變量i會(huì)被放置在起始地址可以被4整除的內(nèi)存位置,局部變量y會(huì)被放置在起始地址可以被8整除的內(nèi)存位置,而結(jié)構(gòu)體內(nèi)的x成員會(huì)被放置在起始地址可以被4整除的內(nèi)存位置。
為了把ptr安置在起始地址可以被8整除的內(nèi)存位置,編譯器會(huì)在s后加入填充,從而使得ptr也滿(mǎn)足對(duì)齊要求。
通過(guò)C malloc()接口動(dòng)態(tài)分配的內(nèi)存,其返回值一般也會(huì)對(duì)齊到8/16字節(jié),如果有更高的內(nèi)存對(duì)齊要求,可以通過(guò)aligned_alloc(alignment, size)接口。C++中的alignas關(guān)鍵字用于設(shè)置結(jié)構(gòu)或變量的對(duì)齊要求。
對(duì)一個(gè)滿(mǎn)足對(duì)齊要求的不大于機(jī)器字長(zhǎng)的類(lèi)型變量賦值是原子的,不會(huì)出現(xiàn)半完成(即只完成一半字節(jié)的賦值),讀的情況亦如此。
注意:對(duì)長(zhǎng)度大于機(jī)器字長(zhǎng)的數(shù)據(jù)讀寫(xiě),是不符合原子操作特征的,比如在x86_64系統(tǒng)上,對(duì)下面結(jié)構(gòu)體變量的讀寫(xiě)都是非原子的:
struct Foo {
int a;
int b;
int c;
} foo1;
void set_foo1(const Foo& f) {
foo1 = f;
}foo1包含3個(gè)int成員共12字節(jié),大于機(jī)器字長(zhǎng)8字節(jié),所以對(duì)`foo1 = f`不是原子的。
基于以上知識(shí),我們便知道,一些getter/setter接口,即使在多線(xiàn)程環(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,強(qiáng)轉(zhuǎn)成Foo,然后調(diào)用它的getter/setter,則是危險(xiǎn)的,有可能破壞前述的對(duì)齊要求。
如果你把一個(gè)int變量編碼進(jìn)一個(gè)buf,則最好使用memcpy,而不是強(qiáng)轉(zhuǎn)+賦值。
Read-Modify-Write指令
但有時(shí)候,我們需要更復(fù)雜的操作指令,而不僅僅是單獨(dú)的讀或?qū)?,它需要把幾個(gè)動(dòng)作組合在一起完成某項(xiàng)任務(wù)。
比如語(yǔ)句`++count`對(duì)應(yīng)到“讀+修改+寫(xiě)”三個(gè)操作,但這3個(gè)操作不是一個(gè)原子操作。所以,多線(xiàn)程程序中使用`++count`,多個(gè)執(zhí)行流會(huì)交錯(cuò)執(zhí)行,會(huì)導(dǎo)致計(jì)數(shù)錯(cuò)誤(通常結(jié)果比預(yù)期數(shù)值?。?/p>
考慮另一個(gè)情況:讀+判斷,來(lái)我們看一下經(jīng)典單件實(shí)現(xiàn):
class Singleton {
static Singleton* instance;
public:
static Singleton* get_instance() {
if (instance == nullptr) {
instance = new Singleton;
}
return instance;
}
};
因?yàn)閷?duì)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;
}第一個(gè)檢查,如果instance不為空,那么直接返回instance,大多數(shù)時(shí)候命中這個(gè)情況,因?yàn)閕nstance一旦被創(chuàng)建,就不再為空。
如果instance為空,那么再加鎖、然后第二次檢查instance是否為空,為什么要雙檢呢?因?yàn)榍懊娴臋z查通過(guò)后,有可能其他線(xiàn)程創(chuàng)建了實(shí)例,導(dǎo)致instance不再為空。
看起來(lái)一切都挺好的,高效又縝密。
但雙檢真的安全嗎?這其實(shí)是一個(gè)非常經(jīng)典的問(wèn)題。它有2個(gè)風(fēng)險(xiǎn):
- 首先,編寫(xiě)者沒(méi)有告訴編譯器,必須假設(shè)instance可能被其他線(xiàn)程修改,所以,編譯器可能認(rèn)為2個(gè)if保留一個(gè)就可以了,當(dāng)然,它也可能不做這個(gè)優(yōu)化,取決于編譯器的策略,因此,instance必須改為volatile,告訴編譯器,兩次讀都必須從內(nèi)存里加載,避免雙檢被優(yōu)化掉。
- 就是前面講的原子性,instance指針不能保證一定在8字節(jié)對(duì)齊的地方保存,所以,需要用std::atomic
代替。
邏輯上,需要幾個(gè)操作是一個(gè)密不可分的整體,現(xiàn)代CPU通常都直接提供這類(lèi)原子指令的支持,這類(lèi)RMW原子指令通常包括:
- test-and-set(TAS),把1寫(xiě)入某個(gè)內(nèi)存位置并返回舊值;如果原來(lái)內(nèi)存位置是1,則返回1,否則原子的寫(xiě)入1并返回0;只能標(biāo)識(shí)0和1兩種情況
- fetch_and_add,增加某個(gè)內(nèi)存位置的值,并返回舊值;可用來(lái)做atomic的后自增
- compare-and-swap(CAS),比較內(nèi)存位置的值和指定的值,如果相等,則將新值寫(xiě)入內(nèi)存位置,如果不等,什么也不做;比tas更強(qiáng)
以上所有操作都是在一個(gè)內(nèi)存位置執(zhí)行多個(gè)動(dòng)作,但這些操作都是原子單步的,它不會(huì)被中斷,也不會(huì)穿插進(jìn)其他操作,這個(gè)重要屬性使得RMW指令非常適合用來(lái)實(shí)現(xiàn)無(wú)鎖編程。
雖然CPU在執(zhí)行機(jī)器指令的時(shí)候,會(huì)把它分成更小粒度的微指令(micro-operations),但程序員應(yīng)把關(guān)注點(diǎn)放在微指令上層的原子指令上。
原子操作
前面講的原子指令是硬件層面,不同架構(gòu)甚至不同型號(hào)CPU有不同的原子指令,它是CPU層面的東西,跨平臺(tái)特性差,用它編寫(xiě)的代碼不可移植,所以應(yīng)該盡量避免直接使用原子指令。
回到軟件層面,軟件層面的原子操作包括三個(gè)層次:
(1) 操作系統(tǒng)層面,linux操作系統(tǒng)提供atomic這種原子類(lèi)型,配合相關(guān)的編程接口使用,大多數(shù)它只是對(duì)原子指令的簡(jiǎn)單封裝,但它屏蔽了硬件差異,比原子指令更易用?:
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對(duì)應(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在實(shí)現(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ǔ)言層面,也通常提供原子操作類(lèi)型和接口,這也是使用原子操作的推薦方式,它有良好的跨平臺(tái)性和可移植性,程序員應(yīng)優(yōu)先使用它們:
- C11新增了原子操作庫(kù),通過(guò)stdatomic.h頭文件提供atomic_fetch_add/atomic_compare_exchange_weak等接口。
- C++11也新增了原子操作庫(kù),提供一種類(lèi)型為std::atomic
的類(lèi)模板,它提供++/--/+=/-=/fetch_sub/fetch_add等原子操作接口。 - 原子操作常用于與順序無(wú)關(guān)的場(chǎng)景,比如計(jì)數(shù)錯(cuò)誤的場(chǎng)景,用原子變量改寫(xiě)后,則會(huì)輸出符合預(yù)期的值。
- 原子操作是編寫(xiě)Lock-free多線(xiàn)程程序的基礎(chǔ),原子操作只保證原子性,不保證操作順序。
- 在Lock-free多線(xiàn)程程序中,光有原子操作是不夠的,需要將原子操作和Memory Barrier結(jié)合起來(lái),才能實(shí)現(xiàn)免鎖。
文章名稱(chēng):關(guān)于多線(xiàn)程的一切:原子操作
網(wǎng)頁(yè)網(wǎng)址:http://m.fisionsoft.com.cn/article/dhddses.html


咨詢(xún)
建站咨詢(xún)
