導(dǎo)讀:
在百度看似簡(jiǎn)簡(jiǎn)單單的界面后面,是遍布全國(guó)的各個(gè)數(shù)據(jù)中心里,運(yùn)轉(zhuǎn)著的海量C++服務(wù)。如何提升性能,降低延時(shí)和成本就成了百度C++工程師的必修功課。伴隨著優(yōu)化的深入攻堅(jiān),誕生并積累下來(lái)一系列的性能優(yōu)化理論和方案,其中不乏一些冷門(mén)但精巧實(shí)用的經(jīng)驗(yàn)和技巧。本文從內(nèi)存訪問(wèn)角度,收集總結(jié)了一些具有通用意義的典型案例,分享出來(lái)和大家學(xué)習(xí)交流。
1 背景
在百度看似簡(jiǎn)簡(jiǎn)單單的界面后面,是遍布全國(guó)的各個(gè)數(shù)據(jù)中心里,運(yùn)轉(zhuǎn)著的海量C++服務(wù)。對(duì)C++的重度應(yīng)用是百度的一把雙刃劍,學(xué)習(xí)成本陡峭,指針類(lèi)錯(cuò)誤定位難、擴(kuò)散性廣另很多開(kāi)發(fā)者望而卻步。然而在另一方面,語(yǔ)言層引入的額外開(kāi)銷(xiāo)低,對(duì)底層能力可操作性強(qiáng),又能夠?yàn)樽非髽O致性能提供優(yōu)異的實(shí)踐環(huán)境。
因此,對(duì)百度的C++工程師來(lái)說(shuō),掌握底層特性并加以利用來(lái)指導(dǎo)應(yīng)用的性能優(yōu)化,就成了一門(mén)必要而且必須的技能。久而久之,百度工程師就將這種追求極致的性能優(yōu)化,逐漸沉淀成了習(xí)慣,甚至形成了對(duì)技術(shù)的信仰。下面我們就來(lái)盤(pán)點(diǎn)和分享一些,在性能優(yōu)化的征途上,百度C++工程師積累下來(lái)的理論和實(shí)踐,以及那些為了追求極致,所發(fā)掘的『奇技淫巧』。
2 重新認(rèn)識(shí)性能優(yōu)化
作為程序員,大家或多或少都會(huì)和性能打交道,尤其是以C++為主的后端服務(wù)工程師,但是每個(gè)工程師對(duì)性能優(yōu)化概念的理解在細(xì)節(jié)上又是千差萬(wàn)別的。下面先從幾個(gè)優(yōu)化案例入手,建立一個(gè)性能優(yōu)化相關(guān)的感性認(rèn)識(shí),之后再?gòu)脑斫嵌龋枋鲆幌卤疚乃v的性能優(yōu)化的切入角度和方法依據(jù)。
2.1 從字符串處理開(kāi)始
2.1.1 string as a buffer
為了調(diào)用底層接口和集成一些第三方庫(kù)能力,在調(diào)用界面層,會(huì)存在對(duì)C++字符串和C風(fēng)格字符串的交互場(chǎng)景,典型是這樣的:
size_t some_c_style_api(char* buffer, size_t size);void some_cxx_style_function(std::string& result) {// 首先擴(kuò)展到充足大小result.resize(estimate_size);// 從c++17開(kāi)始,string類(lèi)型支持通過(guò)data取得非常量指針auto acture_size = some_c_style_api(result.data, result.size);// 最終調(diào)整到實(shí)際大小result.resize(acture_size);}
這個(gè)方法存在一個(gè)問(wèn)題,就是在首次resize時(shí),string對(duì)estimate_size內(nèi)的存儲(chǔ)區(qū)域全部進(jìn)行了0初始化。但是這個(gè)場(chǎng)景中,實(shí)際的有效數(shù)據(jù)其實(shí)是在some_c_style_api內(nèi)部被寫(xiě)入的,所以resize時(shí)的初始化動(dòng)作其實(shí)是冗余的。在交互buffer的size較大的場(chǎng)景,例如典型的編碼轉(zhuǎn)換和壓縮等操作,這次冗余的初始化引入的開(kāi)銷(xiāo)還是相當(dāng)可觀的。
為了解決這個(gè)問(wèn)題,大約從3年前開(kāi)始,已經(jīng)有人在持續(xù)嘗試推動(dòng)標(biāo)準(zhǔn)改進(jìn)。
http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2021/p1072r7.html
注:在這個(gè)問(wèn)題上使用clang + libc++的同學(xué)有福,較新版本的libc++中已經(jīng)非標(biāo)實(shí)現(xiàn)了resize_default_init功能,可以開(kāi)始嘗鮮使用。
在標(biāo)準(zhǔn)落地前,為了能夠在百度內(nèi)部(目前廣泛使用gcc8和gcc10編譯器)提前使用起來(lái),我們專(zhuān)門(mén)制作了適用于gcc的resize_uninitialized,類(lèi)似于上面的功能,在百度,可以這樣編碼:
size_t some_c_style_api(char* buffer, size_t size);void some_cxx_style_function(std::string& result) {auto* buffer = babylon::resize_uninitialized(result, estimate_size);auto acture_size = some_c_style_api(buffer, result.size);result.resize(acture_size);}
2.1.2 split string
實(shí)際業(yè)務(wù)中,有一個(gè)典型場(chǎng)景是一些輕schema數(shù)據(jù)的解析,比如一些標(biāo)準(zhǔn)分隔符,典型是'_'或者't',簡(jiǎn)單分割的分列數(shù)據(jù)(這在日志等信息的粗加工處理中格外常見(jiàn))。由于場(chǎng)景極其單純,可能的算法層面優(yōu)化空間一般認(rèn)為較小,而實(shí)際實(shí)現(xiàn)中,這樣的代碼是廣為存在的:
std::vector<std::string> tokens;// boost::splitboost::split(token, str, (char c) {return c == 't';});// absl::StrSplitfor (std::string_view sv : absl::StrSplit(str, 't')) {tokens.emplace_back(sv);}// absl::StrSplit no copyfor (std::string_view sv : absl::StrSplit(str, 't')) {direct_work_on_segment(sv);}
boost版本廣泛出現(xiàn)在新工程師的代碼中,接口靈活,流傳度高,但是實(shí)際業(yè)務(wù)中效率其實(shí)并不優(yōu)秀,例如和google優(yōu)化過(guò)的absl相比,其實(shí)有倍數(shù)級(jí)的差距。尤其如果工程師沒(méi)有注意進(jìn)行單字符優(yōu)化的時(shí)候(直接使用了官方例子中的is_any_of),甚至達(dá)到了數(shù)量級(jí)的差距。進(jìn)一步地,如果聯(lián)動(dòng)思考業(yè)務(wù)形態(tài),一般典型的分割后處理是可以做到零拷貝的,這也可以進(jìn)一步降低冗余拷貝和大量臨時(shí)對(duì)象的創(chuàng)建開(kāi)銷(xiāo)。
最后,再考慮到百度當(dāng)前的內(nèi)部硬件環(huán)境有多代不同型號(hào)的CPU,進(jìn)一步改造spilt顯式使用SIMD優(yōu)化,并自適應(yīng)多代向量指令集,可以取得進(jìn)一步的性能提升。尤其是bmi指令加速后,對(duì)于一個(gè)SIMD步長(zhǎng)內(nèi)的連續(xù)分隔符探測(cè),比如密集短串場(chǎng)景,甚至可以取得數(shù)量級(jí)的性能提升。
最終在百度,我們可以這樣編碼實(shí)現(xiàn):
babylon::split( (std::string_view sv) {direct_work_on_segment(sv);}, str, 't'};
2.1.3 magic of protobuf
隨著brpc在百度內(nèi)部的廣泛應(yīng)用,protobuf成為了百度內(nèi)部數(shù)據(jù)交換的主流方式,解析、改寫(xiě)、組裝protobuf的代碼在每個(gè)服務(wù)中幾乎都會(huì)有一定的占比。尤其是近幾年,進(jìn)一步疊加了微服務(wù)化的發(fā)展趨勢(shì)之后,這層數(shù)據(jù)交換邊界就變得更加顯著起來(lái)。
在有些場(chǎng)景下,例如傳遞并增加一個(gè)字段,或者從多個(gè)后端存儲(chǔ)獲取分列表達(dá)的數(shù)據(jù)合并后返回,利用標(biāo)準(zhǔn)的C++API進(jìn)行反序列化、修改、再序列化的成本,相對(duì)于實(shí)際要執(zhí)行的業(yè)務(wù)來(lái)說(shuō),額外帶來(lái)的性能開(kāi)銷(xiāo)會(huì)顯著體現(xiàn)出來(lái)。
舉例來(lái)說(shuō),比如我們定義了這樣的message:
message Field {bytes column = 1;bytes value = 2;};message Record {bytes key = 1;repeated Field field = 2;};message Response {repeated Record record = 1;bytes error_message = 2;};
我們?cè)O(shè)想一個(gè)場(chǎng)景,一個(gè)邏輯的record分散于多個(gè)子系統(tǒng),那么我們需要引入一個(gè)proxy層,完成多個(gè)partial record的merge操作,常規(guī)意義上,這個(gè)merge動(dòng)作一般是這樣的:
one_sub_service.query(&one_controller, &request, &one_sub_response, ptr);another_sub_service.query(&another_controller, &request, &another_sub_response, ptr);...for (size_t i = 0; i < record_size; ++i) {final_response.mutable_record(i).MergeFrom(one_sub_response.record(i));final_response.mutable_record(i).MergeFrom(another_sub_response.record(i));...}
對(duì)于一個(gè)輕量級(jí)proxy來(lái)說(shuō),這一層反復(fù)對(duì)后端的解析、合并、再序列化引入的成本,就會(huì)相對(duì)凸現(xiàn)出來(lái)了。進(jìn)一步的消除,就要先從protobuf的本質(zhì)入手。
protobuf的根基先是一套公開(kāi)標(biāo)準(zhǔn)的wire format,其上才是支持多語(yǔ)言構(gòu)造和解析的SDK,因此嘗試降低對(duì)解析和合并等操作的進(jìn)一步優(yōu)化,繞過(guò)c++api,深入wire format層來(lái)嘗試是一種可行且有效的手段。那么我們先來(lái)看一下一些wire format層的特性。
即message的構(gòu)成直接由內(nèi)部包含的field的序列化結(jié)果堆砌而成,field之間不存在分割點(diǎn),在整個(gè)message外部,也不存在框架信息。基于這個(gè)特性,一些合并和修改操作可以在序列化的bytes結(jié)果上被低成本且安全地操作。而另一方面,message field的格式和string又是完全一致的,也就是定義一個(gè)message field,或者定義一個(gè)string field而把對(duì)應(yīng)message序列化后存入,結(jié)果是等價(jià)的(而且這兩個(gè)特性是被官方承諾的)。
https://developers.google.com/protocol-buffers/docs/encoding#optional
結(jié)合這些特性,之前的合并操作在實(shí)現(xiàn)上我們改造為:
message ProxyResponse {// 修改proxy側(cè)的message定義,做到不深層解析repeated string record = 1;bytes error_message = 2;};one_sub_service.query(&one_controller, &request, &one_sub_response, ptr);another_sub_service.query(&another_controller, &request, &another_sub_response, ptr);...for (size_t i = 0; i < record_size; ++i) {// 利用string追加替換message操作final_response.mutable_record(i).Append(one_sub_response.record(i));final_response.mutable_record(i).append(another_sub_response.record(i));...}
在微服務(wù)搭的環(huán)境下,類(lèi)似的操作可以很好地控制額外成本的增加。
2.2 回頭來(lái)再看看性能優(yōu)化
一般來(lái)講,一個(gè)程序的性能構(gòu)成要件大概有三個(gè),即算法復(fù)雜度、IO開(kāi)銷(xiāo)和并發(fā)能力。
首要的影響因素是大家都熟悉的算法復(fù)雜度。一次核心算法優(yōu)化和調(diào)整,可以對(duì)應(yīng)用性能產(chǎn)生的影響甚至是代差級(jí)別的。例如LSM Tree對(duì)No-SQL吞吐的提升,又例如事件觸發(fā)對(duì)epoll大并發(fā)能力的提升。然而正因?yàn)殛P(guān)注度高,在實(shí)際工程實(shí)現(xiàn)層面,無(wú)論是犯錯(cuò)幾率,還是留下的優(yōu)化空間,反而會(huì)大為下降。甚至極端情況下,可能作為非科研主導(dǎo)的工程師,在進(jìn)行性能優(yōu)化時(shí)鮮少遇到改良算法的場(chǎng)景,分析問(wèn)題選擇合適算法會(huì)有一定占比,但是可能范圍也有限。
更多情況下需要工程師解決的性能問(wèn)題,借用一句算法競(jìng)賽用語(yǔ),用『卡常數(shù)』來(lái)形容可能更貼切。也就是采用了正確的合適的算法,但是因?yàn)闆](méi)有采用體系結(jié)構(gòu)下更優(yōu)的實(shí)現(xiàn)方案,導(dǎo)致在O(X)上附加了過(guò)大的常數(shù)項(xiàng),進(jìn)而造成的整體性能不足。雖然在算法競(jìng)賽中,卡常數(shù)和常數(shù)優(yōu)化是出題人和解題人都不愿意大量出現(xiàn)的干擾項(xiàng)(因?yàn)楫吘故且院诵乃惴ū旧頌槟繕?biāo)),但是轉(zhuǎn)換到實(shí)際項(xiàng)目背景下,常數(shù)優(yōu)化卻往往是性能優(yōu)化領(lǐng)域的重要手段。
現(xiàn)在我們?cè)賮?lái)回顧一下上面引出問(wèn)題的三個(gè)優(yōu)化案例。可以看到,其中都不包含算法邏輯本身的改進(jìn),但是通過(guò)深入利用底層(比如依賴(lài)庫(kù)或指令集)特性,依然能夠取得倍數(shù)甚至數(shù)量級(jí)的優(yōu)化效果。這和近些年體系結(jié)構(gòu)變得越發(fā)復(fù)雜有很大關(guān)聯(lián),而這些變化,典型的體現(xiàn)場(chǎng)景就是IO和并發(fā)。并發(fā)的優(yōu)化,對(duì)于工程經(jīng)驗(yàn)比較豐富的同學(xué)應(yīng)該已經(jīng)不陌生了,但是關(guān)于IO,尤其是『內(nèi)存IO』可能需要特別說(shuō)明一下。
與代碼中顯示寫(xiě)出的read/write/socket等設(shè)備IO操作不同,存儲(chǔ)系統(tǒng)的IO很容易被忽略,因?yàn)檫@些IO透明地發(fā)生在普通CPU指令的背后。先列舉2009年Jeff Dean的一個(gè)經(jīng)典講座中的一頁(yè)數(shù)字。
https://www.cs.cornell.edu/projects/ladis2009/talks/dean-keynote-ladis2009.pdf
雖然已經(jīng)是十多年前的數(shù)據(jù),但是依然可以看出,最快速的L1 cache命中和Main memory訪問(wèn)之間,已經(jīng)拉開(kāi)了多個(gè)數(shù)量級(jí)的差距。這些操作并不是在代碼中被顯式控制的,而是CPU幫助我們透明完成的,在簡(jiǎn)化編程難度的同時(shí),卻也引入了問(wèn)題。也就是,如果不能良好地適應(yīng)體系結(jié)構(gòu)的特性,那么看似同樣的算法,在常數(shù)項(xiàng)上就可能產(chǎn)生數(shù)量級(jí)的差異。而這種差異因?yàn)殡[蔽性,恰恰是最容易被新工程師所忽略的。下面,我們就圍繞內(nèi)存訪問(wèn)這個(gè)話題,來(lái)盤(pán)點(diǎn)一下百度C++工程師的一些『常數(shù)優(yōu)化』。
3 從內(nèi)存分配開(kāi)始
要使用內(nèi)存,首先就要進(jìn)行內(nèi)存分配。進(jìn)入了c++時(shí)代后,隨著生命周期管理的便捷化,以及基于class封裝的各種便捷容器封裝的誕生,運(yùn)行時(shí)的內(nèi)存申請(qǐng)和釋放變得越來(lái)越頻繁。但是因?yàn)榈刂房臻g是整個(gè)進(jìn)程所共享的一種資源,在多核心系統(tǒng)中就不得不考慮競(jìng)爭(zhēng)問(wèn)題。有相關(guān)經(jīng)驗(yàn)的工程師應(yīng)該會(huì)很快聯(lián)想到兩個(gè)著名的內(nèi)存分配器,tcmalloc和jemalloc,分別來(lái)自google和facebook。下面先來(lái)對(duì)比一下兩者的大致原理。
3.1 先看看tcmalloc和jemalloc
針對(duì)多線程競(jìng)爭(zhēng)的角度,tcmalloc和jemalloc共同的思路是引入了線程緩存機(jī)制。通過(guò)一次從后端獲取大塊內(nèi)存,放入緩存供線程多次申請(qǐng),降低對(duì)后端的實(shí)際競(jìng)爭(zhēng)強(qiáng)度。而典型的不同點(diǎn)是,當(dāng)線程緩存被擊穿后,tcmalloc采用了單一的page heap(簡(jiǎn)化了中間的transfer cache和central cache,他們也是全局唯一的)來(lái)承載,而jemalloc采用了多個(gè)arena(默認(rèn)甚至超過(guò)了服務(wù)器核心數(shù))來(lái)承載。因此和網(wǎng)上流傳的主流評(píng)測(cè)推導(dǎo)原理一致,在線程數(shù)較少,或釋放強(qiáng)度較低的情況下,較為簡(jiǎn)潔的tcmalloc性能稍勝過(guò)jemalloc。而在核心數(shù)較多、申請(qǐng)釋放強(qiáng)度較高的情況下,jemalloc因?yàn)殒i競(jìng)爭(zhēng)強(qiáng)度遠(yuǎn)小于tcmalloc,會(huì)表現(xiàn)出較強(qiáng)的性能優(yōu)勢(shì)。
一般的評(píng)測(cè)到這里大致就結(jié)束了,不過(guò)我們可以再想一步,如果我們?cè)敢飧冻龈嗟膬?nèi)存到cache層,將后端競(jìng)爭(zhēng)壓力降下來(lái),那么是否tcmalloc依然可以回到更優(yōu)的狀態(tài)呢?如果從上面的分析看,應(yīng)該是可以有這樣的推論的,而且近代服務(wù)端程序的瓶頸也往往并不在內(nèi)存占用上,似乎是一個(gè)可行的方案。
不過(guò)實(shí)際調(diào)整過(guò)后,工程師就會(huì)發(fā)現(xiàn),大多數(shù)情況下,可能并不能達(dá)到預(yù)期效果。甚至明明從perf分析表現(xiàn)上看已經(jīng)觀測(cè)到競(jìng)爭(zhēng)開(kāi)銷(xiāo)和申請(qǐng)釋放動(dòng)作占比很小了,但是整個(gè)程序表現(xiàn)依然不盡如人意。
這實(shí)際上是內(nèi)存分配連續(xù)性的對(duì)性能影響的體現(xiàn),即線程緩存核心的加速點(diǎn)在于將申請(qǐng)批量化,而非單純的降低后端壓力。緩存過(guò)大后,就會(huì)導(dǎo)致持續(xù)反復(fù)的申請(qǐng)和釋放都由緩存承擔(dān),結(jié)果是緩存中存放的內(nèi)存塊地址空間分布越來(lái)越零散,呈現(xiàn)一種洗牌效果。
體系結(jié)構(gòu)的緩存優(yōu)化,一般都是以局部性為標(biāo)準(zhǔn),也就是說(shuō),程序近期訪問(wèn)的內(nèi)存附近,大概率是后續(xù)可能被訪問(wèn)的熱點(diǎn)。因此,如果程序連續(xù)申請(qǐng)和訪問(wèn)的內(nèi)存呈跳躍變化,那么底層就很難正確進(jìn)行緩存優(yōu)化。體現(xiàn)到程序性能上,就會(huì)發(fā)現(xiàn),雖然分配和釋放動(dòng)作都變得開(kāi)銷(xiāo)很低了,但是程序整體性能卻并未優(yōu)化(因?yàn)檎嬲\(yùn)行的算法的訪存操作常數(shù)項(xiàng)增大)。
3.2 那么理想的malloc模型是什么?
通過(guò)前面的分析,我們大概得到了兩條關(guān)于malloc的核心要素,也就是競(jìng)爭(zhēng)性和連續(xù)性。那么是否jemalloc是做到極致了呢?要回答這個(gè)問(wèn)題,還是要先從實(shí)際的內(nèi)存使用模型分析開(kāi)始。
這是一個(gè)很典型的程序,核心是一組持續(xù)運(yùn)行的線程池,當(dāng)任務(wù)到來(lái)后,每個(gè)線程各司其職,完成一個(gè)個(gè)的任務(wù)。在malloc看來(lái),就是多個(gè)長(zhǎng)生命周期的線程,隨機(jī)的在各個(gè)時(shí)點(diǎn)發(fā)射malloc和free請(qǐng)求。如果只是基于這樣的視圖,其實(shí)malloc并沒(méi)有辦法做其他假定了,只能也按照基礎(chǔ)局部性原理,給一個(gè)線程臨近的多次malloc,盡量分配連續(xù)的地址空間出來(lái)。同時(shí)利用線程這一概念,將內(nèi)存分區(qū)隔離,減少競(jìng)爭(zhēng)。這也就是tcmalloc和jemalloc在做的事情了。
但是內(nèi)存分配這件事和程序的邊界就只能在這里了嗎?沒(méi)有更多的業(yè)務(wù)層輸入,可以讓malloc做的更好了嗎?那么我們?cè)購(gòu)臉I(yè)務(wù)視角來(lái)看一下內(nèi)存分配。
微服務(wù)、流式計(jì)算、緩存,這幾種業(yè)務(wù)模型幾乎涵蓋了所有主流的后端服務(wù)場(chǎng)景。而這幾種業(yè)務(wù)對(duì)內(nèi)存的應(yīng)用有一個(gè)重要的特征,就是擁有邊界明確的生命周期。回退到早期的程序設(shè)計(jì)年代,其實(shí)server設(shè)計(jì)中每個(gè)請(qǐng)求單獨(dú)一個(gè)啟動(dòng)線程處理,處理完整體銷(xiāo)毀是一個(gè)典型的方案。即使是使用線程池,一個(gè)請(qǐng)求接受后從頭到尾一個(gè)線程跟進(jìn)完成也是持續(xù)了相當(dāng)長(zhǎng)時(shí)間的成熟設(shè)計(jì)。
而針對(duì)這種早期的業(yè)務(wù)模型,其實(shí)malloc是可以利用到更多業(yè)務(wù)信息的,例如線程動(dòng)態(tài)申請(qǐng)的內(nèi)存,大概率后續(xù)某個(gè)時(shí)點(diǎn)會(huì)全部歸還,從tcmalloc的線程緩存調(diào)整算法中也可以看出對(duì)這樣那個(gè)的額外信息其實(shí)是專(zhuān)門(mén)優(yōu)化過(guò)的。
但是隨著新型的子任務(wù)級(jí)線程池并發(fā)技術(shù)的廣泛應(yīng)用,即請(qǐng)求細(xì)分為多個(gè)子任務(wù)充分利用多核并發(fā)來(lái)提升計(jì)算性能,到malloc可見(jiàn)界面,業(yè)務(wù)特性幾乎已經(jīng)不復(fù)存在。只能看到持續(xù)運(yùn)行的線程在隨機(jī)malloc和free,以及大量?jī)?nèi)存的malloc和free漂移在多個(gè)線程之間。
那么在這樣job化的背景下,怎樣的內(nèi)存分配和釋放策略能夠在競(jìng)爭(zhēng)性和局部性角度工作的更好呢?下面我們列舉兩個(gè)方案。
3.2.1 job arena
第一種是基礎(chǔ)的job arena方案,也就是每個(gè)job有一個(gè)獨(dú)立的內(nèi)存分配器,job中使用的動(dòng)態(tài)內(nèi)存注冊(cè)到j(luò)ob的arena中。因?yàn)閖ob生命周期明確,中途釋放的動(dòng)態(tài)內(nèi)存被認(rèn)為無(wú)需立即回收,也不會(huì)顯著增大內(nèi)存占用。在無(wú)需考慮回收的情況下,內(nèi)存分配不用再考慮分塊對(duì)齊,每個(gè)線程內(nèi)可以完全連續(xù)。最終job結(jié)束后,整塊內(nèi)存直接全部釋放掉,大幅減少實(shí)際的競(jìng)爭(zhēng)發(fā)生。
顯而易見(jiàn),因?yàn)樾枰兄獦I(yè)務(wù)生命周期,malloc接口是無(wú)法獲得這些信息并進(jìn)行支持的,因此實(shí)際會(huì)依賴(lài)運(yùn)行時(shí)使用的容器能夠單獨(dú)暴露內(nèi)存分配接口出來(lái)。幸運(yùn)的是,在STL的帶動(dòng)下,現(xiàn)實(shí)的主流容器庫(kù)一般都實(shí)現(xiàn)了allocator的概念,盡管細(xì)節(jié)并不統(tǒng)一。
例如重度使用的容器之一protobuf,從protobuf 3.x開(kāi)始引入了Arena的概念,可以允許Message將內(nèi)存結(jié)構(gòu)分配通過(guò)Arena完成。可惜直到最新的3.15版本,string field的arena分配依然沒(méi)有被官方支持。
https://github.com/protocolbuffers/protobuf/issues/4327
但是,因?yàn)閟tring/bytes是業(yè)務(wù)廣為使用的類(lèi)型,如果脫離Arena的話,實(shí)際對(duì)連續(xù)性的提升就會(huì)大打折扣。因此在百度,我們內(nèi)部維護(hù)了一個(gè)ArenaString的patch,重現(xiàn)了issue和注釋中的表達(dá),也就是在Arena上分配一個(gè)『看起來(lái)像』string的結(jié)構(gòu)。對(duì)于讀接口,因?yàn)楹蛃tring的內(nèi)存表達(dá)一致,可以直接通過(guò)const string&呈現(xiàn)。對(duì)于mutable接口,會(huì)返回一個(gè)替代的ArenaString包裝類(lèi)型,在使用了auto技術(shù)的情況下,幾乎可以保持無(wú)縫切換。
另外一個(gè)重度使用的容器就是STL系列了,包括STL自身實(shí)現(xiàn)的容器,以及boost/tbb/absl中按照同類(lèi)接口實(shí)現(xiàn)的高級(jí)容器。從C++17開(kāi)始,STL嘗試將之前混合在allocator中的內(nèi)存分配和實(shí)例構(gòu)造兩大功能進(jìn)行拆分,結(jié)果就是產(chǎn)生了PMR(Polymorphic Memory Resouce)的概念。在解耦了構(gòu)造器和分配器之后,程序就不再需要通過(guò)修改模板參數(shù)中的類(lèi)型,來(lái)適應(yīng)自己的內(nèi)存分配方法了。其實(shí)PMR自身也給出了一種連續(xù)申請(qǐng),整體釋放的分配器實(shí)現(xiàn),即monotonic_buffer_resource,但是這個(gè)實(shí)現(xiàn)是非線程安全的。
結(jié)合上面兩個(gè)內(nèi)存分配器的概念,在實(shí)際應(yīng)用中,我們利用線程緩存和無(wú)鎖循環(huán)隊(duì)列(降低競(jìng)爭(zhēng)),整頁(yè)獲取零散供給(提升連續(xù))實(shí)現(xiàn)了一個(gè)SwissMemoryResource,通過(guò)接口適配統(tǒng)一支持STL和protobuf的分配器接口。最終通過(guò)protocol插件集成到brpc中,在百度,我們可以如下使用:
babylon::ReusableRPCProtocol::register_protocol;::baidu::rpc::ServerOptions options;options.enabled_protocols = "baidu_std_reuse";class SomeServiceImpl : public SomeService {public:// request和response本身采用了請(qǐng)求級(jí)別的memory resource來(lái)分配virtual void some_method(google::protobuf::RpcController* controller,const SomeRequest* request,SomeResponse* response,google::protobuf::Closure* done) {baidu::rpc::ClosureGuard guard(done);// 通過(guò)轉(zhuǎn)換到具體實(shí)現(xiàn)來(lái)取得MemoryResourceauto* closure = static_cast<babylon::ReusableRPCProtocol::Closure*>(done);auto& resource = closure->memory_resource;// 做一些請(qǐng)求級(jí)別的動(dòng)態(tài)容器std::pmr::vector<std::pmr::string> tmp_vector(&resource);google::protobuf::Arena::CreateMessage<SomeOtherMessage>(&(Arena&)resource);...// done->Run時(shí)請(qǐng)求級(jí)內(nèi)存整體釋放}};
3.2.2 job reserve
更復(fù)雜一些的是job reserve方案,在job arena的基礎(chǔ)上,結(jié)合了job結(jié)束后不析構(gòu)中間結(jié)構(gòu),也不釋放內(nèi)存,轉(zhuǎn)而定期進(jìn)行緊湊重整。這就進(jìn)一步要求了中間結(jié)構(gòu)是可以在保留內(nèi)存的情況下完成重置動(dòng)作的,并且能夠進(jìn)行容量提取,以及帶容量重新構(gòu)建的功能。這里用vector<string>為例解釋一下:
和典型的vector處理主要不同點(diǎn)是,在clear或者pop_back等操作縮減大小之后,內(nèi)容對(duì)象并沒(méi)有實(shí)際析構(gòu),只是清空重置。
因此,再一次用到這個(gè)槽位的時(shí)候,可以直接拿到已經(jīng)構(gòu)造好的元素,而且其capacity之內(nèi)的內(nèi)存也依然持有。可以看到反復(fù)使用同一個(gè)實(shí)例,容器內(nèi)存和每個(gè)元素自身的capacity都會(huì)逐漸趨向于飽和值,反復(fù)的分配和構(gòu)造需求都被減少了。了解過(guò)protobuf實(shí)現(xiàn)原理的工程師可以對(duì)照參考,這種保留實(shí)例的clear動(dòng)作,也是protobuf的message鎖采用的方法。
不過(guò)關(guān)注到之前提過(guò)局部性的工程師可能會(huì)發(fā)現(xiàn),盡管分配需求降低了,但是最終飽和態(tài)的內(nèi)存分布在連續(xù)性上仍不理想,畢竟中途的動(dòng)態(tài)分配是按需進(jìn)行,而未能參考局部性了。因此容器還需要支持一個(gè)動(dòng)作,也就是重建。
也就是,當(dāng)重復(fù)利用多次,發(fā)生了較多臨時(shí)申請(qǐng)之后,需要能夠提取出當(dāng)前的容量schema,在新的連續(xù)空間中做一次原樣重建,讓內(nèi)存塊重新回歸連續(xù)。
3.3 總結(jié)一下內(nèi)存分配
通過(guò)分析malloc的性能原理,引入這兩種細(xì)粒度的內(nèi)存分配和管理方案,可以在更小的競(jìng)爭(zhēng)下,得到更好的內(nèi)存連續(xù)性。
在實(shí)測(cè)中,簡(jiǎn)單應(yīng)用做到j(luò)ob arena一般就可以取得大部分性能收益,一般能夠達(dá)到倍數(shù)級(jí)提升,在整體服務(wù)角度也能夠產(chǎn)生可觀測(cè)的性能節(jié)省。而job reserve,雖然可以獲得進(jìn)一步地性能提升,但一方面是因?yàn)槿绻婕胺莗rotobuf容器,需要實(shí)現(xiàn)自定義的schema提取和重建接口,另一方面趨于飽和的capacity也會(huì)讓內(nèi)存使用增大一些。引入成本會(huì)提高不少,因此實(shí)際只會(huì)在性能更為緊要的環(huán)節(jié)進(jìn)行使用。
4 再來(lái)看看內(nèi)存訪問(wèn)
內(nèi)存分配完成后,就到了實(shí)際進(jìn)行內(nèi)存訪問(wèn)的階段了。一般我們可以將訪存需求拆解到兩個(gè)維度,一個(gè)是單線程的連續(xù)訪問(wèn),另一個(gè)是多個(gè)線程的共享訪問(wèn)。下面就分拆到兩個(gè)部分來(lái)分別談?wù)劯鱾€(gè)維度的性能優(yōu)化方法。
4.1 順序訪存優(yōu)化
一般來(lái)說(shuō),當(dāng)我們要執(zhí)行大段訪存操作時(shí),如果訪問(wèn)地址連續(xù),那么實(shí)際效率可以獲得提升。典型例如對(duì)于容器遍歷訪問(wèn)操作,數(shù)組組織的數(shù)據(jù),相比于比鏈表組織的數(shù)據(jù),一般會(huì)有顯著的性能優(yōu)勢(shì)。其實(shí)在內(nèi)存分配的環(huán)節(jié),我們引入的讓連續(xù)分配(基本也會(huì)是連續(xù)訪問(wèn))的空間地址連續(xù)性更強(qiáng),也是出于這一目的。
那么下面我們先來(lái)看一看,連續(xù)性的訪問(wèn)產(chǎn)生性能差異的原理是什么。
這里以Intel CPU為例來(lái)簡(jiǎn)要描述一下預(yù)取過(guò)程。詳見(jiàn):
https://www.intel.com/content/dam/www/public/us/en/documents/manuals/64-ia-32-architectures-optimization-manual.pdf
當(dāng)硬件監(jiān)測(cè)到連續(xù)地址訪問(wèn)模式出現(xiàn)時(shí),會(huì)激活多層預(yù)取器開(kāi)始執(zhí)行,參考當(dāng)前負(fù)載等因素,將預(yù)測(cè)將要訪問(wèn)的數(shù)據(jù)加載到合適的緩存層級(jí)當(dāng)中。這樣,當(dāng)后續(xù)訪問(wèn)真實(shí)到來(lái)的時(shí)候,能夠從更近的緩存層級(jí)中獲取到數(shù)據(jù),從而加速訪問(wèn)速度。因?yàn)長(zhǎng)1容量有限,L1的硬件預(yù)取步長(zhǎng)較短,加速目標(biāo)主要為了提升L2到L1,而L2和LLC的預(yù)取步長(zhǎng)相對(duì)較長(zhǎng),用于將主存提升到cache。
在這里局部性概念其實(shí)充當(dāng)了軟硬件交互的某種約定,因?yàn)槌绦蛱烊坏脑L問(wèn)模式總有一些局部性,硬件廠商就通過(guò)預(yù)測(cè)程序設(shè)計(jì)的局部性,來(lái)盡力加速這種模式的訪問(wèn)請(qǐng)求,力求做到通用提升性能。而軟件設(shè)計(jì)師,則通過(guò)盡力讓設(shè)計(jì)呈現(xiàn)更多的局部性,來(lái)激活硬件廠商設(shè)計(jì)的優(yōu)化路徑,使具體程序性能得到進(jìn)一步優(yōu)化。某種意義上講,z不失為一個(gè)相生相伴的循環(huán)促進(jìn)。
這里通過(guò)一個(gè)樣例來(lái)驗(yàn)證體現(xiàn)一下如何尊重局部性,以及局部性對(duì)程序的影響。
// 設(shè)計(jì)一塊很大的存儲(chǔ)區(qū)域用于存儲(chǔ)固定維度的浮點(diǎn)向量// vecs中存儲(chǔ)每個(gè)浮點(diǎn)向量的起始地址std::vector<float> large_memory_buffer;std::vector<float*> vecs;std::shuffle(vecs.begin, vecs.end, random_engine);for (size_t i = 0; i < vecs.size; ++i) {__builtin_prefetch(vecs[i + step]);dot_product(vecs[i], input);}
這是一個(gè)推薦/搜索系統(tǒng)中常見(jiàn)的內(nèi)積打分場(chǎng)景,即通過(guò)向量計(jì)算來(lái)進(jìn)行大規(guī)模打分。同樣的代碼,依據(jù)shuffle和prefetch存在與否,產(chǎn)生類(lèi)似如下的表現(xiàn):
-
shuffle & no prefetch:44ms
-
shuffle & prefetch:36ms
-
shuffle & no prefetch:13ms
-
shuffle & prefetch:12ms
從1和3的區(qū)別可以看出,完全相同的指令,在不同的訪存順序下存在的性能差距可以達(dá)到倍數(shù)級(jí)。而從1和2的區(qū)別可以看出,手動(dòng)添加預(yù)取操作后,對(duì)性能有一定改善,預(yù)期更精細(xì)地指導(dǎo)預(yù)取步長(zhǎng)和以及L1和L2的分布還有改善空間。不過(guò)指令執(zhí)行周期和硬件效率很難完備匹配,手動(dòng)預(yù)取一般用在無(wú)法改造成物理連續(xù)的場(chǎng)景,但調(diào)參往往是一門(mén)玄學(xué)。最后3和4可以看出,即使連續(xù)訪存下,預(yù)取依然有一些有限的收益,推測(cè)和硬件預(yù)取無(wú)法跨越頁(yè)邊界造成的多次預(yù)測(cè)冷啟動(dòng)有關(guān),但是影響已經(jīng)比較微弱了。
最具備指導(dǎo)意義的可能就是類(lèi)似這個(gè)內(nèi)積打分的場(chǎng)景,有時(shí)為了節(jié)省空間,工程師會(huì)將程序設(shè)計(jì)為,從零散的空間取到向量指針,并組成一個(gè)數(shù)組或鏈表系統(tǒng)來(lái)管理。天然來(lái)講,這樣節(jié)省了內(nèi)存的冗余,都引用向一份數(shù)據(jù)。但是如果引入一些冗余,將所需要的向量數(shù)據(jù)一同拷貝構(gòu)成連續(xù)空間,對(duì)于檢索時(shí)的遍歷計(jì)算會(huì)帶來(lái)明顯的性能提升。
4.2 并發(fā)訪問(wèn)優(yōu)化
提到并發(fā)訪問(wèn),可能要先從一個(gè)概念,緩存行(cache line)說(shuō)起。
為了避免頻繁的主存交互,其實(shí)緩存體系采用了類(lèi)似malloc的方法,即劃分一個(gè)最小單元,叫做緩存行(主流CPU上一般64B),所有內(nèi)存到緩存的操作,以緩存行為單位整塊完成。
例如對(duì)于連續(xù)訪問(wèn)來(lái)說(shuō)第一個(gè)B的訪問(wèn)就會(huì)觸發(fā)全部64B數(shù)據(jù)都進(jìn)入L1,后續(xù)的63B訪問(wèn)就可以直接由L1提供服務(wù)了。所以并發(fā)訪問(wèn)中的第一個(gè)問(wèn)題就是要考慮緩存行隔離,也就是一般可以認(rèn)為,位于不同的兩個(gè)緩存行的數(shù)據(jù),是可以被真正獨(dú)立加載/淘汰和轉(zhuǎn)移的(因?yàn)閏ache間流轉(zhuǎn)的最小單位是一個(gè)cache line)。
典型的問(wèn)題一般叫做false share現(xiàn)象,也就是不慎將兩個(gè)本無(wú)競(jìng)爭(zhēng)的數(shù)據(jù),放置在一個(gè)緩存行內(nèi),導(dǎo)致因?yàn)轶w系結(jié)構(gòu)的原因,引入了『本不存在的競(jìng)爭(zhēng)』。這點(diǎn)在網(wǎng)上資料比較充足,例如brpc和disruptor的設(shè)計(jì)文檔都比較詳細(xì)地講解了這一點(diǎn),因此這里就不再做進(jìn)一步的展開(kāi)了。
4.3 那先來(lái)聊聊緩存一致性
排除了false share現(xiàn)象之后,其余就是真正的共享問(wèn)題了,也就是確實(shí)需要位于同一個(gè)緩存行內(nèi)的數(shù)據(jù)(往往就是同一個(gè)數(shù)據(jù)),多個(gè)核心都要修改的場(chǎng)景。由于在多核心系統(tǒng)中cache存在多份,因此就需要考慮這多個(gè)副本間一致性的問(wèn)題。這個(gè)一致性一般由一套狀態(tài)機(jī)協(xié)議保證(MESI及其變體)。
大體是,當(dāng)競(jìng)爭(zhēng)寫(xiě)入發(fā)生時(shí),需要競(jìng)爭(zhēng)所有權(quán),未獲得所有權(quán)的核心,只能等待同步到修改的最新結(jié)果之后,才能繼續(xù)自己的修改。這里要提一下的是有個(gè)流傳甚廣的說(shuō)法是,因?yàn)榫彺嫦到y(tǒng)的引入,帶來(lái)了不一致,所以引發(fā)了各種多線程可見(jiàn)性問(wèn)題。
這么說(shuō)其實(shí)有失偏頗,MESI本質(zhì)上是一個(gè)『一致性』協(xié)議,也就是遵守協(xié)議的緩存系統(tǒng),其實(shí)對(duì)上層CPU多個(gè)核心做到了順序一致性。比如對(duì)比一下就能發(fā)現(xiàn),緩存在競(jìng)爭(zhēng)時(shí)表現(xiàn)出來(lái)的處理動(dòng)作,其實(shí)和只有主存時(shí)是一致的。
只是阻塞點(diǎn)從競(jìng)爭(zhēng)一個(gè)物理主存單元的寫(xiě)入,轉(zhuǎn)移到了雖然是多個(gè)緩存物理單元,但是通過(guò)協(xié)議競(jìng)爭(zhēng)獨(dú)占上。不過(guò)正因?yàn)楦?jìng)爭(zhēng)阻塞情況并沒(méi)有緩解,所以cache的引入其實(shí)搭配了另一個(gè)部件也就是寫(xiě)緩沖(store buffer)。
注:寫(xiě)緩存本身引入其實(shí)同時(shí)收到亂序執(zhí)行的驅(qū)動(dòng),在《并發(fā)篇》會(huì)再次提到。
寫(xiě)緩沖的引入,真正開(kāi)始帶來(lái)的可見(jiàn)性問(wèn)題。
以x86為例,當(dāng)多核發(fā)生寫(xiě)競(jìng)爭(zhēng)時(shí),未取得所有權(quán)的寫(xiě)操作雖然無(wú)法生效到緩存層,但是可以在改為等待在寫(xiě)緩沖中。而CPU在一般情況下可以避免等待而先開(kāi)始后續(xù)指令的執(zhí)行,也就是雖然CPU看來(lái)是先進(jìn)行了寫(xiě)指令,后進(jìn)行讀指令,但是對(duì)緩存而言,先進(jìn)行的是讀指令,而寫(xiě)指令被阻塞到緩存重新同步之后才能進(jìn)行。要注意,如果聚焦到緩存交互界面,整體依然是保證了順序一致,但是在指令交互界面,順序發(fā)生了顛倒。這就是典型的StoreLoad亂序成了LoadStore,也是x86上唯一的一個(gè)亂序場(chǎng)景。而針對(duì)典型的RISC系統(tǒng)來(lái)說(shuō)(arm/power),為了流水線并行度更高,一般不承諾寫(xiě)緩沖FIFO,當(dāng)一個(gè)寫(xiě)操作卡在寫(xiě)緩沖之后,后續(xù)的寫(xiě)操作也可能被先處理,進(jìn)一步造成StoreStore亂序。
寫(xiě)緩沖的引入,讓競(jìng)爭(zhēng)出現(xiàn)后不會(huì)立即阻塞指令流,可以容忍直到緩沖寫(xiě)滿。但因?yàn)榫彺鎸?xiě)入完成需要周知所有L1執(zhí)行作廢操作完成,隨著核心增多,會(huì)出現(xiàn)部分L1作廢長(zhǎng)尾阻塞寫(xiě)緩沖的情況。因此一些RISC系統(tǒng)引入了進(jìn)一步的緩沖機(jī)制。
進(jìn)一步的緩沖機(jī)制一般叫做失效隊(duì)列,也就是當(dāng)一個(gè)寫(xiě)操作只要將失效消息投遞到每個(gè)L1的失效隊(duì)列即視為完成,失效操作長(zhǎng)尾不再影響寫(xiě)入。這一步改動(dòng)甚至確實(shí)地部分破壞了緩存一致性,也就是除非一個(gè)核心等待當(dāng)前失效消息排空,否則可能讀取到過(guò)期數(shù)據(jù)。
到這里已經(jīng)可以感受到,為了對(duì)大量常規(guī)操作進(jìn)行優(yōu)化,近代體系結(jié)構(gòu)設(shè)計(jì)中引入了多個(gè)影響一致性的機(jī)制。但是為了能夠構(gòu)建正確的跨線程同步,某些關(guān)鍵節(jié)點(diǎn)上的一致性又是必不可少的。
因此,配套的功能指令應(yīng)運(yùn)而生,例如x86下mfence用于指導(dǎo)后續(xù)load等待寫(xiě)緩沖全部生效,armv8的lda用于確保后續(xù)load等待invalid生效完成等。這一層因?yàn)楹蜋C(jī)型與指令設(shè)計(jì)強(qiáng)相關(guān),而且指令的配套使用又能帶來(lái)多種不同的內(nèi)存可見(jiàn)性效果。這就大幅增加了工程師編寫(xiě)正確一致性程序的成本,而且難以保證跨平臺(tái)可移植。于是就到了標(biāo)準(zhǔn)化發(fā)揮作用的時(shí)候了,這個(gè)關(guān)于內(nèi)存一致性領(lǐng)域的標(biāo)準(zhǔn)化規(guī)范,就是內(nèi)存序(memory order)。
4.4 再談一談memory order
作為一種協(xié)議機(jī)制,內(nèi)存序和其他協(xié)議類(lèi)似,主要承擔(dān)了明確定義接口層功能的作用。體系結(jié)構(gòu)專(zhuān)家從物理層面的優(yōu)化手段中,抽象總結(jié)出了多個(gè)不同層級(jí)的邏輯一致性等級(jí)來(lái)進(jìn)行刻畫(huà)表達(dá)。這種抽象成為了公用邊界標(biāo)準(zhǔn)之后,硬件和軟件研發(fā)者就可以獨(dú)立開(kāi)展各自的優(yōu)化工作,而最終形成跨平臺(tái)通用解決方案。
對(duì)于硬件研發(fā)者來(lái)說(shuō),只要能夠最終設(shè)計(jì)一些特定的指令或指令組合,支持能夠?qū)崿F(xiàn)這些內(nèi)存序規(guī)范的功能,那么任意的設(shè)計(jì)擴(kuò)展原理上都是可行的,不用考慮有軟件兼容性風(fēng)險(xiǎn)。同樣,對(duì)于軟件研發(fā)者來(lái)說(shuō),只要按照標(biāo)準(zhǔn)的邏輯層來(lái)理解一致性,并使用正確的內(nèi)存序,就可以不用關(guān)注底層平臺(tái)細(xì)節(jié),寫(xiě)出跨平臺(tái)兼容的多線程程序。
內(nèi)存序在官方定義里,是洋洋灑灑一大篇內(nèi)容,為了便于理解,下面從開(kāi)發(fā)程序須知角度,抽出一些簡(jiǎn)潔精煉的概念(雖不是理論完備的)來(lái)輔助記憶和理解。
首先來(lái)看看,內(nèi)存序背后到底發(fā)生了啥。
int payload = 0;int flag = 0;void normal_writer(int i) {payload = flag + i;flag = 1;}int normal_reader {while (flag == 0) {}return payload;}
在這個(gè)樣例中可以看到,在編譯層,默認(rèn)對(duì)于無(wú)關(guān)指令,會(huì)進(jìn)行一定程度的順序調(diào)整(不影響正確性的前提下)。另一方面,編譯器默認(rèn)可以假定不受其他線程影響,因此同一個(gè)數(shù)據(jù)連續(xù)的多次內(nèi)存訪問(wèn)可以省略。
下面看一下最基礎(chǔ)的內(nèi)存序等級(jí),relaxed。
int payload = 0;std::atomic<int> flag {0};void relaxed_writer(int i) {payload = flag.load(std::memory_order_relaxed) + i;flag.store(1, std::memory_order_relaxed);}int relaxed_reader {while (flag.load(std::memory_order_relaxed) == 0) {}return payload;}
在使用了基礎(chǔ)的內(nèi)存序等級(jí)relaxed之后,編譯器不再假設(shè)不受其他線程影響,每個(gè)循環(huán)都會(huì)重新加載flag。另外可以觀測(cè)到flag和payload的亂序被恢復(fù)了,不過(guò)原理上relaxed并不保證順序,也就是這個(gè)順序并不是一個(gè)編譯器的保證承諾。總體來(lái)說(shuō),relaxed等級(jí)和普通的讀寫(xiě)操作區(qū)別不大,只是保證了對(duì)應(yīng)的內(nèi)存訪問(wèn)不可省略。
更進(jìn)一步的內(nèi)存序等級(jí)是consume-release,不過(guò)當(dāng)前沒(méi)有對(duì)應(yīng)的實(shí)現(xiàn)案例,一般都被默認(rèn)提升到了下一個(gè)等級(jí),也就是第一個(gè)真實(shí)有意義的內(nèi)存序等級(jí)acquire-release。先從原理上講,一般可以按照滿足條件/給出承諾的方式來(lái)簡(jiǎn)化理解,即:
-
要求:對(duì)同一變量M分別進(jìn)行寫(xiě)(release)A和讀(acquire)B,B讀到了A寫(xiě)入的值。
-
承諾:A之前的所有其他寫(xiě)操作,對(duì)B之后的讀操作可見(jiàn)。
-
實(shí)際影響:
-
涉及到的操作不會(huì)發(fā)生穿越A/B操作的重排;
-
X86:無(wú)額外指令;
-
ARMv8:A之前排空store buffer,B之后排空invalid queue,A/B保序;
-
ARMv7&Power:A之前全屏障,B之后全屏障。
int payload = 0;std::atomic<int> flag {0};void release_writer(int i) {payload = flag.load(std::memory_order_relaxed) + i;flag.store(1, std::memory_order_release);}int acquire_reader {while (flag.load(std::memory_order_acquire) == 0) {}return payload;}

由于x86默認(rèn)內(nèi)存序不低于acquire-release,這里用ARMv8匯編來(lái)演示效果。可以看出對(duì)應(yīng)指令發(fā)生了替換,從st/ld變更到了stl/lda,從而利用armv8的體系結(jié)構(gòu)實(shí)現(xiàn)了相應(yīng)的內(nèi)存序語(yǔ)義。
再進(jìn)一步的內(nèi)存序,就是最強(qiáng)的一級(jí)sequentially-consistent,其實(shí)就是恢復(fù)到了MESI的承諾等級(jí),即順序一致。同樣可以按照滿足條件/給出承諾的方式來(lái)簡(jiǎn)化理解,即:
-
要求:對(duì)兩個(gè)變量M,N的(Sequentially Consistent)寫(xiě)操作Am,An。在任意線程中,通過(guò)(Sequentially Consistent)的讀操作觀測(cè)到Am先于An。
-
承諾:在其他線程通過(guò)(Sequentially Consistent)的讀操作B也會(huì)觀測(cè)到Am先于An。
-
實(shí)際影響:
-
X86:Am和An之后清空store buffer,讀操作B無(wú)額外指令;
-
ARMv8:Am和An之前排空store buffer, B之后排空invalid queue,A/B保序;
-
ARMv7:Am和An前后全屏障,B之后全屏障;
-
POWER:Am和An前全屏障,B前后全屏障。
值得注意的是,ARMv8開(kāi)始,特意優(yōu)化了sequentially-consistent等級(jí),省略了全屏障成本。推測(cè)是因?yàn)轫樞蛞恢略趕td::atomic實(shí)現(xiàn)中作為默認(rèn)等級(jí)提供,為了通用意義上提升性能做了專(zhuān)門(mén)的優(yōu)化。
4.5 理解memory order如何幫助我們
先給出一個(gè)基本測(cè)試的結(jié)論,看一下一組對(duì)比數(shù)據(jù):
-
多線程競(jìng)爭(zhēng)寫(xiě)入近鄰地址sequentially-consistent:0.71單位時(shí)間
-
多線程競(jìng)爭(zhēng)寫(xiě)入近鄰地址release:0.006單位時(shí)間
-
多線程競(jìng)爭(zhēng)寫(xiě)入cache line隔離地址sequentially-consistent:0.38單位時(shí)間
-
多線程競(jìng)爭(zhēng)寫(xiě)入cache line隔離地址release:0.02單位時(shí)間
這里可以看出,做cache line隔離,對(duì)于sequentially-consistent內(nèi)存序下,有一定的收益,但是對(duì)release內(nèi)存序,反而有負(fù)效果。這是由于release內(nèi)存序下,因?yàn)闆](méi)有強(qiáng)內(nèi)存屏障,寫(xiě)緩沖起到了競(jìng)爭(zhēng)緩解的作用。而在充分緩解了競(jìng)爭(zhēng)之后,因?yàn)閏ache line隔離引入了相同吞吐下更多cache line的傳輸交互,反而開(kāi)銷(xiāo)變大。
在這個(gè)信息指導(dǎo)下,我們?cè)趯?shí)現(xiàn)無(wú)鎖隊(duì)列時(shí),采用了循環(huán)數(shù)組 + 分槽位版本號(hào)的模式來(lái)實(shí)現(xiàn)。因?yàn)殛?duì)列操作只需要acquire-release等級(jí),分槽位版本號(hào)間無(wú)需采用cache line隔離模式設(shè)計(jì),整體達(dá)到了比較高的并發(fā)性能。






