引子
考慮如下的簡單程序,全局變量x初始值為0:
int x = 0;
void thread1_func() {
x++;
print(x);
}
void thread2_func() {
x++;
print(x);
}
程序輸出 1 2 或 2 2很容易理解,但也有可能輸出為1 1。 Why?
原因便是x++不是原子操作,如果把它轉為CPU指令形式,則很容易理解:
(1) Load x
(2) Inc x
(3) Store x
當第一個線程運行完第一步時,第二個線程也運行到此,這時它們得到的值都是0,然后將值加1再存回去,這時兩個線程運行完時,x的值是1。
原子操作
最簡單的解決方式便是使用原子操作,Linux中提供了以atomic_開頭的原子操作函數,例如:
#define atomic_inc(v)
#define atomic_dec(v)
#define atomic_add(i, v)
...
v是atomic_t類型的變量,定義如下:
typedef struct {
inc counter;
} atomic_t;
原子操作需要靠硬件實現,我們以arm64平台為例,看看atomic_add函數是如何實現的。
<arch/arm64/include/asm/atomic_lse.h>
#define ATOMIC_OP(op, asm_op) \
static inline void atomic_##op(int i, atomic_t *v) \
{ \
register int w0 asm ("w0") = i; \
register atomic_t *x1 asm ("x1") = v; \
\
asm volatile(ARM64_LSE_ATOMIC_INSN(__LL_SC_ATOMIC(op), \
" " #asm_op " %w[i], %[v]\n") \
: [i] "+r" (w0), [v] "+Q" (v->counter) \
: "r" (x1) \
: __LL_SC_CLOBBERS); \
}
ATOMIC_OP(add, stadd)
GCC內聯匯編
在介始具體實現前,我們先了解一下GCC內聯匯編,GCC內聯匯編的格式如下:
asm volatile(指令部:輸出部:輸入部:損壞部)
- 指令部中,數字加上前綴%,例如%0,%1,表示需要使用寄存器的操作數。為了提高可讀性,可以使用匯編符號名來替代%前綴表示的操作數。
- 輸出部用於規定輸出變量的約束條件,通常以=號開頭,接着一個字母表示操作數類型的說明,然后是關於變量結合的約束
- 輸入部描述輸入操作數,用逗號隔開
- 損壞部一般以memory開頭,告訴編譯器指令改變了內存中的值,在執行完匯編代碼后重新加載該值,目的是防止編譯亂序。clobber list描述了匯編代碼對寄存器的修改情況。為何要有clober list?我們的c代碼是gcc來處理的,當遇到嵌入匯編代碼的時候,gcc會將這些嵌入式匯編的文本送給gas進行后續處理。這樣,gcc需要了解嵌入匯編代碼對寄存器的修改情況,否則有可能會造成大麻煩。例如:gcc對c代碼進行處理,將某些變量值保存在寄存器中,如果嵌入匯編修改了該寄存器的值,又沒有通知gcc的話,那么,gcc會以為寄存器中仍然保存了之前的變量值,因此不會重新加載該變量到寄存器,而是直接使用這個被嵌入式匯編修改的寄存器,這時候,我們唯一能做的就是靜靜的等待程序的崩潰。還好,在output operand list 和 input operand list中涉及的寄存器都不需要體現在clobber list中(gcc分配了這些寄存器,當然知道嵌入匯編代碼會修改其內容),因此,大部分的嵌入式匯編的clobber list都是空的,或者只有一個cc,通知gcc,嵌入式匯編代碼更新了condition code register。
有了基本的了解后,現在開始解讀上面的代碼: - 將變量i存放到寄存器w0中
- 將atomic_t類型的指針v存放到寄存器x1中
- 指令部使用原子指令stadd把變量i的值加到v->counter中,w表示位寬是32bit, x表示64bit
- 輸出部[i]表示匯編符號為i的變量,+表示可讀可寫,r表示變量放入寄存器,Q表示需要通過指針間接尋址
- 輸入部, r表示輸入量在寄存器x1中
- 損壞部,通知GCC有資源更新
原子指令
上節中我們發現原子add操作是通過原子指令stadd實現的,在不同的架構上實現的方式可能不一樣。
Bus Lock(鎖總線)
CPU執行原子指令時,給總線上鎖,這樣在釋放前,可以防止其它CPU的內存操作。
Cache Lock
除了和IO緊密相關的(如MMIO),大部分的內存都是可以被cache的,由前面介紹的cache一致性原理,我們知道由cacheline處於Exclusive或Modified時,該變量只有當前CPU緩存了數據,因此當進行原子操作時,發出Read Invalidate消息,使其它CPU上的緩存無效,cacheline變成Exclusive狀態然后將該cacheline上鎖,接着就可以取數據,修改並寫入cacheline,如果這時有其它CPU也進行原子操作,發出read invalidate消息,但由於當前CPU的cacheline是locked狀態,因此暫時不會回復消息,這樣其它CPU就一直在等待,直到當前CPU完成,使cacheline變為unlocked狀態。
LL/SC
在ARMv8.1之前,為實現RMW的原子操作的方法主要是LL/SC(Load-link/Store-condition).ARMv7中實現的指令是LDREX/STREX,原理如下:
假設CPU0進行load操作,標記變量V所在的內存地址為exclusive, 在CPU0進行store前,這時CPU1也對變量V進行了load操作,這時exclusive標記屬於CPU1而不再屬於CPU0,在CPU0進行store時會測試該地址的exclusive標記是不是自己的,如果不是,store失敗。CPU1進行store, 因為exclusive標記是自己的,所以store成功,同時exclusive失效,這時CPU0會再次嘗試一試LL/SC操作,直天成功為止。
如果CPU之間競爭比較激烈,可能導致重試的次數比較多,因此從2014年ARMv8.1開始,ARM推出了原子操作的LSE(Large System Extention)指令集擴展,新增的指令包括CAS, SWP和LD