漫谈 C++ 的各种检查
以下文章來源于BOTManJL?,作者BOT Man
What you don't use you don't pay for. (zero-overhead principle)?—— Bjarne Stroustrup
背景閱讀
在學習了?Chromium/base 庫(筆記)后,我體會到了一般人和?優(yōu)秀工程師?的差距 —— 擁有較高的個人素質固然重要,但更重要的是能?降低開發(fā)門檻,讓其他人更快的融入團隊,一起協(xié)作(尤其像 Chromium?開源項目?由社區(qū)維護,開發(fā)者水平參差不齊)。
沒吃過豬肉,但見過豬跑。?
項目中,降低開發(fā)門檻的方法有很多:除了 制定?代碼規(guī)范、劃分?功能模塊、完善?單元測試?(unit test)、推行?代碼審查?(code review)、整理?相關文檔?之外,針對強類型的編譯語言 C++,Chromium/base 庫加入了大量的?檢查?(check)。
為什么代碼中需要各種檢查?在 C++ 中調用一個函數、使用一個類、實例化一個模板時,對傳入的參數、使用的時機,往往會有很多?限制?(constraint/restriction)(例如,數值參數不能傳入負數、對象的訪問不是線程安全的、函數調用不能重入);而處理限制的方法有很多:
口口相傳:在?代碼審查?時,有經驗的開發(fā)者 向 新手開發(fā)者 傳授經驗(很容易失傳)
文檔說明:在?相關文檔?中,提示使用者 功能模塊的各種隱含限制(很容易被忽略)
檢查限制:在合理劃分?功能模塊?的前提下,對模塊的隱含限制 進行檢查,并加入針對檢查的?單元測試(最安全的保障,單元測試即文檔)
本文主要分享 Chromium/base 庫中使用的一些限制檢查。
漫談 C++ 的各種檢查??1 編譯時檢查
編譯時靜態(tài)檢查,主要依靠 C++ 語言提供的?語法支持/靜態(tài)斷言?和?編譯器擴展?實現 —— 在檢查失敗的情況下,編譯失敗。
1.1 測試設施
如何確保代碼中添加的檢查有效呢?最高效的方法是:為 “檢查” 添加單元測試。但對于 編譯時檢查 遇到了一個?難點?—— 如果檢查失敗,那么編譯就無法通過。
為此,Chromium 支持?編譯失敗測試?(no-compile test):
單元測試文件中,每個用例通過?#ifdef?切割
每個用例中,標明 編譯失敗后期望的 報錯細節(jié)
通過?#define?運行各個用例
在編譯失敗后,檢查 報錯細節(jié) 是否和預期一致
對應的單元測試文件后綴為?*_unittest.nc,通過?nocompile.gni?加入單元測試工程。
1.2 可拷貝性檢查
C++ 語言本身有很多編譯時檢查(例如 類的成員訪問控制?(member access control)、const?關鍵字 在編譯成匯編語言后,不能反編譯還原),但 C++ 對象默認是可拷貝的,從而帶來了許多問題(參考?資源管理小記)。
尤其是?多態(tài)?(polymorphic)?類的默認拷貝行為,一般都不符合預期:
C.67: A polymorphic class should suppress copying
C.130: For making deep copies of polymorphic classes prefer a virtual clone function instead of copy construction/assignment
為此,Chromium 提供了兩個?常用的宏:
DISALLOW_COPY_AND_ASSIGN?用于禁用類的 拷貝構造函數 和 拷貝賦值函數
DISALLOW_IMPLICIT_CONSTRUCTORS?用于禁用類的 默認構造函數 和 拷貝行為
由于 Chromium 大量使用了 C++ 的多態(tài)特性,這些宏隨處可見。
1.3 參數類型檢查
Chromium 還基于?現代 C++ 元編程?技術,通過?static_assert?進行靜態(tài)斷言。
在之前寫的?深入 C++ 回調?中分析了:
?Chromium 的base::Callback <>?+?
base::Bind()?回調機制,提到了相關的靜態(tài)斷言檢查。
base::Bind?為了?處理失效的(弱引用)上下文,針對弱引用指針base::WeakPtr擴展了base::IsWeakReceiver檢查,判斷弱引用的上下文是否有效;并通過靜態(tài)斷言檢查傳入參數,強制要求使用者遵循?弱引用檢查的規(guī)范:
base::Bind?不允許直接將?`this` 指針?綁定到 類的成員函數 上,因為?this?裸指針可能失效 變成野指針
base::Bind?不允許綁定?lambda 表達式,因為?base::Bind?無法檢查 lambda 表達式捕獲的 弱引用 的 有效性
base::Bind?只允許將?base::WeakPtr?指針綁定到?沒有返回值的(返回?void)類的成員函數 上,因為 當弱引用失效時不調用回調,也沒有返回值
base::Callback區(qū)分回調只能執(zhí)行一次還是可以多次,通過引用限定符?(reference qualifier)?&&?/?const &,區(qū)分在對象處于 非 const 右值 / 其他狀態(tài)時的?Run?成員函數,只允許一次回調?base::OnceCallback?在非 const 右值狀態(tài)下調用?Run?函數,保證嚴謹的?資源管理語義:
base::OnceClosure?cb;?std::move(cb).Run();????????//?OK base::OnceClosure?cb;?cb.Run();???????????????????//?not?compile const?base::OnceClosure?cb;?cb.Run();?????????????//?not?compile const?base::OnceClosure?cb;?std::move(cb).Run();??//?not?compile另外,靜態(tài)斷言檢查還廣泛應用在 Chromium/base 的容器、智能指針?模板的實現中,用于生成可讀性更好的實例化錯誤信息。
1.4 線程標記檢查
最新的 Chromium 使用了 Clang 編譯,通過擴展?線程標記?(thread annotation),靜態(tài)分析線程安全問題。(參考:Thread Safety Annotations for Clang - DeLesley Hutchins)
Chromium/base 的單元測試文件 :
thread_annotations_unittest.nc?描述了一些 鎖的錯誤使用場景(假設數據 data 被鎖 lock 保護,定義標記為 Type data GUARDED_BY(lock);):
訪問 data 之前,忘記獲取 lock
獲取 lock 之后,忘記釋放 lock
這些錯誤能在編譯時被 Clang 檢查到,從而編譯失敗。
2 運行時檢查
運行時動態(tài)檢查,主要基于 Chromium/base 庫提供的?斷言?DCHECK/CHECK?實現 —— 如果斷言失敗,運行著的程序會立即終止。
其中,DCHECK?只對調試版?(debug)?有效,而?CHECK?也可用于發(fā)布版?(release)?—— 從而避免在發(fā)布版進行無用的檢查。
2.1 測試設施
檢查的方法很直觀 —— 構造一個檢查失敗的場景,期望斷言失敗。
Chromium/base?基礎設施中的EXPECT_DCHECK_DEATH提供了這個功能,對應的單元測試文件后綴為?*_unittest.cc。
2.2 數值溢出檢查
C++ 的數值類型,都是固定大小的標量類型?—— 如果存儲數值超出范圍,會導致溢出?(overflow)。
例如,嘗試通過?使用無符號數 避免出現負數,往往是一個典型的徒勞之舉。(比如?unsigned(0) - unsigned(1) == UINT_MAX,參考?ES.106: Don’t try to avoid negative values by using?unsigned)
為此,Chromium 的?base/numerics?提供了一個 無依賴?(dependency-free)、僅頭文件?(header-only)?的模板庫,處理數值溢出問題:
base::StrictNumeric/base::strict_cast<>()?編譯時 阻止溢出?—— 如果 類型轉換 有溢出的可能性,通過靜態(tài)斷言報錯
base::CheckedNumeric/base::checked_cast<>()?運行時 檢查溢出?—— 如果 數值運算/類型轉換 出現溢出,立即終止程序
base::ClampedNumeric/base::saturated_cast<>()?運行時 截斷運算?—— 如果 數值運算/類型轉換 出現溢出,對計算結果?截斷?(non-sticky saturating)?處理
2.3 線程相關檢查
最新的 Chromium/base 線程模型引入了線程池,并支持了序列?(sequence)?的概念 —— 相對于線程池中的普通任務亂序調度,同一序列的任務 能保證被順序調度 —— 因此,推薦使用邏輯序列 而不是物理線程:
同一物理線程 只能同時運行 一個邏輯序列,使得 序列模型 等效于 單線程模型
同一物理線程 可以用于運行 多個邏輯序列,提高 物理線程 的利用率
線程/序列 相關的檢查主要依賴于?線程/序列 本地存儲:
每個線程有獨立的?`base::ThreadLocalStorage`?
線程本地存儲?(thread local storage, TLS)
每個序列有獨立的?`base::SequenceLocalStorageSlot`?
序列本地存儲?(sequence local storage, SLS)
當 邏輯序列 被放到 物理線程 上執(zhí)行時,把當前序列的 SLS 關聯(lián)到 執(zhí)行線程的 TLS
2.3.1 線程安全檢查
很多時候,某個對象只會在?同一線程/序列?中?創(chuàng)建/訪問/銷毀:
正常情況下,無競爭?(contention-free)?模型沒必要保證?線程安全?(thread-safety),因為 線程同步操作/原子操作 會帶來?不必要的開銷
異常情況下,一旦被 多線程同時使用,訪問沖突導致?數據競爭?(data race),可能出現 未定義行為
為此,Chromium 借助:
base::ThreadChecker/base::SequenceChecker
檢查對象是否只在 同一線程/序列 中使用:
[THREAD|SEQUENCE]_CHECKER(checker)?創(chuàng)建并關聯(lián) 線程/序列?checker
DCHECK_CALLED_ON_VALID_THREAD|SEQUENCE?檢查或關聯(lián)?checker?和 當前執(zhí)行環(huán)境的 線程/序列
DETACH_FROM_THREAD|SEQUENCE?解除?checker?和 線程/序列 的關聯(lián)
另外,發(fā)布版的檢查實現為?空對象,即總能通過檢查
實現的?核心思想?非常簡單:
線程/序列 創(chuàng)建時,通過 TLS/SLS 記錄 當前線程/序列的 ID(例如 線程 ID、序列 ID)
checker?構造時,記錄 當前線程/序列的 ID
checker?檢查時,讀取 當前線程/序列的 ID,和?checker?記錄的 ID 比較
checker?析構時,先執(zhí)行檢查(可以提前 解除關聯(lián))
另外,checker?讀寫 數據成員時,需要進行互斥的 線程同步操作(鎖)
在[sec|通知迭代檢查] 提到,base::ObserverList借助?iteration_sequence_checker_?在迭代時檢查 對象操作 是否線程/序列安全。
2.3.2 線程限制檢查
程序中常常會有一些?特殊用途的線程(例如 客戶端 UI 主線程),而這些線程往往有著?特殊的限制(例如,UI 線程要求保持?響應性?(responsive),實時響應用戶輸入)。
為此,Chromium 借助 :
base::ThreadRestrictions?檢查可能涉及線程限制的函數在當前執(zhí)行的線程上是否允許:
阻塞?(blocking)?操作
主要包括文件 I/O 操作(有可能被系統(tǒng)緩存,從而不阻塞)
可能導致線程 交出 CPU 執(zhí)行機會,進入 wait 狀態(tài)
同步原語?(sync primitive)
執(zhí)行 線程同步操作
可能導致程序 死鎖?(deadlock)/卡頓?(jank)
CPU 密集工作?(CPU intensive work)
超過 100ms CPU 時間的操作
可能導致程序 卡頓?(jank)
單例?(singleton)?操作
對于 非泄露型?`base::Singleton`,會在?`base::AtExitManager`?注冊 “退出時銷毀單例對象”
如果主線程先退出,在?base::AtExitManager?中銷毀單例,導致仍在運行的 non-joinable 線程再訪問單例時,出現野指針崩潰
實現的?核心思想?也很簡單:
通過 TLS 記錄 當前線程的限制情況(每種限制用一個 TLS?bool?存儲)
對于 可能涉及限制的函數,調用前先檢查 當前線程 是否允許某個限制
在最新的Chromium/base 中,線程限制檢查被進一步封裝為:
base::ScopedBlockingCall,并應用于大量文件 I/O 相關函數中。
2.3.3 死鎖檢查
Chromium 通過?base::internal::CheckedLock
檢查 死鎖?(deadlock)。
實現的?核心思想?非常簡單 ——?檢查等待鏈是否成環(huán):
維護一個 全局的 <從每個 lock 到其 predecessor lock> 映射表(創(chuàng)建時添加,銷毀時移除)
維護一個 當前線程的 <已獲取 lock> 列表(TLS 存儲;獲取時記錄,釋放時移除)
創(chuàng)建時,斷言 predecessor 已創(chuàng)建(如果 predecessor 不存在,可能順序錯誤)
獲取時,斷言 predecessor 是當前線程最近獲取的 lock(若不是,可能順序錯誤)
2.4 觀察者模式檢查
在之前寫的?令人抓狂的觀察者模式?中,介紹了如何通過 :
Chromium/base 提供的base::ObserverList,檢查觀察者模式的一些潛在問題。
2.4.1 生命周期檢查
由于觀察者和被觀察者的生命周期往往是解耦的,所以總會出現一些陰差陽錯的問題:
觀察者先銷毀
問題:若?base::ObserverList?通知時不檢查 觀察者是否有效,可能導致 野指針崩潰
解決:觀察者繼承于?`base::CheckedObserver`
在通知前?base::ObserverList?檢查觀察者弱引用?base::WeakPtr?的有效性
被觀察者先銷毀
問題:若?base::ObserverList?銷毀時不檢查 觀察者列表是否為空,可能導致 被觀察者銷毀后,觀察者不能再移除(野指針崩潰)
解決:模板參數?check_empty?若為?true,在析構時斷言 “觀察者已被全部移除”
2.4.2 通知迭代檢查
觀察者可能在?base::ObserverList?通知時,再訪問同一個?base::ObserverList?對象:
添加觀察者
問題:是否需要在 本次迭代中,繼續(xù)通知 新加入的觀察者
解決:被觀察者參數?`base::ObserverListPolicy`?
決定迭代過程中,是否通知 新加入的觀察者
移除觀察者
問題:循環(huán)內(間接)刪除節(jié)點,導致迭代器失效(崩潰)for(auto it = c.begin(); it != c.end(); ++it) c.erase(it);
解決:觀察者節(jié)點?MarkForRemoval()?標記為 “待移除”,然后等迭代結束后移除
通知迭代重入
問題:許多情況下,若不考慮 重入情況,可能會導致?死循環(huán)問題
解決:模板參數?allow_reentrancy?若為?false,在迭代時斷言 “正在通知迭代時 不允許重入”
銷毀被觀察者
問題:需要立即停止 迭代過程,讓所有迭代器 全部失效
解決:通過特殊的?`base::internal::WeakLinkNode`?+
雙向鏈表?`base::LinkedList`?存儲?base::ObserverList?所有的迭代器;在?base::ObserverList?析構時,將迭代器 標記為無效(自動停止迭代),并 移除、銷毀
線程安全問題
問題:由于?base::ObserverList?不是線程安全的,在通知迭代時,需要保證其他操作在 同一線程/序列
解決:被觀察者成員?iteration_sequence_checker_
在迭代開始時關聯(lián)序列,在結束時解除關聯(lián),在迭代過程中檢查 移除觀察者/通知重入/銷毀被觀察者 操作是否序列安全(參考 [sec|線程安全檢查])
和?base::Singleton?一樣,Chromium/base 的設計模式實現 堪稱 C++ 里的典范 —— 無論是功能上,還是性能上,均為 “人無我有,人有我優(yōu)”。
寫在最后??站在巨人的肩膀上。—— 艾薩克·牛頓
Chromium/base 庫一直在?迭代、優(yōu)化,學習、借鑒?許多其他優(yōu)秀的開源項目。例如,[sec|線程標記檢查] 使用的標記就來源于?abseil。
由于 Chromium/base 改動頻繁,本文某些細節(jié)?可能會過期。如果有什么新發(fā)現,歡迎補充~ ?
總結
以上是生活随笔為你收集整理的漫谈 C++ 的各种检查的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 报名|腾讯技术开放日·5G技术专场
- 下一篇: 从零开始的C++网络编程