簡單C++執行緒池

與MPI做鬥爭發表於2021-09-05

簡單C++執行緒池

Java 中有一個很方便的 ThreadPoolExecutor,可以用做執行緒池。想找一下 C++ 的類似設施,尤其是能方便理解底層原理可上手的。網上找到的 demo,基本都是介紹的 projschj 的C++11執行緒池。這份原始碼最後的commit日期是2014年,現在是2021年了,本文將在閱讀原始碼的基礎上,對這份程式碼進行一些改造。關於執行緒池,目前網上講解最好的一篇文章是這篇 Java執行緒池實現原理及其在美團業務中的實踐,值得一讀。

改造後的原始碼在 https://gitee.com/zhcpku/ThreadPool 進行提供。


更新,微軟工程院大佬指出了參考程式碼的一些問題,並給出了自己的固定個數執行緒池程式碼,貼在文末了。感覺這部分的討論更有價值。

projschj 的程式碼

1. 資料結構

主要包含兩個部分,一組執行執行緒、一個任務佇列。執行執行緒空閒時,總是從任務佇列中取出任務執行。具體執行邏輯後面會進行解釋。

class ThreadPool {
    // ...
private:
    using task_type = std::function<void()>;
    // need to keep track of threads so we can join them
    std::vector<std::thread> workers;
    // the task queue
    std::queue<task_type> tasks;
};

2. 同步機制

這裡包括一把鎖、一個條件變數,還有一個bool變數:

  • 鎖用於保護任務佇列、條件變數、bool變數的訪問;
  • 條件變數用於喚醒執行緒,通知任務到來、或者執行緒池停用;
  • bool變數用於停用執行緒池;
class ThreadPool {
    // ...
private:
    // synchronization
    std::mutex queue_mutex;
    std::condition_variable condition;
    bool stop;
};

3. 執行緒池啟動

啟動執行緒池,首先要做的是構造指定數量的執行緒出來,然後讓每個執行緒開始執行。
對於每個執行緒,執行邏輯是一樣的:嘗試從任務佇列中獲取任務並執行,如果拿不到任務、並且執行緒池沒有被停用,則睡眠等待。
這裡執行緒等待任務使用的是條件變數,而不是訊號量或者自旋鎖等其他設施,是為了讓執行緒睡眠,避免CPU空轉浪費。

// the constructor just launches some amount of workers
inline ThreadPool::ThreadPool(size_t thread_num)
    : stop(false)
{
    for (size_t i = 0; i < thread_num; ++i) {
        workers.emplace_back([this] {
            for (;;) {
                task_type task;
                {
                    std::unique_lock<std::mutex> lock(this->queue_mutex);
                    this->condition.wait(
                        lock, [this] { return this->stop || !this->tasks.empty(); });
                    if (this->stop && this->tasks.empty()) {
                        return;
                    }
                    task = std::move(this->tasks.front());
                    this->tasks.pop();
                }
                task();
            }
        });
    }
}

4.停用執行緒池

執行緒的停用,需要讓每一個執行緒停下來,並且等到每個執行緒都停止再退出主執行緒才是比較安全的操作。
停止分三步:設定停止標識、通知到每一個執行緒(睡眠的執行緒需要喚醒)、等到每一個執行緒停止。

// the destructor joins all threads
inline ThreadPool::~ThreadPool()
{
    {
        std::unique_lock<std::mutex> lock(queue_mutex);
        stop = true;
    }
    condition.notify_all();
    for (std::thread& worker : workers) {
        worker.join();
    }
}

5. 提交新任務

這是整個執行緒池的核心,也是寫的最複雜,用C++新特性最多的地方,包括但不限於:
自動型別推導、變長模板函式、右值引用、完美轉發、原地構造、智慧指標、future、bind ……
順帶提一句,要是早有變長模板引數,std::min / std::max 也不至於只能比較兩個數大小,再多就得用大括號包起來作為 initialize_list 傳進去了。

這裡提交任務時,由於我們的任務型別定義為一個無參無返回值的函式物件,所以需要先通過 std::bind 把函式及其引數打包成一個 對應型別的可呼叫物件,返回值將通過 future 非同步獲取。然後是要把這個任務插入任務佇列末尾,因為任務佇列被多執行緒併發訪問,所以需要加鎖。
另外需要處理的兩個情況,一個是執行緒睡眠時,新入隊任務需要主要喚醒執行緒;另一個是執行緒池要停用時,入隊操作是非法的。

// add new work item to the pool
template <class F, class... Args>
auto ThreadPool::enqueue(F&& f, Args&&... args)
    -> std::future<typename std::result_of<F(Args...)>::type>
{
    using return_type = typename std::result_of<F(Args...)>::type;

    auto task = std::make_shared<std::packaged_task<return_type()>>(
        std::bind(std::forward<F>(f), std::forward<Args>(args)...));

    std::future<return_type> res = task->get_future();
    {
        std::unique_lock<std::mutex> lock(queue_mutex);

        // don't allow enqueueing after stopping the pool
        if (stop) {
            throw std::runtime_error("enqueue on stopped ThreadPool");
        }
        tasks.emplace([task]() { (*task)(); });
    }
    condition.notify_one();
    return res;
}

改造

以上程式碼已經足以闡釋執行緒池基本原理了,以下改進主要從可靠性、易用性、使用場景等方面進行改進。

1. non-copyable

執行緒池本身應該是不可複製的,這裡我們通過刪除拷貝建構函式和賦值操作符,以及其對用的右值引用版本來實現:

class ThreadPool {
  // ...
private:
    // non-copyable
    ThreadPool(const ThreadPool&) = delete;
    ThreadPool(ThreadPool&&) = delete;
    ThreadPool& operator=(const ThreadPool&) = delete;
    ThreadPool& operator=(ThreadPool&&) = delete;
};

2. default-thread-number

除了手動指定執行緒個數,更合適的做法是主動探測CPU支援的物理執行緒數,並以此作為執行執行緒個數:

class ThreadPool {
public:
    explicit ThreadPool(size_t thread_num = std::thread::hardware_concurrency());
    size_t ThreadCount() { return workers.size(); }
    // ...
};

3. 延遲建立執行緒

執行緒不必一次就建立出來,可以等到任務到來的時候再建立,降低資源佔用。
// TBD

4. 臨時執行緒數量擴充

執行緒池的應用場景主要針對的是CPU密集型應用,但是遇到IO密集型場景,也要保證可用性。如果我們的執行緒個數固定的話,會出現一些問題,比如:

  • 幾個IO任務佔據了執行緒,並且進入了睡眠,這個時候CPU空閒,但是後面的任務卻得不到處理,任務佇列越來越長;
  • 幾個執行緒在睡眠等待某個訊號或者資源,但是這個訊號或資源的提供者是任務佇列中的某個任務,沒有空閒執行緒,提供者永遠提供此訊號或資源。
    因此我們需要一種機制,臨時擴充執行緒數量,從執行緒池中的睡眠執行緒手中“搶回”CPU。
    其實,更好的解決辦法是改造執行緒池,使用固定個數的執行緒,然後把任務打包到協程中執行,當遇到IO的時候協程主動讓出CPU,這樣其他任務就能上CPU執行了。畢竟,多執行緒擅長處理的是CPU密集型任務,多協程才是處理IO密集型任務的。…… 這不就是協程庫了嘛!比如 libco、libgo 就是這種解決方案。
    // TBD

5. 執行緒池停用啟動

上面的執行緒池,其啟動停止時機分別是構造和析構的時候,還是太粗糙了。我們為其提供手動啟動、停止的函式,並支援停止之後重新啟動:
// TBD


總結

不幹了,2021年了,研究協程庫去了!

更新:微軟工程師的簡單執行緒池

微軟工程師在 用 C++ 寫執行緒池是怎樣一種體驗? 這個問題下,指出之前的參考程式碼存在以下幾個問題:

1. 沒有必要把停止標誌設計為 atomic,更沒有必要用 acquire-release 同步。
2. 執行緒池銷燬時等待所有任務執行完成,通常是沒有必要的。
3. 工作執行緒內部存在冗餘邏輯;在尚有任務未完成時沒有必要檢查執行緒池是否停止。
4. 新增任務時沒有必要將任務封裝為 std::packaged_task,因為執行緒池的基本職能是管理 Execution Agents; 如果一定要設計這樣一個方法,那也應該保留一個直接提交任務不返回 Future 的方法;事實上,在 C++ 提案 P0443 (http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2018/p0443r5.html)中,也有類似的設計,不返回 Future 的叫做 "one-way",返回 Future 的叫做 "two-way",很多時候需要從外部控制同步時,"one-way" 比 "two-way" 更實用。
5. 一個 bug:"add" 方法實現中使用了 std::bind,而 std::bind 看起來並不適用於這裡。我猜你設計這個方法的語義是執行 add(std::forward<F>(fcn), std::forward<Args>(args)...),從你的返回值型別上也可以看出這一點;但不幸的是,std::bind 的返回值被呼叫時會講所有引數轉為左值引用! 也就是說,在你的實現中,所有引數在 fnc 執行時都會被拷貝構造一份,對於不能拷貝構造的引數會直接編譯出錯!
6. 另一個 bug:線上程池的解構函式中,沒有對 stop_ 的賦值加鎖。為什麼需要對 stop_ 的賦值加鎖呢?因為這個操作 必須 與工作執行緒對於 std::condition_variable 的 wait 檢查操作互斥!具體原因如下:對於 std::condition_variable 的 wait(帶 Predicate 的版本)展開的程式碼與下面的程式碼等價:
while (!pred()) {
  cond_var.wait();
}
如果 pred() 不與對於狀態的修改互斥的話,工作執行緒可能會陷入無線等待,也就導致了執行緒洩漏。

對於為什麼等待工作執行緒結束不必要,他的解釋如下:

跌宕的月光​2018-11-29
執行緒池銷燬時等待所有任務執行完成,通常是沒有必要的。可否麻煩解釋一下這個呢?因為比方說當main exit的時候,除非main 去等,否則detached thread就直接停掉了,出現一些任務做了一半的情況

「已登出」 (作者) 回覆跌宕的月光​2018-11-29
確實有這樣的問題,但我認為這屬於更底層的“執行緒模型”的問題範疇,而非“執行緒池”本身的問題;即使不使用執行緒池,這個問題依然存在,在某些情況下我們也希望子執行緒執行更久些。
解決這個問題的方法就是引入“守護執行緒”和“非守護執行緒”的概念,這個概念也廣泛存在於其他程式語言(如 Java)和作業系統(參考“守護程式”)中。作為我併發庫的一部分,我也自己實現過 C++ 的守護執行緒模型,可以參考:https://github.com/wmx16835/wang/blob/b8ecd554c4bf18cd181500e9594223e96dfede30/src/main/experimental/concurrent.h#L508-L525
這樣,建立執行緒的時候直接使用 thread_executor<false>::operator(F&&) 即可讓新建立的執行緒(看上去)擁有與主執行緒同等的地位。

至於等待任務結束,討論的結論是,應該由任務提交方選擇主動等待之後再結束執行緒池,而不是執行緒池自己來等待:

章佳傑​2018-06-25
你好,看了上面的程式碼受益良多。我有一個小問題,如果在上面這個程式碼的基礎上要實現這樣的功能:提交了一批 task,然後等這批 task 都完成,再繼續提交其他 task。這個中間的「等這批 task 都完成」的操作怎麼來實現比較好呢?

「已登出」 (作者) 回覆章佳傑​2018-06-25
首先,如果有這樣的需求,執行緒池可以開一個批量提交任務的介面,不是為了批量等待,而是減少多次進入臨界區的開銷。
然後回到你的問題。現在普遍的做法有兩種,一種是使用 Future,即將每一個任務包裝為 std::packaged_task 再提交(需要考慮我回答中提及的“拷貝構造”問題),然後將這些 Future 儲存起來逐一等待;另一種是使用 Latch,這樣程式碼量會稍微增加一點,即在每個任務結束後對 Latch 執行“減一”操作,外部對所述 Latch 執行阻塞等待。
如果使用我回答中提及的 ISO C++ 併發提案(http://www.http://open-std.org/jtc1/sc22/wg21/docs/papers/2017/p0642r1.pdf )中的解決方案,則不需要使用 Future 或 Latch,所需程式碼量較少,並且對於阻塞敏感的程式可以使用非同步的方式避免等待的阻塞。

關於第5點的bug討論:

鄭威狄2018-06-13
您好,針對您第五點提出的bug有沒有更好的實現方法來避免這個問題呢?

「已登出」 (作者) 回覆鄭威狄2018-06-14
需要將可呼叫物件的可變引數列表中的引數全部轉為右值。如果不使用第三方庫的話,我見過最簡單的方法就是寫一個轉發引數的中間層仿函式。但在我見過的大多數情形中都沒有必要在這一層新增呼叫所需引數,所以在我提供的實現中沒有 `Args&&...` 引數列表,也就沒有這個 bug。

參考程式碼

他給出的參考程式碼更簡單一些,對於有返回值的函式,並沒有直接在 execute 時返回返回值的 future,而是統一返回void,需要返回值的話手動把 future 打包一個 packged_task 傳入 execute 即可:

#include <condition_variable>
#include <functional>
#include <mutex>
#include <queue>
#include <thread>

class fixed_thread_pool {
public:
    explicit fixed_thread_pool(size_t thread_count)
        : data_(std::make_shared<data>())
    {
        for (size_t i = 0; i < thread_count; ++i) {
            std::thread([data = data_] {
                std::unique_lock<std::mutex> lk(data->mtx_);
                for (;;) {
                    if (!data->tasks_.empty()) {
                        auto current = std::move(data->tasks_.front());
                        data->tasks_.pop();
                        lk.unlock();
                        current();
                        lk.lock();
                    } else if (data->is_shutdown_) {
                        break;
                    } else {
                        data->cond_.wait(lk);
                    }
                }
            }).detach();
        }
    }

    fixed_thread_pool() = default;
    fixed_thread_pool(fixed_thread_pool&&) = default;

    ~fixed_thread_pool()
    {
        if ((bool)data_) {
            {
                std::lock_guard<std::mutex> lk(data_->mtx_);
                data_->is_shutdown_ = true;
            }
            data_->cond_.notify_all();
        }
    }

    template <class F>
    void execute(F&& task)
    {
        {
            std::lock_guard<std::mutex> lk(data_->mtx_);
            data_->tasks_.emplace(std::forward<F>(task));
        }
        data_->cond_.notify_one();
    }

private:
    struct data {
        std::mutex mtx_;
        std::condition_variable cond_;
        bool is_shutdown_ = false;
        std::queue<std::function<void()>> tasks_;
    };
    std::shared_ptr<data> data_;
};

補充測試用例

這裡我補充了一個例子來說明,如何等待任務結束,以及返回值的獲取:

class count_down_latch {
public:
    explicit count_down_latch(size_t n = 1) : cnt(n) {};
    void done() {
        std::unique_lock<std::mutex> lck(mu);
        if (--cnt <= 0) {
            cv.notify_one();
        }
    }
    void wait() {
        std::unique_lock<std::mutex> lck(mu);
        for(;;) {
            cv.wait(lck);
            mu.unlock();
            if (cnt <= 0) break;
            mu.lock();
        }
    }
private:
    size_t cnt;
    std::mutex mu;
    std::condition_variable cv;
};

void test_lambda()
{
    fixed_thread_pool pool(4);
    std::vector<int> results(8);
    count_down_latch latch(8);

    for (int i = 0; i < 8; ++i) {
        pool.execute([i, &latch, &results] {
            printf("hello %d\n", i);
            std::this_thread::sleep_for(std::chrono::seconds(1));
            printf("world %d\n", i);
            latch.done();
            results[i] = i * i;
        });
    }
    latch.wait();
    for (auto&& result : results) {
        printf("%d ", result);
    }
    printf("\n");
    printf("--------------------\n");
}

參考文獻

  1. projschj 的C++11 執行緒池
  2. Java執行緒池實現原理及其在美團業務中的實踐
  3. 用 C++ 寫執行緒池是怎樣一種體驗? - 「已登出」的回答 - 知乎

相關文章