概要
筆者根據 Anthony Williams 在《C++併發程式設計實戰》中所述,
某個工作執行緒在任務佇列的頭部操作,而其它工作執行緒在任務佇列的尾部操作。這實際上意味著這個佇列對於擁有執行緒來說是一個後進先出的棧。最近被放到佇列中的任務會最先被取出來執行。從快取的角度來說這可以提高效能,因為對比之前被放入佇列中的任務,被取出的任務的資料更加有可能在快取中。
嘗試調整了 《簡單的執行緒池(七)》 中提及的非阻塞互助式執行緒池的實現方案,用 std::deque 替換 std::queue 作為儲存工作任務的底層資料結構,並新定義非阻塞執行緒安全的雙端佇列 Lockwise_Deque<>。
工作任務始終被新增至雙端佇列的尾部。為了驗證從快取的角度是否可以提高效能,筆者用四種方式實現工作任務的獲取,
- 當前工作執行緒從自己的任務佇列的頭部獲取任務,如無工作任務則從其他任務佇列的頭部獲取任務(A);
- 當前工作執行緒從自己的任務佇列的尾部獲取任務,如無工作任務則從其他任務佇列的頭部獲取任務(B);
- 當前工作執行緒從自己的任務佇列的頭部獲取任務,如無工作任務則從其他任務佇列的尾部獲取任務(C);
- 當前工作執行緒從自己的任務佇列的尾部獲取任務,如無工作任務則從其他任務佇列的尾部獲取任務(D)。
A 方式即為最初的非阻塞互助式,差別在於底層資料結構由 std::queue 更改為 std::deque;B 方式即按照 Anthony 所述的實現;C 和 D 是筆者為了全面確認快取對效能的影響而增加的方式。
本文不再贅訴與 《簡單的執行緒池(七)》 相同的內容。如有不明之處,請參考該部落格。
實現
以下程式碼給出了非阻塞執行緒安全雙端佇列的實現,(lockwise_deque.h)
template<class T>
class Lockwise_Deque {
private:
struct Spinlock_Mutex { ...
} mutable _m_;
deque<T> _q_;
public:
void push(T&& element) {
lock_guard<Spinlock_Mutex> lk(_m_);
_q_.push_back(std::move(element)); // #1
}
bool pop(T& element) {
lock_guard<Spinlock_Mutex> lk(_m_);
if (_q_.empty())
return false;
element = std::move(_q_.front()); // #2
_q_.pop_front();
return true;
}
bool pull(T& element) {
lock_guard<Spinlock_Mutex> lk(_m_);
if (_q_.empty())
return false;
element = std::move(_q_.back()); // #3
_q_.pop_back();
return true;
}
...
};
push(T&&) 函式實現從雙端佇列尾部新增任務(#1),pop(T&) 函式實現從雙端佇列頭部獲取任務(#2),pull(T&) 函式實現從雙端佇列尾部獲取任務(#3)。
以下程式碼給出 A 方式的實現,(lockwise_mutual_2a_pool.h)
class Thread_Pool {
...
void work(unsigned index) {
Task_Wrapper task;
while (!_done_.load(memory_order_acquire)) {
if (_workerqueues_[index].pop(task)) // #1
task();
else
for (unsigned i = 0; i < _workersize_; ++i)
if (_workerqueues_[(index + i + 1) % _workersize_].pop(task)) { // #2
task();
break;
}
while (_suspend_.load(memory_order_acquire))
std::this_thread::yield();
}
}
...
}
當前工作執行緒從自己的任務佇列的頭部獲取工作任務(#1),如無工作任務則從其他任務佇列的頭部獲取任務(#2)。
以下程式碼給出 B 方式的實現,(lockwise_mutual_2b_pool.h)
class Thread_Pool {
...
void work(unsigned index) {
Task_Wrapper task;
while (!_done_.load(memory_order_acquire)) {
if (_workerqueues_[index].pull(task)) // #1
task();
else
for (unsigned i = 0; i < _workersize_; ++i)
if (_workerqueues_[(index + i + 1) % _workersize_].pop(task)) { // #2
task();
break;
}
while (_suspend_.load(memory_order_acquire))
std::this_thread::yield();
}
}
...
}
當前工作執行緒從自己的任務佇列的尾部獲取工作任務(#1),如無工作任務則從其他任務佇列的頭部獲取任務(#2)。
以下程式碼給出 C 方式的實現,(lockwise_mutual_2c_pool.h)
class Thread_Pool {
...
void work(unsigned index) {
Task_Wrapper task;
while (!_done_.load(memory_order_acquire)) {
if (_workerqueues_[index].pop(task)) // #1
task();
else
for (unsigned i = 0; i < _workersize_; ++i)
if (_workerqueues_[(index + i + 1) % _workersize_].pull(task)) { // #2
task();
break;
}
while (_suspend_.load(memory_order_acquire))
std::this_thread::yield();
}
}
...
}
當前工作執行緒從自己的任務佇列的頭部獲取工作任務(#1),如無工作任務則從其他任務佇列的尾部獲取任務(#2)。
以下程式碼給出 D 方式的實現,(lockwise_mutual_2d_pool.h)
class Thread_Pool {
...
void work(unsigned index) {
Task_Wrapper task;
while (!_done_.load(memory_order_acquire)) {
if (_workerqueues_[index].pull(task)) // #1
task();
else
for (unsigned i = 0; i < _workersize_; ++i)
if (_workerqueues_[(index + i + 1) % _workersize_].pull(task)) { // #2
task();
break;
}
while (_suspend_.load(memory_order_acquire))
std::this_thread::yield();
}
}
...
}
當前工作執行緒從自己的任務佇列的尾部獲取工作任務(#1),如無工作任務則從其他任務佇列的尾部獲取任務(#2)。
邏輯
以下類圖展現了此執行緒池的程式碼主要邏輯結構。
執行緒池使用者提交任務與工作執行緒執行任務的併發過程與 《簡單的執行緒池(一)》 中的一致,此處略。
驗證
驗證過程採用了 《簡單的執行緒池(三)》 中定義的的測試用例。筆者對比了測試結果與 《簡單的執行緒池(七)》 的資料,結果如下,
圖1 列舉了 吞吐量1的差異 在 0.5 分鐘、1 分鐘和 3 分鐘的提交週期內不同思考時間上的對比。
【注】四種非阻塞執行緒安全的雙端佇列略稱為 LM2A、LM2B、LM2C 和 LM2D,下同。
圖2 列舉了 吞吐量2的差異 在 0.5 分鐘、1 分鐘和 3 分鐘的提交週期內不同思考時間上的對比。
圖3 列舉了 吞吐量3的差異 在 0.5 分鐘、1 分鐘和 3 分鐘的提交週期內不同思考時間上的對比。
可以看到,B 方式的非阻塞互助式執行緒池與 A、C、D 方式的以及普通佇列的非阻塞互助式在吞吐量上的表現近乎一致。
基於以上的對比分析,筆者認為,在非阻塞式執行緒池中採用雙端佇列,
從快取的角度來說這可以提高效能
這樣的論述是值得討論的。
最後
完整的程式碼示例和測試資料請參考 [github] cnblogs/15723078 。
作者參考了 C++併發程式設計實戰 / (美)威廉姆斯 (Williams, A.) 著; 周全等譯. - 北京: 人民郵電出版社, 2015.6 (2016.4重印) 一書中的部分設計思路。致 Anthony Williams、周全等譯者。