|
最近有讀者和我描述他最近的一次面試,他說面試官不講武德,我問發(fā)生了啥,原來是這么回事:
讀者(我們簡稱為A)去面試,開始問了一些C++多態(tài)方面的,回答一切順利,后面就情況不對了。
開始
yalxdlocwkl64012407231.png (2.18 KB, 下載次數(shù): 1)
下載附件
保存到相冊
yalxdlocwkl64012407231.png
前天 06:55 上傳
面試官:static局部變量是線程安全的嗎?
A(竊喜 這題簡單啊):C++11之前不安全,C++11之后是安全的
面試官:好,那你知道原理嗎?C++11后是如何實現(xiàn)保證線程安全的嗎?
A:此處愣神30秒,(內(nèi)心:我看的八股文也沒講原理。
面試官:可以從GCC、CLang、MSVC 或者其他常用編譯器說一個熟悉的就行
A:我尼瑪,再次愣神30秒,回答:應該是加了鎖吧
面試官:好,那具體是怎么加的?只有一個鎖就能實現(xiàn)效果嗎?
A:(內(nèi)心:這面試官不講武德啊,我不知道。┗卮穑何也皇呛芏,我就是知道它線程安全,沒有研究過原理。
面試官:嗯哼
A:內(nèi)心:嗯哼你妹啊
面試官:
C++無鎖隊列的底層原理是什么?
紅黑樹原理是什么?
多路復用select原理是什么?
......
A內(nèi)心直呼:這面試官不講武德啊,為難我一個沒工作的大學生
面試結(jié)束
嗯,好吧,面試官確實有點不講武德,不過現(xiàn)在大環(huán)境這么卷,沒得辦法。
我們就來拆解下:C++11如何保證static局部變量初始化的線程安全?
首先強調(diào)一點,C++11保證的只是初始化的安全。
首先,我要推薦一個網(wǎng)站,可以實時的把代碼轉(zhuǎn)換為編譯器里面的代碼
https://compiler-explorer.com/我們看個例子,左邊代碼,右邊直接顯示匯編代碼,非常方便。如果匯編代碼看不懂,現(xiàn)在可以丟給AI。
xxazt4cxr4t64012407331.png (87.46 KB, 下載次數(shù): 1)
下載附件
保存到相冊
xxazt4cxr4t64012407331.png
前天 06:55 上傳
我們把匯編代碼丟給Cursor看看,Cursor回答:
這是一段 x86_64 匯編代碼,讓我用中文為您詳細解釋每一行的含義:
func(int): # 函數(shù)聲明,接受一個整數(shù)參數(shù) push rbp # 保存舊的基址指針 mov rbp, rsp # 設(shè)置新的基址指針,建立新的棧幀 mov DWORD PTR [rbp-4], edi # 將參數(shù)(存儲在edi寄存器中)存入局部變量 add DWORD PTR [rbp-4], 20 # 將局部變量的值加20 mov eax, DWORD PTR [rbp-4] # 將結(jié)果移動到eax寄存器(作為返回值) pop rbp # 恢復舊的基址指針 ret # 返回函數(shù)這個函數(shù)的功能很簡單:
接收一個整數(shù)參數(shù)
將這個參數(shù)加上20
返回計算結(jié)果
用C語言表示相當于:
int func(int x) { return x + 20;}
不得不說AI工具確實強大,直接反推出來了函數(shù)。好了,我們言歸正傳,看一下靜態(tài)局部變量:
寫如下代碼:
2zmgif1dklw64012407431.png (21.9 KB, 下載次數(shù): 1)
下載附件
保存到相冊
2zmgif1dklw64012407431.png
前天 06:55 上傳
右邊匯編代碼如下:
qzohnov1b5j64012407531.png (50.21 KB, 下載次數(shù): 1)
下載附件
保存到相冊
qzohnov1b5j64012407531.png
前天 06:55 上傳
看這個匯編代碼,看不出來哪里對初始化線程安全有做處理,上面是GCC,我們換成CLang再試一次
bvzzfgw0fpq64012407631.png (72.75 KB, 下載次數(shù): 1)
下載附件
保存到相冊
bvzzfgw0fpq64012407631.png
前天 06:55 上傳
換成CLang,發(fā)現(xiàn)多了一個global_var_init,但是這個和static沒關(guān)系,這個是會導致生成的。
可以發(fā)現(xiàn),對于int類型,不好觀察。因為int類型太簡單了,編譯器可能執(zhí)行了優(yōu)化處理。下一步我們把int類型換成string類型再看看。
ufnof5kcki264012407731.png (34.08 KB, 下載次數(shù): 1)
下載附件
保存到相冊
ufnof5kcki264012407731.png
前天 06:55 上傳
匯編代碼,有點長不好截圖
.LC0: .string "Hello"func[abi:cxx11](): push rbp mov rbp, rsp push r12 push rbx sub rsp, 32 mov QWORD PTR [rbp-40], rdi movzx eax, BYTE PTR guard variable for func[abi:cxx11]()::key[rip] test al, al sete al test al, al je .L6 mov edi, OFFSET FLAT:guard variable for func[abi:cxx11]()::key call __cxa_guard_acquire test eax, eax setne al test al, al je .L6 mov r12d, 0 lea rax, [rbp-25] mov QWORD PTR [rbp-24], rax nop nop lea rax, [rbp-25] mov rdx, rax mov esi, OFFSET FLAT:.LC0 mov edi, OFFSET FLAT:func[abi:cxx11]()::key call std::__cxx11::basic_stringchar, std::char_traitschar>, std::allocatorchar> >::basic_stringstd::allocatorchar> >(char const*, std::allocatorchar> const&) mov edx, OFFSET FLAT:__dso_handle mov esi, OFFSET FLAT:func[abi:cxx11]()::key mov edi, OFFSET FLAT:std::__cxx11::basic_stringchar, std::char_traitschar>, std::allocatorchar> >::~basic_string() [complete object destructor] call __cxa_atexit mov edi, OFFSET FLAT:guard variable for func[abi:cxx11]()::key call __cxa_guard_release lea rax, [rbp-25] mov rdi, rax call std::__new_allocatorchar>::~__new_allocator() [base object destructor] nop.L6: mov rax, QWORD PTR [rbp-40] mov esi, OFFSET FLAT:func[abi:cxx11]()::key mov rdi, rax call std::__cxx11::basic_stringchar, std::char_traitschar>, std::allocatorchar> >::basic_string(std::__cxx11::basic_stringchar, std::char_traitschar>, std::allocatorchar> > const&) [complete object constructor] jmp .L11 mov rbx, rax lea rax, [rbp-25] mov rdi, rax call std::__new_allocatorchar>::~__new_allocator() [base object destructor] nop test r12b, r12b jne .L9 mov edi, OFFSET FLAT:guard variable for func[abi:cxx11]()::key call __cxa_guard_abort.L9: mov rax, rbx mov rdi, rax call _Unwind_Resume.L11: mov rax, QWORD PTR [rbp-40] add rsp, 32 pop rbx pop r12 pop rbp ret下面拆解下這份匯編代碼,看看能不能窺探到static局部變量的初始化是如何做到線程安全的?
這段匯編代碼展示了C++中靜態(tài)局部變量的線程安全初始化機制(也稱為"Double-Checked Locking Pattern")
首先看第一次檢查:
movzx eax, BYTE PTR guard variable for func[abi:cxx11]()::key[rip] test al, al sete al test al, al je .L6這是在檢查guard變量,guard variable是一個字節(jié)大小的變量,初始值為0,如果已經(jīng)初始化則跳轉(zhuǎn)到 .L6
第二次檢查:
mov edi, OFFSET FLAT:guard variable for func[abi:cxx11]()::keycall __cxa_guard_acquiretest eax, eaxsetne altest al, alje .L6第二次檢查是在獲取鎖,如果guard變量為0,調(diào)用__cxa_guard_acquire,__cxa_guard_acquire嘗試獲取鎖,如果獲取失敗則轉(zhuǎn)到.L6
__cxa_guard_acquire的實現(xiàn)通常包含:
使用原子操作檢查guard變量如果已初始化,返回0如果未初始化,獲取互斥鎖雙重檢查(double-check)guard變量返回1表示獲得了初始化權(quán)限只有一個線程能獲得初始化權(quán)限,這個線程會:
初始化靜態(tài)變量調(diào)用__cxa_guard_release設(shè)置guard變量釋放互斥鎖其他線程在__cxa_guard_acquire中等待,直到初始化完成
如果初始化過程發(fā)生異常,調(diào)用__cxa_guard_abort
mov edi, OFFSET FLAT:guard variable for func[abi:cxx11]()::keycall __cxa_guard_abort
這里實現(xiàn)線程安全的關(guān)鍵機制是:
雙重檢查
第一次檢查:快速路徑,檢查guard變量是否已初始化
第二次檢查:通過__cxa_guard_acquire進行加鎖檢查
Guard變量
編譯器為每個靜態(tài)局部變量生成一個guard變量
用于追蹤變量的初始化狀態(tài)
關(guān)鍵函數(shù)
__cxa_guard_acquire:獲取鎖,返回值表示是否需要初始化
__cxa_guard_release:釋放鎖,標記初始化完成
__cxa_guard_abort:初始化失敗時的清理
到這里,應該已經(jīng)明白了面試官要問的底層原理了。
上面我們看的是匯編代碼,下面我們再從源碼看一看,上面匯編看了GCC的,我們源碼就看CLang的。
關(guān)于static局部變量初始化線程安全最主要的實現(xiàn)在:
https://github.com/llvm/llvm-project/blob/main/libcxxabi/src/cxa_guard.cpphttps://github.com/llvm/llvm-project/blob/main/libcxxabi/src/cxa_guard_impl.h源碼中詳細解釋了guard變量的定義和布局
The first "guard byte" (which is checked by the compiler) is set only upon * the completion of cxa release. * * The second "init byte" does the rest of the bookkeeping. It tracks if * initialization is complete or pending, and if there are waiting threads. * * If the guard variable is 64-bits and the platforms supplies a 32-bit thread * identifier, it is used to detect recursive initialization. The thread ID of * the thread currently performing initialization is stored in the second word. * * Guard Object layout: * --------------------------------------------------------------------------- * | a+0: guard byte | a+1: init byte | a+2: unused ... | a+4: thread-id ... | * ---------------------------------------------------------------------------
guard對象的內(nèi)存布局包含:
guard byte (位于偏移量0): 由編譯器檢查,只在cxa release完成時設(shè)置
init byte (位于偏移量1): 用于跟蹤初始化狀態(tài)(完成/pending)和等待線程
unused bytes (位于偏移量2-3): 未使用的字節(jié)
thread-id (位于偏移量4開始): 存儲當前執(zhí)行初始化的線程ID(在64位guard變量且平臺提供32位線程ID的情況下使用)
我們看一下幾個核心類:
GuardByte類
struct GuardByte { GuardByte(uint8_t* const guard_byte_address) : guard_byte(guard_byte_address) {} bool acquire() { // 如果guard_byte非0,說明初始化已完成 return guard_byte.load(std::_AO_Acquire) != UNSET; } void release() { guard_byte.store(COMPLETE_BIT, std::_AO_Release); } void abort() {} // 終止時不需要做任何事
private: AtomicIntuint8_t> guard_byte;};表示guard的不同狀態(tài)定義:
static constexpr uint8_t UNSET = 0;static constexpr uint8_t COMPLETE_BIT = (1 0);static constexpr uint8_t PENDING_BIT = (1 1);static constexpr uint8_t WAITING_BIT = (1 2);工具類 LazyValue
templateclass T, T (*Init)()>struct LazyValue { LazyValue() : is_init(false) {} T& get() { if (!is_init) { value = Init(); is_init = true; } return value; }private: T value; bool is_init = false;};LazyValue是一個實現(xiàn)延遲初始化(lazy initialization)的模板工具類。
主要工作流程:
1、初始化檢查:
當遇到靜態(tài)局部變量時,編譯器會生成檢查代碼
使用guard byte來追蹤變量是否已經(jīng)初始化
2、線程同步:
init byte用于處理多線程情況
可能的狀態(tài):
UNSET:未初始化
COMPLETE:已完成初始化
PENDING:正在初始化
WAITING:有線程等待初始化完成
3、防止遞歸初始化:
使用thread ID來檢測是否存在遞歸初始化
64位guard變量可以存儲32位thread ID
4、原子操作:
使用AtomicInt類來確保線程安全
實現(xiàn)了load、store、exchange等原子操作
可以看到CLang源碼分析實現(xiàn)基本和GCC匯編代碼分析是一樣的,需要一個守護guard變量加上acquire鎖。
我簡單做一個總結(jié),用一句話回答這個問題就是:
C++11會通過編譯器生成的guard變量和調(diào)用__cxa_guard_acquire/__cxa_guard_release實現(xiàn)雙重檢查鎖模式(DCLP),確保static局部變量只被初始化一次,其他線程在初始化未完成時會在__cxa_guard_acquire內(nèi)部等待。
更細節(jié)的部分,各位讀者可以去看源碼,這篇文章只是拋磚引玉,不過感嘆下現(xiàn)在校招太卷了,我知道答案,但是面試官反手問一個原理是啥?IT開發(fā)也不再是會用工具會調(diào)包就能獲得的崗位了。
end
一口Linux
關(guān)注,回復【1024】海量Linux資料贈送
精彩文章合集
文章推薦
?【專輯】ARM?【專輯】粉絲問答?【專輯】所有原創(chuàng)?【專輯】linux入門?【專輯】計算機網(wǎng)絡(luò)?【專輯】Linux驅(qū)動?【干貨】嵌入式驅(qū)動工程師學習路線?【干貨】Linux嵌入式所有知識點-思維導圖 |
|