之前的章節(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)的工作。因此,在測試多線程代碼時,會介紹一些代碼審閱的技巧。
在審閱多線程代碼時,重點要檢查與并發(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ù)是否被其他線程修改過?
最后一個問題,我最喜歡,因為它讓我著實的去考慮線程之間的關(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呢?
寫單線程應(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()
這是我所能想到的場景,可能還有更多,之后你需要考慮測試環(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)用更容易測試。
測試多線程代碼很困難,所以你需要將其變得簡單一些。很重要的一件事就是,在設(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ù)。
想通過一些技巧寫一些較短的代碼,來對函數(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.2節(jié)中提過,需要找一種合適的調(diào)度方式來處理測試中“同時”的部分,現(xiàn)在就是解決這個問題的時候。
在特定時間內(nèi),你需要安排一系列線程,同時去執(zhí)行指定的代碼段。最簡單的情況:兩個線程的情況,就很容易擴展到多個線程。
首先,你需要知道每個測試的不同之處:
環(huán)境布置代碼,必須首先執(zhí)行
線程設(shè)置代碼,需要在每個線程上執(zhí)行
線程上執(zhí)行的代碼,需要有并發(fā)性
這幾條后面再解釋,先讓我們考慮一下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)了解了多線程代碼的正確性測試。
雖然這是最最重要的問題,但是其不是我們做測試的唯一原因:多線程性能的測試同樣重要。
下面就讓我們來了解一下性能測試。
選擇以并發(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)上進行測試。