C++線程池實現原理

『背景』
C++多線程編程是C++開發者的一個基本功, 但是很多開發者都是直接使用公司給包裝好的線程池庫, 沒有去了解具體實現,有些實現也都因為高度優化而寫得諱莫如深,讓初學者看得吃力。
所以寫這篇文章主要是想以非常簡單的方式講講實現原理, 希望初學者看完之后不是覺得「不明覺厲」,而是覺得「原來如此」。
『面朝代碼』
首先先來一段超級簡單(注釋豐富)的代碼展示多線程編程的經典寫法。
注: 該段代碼和完整運行示例請見 limonp-thread-pool-programming-example , 可以通過以下命令跑通示例代碼和輸出結果,建議嘗試以下。當然記得前提是該機器的網絡可以訪問 GitHub 的情況下。 可能因為在中國訪問 GitHub 并不是很穩定,不是每次都能成功,所以如果卡住的話就多make幾次。
git clone https://github.com/yanyiwu/practice cd practice/cpp/limonp-v0.5.1-demo make
#include "limonp/ThreadPool.hpp" #include "limonp/StdExtension.hpp" using namespace std; const size_t THREAD_NUM = 4; // 這個類本身沒什么實際含義,只是為了示例多線程編程而寫出來的而已。 class Foo { public: // 這個類成員函數Append會在多個線程中被調用,多個線程同時對 chars 這個類成員變量進行寫操作,所以需要加鎖保證線程安全。 void Append(char c) { limonp::MutexLockGuard lock(mutex_); chars.push_back(c); } string chars; // 多線程共享的對象 limonp::MutexLock mutex_; // 線程鎖 }; void DemoClassFunction() { Foo foo; cout << foo.chars << endl; // 初始化一個線程池。 limonp::ThreadPool thread_pool(THREAD_NUM); thread_pool.Start(); // 啟動線程池 for (size_t i = 0; i < 20; i++) { char c = i % 10 + '0'; // 使用 NewClosure 綁定 foo 對象和 Append 函數和對應參數,構造一個閉包扔進線程池中運行,關于這個 NewClosure 后面會講。 thread_pool.Add(limonp::NewClosure(&foo, &Foo::Append, c)); } thread_pool.Stop(); // 等待所有線程工作(工作是指NewClosure生成的閉包函數)都完成,然后停止所有線程。 cout << foo.chars << endl; } int main() { DemoClassFunction(); return 0; }
上面代碼注釋已經非常詳細,一路看下來可能最困惑的應該是 NewClosure 閉包創建函數。 很多人談到閉包就覺得是一個很高深或者是坑很深的問題,但是此閉包非JavaScript的閉包。 是一個簡單版本,只考慮值拷貝,不考慮引用類型的參數。
limonp::NewClosure(&foo, &Foo::Append, c)
所以在該代碼中三個參數,第一個是類對象的指針,第二個是成員函數的指針,第三個是int,都是值傳遞。 在此不支持引用傳遞的參數。
『簡單閉包實現』
假設你現在已經運行了剛才的這三行命令。
git clone https://github.com/yanyiwu/practice cd practice/cpp/limonp-v0.5.1-demo make
一切運行正常的話,應該目前所在的目錄里面有 limonp-0.5.1 這個目錄。 然后可以在
limonp-0.5.1/include/limonp/Closure.hpp
文件中看到 NewClosure 函數的定義,NewClosure 是一個模板函數,所以才能支持多種多樣的類型。 但是也讓代碼變得晦澀很多。
但是 NewClosure 的實現原理最主要的是生成一個滿足 ClosureInterface 接口定義的對象而已。
同時這個 ClosureInterface 也異常的簡單,如下:
class ClosureInterface { public: virtual ~ClosureInterface() { } virtual void Run() = 0; };
因為當這個 Closure 對象被扔進線程池之后,其實是進入了一個隊列中。 然后線程池的多個線程去從隊列中獲取Closure指針,然后調用該 Run() 函數。
注意到,NewClosure 其實是生成一個對象指針,我們只是構造了這個對象,但是沒有銷毀它? 是否會造成內存泄露? 顯然不會,原因是在線程池中,調用完 Closure 中的 Run() 函數之后, 會 delete 該指針,所以不會造成內存泄露。
到這里只剩下一個最關鍵的困惑點就是 Run() 的具體實現。 也就是如何通過如下代碼將 Append 這個類成員函數變成一個實現了 Run() 函數的 Closure 對象?
limonp::NewClosure(&foo, &Foo::Append, c)
因為 NewClosure 是模板函數,支持各種參數類型,但是對于本文的例子,實際上調用的 NewClosure 函數實現如下:
template<class R, class Obj, class Arg1> ClosureInterface* NewClosure(Obj* obj, R (Obj::* fun)(Arg1), Arg1 arg1) { return new ObjClosure1<Obj, R (Obj::* )(Arg1), Arg1>(obj, fun, arg1); }
所以實際上創建的對象是 ObjClosure1 , 依然是在源碼
limonp-0.5.1/include/limonp/Closure.hpp
中可以找到 ObjClosure1 的實現,如下代碼:
template <class Obj, class Funct, class Arg1> class ObjClosure1: public ClosureInterface { public: ObjClosure1(Obj* p, Funct fun, Arg1 arg1) { p_ = p; fun_ = fun; arg1_ = arg1; } virtual ~ObjClosure1() { } virtual void Run() { (p_->*fun_)(arg1_); } private: Obj* p_; Funct fun_; Arg1 arg1_; };
『真相大白』
到這里真相基本上就浮出水面了。 類的對象指針,成員函數指針,函數的參數,都作為 ObjClosure1 的類成員變量存儲著。 然后在函數 Run() 里面再用起來。
virtual void Run() { (p_->*fun_)(arg1_); }
可能看到這里的時候有點不理解,這里就涉及到類成員函數指針的調用。 顯然因為類的成員函數是需要 this 指針的,這個寫過 C++ 的應該都知道。 所以直接像C語言那樣調用函數(如下),顯然是不可能的,沒有傳入 this 指針嘛。
(*fun)(arg1_);
所以在 C++ 中,調用類的成員函數指針,是如下這樣:
(p_->*fun_)(arg1_);
這樣才能把 p_ 當成 this 指針傳進去。
這樣就實現了之前的 NewClosure 將 「類,類成員函數指針,函數參數」打包成一個閉包供線程池調用的意圖。
所以就有了最開始示例代碼那種寫法。
『最后』
感覺自己寫的還是很通俗易懂的,有具體代碼,而且還是可一鍵下載運行的, 然后最底層的代碼實現也都解釋了。 應該算是很對得起本文題目了吧。
『題圖』
題圖是愛因斯坦,愛因斯坦代表高智商的大腦。 而在計算機領域,單核CPU計算性能已經很難有大的突破,發展趨勢是多核計算。 所以多線程編程是這個時代的必備基礎技能。