鍍金池/ 教程/ C/ 10.2 定位并發(fā)錯誤的技術(shù)
3.4 本章總結(jié)
6.3 基于鎖設(shè)計更加復(fù)雜的數(shù)據(jù)結(jié)構(gòu)
6.1 為并發(fā)設(shè)計的意義何在?
5.2 <code>C++</code>中的原子操作和原子類型
A.7 自動推導(dǎo)變量類型
2.1 線程管理的基礎(chǔ)
8.5 在實踐中設(shè)計并發(fā)代碼
2.4 運行時決定線程數(shù)量
2.2 向線程函數(shù)傳遞參數(shù)
第4章 同步并發(fā)操作
2.3 轉(zhuǎn)移線程所有權(quán)
8.3 為多線程性能設(shè)計數(shù)據(jù)結(jié)構(gòu)
6.4 本章總結(jié)
7.3 對于設(shè)計無鎖數(shù)據(jù)結(jié)構(gòu)的指導(dǎo)建議
關(guān)于這本書
A.1 右值引用
2.6 本章總結(jié)
D.2 &lt;condition_variable&gt;頭文件
A.6 變參模板
6.2 基于鎖的并發(fā)數(shù)據(jù)結(jié)構(gòu)
4.5 本章總結(jié)
A.9 本章總結(jié)
前言
第10章 多線程程序的測試和調(diào)試
5.4 本章總結(jié)
第9章 高級線程管理
5.1 內(nèi)存模型基礎(chǔ)
2.5 識別線程
第1章 你好,C++的并發(fā)世界!
1.2 為什么使用并發(fā)?
A.5 Lambda函數(shù)
第2章 線程管理
4.3 限定等待時間
D.3 &lt;atomic&gt;頭文件
10.2 定位并發(fā)錯誤的技術(shù)
附錄B 并發(fā)庫的簡單比較
5.3 同步操作和強制排序
A.8 線程本地變量
第8章 并發(fā)代碼設(shè)計
3.3 保護共享數(shù)據(jù)的替代設(shè)施
附錄D C++線程庫參考
第7章 無鎖并發(fā)數(shù)據(jù)結(jié)構(gòu)設(shè)計
D.7 &lt;thread&gt;頭文件
D.1 &lt;chrono&gt;頭文件
4.1 等待一個事件或其他條件
A.3 默認函數(shù)
附錄A 對<code>C++</code>11語言特性的簡要介紹
第6章 基于鎖的并發(fā)數(shù)據(jù)結(jié)構(gòu)設(shè)計
封面圖片介紹
7.2 無鎖數(shù)據(jù)結(jié)構(gòu)的例子
8.6 本章總結(jié)
8.1 線程間劃分工作的技術(shù)
4.2 使用期望等待一次性事件
8.4 設(shè)計并發(fā)代碼的注意事項
D.5 &lt;mutex&gt;頭文件
3.1 共享數(shù)據(jù)帶來的問題
資源
9.3 本章總結(jié)
10.3 本章總結(jié)
10.1 與并發(fā)相關(guān)的錯誤類型
D.4 &lt;future&gt;頭文件
3.2 使用互斥量保護共享數(shù)據(jù)
9.1 線程池
1.1 何謂并發(fā)
9.2 中斷線程
4.4 使用同步操作簡化代碼
A.2 刪除函數(shù)
1.3 C++中的并發(fā)和多線程
1.4 開始入門
第5章 C++內(nèi)存模型和原子類型操作
消息傳遞框架與完整的ATM示例
8.2 影響并發(fā)代碼性能的因素
7.1 定義和意義
D.6 &lt;ratio&gt;頭文件
A.4 常量表達式函數(shù)
7.4 本章總結(jié)
1.5 本章總結(jié)
第3章 線程間共享數(shù)據(jù)

10.2 定位并發(fā)錯誤的技術(shù)

之前的章節(jié),我們了解了與并發(fā)相關(guān)的錯誤類型,以及如何在代碼中體現(xiàn)出來的。這些信息可以幫助我們來判斷,的代碼中是否存在有隱藏的錯誤。

最簡單直接的就是直接看代碼。雖然看起來比較明顯,但是要徹底的修復(fù)問題,卻是很難的。讀剛寫完的代碼,要比讀已經(jīng)存在的代碼容易的多。同理,當(dāng)在審查別人寫好的代碼時,給出一個通讀結(jié)果是很容易的,比如:與你自己的代碼標準作對比,以及高亮標出顯而易見的問題。為什么要花時間來仔細梳理代碼?想想之前提到的并發(fā)相關(guān)的問題——也要考慮非并發(fā)問題。(也可以在很久以后做這件事。不過,最后bug依舊存在)我們可以在審閱代碼的時候,考慮一些具體的事情,并且發(fā)現(xiàn)問題。

即使已經(jīng)很對代碼進行了很詳細的審閱,依舊會錯過一些bug,這就需要確定一下代碼是否做了對應(yīng)的工作。因此,在測試多線程代碼時,會介紹一些代碼審閱的技巧。

10.2.1 代碼審閱——發(fā)現(xiàn)潛在的錯誤

在審閱多線程代碼時,重點要檢查與并發(fā)相關(guān)的錯誤。如果可能,可以讓同事/同伴來審閱。因為不是他們寫的代碼,他們將會考慮這段代碼是怎么工作的,就可能會覆蓋到一些你沒有想到的情況,從而找出一些潛在的錯誤。審閱人員需要有時間去做審閱——并非在休閑時間簡單的掃一眼。大多數(shù)并發(fā)問題需要的不僅僅是一次快速瀏覽——通常需要在找到問題上花費很多時間。

如果你讓你的同時來審閱代碼,他/她肯定對你的代碼不是很熟悉。因此,他/她會從不同的角度來看你的代碼,然后指出你沒有注意的事情。如果你的同事都沒有空,你可以叫你的朋友,或傳到網(wǎng)絡(luò)上,讓網(wǎng)友審閱(注意,別傳一些機密代碼上去)。實在沒有人審閱你的代碼,不要著急——你還可以做很多事情。對于初學(xué)者,可以將代碼放置一段時間——先去做應(yīng)用的另外的部分,或是閱讀一本書籍,亦或出去溜達溜達。在休息之后,當(dāng)再集中注意力做某些事情(潛意識會考慮很多問題)。同樣,當(dāng)你做完其他事情,回頭再看這段代碼,就會有些陌生——你可能會從另一個角度來看你自己以前寫的代碼。

另一種方式就是自己審閱??梢韵騽e人詳細的介紹你所寫的功能,可能并不是一個真正的人——可能要對一只玩具熊或一只橡皮雞來進行解釋,并且我個人覺得寫一些比較詳細的注釋是非常有益的。在解釋過程中,會考慮每一行過后,會發(fā)生什么事情,有哪些數(shù)據(jù)被訪問了,等等。問自己關(guān)于代碼的問題,并且向自己解釋這些問題。我覺得這是種非常有效的技巧——通過自問自答,對每個問題認真考慮,這些問題往往都會揭示一些問題,也會有益于任何形式的代碼審閱。

審閱多線程代碼需要考慮的問題

審閱代碼的時候考慮和代碼相關(guān)的問題,以及有利于找出代碼中的問題。問題審閱者需要在代碼中找到相應(yīng)的回答或錯誤。我認為下面這些問題必須要問(當(dāng)然,不是一個綜合性的列表),你也可以找一些其他問題來幫助你找到代碼的問題。

這里,列一下我的清單:

  • 并發(fā)訪問時,那些數(shù)據(jù)需要保護?

  • 如何確定訪問數(shù)據(jù)受到了保護?

  • 是否會有多個線程同時訪問這段代碼?

  • 這個線程獲取了哪個互斥量?

  • 其他線程可能獲取哪些互斥量?

  • 兩個線程間的操作是否有依賴關(guān)系?如何滿足這種關(guān)系?

  • 這個線程加載的數(shù)據(jù)還是合法數(shù)據(jù)嗎?數(shù)據(jù)是否被其他線程修改過?

  • 當(dāng)假設(shè)其他線程可以對數(shù)據(jù)進行修改,這將意味著什么?并且,怎么確保這樣的事情不會發(fā)生?

最后一個問題,我最喜歡,因為它讓我著實的去考慮線程之間的關(guān)系。通過假設(shè)一個bug和一行代碼相關(guān)聯(lián),你就可以扮演偵探來追蹤bug出現(xiàn)的原因。為了讓你自己確定代碼里面沒有bug,需要考慮代碼運行的各種情況。在數(shù)據(jù)被多個互斥量所保護的時候,這種方式尤其有用,比如:使用線程安全隊列(第6章),可以對隊頭和隊尾使用獨立的互斥量:就是為了確保在持有一個互斥量的時候,訪問是安全的,這里必須確保持有其他互斥量的線程不能同時訪問同一元素。還需要特別關(guān)注的是,對公共數(shù)據(jù)的顯式處理,使用一個指針或引用的方式讓其他代碼來獲取數(shù)據(jù)。

倒數(shù)第二個問題也很重要,因為這是很容易產(chǎn)生錯誤的地方:先釋放再獲取一個互斥量的前提是,其他線程可能會修改共享數(shù)據(jù)。雖然很明顯,但當(dāng)互斥鎖不是立即可見——可能因為是內(nèi)部對象——就會不知不覺的掉入陷阱中。在第6章,已經(jīng)了解到這種情況是怎么引起條件競爭,以及如何給細粒度線程安全數(shù)據(jù)結(jié)構(gòu)帶來麻煩。不過,非線程安全棧將top()和pop()操作分開是有意義的,當(dāng)多線程會并發(fā)的訪問這個棧,問題會馬上出現(xiàn),因為在兩個操作的調(diào)用間,內(nèi)部互斥鎖已經(jīng)被釋放,并且另一個線程對棧進行了修改。解決方案就是將兩個操作合并,就能用同一個鎖來對操作的執(zhí)行進行保護,就消除了條件競爭的問題。

OK,你已經(jīng)審閱過代碼了(或者讓別人看過)?,F(xiàn)在,你確信代碼沒有問題。

就像需要用味覺來證明,你現(xiàn)在吃的東西——怎么測試才能確認你的代碼沒有bug呢?

10.2.2 通過測試定位并發(fā)相關(guān)的錯誤

寫單線程應(yīng)用時,如果時間充足,測試起來相對簡單。原則上,設(shè)置各種可能的輸入(或設(shè)置成感興趣的情況),然后執(zhí)行應(yīng)用。如果應(yīng)用行為和輸出正確,就能判斷其能對給定輸入集給出正確的答案。檢查錯誤狀態(tài)(比如:處理磁盤滿載錯誤)就會比處理可輸入測試復(fù)雜的多,不過原理是一樣的——設(shè)置初始條件,然后讓程序執(zhí)行。

測試多線程代碼的難度就要比單線程大好幾個數(shù)量級,因為不確定是線程的調(diào)度情況。因此,即使使用測試單線程的輸入數(shù)據(jù),如果有條件變量潛藏在代碼中,那么代碼的結(jié)果可能會時對時錯。只是因為條件變量可能會在有些時候,等待其他事情,從而導(dǎo)致結(jié)果錯誤或正確。

因為與并發(fā)相關(guān)的bug相當(dāng)難判斷,所以在設(shè)計并發(fā)代碼時需要格外謹慎。設(shè)計的時候,每段代碼都需要進行測試,以保證沒有問題,這樣才能在測試出現(xiàn)問題的時候,剔除并發(fā)相關(guān)的bug——例如,對隊列的push和pop,分別進行并發(fā)的測試,就要好于直接使用隊列測試其中全部功能。這種思想能幫你在設(shè)計代碼的時候,考慮什么樣的代碼是可以用來測試正在設(shè)計的這個結(jié)構(gòu)——本章后續(xù)章節(jié)中看到與設(shè)計測試代碼相關(guān)的內(nèi)容。

測試的目的就是為了消除與并發(fā)相關(guān)的問題。如果在單線程測試的時候,遇到了問題,那這個問題就是普通的bug,而非并發(fā)相關(guān)的bug。當(dāng)問題發(fā)生在未測試區(qū)域(in the wild),也就是沒有在測試范圍之內(nèi),像這樣的情況就要特別注意。bug出現(xiàn)在應(yīng)用的多線程部分,并不意味著該問題是一個多線程相關(guān)的bug。使用線程池管理某一級并發(fā)的時候,通常會有一個可配置的參數(shù),用來指定工作線程的數(shù)量。當(dāng)手動管理線程時,就需要將代碼改成單線程的方式進行測試。不管哪種方式,將多線程簡化為單線程后,就能將與多線程相關(guān)的bug排除掉。反過來說,當(dāng)問題在單芯系統(tǒng)中消失(即使還是以多線程方式),不過問題在多芯系統(tǒng)或多核系統(tǒng)中出現(xiàn),就能確定你被多線程相關(guān)的bug坑了,可能是條件變量的問題,還有可能是同步或內(nèi)存序的問題。

測試并發(fā)的代碼很多,不過通過測試的代碼結(jié)構(gòu)就沒那么多了;對結(jié)構(gòu)的測試也很重要,就像對環(huán)境的測試一樣。

如果你依舊將測試并發(fā)隊列當(dāng)做一個測試例,你就需要考慮這些情況:

  • 使用單線程調(diào)用push()或pop(),來確定在一般情況下隊列是否工作正常

  • 其他線程調(diào)用pop()時,使用另一線程在空隊列上調(diào)用push()

  • 在空隊列上,以多線程的方式調(diào)用push()

  • 在滿載隊列上,以多線程的方式調(diào)用push()

  • 在空隊列上,以多線程的方式調(diào)用pop()

  • 在滿載隊列上,以多線程的方式調(diào)用pop()

  • 在非滿載隊列上(任務(wù)數(shù)量小于線程數(shù)量),以多線程的方式調(diào)用pop()

  • 當(dāng)一線程在空隊列上調(diào)用pop()的同時,以多線程的方式調(diào)用push()

  • 當(dāng)一線程在滿載隊列上調(diào)用pop()的同時,以多線程的方式調(diào)用push()

  • 當(dāng)多線程在空隊列上調(diào)用pop()的同時,以多線程方式調(diào)用push()

  • 當(dāng)多線程在滿載隊列上調(diào)用pop()的同時,以多線程方式調(diào)用push()

這是我所能想到的場景,可能還有更多,之后你需要考慮測試環(huán)境的因素:

  • “多線程”是有多少個線程(3個,4個,還是1024個?)

  • 系統(tǒng)中是否有足夠的處理器,能讓每個線程運行在屬于自己的處理器上

  • 測試需要運行在哪種處理器架構(gòu)上

  • 在測試中如何對“同時”進行合理的安排

這些因素的考慮會具體到一些特殊情況。四個因素都需要考慮,第一個和最后一個會影響測試結(jié)構(gòu)本身(在10.2.5節(jié)中會介紹),另外兩個就和實際的物理測試環(huán)境相關(guān)了。使用線程數(shù)量相關(guān)的測試代碼需要獨立測試,可通過很多結(jié)構(gòu)化測試獲得最合適的調(diào)度方式。在了解這些技巧前,先來了解一下如何讓你的應(yīng)用更容易測試。

10.2.3 可測試性設(shè)計

測試多線程代碼很困難,所以你需要將其變得簡單一些。很重要的一件事就是,在設(shè)計代碼時,考慮其的可測試性??蓽y試的單線程代碼設(shè)計已經(jīng)說爛了,而且其中許多建議,在現(xiàn)在依舊適用。通常,如果代碼滿足一下幾點,就很容易進行測試:

  • 每個函數(shù)和類的關(guān)系都很清楚。

  • 函數(shù)短小精悍。

  • 測試用例可以完全控制被測試代碼周邊的環(huán)境。

  • 執(zhí)行特定操作的代碼應(yīng)該集中測試,而非分布式測試。

  • 需要在完成編寫后,考慮如何進行測試。

以上這些在多線程代碼中依舊適用。實際上,我會認為對多線程代碼的可測試性要比單線程的更為重要,因為多線程的情況更加復(fù)雜。最后一個因素尤為重要:即使不在寫完代碼后,去寫測試用例,這也是一個很好的建議,能讓你在寫代碼之前,想想應(yīng)該怎么去測試它——用什么作為輸入,什么情況看起來會讓結(jié)果變得糟糕,以及如何激發(fā)代碼中潛在的問題,等等。

并發(fā)代碼測試的一種最好的方式:去并發(fā)化測試。如果代碼在線程間的通訊路徑上出現(xiàn)問,就可以讓一個已通訊的單線程進行執(zhí)行,這樣會減小問題的難度。在對數(shù)據(jù)進行訪問的應(yīng)用進行測試時,可以使用單線程的方式進行。這樣線程通訊和對特定數(shù)據(jù)塊進行訪問時只有一個線程,就達到了更容易測試的目的。

例如,當(dāng)應(yīng)用設(shè)計為一個多線程狀態(tài)機時,可以將其分為若干塊。將每個邏輯狀態(tài)分開,就能保證對于每個可能的輸入事件、轉(zhuǎn)換或其他操作的結(jié)果是正確的;這就是使用了單線程測試的技巧,測試用例提供的輸入事件將來自于其他線程。之后,核心狀態(tài)機和消息路由的代碼,就能保證時間能以正確的順序,正確的傳遞給可單獨測試的線程上,不過對于多并發(fā)線程,需要為測試專門設(shè)計簡單的邏輯狀態(tài)。

或者,如果將代碼分割成多個塊(比如:讀共享數(shù)據(jù)/變換數(shù)據(jù)/更新共享數(shù)據(jù)),就能使用單線程來測試變換數(shù)據(jù)的部分。麻煩的多線程測試問題,轉(zhuǎn)換成單線程測試讀和更新共享數(shù)據(jù),就會簡單許多。

一件事需要小心,就是某些庫會用其內(nèi)部變量存儲狀態(tài),當(dāng)多線程使用同一庫中的函數(shù),這個狀態(tài)就會被共享。這的確是一個問題,并且這個問題不會馬上出現(xiàn)在訪問共享數(shù)據(jù)的代碼中。不過,隨著你對這個庫的熟悉,就會清楚這樣的情況會在什么時候出現(xiàn)。之后,可以適當(dāng)?shù)募右恍┍Wo和同步,或使用B計劃——讓多線程安全并發(fā)訪問的功能。

將并發(fā)代碼設(shè)計的有更好的測試性,要比以代碼分塊的方式處理并發(fā)相關(guān)的問題好很多。當(dāng)然,還要注意對非線程安全庫的調(diào)用。10.2.1節(jié)中那些問題,也需要在審閱自己代碼的時候格外注意。雖然,這些問題和測試(可測試性)沒有直接的關(guān)系,但帶上“測試帽子”時候,就要考慮這些問題了,并且還要考慮如何測試已寫好的代碼,這就會影響設(shè)計方向的選擇,也會讓測試做的更加容易一些。

我們已經(jīng)了解了如何能讓測試變得更加簡單,以及將代碼分成一些“并發(fā)”塊(比如,線程安全容器或事件邏輯狀態(tài)機)以“單線程”的形式(可能還通過并發(fā)塊和其他線程進行互動)進行測試。

下面就讓我們了解一下測試多線程代碼的技術(shù)。

10.2.4 多線程測試技術(shù)

想通過一些技巧寫一些較短的代碼,來對函數(shù)進行測試,比如:如何處理調(diào)度序列上的bug?

這里的確有幾個方法能進行測試,讓我們從蠻力測試(或稱壓力測試)開始。

蠻力測試

代碼有問題的時候,就要求蠻力測試一定能看到這個錯誤。這就意味著代碼要運行很多遍,可能會有很多線程在同一時間運行。要是有bug出現(xiàn),只能線程出現(xiàn)特殊調(diào)度的時候;代碼運行次數(shù)的增加,就意味著bug出現(xiàn)的次數(shù)會增多。當(dāng)有幾次代碼測試通過,你可能會對代碼的正確性有一些信心。如果連續(xù)運行10次都通過,你就會更有信心。如果你運行十億次都通過了,那么你就會認為這段代碼沒有問題了。

自信的來源是每次測試的結(jié)果。如果你的測試粒度很細,就像測試之前的線程安全隊列,那么蠻力測試會讓你對這段代碼持有高度的自信。另一方面,當(dāng)測試對象體積較大的時候,調(diào)度序列將會很長,即使運行了十億次測試用例,也不讓你對這段代碼產(chǎn)生什么信心。

蠻力測試的缺點就是,可能會誤導(dǎo)你。如果寫出來的測試用例就為了不讓有問題的情況發(fā)生,那么怎么運行,測試都不會失敗,可能會因環(huán)境的原因,出現(xiàn)幾次失敗的情況。最糟糕的情況就是,問題不會出現(xiàn)在你的測試系統(tǒng)中,因為在某些特殊的系統(tǒng)中,這段代碼就會出現(xiàn)問題。除非代碼運行在與測試機系統(tǒng)相同的系統(tǒng)中,不過特殊的硬件和操作系統(tǒng)的因素結(jié)合起來,可能就會讓運行環(huán)境與測試環(huán)境有所不同,問題可能就會隨之出現(xiàn)。

這里有一個經(jīng)典的案例,在單處理器系統(tǒng)上測試多線程應(yīng)用。因為每個線程都在同一個處理器上運行,任何事情都是串行的,并且還有很多條件競爭和乒乓緩存,這些問題可能在真正的多處理器系統(tǒng)中,根本不會出現(xiàn)。還有其他變數(shù):不同處理器架構(gòu)提供不同的的同步和內(nèi)存序機制。比如,在x86和x86-64架構(gòu)上,原子加載操作通常是相同的,無論是使用memory_order_relaxed,還是memory_order_seq_cst(詳見5.3.3節(jié))。這就意味著在x86架構(gòu)上使用松散內(nèi)存序沒有問題,但在有更精細的內(nèi)存序指令集的架構(gòu)(比如:SPARC)下,這樣使用就可能產(chǎn)生錯誤。

如果你希望你的應(yīng)用能跨平臺使用,就要在相關(guān)的平臺上進行測試。這就是我把處理器架構(gòu)也列在測試需要考慮的清單中的原因(詳見10.2.2)。

要避免誤導(dǎo)的產(chǎn)生,關(guān)鍵點在于成功的蠻力測試。這就需要進行仔細考慮和設(shè)計,不僅僅是選擇相關(guān)單元測試,還要遵守測試系統(tǒng)設(shè)計準則,以及選定測試環(huán)境。保證代碼分支被盡可能的測試到,盡可能多的測試線程間的互相作用。還有,需要知道哪部分被測試覆蓋到,哪些沒有覆蓋。

雖然,蠻力測試能夠給你一些信心,不過其不保證能找到所有的問題。如果有時間將下面的技術(shù)應(yīng)用到你的代碼或軟件中,就能保證所有的問題都能被找到。

組合仿真測試

名字比較口語化,我需要解釋一下這個測試是什么意思:使用一種特殊的軟件,用來模擬代碼運行的真實情況。你應(yīng)該知道這種軟件,能讓一臺物理機上運行多個虛擬環(huán)境或系統(tǒng)環(huán)境,而硬件環(huán)境則由監(jiān)控軟件來完成。除了環(huán)境是模擬的以外,模擬軟件會記錄對數(shù)據(jù)序列訪問,上鎖,以及對每個線程的原子操作。然后使用C++內(nèi)存模型的規(guī)則,重復(fù)的運行,從而識別條件競爭和死鎖。

雖然,這種組合測試可以保證所有與系統(tǒng)相關(guān)的問題都會被找到,不過過于零碎的程序?qū)谶@種測試中耗費太長時間,因為組合數(shù)目和執(zhí)行的操作數(shù)量將會隨線程的增多呈指數(shù)增長態(tài)勢。這個測試最好留給需要細粒度測試的代碼段,而非整個應(yīng)用。另一個缺點就是,代碼對操作的處理,往往會依賴與模擬軟件的可用性。

所以,測試需要在正常情況下,運行很多次,不過這樣可能會錯過一些問題;也可以在一些特殊情況下運行多次,不過這樣更像是為了驗證某些問題。

還有其他的測試選項嗎?

第三個選項就是使用一個庫,在運行測試的時候,檢查代碼中的問題。

使用專用庫對代碼進行測試

雖然,這個選擇不會像組合仿真的方式提供徹底的檢查,不過可以通過特別實現(xiàn)的庫(使用同步原語)來發(fā)現(xiàn)一些問題,比如:互斥量,鎖和條件變量。例如,訪問某塊公共數(shù)據(jù)的時候,就要將指定的互斥量上鎖。數(shù)據(jù)被訪問后,發(fā)現(xiàn)一些互斥量已經(jīng)上鎖,就需要確定相關(guān)的互斥量是否被訪問線程鎖??;如果沒有,測試庫將報告這個錯誤。當(dāng)需要測試庫對某塊代碼進行檢查時,可以對對應(yīng)的共享數(shù)據(jù)進行標記。

當(dāng)不止一個互斥量同時被一個線程持有,測試庫也會對鎖的序列進行記錄。如果其他線程以不同的順序進行上鎖,即使在運行的時候測試用例沒有發(fā)生死鎖,測試庫都會將這個行為記錄為“有潛在死鎖”可能。

當(dāng)測試多線程代碼的時候,另一種庫可能會用到,以線程原語實現(xiàn)的庫,比如:互斥量和條件變量;當(dāng)多線程代碼在等待,或是被條件變量通過notify_one()提醒的某個線程,測試者可以通過線程,獲取到鎖。就可以讓你來安排一些特殊的情況,以驗證代碼是否會在這些特定的環(huán)境下產(chǎn)生期望的結(jié)果。

C++標準庫實現(xiàn)中,某些測試工具已經(jīng)存在于標準庫中,沒有實現(xiàn)的測試工具,可以基于標準庫進行實現(xiàn)。

了解完各種運行測試代碼的方式,將讓我們來了解一下,如何以你想要的調(diào)度方式來構(gòu)建代碼。

10.2.5 構(gòu)建多線程測試代碼

10.2.2節(jié)中提過,需要找一種合適的調(diào)度方式來處理測試中“同時”的部分,現(xiàn)在就是解決這個問題的時候。

在特定時間內(nèi),你需要安排一系列線程,同時去執(zhí)行指定的代碼段。最簡單的情況:兩個線程的情況,就很容易擴展到多個線程。

首先,你需要知道每個測試的不同之處:

  • 環(huán)境布置代碼,必須首先執(zhí)行

  • 線程設(shè)置代碼,需要在每個線程上執(zhí)行

  • 線程上執(zhí)行的代碼,需要有并發(fā)性

  • 在并發(fā)執(zhí)行結(jié)束后,后續(xù)代碼需要對代碼的狀態(tài)進行斷言檢查

這幾條后面再解釋,先讓我們考慮一下10.2.2節(jié)中的一個特殊的情況:一個線程在空隊列上調(diào)用push(),同時讓其他線程調(diào)用pop()。

通常,布置環(huán)境的代碼比較簡單:創(chuàng)建隊列即可。線程在執(zhí)行pop()的時候,沒有線程設(shè)置代碼。線程設(shè)置代碼是在執(zhí)行push()操作的線程上進行的,其依賴與隊列的接口和對象的存儲類型。如果存儲的對象需要很大的開銷才能構(gòu)建,或必須在堆上分配的對象,那么最好在線程設(shè)置代碼中進行構(gòu)建或分配;這樣,就不會影響到測試結(jié)果。另外,如果隊列中只存簡單的int類型對象,構(gòu)建int對象時就不會有太多額外的開銷。實際上,已測試代碼相對簡單——一個線程調(diào)用push(),另一個線程調(diào)用pop()——那么,“完成后”的代碼到底是什么樣子呢?

在這個例子中,pop()具體做的事情,會直接影響“完成后”代碼。如果有數(shù)據(jù)塊,返回的肯定就是數(shù)據(jù)了,push()操作就成功的向隊列中推送了一塊數(shù)據(jù),并在在數(shù)據(jù)返回后,隊列依舊是空的。如果pop()沒有返回數(shù)據(jù)塊,也就是隊列為空的情況下,操作也能執(zhí)行,這樣就需要兩個方向的測試:要不pop()返回push()推送到隊列中的數(shù)據(jù)塊,之后隊列依舊為空;要不pop()會示意隊列中沒有元素,但同時push()向隊列推送了一個數(shù)據(jù)塊。這兩種情況都是真實存在的;你需要避免的情況是:pop()示意隊列中沒有數(shù)據(jù)的同時,隊列還是空的,或pop()返回數(shù)據(jù)塊的同時,隊列中還有數(shù)據(jù)塊。為了簡化測試,可以假設(shè)pop()是可阻塞的。在最終代碼中,需要用斷言判斷彈出的數(shù)據(jù)與推入的數(shù)據(jù),還要判斷隊列為空。

現(xiàn)在,了解了各個代碼塊,就需要保證所有事情按計劃進行。一種方式是使用一組std::promise來表示就緒狀態(tài)。每個線程使用一個promise來表示是否準備好,然后讓std::promise等待(復(fù)制)一個std::shared_future;主線程會等待每個線程上的promise設(shè)置后,才按下“開始”鍵。這就能保證每個線程能夠同時開始,并且在準備代碼執(zhí)行完成后,并發(fā)代碼就可以開始執(zhí)行了;任何的線程特定設(shè)置都需要在設(shè)置線程的promise前完成。最終,主線程會等待所有線程完成,并且檢查其最終狀態(tài)。還需要格外關(guān)心的是——異常,所有線程在準備好的情況下,才按下“開始”鍵;否則,未準備好的線程就不會運行。

下面的代碼,構(gòu)建了這樣的測試。

清單10.1 對一個隊列并發(fā)調(diào)用push()和pop()的測試用例

void test_concurrent_push_and_pop_on_empty_queue()
{
  threadsafe_queue<int> q;  // 1

  std::promise<void> go,push_ready,pop_ready;  // 2
  std::shared_future<void> ready(go.get_future());  // 3

  std::future<void> push_done;  // 4
  std::future<int> pop_done;

  try
  {
    push_done=std::async(std::launch::async,  // 5
                         [&q,ready,&push_ready]()
                         {
                           push_ready.set_value();
                           ready.wait();
                           q.push(42);
                         }
      );
    pop_done=std::async(std::launch::async,  // 6
                        [&q,ready,&pop_ready]()
                        {
                          pop_ready.set_value();
                          ready.wait();
                          return q.pop();  // 7
                        }
      );
    push_ready.get_future().wait();  // 8
    pop_ready.get_future().wait();
    go.set_value();  // 9

    push_done.get();  // 10
    assert(pop_done.get()==42);  // 11
    assert(q.empty());
  }
  catch(...)
  {
    go.set_value();  // 12
    throw;
  }
}

首先,環(huán)境設(shè)置代碼中創(chuàng)建了空隊列①。然后,為準備狀態(tài)創(chuàng)建promise對象②,并且為go信號獲取一個std::shared_future對象③。再后,創(chuàng)建了future用來表示線程是否結(jié)束④。這些都需要放在try塊外面,再設(shè)置go信號時拋出異常,就不需要等待其他城市線程完成任務(wù)了(這將會產(chǎn)生死鎖——如果測試代碼產(chǎn)生死鎖,測試代碼就是不理想的代碼)。

try塊中可以啟動線程⑤⑥——使用std::launch::async保證每個任務(wù)在自己的線程上完成。注意,使用std::async會讓你任務(wù)更容易成為線程安全的任務(wù);這里不用普通std::thread,因為其析構(gòu)函數(shù)會對future進行線程匯入。lambda函數(shù)會捕捉指定的任務(wù)(會在隊列中引用),并且為promise準備相關(guān)的信號,同時對從go中獲取的ready做一份拷貝。

如之前所說,每個任務(wù)集都有自己的ready信號,并且會在執(zhí)行測試代碼前,等待所有的ready信號。而主線程不同——等待所有線程的信號前⑧,提示所有線程可以開始進行測試了⑨。

最終,異步調(diào)用等待線程完成后⑩?,主線程會從中獲取future,再調(diào)用get()成員函數(shù)獲取結(jié)果,最后對結(jié)果進行檢查。注意,這里pop操作通過future返回檢索值⑦,所以能獲取最終的結(jié)果?。

當(dāng)有異常拋出,需要通過對go信號的設(shè)置來避免懸空指針的產(chǎn)生,再重新拋出異常?。future與之后聲明的任務(wù)相對應(yīng)④,所以future將會被首先銷毀,如果future都沒有就緒,析構(gòu)函數(shù)將會等待相關(guān)任務(wù)完成后執(zhí)行操作。

雖然,像是使用測試模板對兩個調(diào)用進行測試,但使用類似的東西是必要的,這樣會便于測試的進行。例如,啟動一個線程就是一個很耗時的過程,如果沒有線程在等待go信號時,推送線程可能會在彈出線程開始之前,就已經(jīng)完成了;這樣就失去了測試的作用。以這種方式使用future,就是為了保證線程都在運行,并且阻塞在同一個future上。future解除阻塞后,將會讓所有線程運行起來。當(dāng)你熟悉了這個結(jié)構(gòu),其就能以同樣的模式創(chuàng)建新的測試用例。測試兩個以上的線程,這種模式很容易進行擴展。

目前,我們已經(jīng)了解了多線程代碼的正確性測試。

雖然這是最最重要的問題,但是其不是我們做測試的唯一原因:多線程性能的測試同樣重要。

下面就讓我們來了解一下性能測試。

10.2.6 測試多線程代碼性能

選擇以并發(fā)的方式開發(fā)應(yīng)用,就是為了能夠使用日益增長的處理器數(shù)量;通過處理器數(shù)量的增加,來提升應(yīng)用的執(zhí)行效率。因此,確定性能是否有真正的提高就很重要了(就像其他優(yōu)化一樣)。

并發(fā)效率中有個特別的問題——可擴展性——你希望代碼能很快的運行24次,或在24芯的機器上對數(shù)據(jù)進行24(或更多)次處理,或其他等價情況。你不會希望,你的代碼運行兩次的數(shù)據(jù)和在雙芯機器上執(zhí)行一樣快的同時,在24芯的機器上會更慢。如8.4.2節(jié)中所述,當(dāng)有重要的代碼以單線程方式運行時,就會限制性能的提高。因此,在做測試之前,回顧一下代碼的設(shè)計結(jié)構(gòu)是很有必要的;這樣就能判斷,代碼在24芯的機器上時,性能會不會提高24倍,或是因為有串行部分的存在,最大的加速比只有3。

在對數(shù)據(jù)訪問的時候,處理器之間會有競爭,會對性能有很大的影響。需要合理的權(quán)衡性能和處理器的數(shù)量,處理器數(shù)量太少,就會等待很久;處理器過多,又會因為競爭的原因等待很久。

因此,在對應(yīng)的系統(tǒng)上通過不同的配置,檢查多線程的性能就很有必要,這樣可以得到一張性能伸縮圖。最起碼,(如果條件允許)你應(yīng)該在一個單處理器的系統(tǒng)上和一個多處理核芯的系統(tǒng)上進行測試。