線程定義
線程的定義在維基百科和各種教材書(shū)中都能找到,這里再簡(jiǎn)單描述一下:操作系統(tǒng)中能夠被調(diào)度的最小單位,有自己的context、stack以及thread-local存儲(chǔ),但與同一進(jìn)程中的其他線程共享進(jìn)程資源。如果從另外一個(gè)角度來(lái)思考:比如把C語(yǔ)言的指針作為機(jī)器地址的抽象的話,那么線程可以認(rèn)為是機(jī)器流水線或者“虛擬處理器”的抽象。只是流水線之間的耦合并沒(méi)有線程來(lái)的那么緊密,原因在于線程之間還是共享了很多東西,編碼時(shí)則需要線程安全。
線程安全
很多場(chǎng)合,線程安全總是和可重入混在一起。需要引起注意的是,這兩個(gè)并不是同一個(gè)概念。可重入(別名 異步信號(hào)安全)是一個(gè)更嚴(yán)格的要求,首先它要求線程安全,其次當(dāng)發(fā)生信號(hào)中斷并執(zhí)行完處理例程后繼續(xù)執(zhí)行仍然保證正確方可。使用mutex的函數(shù)可能是線程安全的,但不是可重入的。二者大致關(guān)系如下圖,可重入函數(shù)只是線程安全函數(shù)的一部分。
(面積比例不代表函數(shù)數(shù)量比例)
不過(guò)無(wú)論是線程安全或者可重入,二者都具備傳遞性:若當(dāng)前函數(shù)調(diào)用了非線程安全函數(shù),則當(dāng)前函數(shù)一定不是線程安全的。
線程同步與線程安全的實(shí)現(xiàn)機(jī)制
最好就是不共享任何的資源,從而避免競(jìng)爭(zhēng)(race condition),比如比較兩個(gè)參數(shù)大小的函數(shù)
inline int max(int a, int b){ return a > b ? a : b; }
如果一定要共享,那么可以通過(guò)互斥鎖來(lái)實(shí)現(xiàn)。但在共享前,需要識(shí)別的是是否僅僅是偽共享,比如errno。業(yè)務(wù)邏輯如果允許每個(gè)線程一份copy,而且無(wú)須同步的話,則可通過(guò)將資源thread-local化來(lái)實(shí)現(xiàn)。
POSIX提供pthread_key_create
,pthread_get(set)specific
接口來(lái)實(shí)現(xiàn),而新的C++11更是將thread_local設(shè)置成了關(guān)鍵字更好的進(jìn)行支持。
互斥鎖
在操作系統(tǒng)提供的編程接口層面,開(kāi)發(fā)者可用的同步手段很多,其中以互斥鎖應(yīng)用最為廣泛(信號(hào)量是一種將資源數(shù)目從1泛化到n的互斥鎖)。當(dāng)線程進(jìn)入臨界區(qū)前獲得鎖,只有獲得了鎖的線程才可能繼續(xù)執(zhí)行,當(dāng)退出臨界區(qū)后歸還鎖。如果鎖被占用,則線程進(jìn)入阻塞狀態(tài)。但是要預(yù)防錯(cuò)誤的加鎖順序或者持有鎖但不釋放的場(chǎng)景,否則會(huì)造成程序死鎖、異常,陷入不易復(fù)現(xiàn)的bug。通過(guò)合理的利用RAII機(jī)制來(lái)保證鎖的獲取與釋放是多線程編程的一種最佳實(shí)踐,以至于這也進(jìn)入了C++11的新標(biāo)準(zhǔn),比如std::unique_lock
。
即使編碼時(shí)非常小心,已經(jīng)注意到了加鎖順序、鎖申請(qǐng)必然釋放等規(guī)則,但仍然有可能存在問(wèn)題。
還記的發(fā)生在火星上的災(zāi)難吧:優(yōu)先級(jí)翻轉(zhuǎn)?,F(xiàn)代操作系統(tǒng)通過(guò)優(yōu)先級(jí)繼承較好的解決了這個(gè)問(wèn)題,但程序員需要注意自己代碼所運(yùn)行的平臺(tái)是否有這個(gè)機(jī)制,然后正確的設(shè)置線程屬性方可。
此外,如果所有優(yōu)先級(jí)都調(diào)到一個(gè)數(shù)量級(jí),那么還需要注意lock convoy問(wèn)題。
發(fā)生lock convoy的場(chǎng)景猶如2人迎面通過(guò)一獨(dú)木橋,2人相遇后均主動(dòng)放棄退回,然后再次上橋相遇。注意,這不是死鎖。我們的業(yè)務(wù)軟件需要避免這種設(shè)計(jì)。類(lèi)似的問(wèn)題比如當(dāng)初accept的驚群?jiǎn)栴}以及當(dāng)在條件變量中喚醒所有的等待線程時(shí)也會(huì)瞬時(shí)觸發(fā)單回合的類(lèi)似場(chǎng)景。
條件變量
條件變量也是同步的一種手段,由一把鎖(mutex)和一個(gè)condition組成。它可以使線程阻塞在某一條件上,比如queue.not_empty()
。當(dāng)條件滿(mǎn)足時(shí),線程喚醒。需要注意是要小心虛假喚醒,即當(dāng)wait返回后,需要再次判斷條件是否滿(mǎn)足。C++11中的wait接口有了第二個(gè)參數(shù),允許傳入predicate,借用lambda可以省掉幾行代碼,極大簡(jiǎn)化了編碼。比如
_cv_ready.wait(lock,[=]{return _tail != _head;})
當(dāng)隊(duì)列頭不等于隊(duì)列尾的條件滿(mǎn)足時(shí)(不為空),線程喚醒并再次檢查條件。
讀寫(xiě)鎖
允許讀鎖并發(fā),寫(xiě)-寫(xiě),寫(xiě)讀互斥。但尷尬的是,這是一個(gè)充滿(mǎn)誘惑的坑:由于語(yǔ)義復(fù)雜性,其內(nèi)部實(shí)現(xiàn)效率要比普通mutex慢很多。因此當(dāng)且僅當(dāng)在多讀一寫(xiě)的情況下,并且讀鎖臨界區(qū)非常大時(shí),比如要做IO,才適合用讀寫(xiě)鎖。不過(guò)針對(duì)不同的場(chǎng)景,可以借鑒這種思路,比如實(shí)現(xiàn)一種用于監(jiān)控統(tǒng)計(jì)功能的“寫(xiě)讀鎖”,即寫(xiě)遠(yuǎn)遠(yuǎn)大于讀的頻率,等到運(yùn)維人員讀某個(gè)統(tǒng)計(jì)時(shí)才合并所有的寫(xiě)。這樣可以有效降低cache bouncing。
spinlock
spinlock類(lèi)似互斥鎖,但等待鎖期間不會(huì)被切換,而是一直空轉(zhuǎn)。因此比較耗費(fèi)cpu資源,尤其當(dāng)持有鎖的線程被換出時(shí)。因此使用spinlock的場(chǎng)景,僅適用實(shí)時(shí)、短小且不會(huì)主動(dòng)切換的場(chǎng)景(比如持有鎖期間肯定不包含IO之類(lèi)的操作)。實(shí)際上mutex的實(shí)現(xiàn)中自帶了部分spinlock,所以用戶(hù)態(tài)下除非必要,盡量選用互斥鎖,否則容易適得其反。
原子操作
原子操作可類(lèi)比數(shù)據(jù)庫(kù)ACID的atomic。很多公司的平臺(tái)部門(mén)在做基礎(chǔ)庫(kù)時(shí)會(huì)使用asm匯編實(shí)現(xiàn)原子操作。C++11則直接提供了std::atomic供程序員使用。和前面的介紹相同,本文重點(diǎn)介紹使用陷阱:編譯器和CPU在生成或執(zhí)行指令時(shí)可能打亂某些看似無(wú)關(guān)的代碼(指令)。但實(shí)際上在多線程環(huán)境下存在依賴(lài)關(guān)系,如下例,假設(shè)thread_1/2運(yùn)行在同一進(jìn)程的不同線程,則可能打印a值為0。
atomic atomic a = t; b = 2; }int thread_2{ while(b != 2); cout < a=""><> } 因此C++11中大部分atomic都需要指定當(dāng)前場(chǎng)景所需的memory order來(lái)提高性能。 除此之外,一個(gè)初學(xué)者常犯的錯(cuò)誤,比如a,b 兩個(gè)變量都是atomic屬性,但 性能相關(guān) 當(dāng)通過(guò)互斥或條件變量來(lái)實(shí)現(xiàn)線程同步,不可避免的會(huì)發(fā)生主動(dòng)的線程切換。而不恰當(dāng)?shù)那袚Q則會(huì)影響到系統(tǒng)的吞吐和效率,這里僅從同步原語(yǔ)出發(fā)針對(duì)多線程編程總結(jié)一下。 顯然,多線程加鎖的消耗取決于競(jìng)爭(zhēng)的激烈程度以及上下文切換的開(kāi)銷(xiāo)。因此縮小臨界區(qū),將不用鎖的尤其那些重量級(jí)的不用鎖的部分移到臨界區(qū)外是提高性能的不二法則。而 此外,通過(guò)對(duì)不同的業(yè)務(wù)邏輯采用恰當(dāng)?shù)木€程模型,也能夠避免競(jìng)爭(zhēng)的激烈程度。 線程模型 對(duì)于不同的業(yè)務(wù)邏輯,多線程的設(shè)計(jì)存在不同的模型,注意,這里的模型不是指操作系統(tǒng)的1:1 、N:1、N:M線程模型,而是如何使用線程來(lái)應(yīng)對(duì)不同的業(yè)務(wù)場(chǎng)景。對(duì)此可以大致分為運(yùn)算密集型、IO密集型。 眾所周知,對(duì)于多線程編程通常采用線程池技術(shù)來(lái)降低線程創(chuàng)建和退出的消耗,但如何使用這些線程呢,一般來(lái)說(shuō),策略分為 work group pipeline work-group模型 在工作組模型中,請(qǐng)求(或稱(chēng)數(shù)據(jù))是被一組線程處理的,如下圖: 根據(jù)操作的不同,可分為MIMD和SIMD兩種。 如果每個(gè)工作線程從共享隊(duì)列中獲取工作請(qǐng)求并處理,由于隊(duì)列中的操作和數(shù)據(jù)不盡相同——類(lèi)似MIMD(多指令多數(shù)據(jù))。 如果工作組的線程,每個(gè)負(fù)責(zé)處理數(shù)據(jù)或請(qǐng)求的一部分(例如,某列或行),所有工作線程在不同的數(shù)據(jù)上執(zhí)行相同的操作——類(lèi)似SIMD(單指令多數(shù)據(jù))。 在實(shí)際編程中,leader-follower pattern為大家所熟知。其工作流程為:線程池初始化后,存在唯一的leader線程,等待工作請(qǐng)求的到來(lái),請(qǐng)求到達(dá)后leader讀取請(qǐng)求并開(kāi)始處理,將自己身份變成follower,同時(shí)從線程池中選出下一個(gè)線程作為新leader,當(dāng)這次請(qǐng)求完成結(jié)果送回后該線程把自己重新送回到線程池中。if(a > b)
這種寫(xiě)法可不是線程安全的,畢竟a > b其實(shí)是多個(gè)指令并不是原子的。眾多的lock-free實(shí)現(xiàn)也是基于atomic的,但同時(shí)也有諸多問(wèn)題需要注意,比如ABA problem。所以,在使用atomic的時(shí)候,盡量選擇使用在簡(jiǎn)單變量的讀寫(xiě)共享上,即始終明確臨界區(qū)的概念中所謂的鎖,并不是鎖定一個(gè)資源,而是鎖定對(duì)該資源的訪問(wèn)操作。pthread_mutex_t
實(shí)現(xiàn)基于Linux的futex,當(dāng)臨界區(qū)足夠小時(shí),一次pthread_mutex_lock
消耗很非常小。
pipe-line模型
在流水線方式中,數(shù)據(jù)流串行的被一組線程順序處理。每個(gè)線程依次在數(shù)據(jù)上執(zhí)行自身特定的操作,并將執(zhí)行結(jié)果傳遞給流水線中的下一個(gè)線程。編程中常見(jiàn)的即:生產(chǎn)者和消費(fèi)者。
這里需要注意個(gè)平均分配到線程工作量不應(yīng)差別太大,否則很容易在后續(xù)優(yōu)化中會(huì)碰到串行化方面的難題:Amdahl's Law.即,即使將某個(gè)部分效率大幅提升,但總的吞吐仍然維持在意想不到的小幅度提高。
總結(jié)
本文主要介紹了在多線程編程中的諸多注意事項(xiàng)和常見(jiàn)多線程模型,希望讀者在實(shí)戰(zhàn)中能過(guò)避開(kāi)已知的坑,在這里祝大家線程安全!
聯(lián)系客服