手擼一款簡單高效的線程池(四)
在前幾章的內容中,我們給大家介紹了一些 C++線程池中的優(yōu)化思路和實現(xiàn)方案。這一章中,我們來聊一聊在編程實現(xiàn)過程中,一些工程層面的優(yōu)化。讓我們的代碼執(zhí)行的速度,跟得上自己的思路。
大家好,我是不會寫代碼的純序員——Chunel Feng,有兩周的時間沒有更新文章了哈。主要是上上周啊,我隨便 copy 了一段功能代碼,也不知道執(zhí)行后會是啥結果,還沒測試就直接發(fā)布到線上了。然后這兩周就一直在改 bug 了。當然了,如果不是組里朋友一起來幫忙,可能還會耽擱的更久。事后,我就想啊,這樣一個程序員毀了一個組的狗血劇情,也可能發(fā)生在多線程開發(fā)過程中。有必要進行一些針對性的優(yōu)化。
在之前的幾篇文章里,我們主要跟大家介紹了線程池中一些增加扇入扇出、增加負載均衡的優(yōu)化思路和方法。這一章,我們跟大家聊一下CGraph中的 threadpool 實現(xiàn)過程中,在工程層面做的一些考量。主要會涉及到 避免 busy waiting,分支預測優(yōu)化,減少無用 copy 等機制。有些東西看似跟多線程沒有什么關系,但是落地后的提升是明顯的——甚至會比線程調度理論上的優(yōu)化,來的更加明顯。
首先,還是照例,先上源碼鏈接:CGraph 源碼鏈接[1] 其中,線程池的實現(xiàn)在
/src/UtilsCtrl/ThreadPool/文件夾中
避免 busy waiting
寫多線程代碼的時候,單個線程在一些特定的流程中(比如:等著隊列中來信息),會有可能陷入不定期的無效等待中去,并且這種等待是不會自動釋放 cpu 資源的,這就是所謂的busy waiting邏輯。
它的缺點是顯而易見的:自身陷入無用等待的過程中的同時,也阻礙了其他線程順利執(zhí)行——像極了自己寫了 bug 卻查不出來,還要拉著同事一起來查的純序員本員了。

我舉個例子:
void push(UTaskWrapper&& task) {while (true) {if (mutex_.try_lock()) {// 可能出現(xiàn)長期無法搶到鎖的情況queue_.emplace_front(std::move(task));mutex_.unlock();break;} else {// 讓出cpu執(zhí)行權std::this_thread::yield();}}}
看上面一段代碼,多個線程往一個 queue中 push 任務的時候,可能遇到一些情況,使得 mutex被其他的線程占著。這個時候是保持現(xiàn)狀,不斷的重復嘗試搶鎖,還是直接讓出當前線程的執(zhí)行權,過 n 個時間片再來重新嘗試一次?我想絕大部分情況下,應該選擇后者。而yield()函數(shù)的用途,就是使得當前線程讓出 cpu 執(zhí)行權。
之所以在最外面加了 while (true),是因為無論嘗試多少次,最終總需要把這個任務 push 進 queue_中——總不能半途而廢吧。
順便說一句,這種 busy waiting 的現(xiàn)象,在 cas 操作和 atomic 操作中會經(jīng)常出現(xiàn)。cas 是因為需要一直比較
expected和ptr是否一致,而 atomic 是因為還需要保證當前線程不退出。
分支預測優(yōu)化
來看下面很簡單的一個邏輯,函數(shù)入口處先判定一下傳入的指針是否非空,非空的話則繼續(xù)往下執(zhí)行。
void function(CObject* ptr, CParam* param) {if (nullptr == ptr || nullptr == param) {return;}// do somethingptr->doSomeThing(param);}
這種寫法,在一定程度上體現(xiàn)了編程的嚴謹性。但是有個問題:如果所有函數(shù)的開頭處,都對所有的指針入?yún)⒆龇强张袛?,這會是一個很繁雜的邏輯。而且很多指針(比如:指針型成員變量)基本上是初始化一次,之后就都不會為空了。還要每次進入函數(shù)開頭進行邏輯判斷么?為此,我們引入了執(zhí)行分支預測邏輯。

先來簡單的說一下程序執(zhí)行的流程哈。我們上面的那段代碼雖然是線性的流程,但是在執(zhí)行的時候,程序在if那個地方,并不會等著判斷條件(例中為:(nullptr == ptr || nullptr == param),也可能是一個非常復雜的邏輯)得出一個 true or false 的結果后,再決定從哪個分支開始執(zhí)行。
執(zhí)行邏輯是:當遇到if判斷的時候,“隨便”選一個分支(反正是 2 選 1 嘛,蒙對的概率還不?。┌褜闹噶罴虞d進來執(zhí)行。如果蒙對了最好;蒙不對,就掉頭回去,再執(zhí)行另外分支的邏輯。從而在一定程度上,達到了加速執(zhí)行的目的——像極了還不確定代碼功能是否正常,就直接發(fā)布到線上,有問題再回滾的純序員本員了。
注:我再來解釋一下我剛才“隨便”說的那個“隨便”哈。這其中的優(yōu)化算法和調度邏輯,是很多資深的行業(yè)大佬和一些巨頭科技公司一起合作研究出來的,復雜程度難以想象。如果不是專門研究這個方向,我認為知道有這事即可,術業(yè)有專攻嘛。
那我們再往后想一步,針對例子中這種入?yún)榭者壿?,是不是應該極大概率不會出現(xiàn)呢?這個時候,如果我們能給編譯器一個明確的提示,是不是就能從 50%的命中率,提高到 90%甚至是 99.99%呢。
#define likely(x) __builtin_expect(!!(x), 1)#define unlikely(x) __builtin_expect(!!(x), 0)bool stealTask(UTaskWrapperRef task) {if (unlikely(pool_threads_->size() < CGRAPH_DEFAULT_THREAD_SIZE)) {// 線程池還未初始化完畢的時候無法進行steal。確保程序安全運行return false;}// do somethingreturn true;}
結合代碼說一下:上述代碼中 當且僅當程序剛開始運行,線程池中所有的 Primary 線程未初始化完畢,且未初始化線程又被 steal 的情況下 才會出現(xiàn)if那個分支為 true 的情況。這個時候,我們可以明確的告訴編譯器,這里unlikely(不太可能)被執(zhí)行。
__builtin_expect是 C++自帶的函數(shù),這個沒什么好說的(主要是我也說不出來啥,嘿嘿)。一句話介紹一下為什么是!!(x):目的就是為了將 x 值變成一個 bool 類型。例:
?!!(5) = 1?!!(1) = 1?!!(0) = 0
關于這一點,我們來做個有意思的小實驗:
#include <iostream>#define likely(x) __builtin_expect(!!(x), 1)#define unlikely(x) __builtin_expect(!!(x), 0)int main() {long long outLoop = 10000;long long inLoop = 10000000;while (outLoop--) {long long loop = inLoop;static long long i = 1; // tag-1while (loop--) {if (unlikely(0 == i % 2)) { // tag-2i += 1;} else {i += 2;}}}return 0;}
大家看一下上面這段又臭又長的代碼哈,具體邏輯沒啥好說的——就是循環(huán)里面套循環(huán)。主要看 unlikely 那句話:如果直接運行的話,執(zhí)行耗時是 35.69 秒;但如果刪除unlikely修飾的話,則執(zhí)行耗時是 100.44 秒——接近 3 倍的差距。
我再說幾個問題,有興趣的朋友可以自己試試看:
?如果tag-1那句話,不加static,會怎樣??如果tag-2那句話,使用likely修飾,會怎樣?
如果你沒親身試過,結果很有可能跟你想的不一樣哦。
減少無用 copy
C++ 中存在著各種形式的或默認或自定義的賦值構造、拷貝構造。搞不好的話,會出邏輯問題;搞好的話,來回賦值其實也是挺費時費力的。最好的解決方法,就是在不需要 copy 的時候,直接轉移當前對象——類似 C++11 中提出的std::move概念。
為此,CGraph 中 threadpool 在開發(fā)過程中,全程傳參和賦值中,采用的都是std::move和emplace的傳遞方式,盡可能的避免出現(xiàn)中間流程無意義 copy 的情況。
同時,在指針類型的選取方面,也是基本上采用原生指針,自行對資源進行分發(fā)和管理。僅針對不定期申請/釋放的資源,會通過 make_unique 進行申請,以 unique_ptr 的方式進行生命周期管理。且全程均未使用 shared_ptr。這些,都是基于性能方面的考量。
注:shared_ptr 和 unique_ptr 在反復多次申請和來回賦值的情況下,有一定的性能差距,同時,shared_ptr 自身內存占用也比 unique_ptr 大(主要都是因為 shared_ptr 中的 cas 校驗機制)。很多大型項目,是明文禁止使用 shared_ptr 的。

在這里跟大家分享一個我前段時間遇到的一個問題:
#include <iostream>struct Message {Message(std::string msg) : msg_(std::move(msg)) {}Message(const Message& msg) : msg_(std::move(msg.msg_)) {std::cout << "copy construct\n";}Message(Message&& msg) : msg_(std::move(msg.msg_)) {std::cout << "move construct\n";}// 如果把這個函數(shù)注釋掉,會如何執(zhí)行??Message(const Message&& msg) : msg_(std::move(msg.msg_)) {std::cout << "right move construct\n";}std::string msg_;};int main() {const Message cm{"aaa"}; // 如果把const去掉,會如何執(zhí)行?Message cm1 = std::move(cm);}
大家可以看一下,上這段代碼中,如果直接運行,應該是輸出:
>> right move construct這個應該沒有什么疑問。但是,如果把第四個函數(shù)Message(const Message&& msg)注釋掉,再重新執(zhí)行,則是輸出:
>> copy construct (打印)>> move construct (不打印)
你以為它 move 了,實際上卻是在 copy。再如果,把 main 函數(shù)中,const Message cm{"aaa"};中的const字段刪除,那結果又會不一樣了,大家可以自己嘗試一下。
聊這些的意思,主要目的就是提醒大家,盡可能不要在自己定義各種構造函數(shù)的時候踩坑——像極了反復 ctrl c/v 代碼,但卻又不知道這一段代碼會不會被執(zhí)行的純序員本員。
本章小結
本章內容,主要介紹了一些在實現(xiàn) CGraph 框架中 threadpool 功能的過程中,用到了一些實用工程側的小技巧。其實都蠻簡單的,很多技巧在寫其他工程邏輯的時候,都可以被用到。

再說一個事情哈,我們之前聊到過work-stealing機制。介紹了該機制的優(yōu)劣,并且通過實驗,證明了在線程數(shù)量遠大于 cpu 數(shù)量情況下,僅 steal 相鄰 index 的幾個 thread,會顯著提升整體調度性能(調度時間降低)。
最近,有一位在國內頂尖 AI 公司做高性能計算的資深大佬跟我提到,之所以在限制 stealing 個數(shù)后會有性能提升,還跟 CPU 自身的構造和機制有關系,不同的系統(tǒng)架構上,也可能會有不同的效果。至于最底層的內容究竟怎樣,作為上層的純序員已經(jīng)無法探究,甚至連能夠探究的實驗也不會設計,驗證的專業(yè)工具也不會使用。
就是借此感慨一下,計算機知識的海洋深不見底,有時候親眼所見所得,也未必就是全部的真相。作為新晉民工的我們,更應該保持不斷學習和充電,不斷去打破自己思維和認知的邊界,提高水平。這樣才會讓我們看到的世界更加完整真實,思維的武器更加強大,順便認識更多的妹子。

當然,今天我們聊到的所有內容也均僅限于本人的既有認知。歡迎大家加我微信,以便隨時交流。我們也會在接下來的文章中,介紹 CGraph 中線程池的使用 demo 和一些性能測試數(shù)據(jù)。歡迎大家繼續(xù)關注。
推薦閱讀
?純序員給你介紹圖化框架的簡單實現(xiàn)——執(zhí)行邏輯[2]?純序員給你介紹圖化框架的簡單實現(xiàn)——循環(huán)邏輯[3]?純序員給你介紹圖化框架的簡單實現(xiàn)——參數(shù)傳遞[4]?純序員給你介紹圖化框架的簡單實現(xiàn)——條件判斷[5]?純序員給你介紹圖化框架的簡單實現(xiàn)——線程池優(yōu)化(一)[6]?純序員給你介紹圖化框架的簡單實現(xiàn)——線程池優(yōu)化(二)[7]?純序員給你介紹圖化框架的簡單實現(xiàn)——線程池優(yōu)化(三)[8]
引用鏈接
[1] CGraph 源碼鏈接: https://github.com/ChunelFeng/CGraph[2] 純序員給你介紹圖化框架的簡單實現(xiàn)——執(zhí)行邏輯: http://www.chunel.cn/archives/cgraph-run-introduce[3] 純序員給你介紹圖化框架的簡單實現(xiàn)——循環(huán)邏輯: http://www.chunel.cn/archives/cgraph-loop-introduce[4] 純序員給你介紹圖化框架的簡單實現(xiàn)——參數(shù)傳遞: http://www.chunel.cn/archives/cgraph-param-introduce[5] 純序員給你介紹圖化框架的簡單實現(xiàn)——條件判斷: http://www.chunel.cn/archives/cgraph-condition-introduce[6] 純序員給你介紹圖化框架的簡單實現(xiàn)——線程池優(yōu)化(一): http://www.chunel.cn/archives/cgraph-threadpool-1-introduce[7] 純序員給你介紹圖化框架的簡單實現(xiàn)——線程池優(yōu)化(二): http://www.chunel.cn/archives/cgraph-threadpool-2-introduce[8] 純序員給你介紹圖化框架的簡單實現(xiàn)——線程池優(yōu)化(三): http://www.chunel.cn/archives/cgraph-threadpool-3-introduce
歡迎關注我的公眾號“Doocs 開源社區(qū)”,原創(chuàng)技術文章第一時間推送。
