在我們的工作中,多線程編程是一件太稀松平常的事。在多線程環(huán)境下操作一個變量或者一塊緩存,如果不對其操作加以限制,輕則變量值或者緩存內(nèi)容不符合預(yù)期,重則會產(chǎn)生異常,導(dǎo)致進程崩潰。為了解決這個問題,操作系統(tǒng)提供了鎖、信號量以及條件變量等幾種線程同步機制供我們使用。如果每次操作都使用上述機制,在某些條件下(系統(tǒng)調(diào)用在很多情況下不會陷入內(nèi)核),系統(tǒng)調(diào)用會陷入內(nèi)核從而導(dǎo)致上下文切換,這樣就會對我們的程序性能造成影響。 今天,借助此文,分享一下去年引擎優(yōu)化的一個點,最終優(yōu)化結(jié)果就是在多線程環(huán)境下訪問某個變量,實現(xiàn)了無鎖(lock-free)操作。 背景對于后端開發(fā)者來說,服務(wù)穩(wěn)定性第一,性能第二,二者相輔相成,缺一不可。 作為IT開發(fā)人員,秉承著一句話:只要程序正常運行,就不要隨便動。所以程序優(yōu)化就一直被擱置,因為沒有壓力,所以就沒有動力嘛??。在去年的時候,隨著廣告訂單數(shù)量越來越多,導(dǎo)致服務(wù)rt上漲,光報警郵件每天都能收到上百封,于是痛定思痛,決定優(yōu)化一版。 秉承小步快跑的理念,決定從各個角度逐步優(yōu)化,從簡單到困難,逐個擊破。所以在分析了代碼之后,準備從鎖這個角度入手,看看能否進行優(yōu)化。 在進行具體的問題分析以及優(yōu)化之前,先看下現(xiàn)有召回引擎的實現(xiàn)方案,后面的方案是針對現(xiàn)有方案的優(yōu)化。
從上面圖中可以看出,召回引擎是一個多線程應(yīng)用,一方面有個線程專門從kafka中獲取最新的廣告訂單消息建立維度索引(此為寫線程),另一方面,接收線上流量,根據(jù)流量屬性,獲取廣告候選集(此為讀線程)。因為召回引擎涉及到同時讀和寫同一塊變量,因此讀寫不能同時操作。 概述在多線程環(huán)境下,對同一個變量訪問,大致分為以下幾種情況:
在上述幾種情況中,多個線程同時讀顯然是線程安全的,而對于其他幾種情況,則需要保證其_互斥排他_性,即讀寫不能同時進行,管他幾個線程讀幾個線程寫,代碼走起。 在上述代碼中,每一個線程對共享變量的訪問,都會通過mutex來加鎖操作,這樣完全就避免了共享變量競爭的問題。 如果對于性能要求不是很高的業(yè)務(wù),上述實現(xiàn)完全滿足需求,但是對于性能要求很高的業(yè)務(wù),上述實現(xiàn)就不是很好,所以可以考慮通過其他方式來實現(xiàn)。 我們設(shè)想一個場景,假如某個業(yè)務(wù),寫操作次數(shù)遠遠小于讀操作次數(shù),例如我們的召回引擎,那么我們完全可以使用讀寫鎖來實現(xiàn)該功能,換句話說_讀寫鎖適合于讀多寫少的場景_。
代碼實現(xiàn)也比較簡單,如下: 在此,說下讀寫鎖的特性:
那么,對于一寫多讀的場景,還有沒有可能進行再次優(yōu)化呢? 答案是:有的。 下面,我們將針對一寫多讀,讀多寫少的場景,進行優(yōu)化。 方案在上一節(jié)中,我們提到對于多線程訪問,可以使用mutex對共享變量進行加鎖訪問。對于一寫多讀的場景,使用讀寫鎖進行優(yōu)化,使用讀寫鎖,在讀的時候,是不進行加鎖操作的,但是當(dāng)有寫操作的時候,就需要加鎖,這樣難免也會產(chǎn)生性能上的影響,在本節(jié),我們提供終極優(yōu)化版本,目的是在寫少讀多的場景下實現(xiàn)lock-free。 如何在讀寫都存在的場景下實現(xiàn)lock-free呢?假設(shè)如果有兩個共享變量,一個變量用來專供寫線程來寫,一個共享變量用來專供讀線程來讀,這樣就不存在讀寫同步的問題了,如下所示: 在上節(jié)中,我們有提到,多個線程對一個變量同時進行讀操作,是線程安全的。一個線程對一個變量進行寫操作也是線程安全的(這不廢話么,都沒人跟它競爭),那么結(jié)合上述兩點,上圖就是線程安全的(多個線程讀一個資源,一個線程寫另外一個資源)。 好了,截止到現(xiàn)在,我們lock-free的雛形已經(jīng)出來了,就是_使用雙變量_來實現(xiàn)lock-free的目標。那么reader線程是如何第一時間能夠訪問writer更新后的數(shù)據(jù)呢?
實現(xiàn)在上節(jié)中,我們提出了使用雙buffer來實現(xiàn)lock-free的目標,那么如何實現(xiàn)讀寫buffer無損切換呢? 指針互換假設(shè)有兩個資源,其指針分別為ptrA和ptrB,在某一時刻,ptrA所指向的資源正在被多個線程讀,而ptrB所指向的資源則作為備份資源,此時,如果有寫線程進行寫操作,按照我們之前的思路,寫完之后,馬上啟用ptrA作為讀資源,然后寫線程繼續(xù)寫ptrB所指向的資源,這樣會有什么問題呢? 我們就以std::vector為例,如下圖所示: 在上圖左半部分,假設(shè)ptr指向讀對象的指針,也就是說讀操作只能訪問ptr所指向的對象。 某一時刻,需要對對象進行寫操作(刪除對象Obj4),因為此時ptr = ptrA,因此寫操作只能操作ptrB所指向的對象,在寫操作執(zhí)行完后,將ptr賦值為ptrB(保證后面所有的讀操作都是在ptrB上),即保證當(dāng)前ptr所指向的對象永遠為最新操作,然后寫操作去刪除ptrA中的Obj4,但是此時,有個線程正在訪問ptrA的Obj4,自然而然會輕則當(dāng)前線程獲取的數(shù)據(jù)為非法數(shù)據(jù),重則程序崩潰。
原子性在上述方案中,簡單的變量交換,最終仍然可能存在讀寫同一個變量,進而導(dǎo)致崩潰。那么如果保證在寫的時候,沒有讀是不是就能解決上述問題了呢?如果是的話,那么應(yīng)該如何做呢? 顯然,此問題就轉(zhuǎn)換成如何判斷一個對象上存在線程讀操作。 在上述代碼中
好了,截止到此,lock-free的實現(xiàn)目標基本已經(jīng)完成。實現(xiàn)原理也也相對來說比較簡單,重點是要保證_寫的時候沒有讀操作_即可。 ![image-20211212162535172](/Users/lijun/Library/Application Support/typora-user-images/image-20211212162535172.png) 上圖是召回引擎做了lock-free優(yōu)化后的效果圖,從圖上來看,效果還是很明顯的。 擴展雙buffer方案在“一寫多讀”的場景下能夠?qū)崿F(xiàn)lock-free的目標,那么對于“多寫一讀”或者“多寫多讀”場景,是否也能夠滿足呢? 答案是不太適合,主要是以下兩個原因:
缺點通過前面的章節(jié),我們知道通過雙buffer方式可以實現(xiàn)在一寫多讀場景下的lock-free,該方式要求兩個對象或者buffer最終持有的數(shù)據(jù)是完全一致的,也就是說在單buffer情況下,只需要一個buffer持有數(shù)據(jù)就行,但是雙buffer情況下,需要持有兩份數(shù)據(jù),所以存在內(nèi)存浪費的情況。 其實說白了,雙buffer實現(xiàn)lock-free,就是采用的空間換時間的方式。 結(jié)語雙buffer方案在多線程環(huán)境下能較好的解決 “一寫多讀” 時的數(shù)據(jù)更新問題,特別是適用于數(shù)據(jù)需要定期更新,且一次更新數(shù)據(jù)量較大的情形。 性能優(yōu)化是一個漫長的不斷自我提升的過程,項目中的一點點優(yōu)化往往就可以使得性能得到質(zhì)的提升。 好了,今天的文章就到這,我們下期見。 |
|