c++ 11 執行緒池---完全使用c++ 11新特性

MicroDeLe發表於2022-03-15

前言:

目前網上的c++執行緒池資源多是使用老版本或者使用系統介面實現,使用c++ 11新特性的不多,最近研究了一下,實現一個簡單版本,可實現任意任意引數函式的呼叫以及獲得返回值。

0 前置知識

首先介紹一下用到的c++新特性

  1. 可變引數模板:利用這一特性實現任意引數的傳遞
  2. bind函式,lambda表示式: 用於將帶引數的函式封裝為不帶形參和無返回值的函式,統一介面
  3. forward: 完美轉發,防止在函式封裝繫結時改變形參的原始屬性(引用,常量等屬性)
  4. shared_ptr, unique_ptr:智慧指標,程式結束自動析構,不用手動管理資源,省心省力
  5. thread:c++11 引入的多執行緒標準庫,完美跨平臺
  6. future:期物,用於子執行緒結束後獲取結果
  7. package_task: 非同步任務包裝模板,可以包裝函式用於其它執行緒.有點類似與function
  8. function: 函式包裝模板庫,可以理解為將不同型別但形參和返回值相同的函式統一的介面
  9. queue,vecort: 向量,佇列
  10. mutex: c++ 11引入的互斥鎖物件
  11. condition_variable: c++ 11引入的條件變數,用於控制執行緒阻塞
  12. atmoic:原子變數,++,--,+=,-=這些操作時原子型別的,防止讀取寫於入失敗

1 理論知識

問題0:執行緒執行完函式後自動就被系統回收了,怎麼才能實現複用呢
:剛開始我也是比較疑惑,以為有個什麼狀態方法可以呼叫,線上程結束被銷燬前阻塞住,從而接取下一個任務,實現複用,其實並非如此,執行緒池實現的原理是,讓執行緒執行一個死迴圈任務,當任務佇列為空時,就讓他阻塞防止資源浪費,當有任務時,解除阻塞,讓執行緒向下執行,當執行完當前函式後,又會再次執行到死迴圈的的上方,繼續向下執行,從而周而復始的不斷接任務--完成任務--接任務的迴圈,這裡可以設定一個變數來控制,當想銷燬執行緒池的時候,讓死迴圈不再成立,當該執行緒執行完當前函式後,退出迴圈,從而銷燬執行緒,思路很精妙

問題1:傳入的函式多種多樣,怎麼能實現一個統一呼叫的模式呢
:用過c++多執行緒的就應該知道,我們在建立執行緒時,需要給thread傳遞函式地址和引數,但是我們的任務引數是多種多樣的,數量不一,這時候,我們就需要使用可變引數模板將函式經過兩次封裝,封裝為統一格式,第一次封裝,封裝為不含有形參的函式,即引數繫結,但此時是有返回值的,第二次封裝,將函式的返回值也去除,這樣我們就能使用void()這種統一的形式去呼叫了。第一次封裝我們使用bind()函式將多個引數的函式封裝為沒有形參的package_task物件,為什麼呢,因為package_task物件可以通過get_future得到future物件,然後future物件可以通過get方法獲取返回值,這樣我們第二步,就能直接把返回值也去掉了。

說了這麼多,有點繞,對於沒怎麼使用過新特性的同學來說,可能雲霧繚繞,其實真正想明白這兩個問題,執行緒池的理論問題就解決了

2程式碼實現

總共包含5個檔案,兩個標頭檔案,3個原始檔,

2.1 任務佇列標頭檔案和實現

這兩個檔案是實現任務佇列的,其實很簡單,兩個方法,一個放入任務,一個取出任務,放入任務就放我們封裝後的

/** Created by Jiale on 2022/3/14 10:19.
 *  Decryption: 任務佇列標頭檔案
**/

#ifndef THREADPOOL_TASKQUEUE_H
#define THREADPOOL_TASKQUEUE_H

#include <queue>
#include <functional>
#include <mutex>
#include <future>
#include <iostream>

class TaskQueue {

public:
    using Task = std::function<void()>; // 任務類
    template<typename F, typename ...Args>
    auto addTask(F &f, Args &&...args) -> std::future<decltype(f(args...))>;  // 新增任務
    Task takeTask(); // 取任務
    bool empty() {return taskQueue.empty();}

private:
    std::mutex taskQueueMutex;  // 任務佇列互斥鎖
    std::queue<Task> taskQueue; // 任務佇列
};

template <typename F, typename ...Args> // 可變引數模板,模板必須在標頭檔案定義
auto TaskQueue::addTask(F &f, Args &&...args)-> std::future<decltype(f(args...))> {
    using RetType = decltype(f(args...));  // 獲取函式返回值型別
  // 將函式封裝為無形參的型別 std::bind(f, std::forward<Args>(args)...):將引數與函式名繫結
  // packaged_task<RetType()>(std::bind(f, std::forward<Args>(args)...)); 將繫結引數後的函式封裝為只有返回值沒有形參的任務物件,這樣就能使用get_future得到future物件,然後future物件可以通過get方法獲取返回值了
  // std::make_shared<std::packaged_task<RetType()>>(std::bind(f, std::forward<Args>(args)...)); 生成智慧指標,離開作用域自動析構
    auto task = std::make_shared<std::packaged_task<RetType()>>(std::bind(f, std::forward<Args>(args)...)); 
    std::lock_guard<std::mutex> lockGuard(taskQueueMutex);  // 插入時上鎖,防止多個執行緒同時插入
    // 將函式封裝為無返回無形參型別,通過lamdba表示式,呼叫封裝後的函式,注意,此時返回一個無形參無返回值的函式物件
    taskQueue.emplace([task]{(*task)();});      
    return task->get_future();
}


#endif //THREADPOOL_TASKQUEUE_H

/** Created by Jiale on 2022/3/14 10:19.
 *  Decryption: 任務佇列原始檔
**/

#include "include/TaskQueue.h"

/**
 * 從任務佇列中取任務
 * @return 取出的任務
 */
TaskQueue::Task TaskQueue::takeTask() {
    Task task;
    std::lock_guard<std::mutex> lockGuard(taskQueueMutex);  // 上鎖
    if (!taskQueue.empty()) {
        task = std::move(taskQueue.front());    // 取出任務
        taskQueue.pop();  // 將任務從佇列中刪除
        return task;
    }
    return nullptr;
}

可以看出,程式碼不多,就是一個簡單的放入任務,取出任務,但是如果沒接觸過這種寫法的時候還是比較難想的,我把那句難理解的程式碼拆成三部分

2.2 執行緒池程式碼實現

/** Created by Jiale on 2022/3/14 10:42.
 *  Decryption: 執行緒池標頭檔案
**/

#ifndef THREADPOOL_THREADPOOL_H
#define THREADPOOL_THREADPOOL_H

#include <atomic>
#include <thread>
#include <condition_variable>
#include "TaskQueue.h"

class ThreadPool {
    std::atomic<int> threadNum{};  // 最小執行緒數
    std::atomic<int> busyThreadNum; // 忙執行緒數
    std::condition_variable notEmptyCondVar; // 判斷任務佇列是否非空
    std::mutex threadPoolMutex; // 執行緒池互斥鎖
    bool shutdown;          // 執行緒池是否啟動
    std::unique_ptr<TaskQueue> taskQueue;  // 任務佇列
    std::vector<std::shared_ptr<std::thread>> threadVec;  // 執行緒池
public:
    explicit ThreadPool(int threadNum = 5);   // 建立執行緒池
    ~ThreadPool();          // 銷燬執行緒池

    template <typename F, typename ...Args>
    auto commit(F &f, Args &&...args) -> decltype(taskQueue->addTask(f, std::forward<Args>(args)...)); // 提交一個任務
    void worker();
};

template <typename F, typename ...Args>  // 可變引數模板
auto ThreadPool::commit(F &f, Args &&...args) -> decltype(taskQueue->addTask(f, std::forward<Args>(args)...)){
    // 這個目的就是把接收的引數直接轉發給我們上面寫的addTask方法,這樣,就可以對使用者隱藏TaskQueue的細節,只向使用者暴露ThreadPool就行
    auto ret = taskQueue->addTask(f, std::forward<Args>(args)...);
    notEmptyCondVar.notify_one();
    return ret;
}


#endif //THREADPOOL_THREADPOOL_H

/** Created by Jiale on 2022/3/14 10:42.
 *  Decryption:執行緒池原始檔
**/

#include "include/ThreadPool.h"

ThreadPool::ThreadPool(int threadNum) : taskQueue(std::make_unique<TaskQueue>()), shutdown(false), busyThreadNum(0) { 
    this->threadNum.store(threadNum);
    for (int i = 0; i < this->threadNum; ++i) {
        threadVec.push_back(std::make_shared<std::thread>(&ThreadPool::worker, this)); // 建立執行緒
        threadVec.back()->detach();    // 建立執行緒後detach,與主執行緒脫離
    }
}

ThreadPool::~ThreadPool() {
    shutdown = true;  // 等待執行緒執行完,就不在去佇列取任務
}

void ThreadPool::worker() {
    while (!shutdown) {
        std::unique_lock<std::mutex> uniqueLock(threadPoolMutex);
        notEmptyCondVar.wait(uniqueLock, [this] { return !taskQueue->empty() || shutdown; });  // 任務佇列為空,阻塞在此,當任務佇列不是空或者執行緒池關閉時,向下執行
        auto currTask = std::move(taskQueue->takeTask());  // 取出任務
        uniqueLock.unlock();
        ++busyThreadNum;
        currTask();  // 執行任務
        --busyThreadNum;
    }
}

2.3 測試

執行緒池設計好了,我們進行測試,如果我們開5個子執行緒,處理20個任務,那麼,應該有5個執行緒ID,且是5個執行緒併發執行的,我們在測試函式裡睡眠2秒,那麼,總的時間應該是8秒執行完

#include <iostream>
#include <thread>
#include <future>
#include "ThreadPool.h"
using namespace std;

mutex mut;
int func(int x) {
    auto now = time(nullptr);
    auto dateTime = localtime(&now);
    mut.lock(); // 為了防止列印錯亂,我們在這裡加鎖
    cout << "任務編號:" << x <<" 執行執行緒ID: " << this_thread::get_id() << " 當前時間: " << dateTime->tm_min << ":" << dateTime->tm_sec << endl;
    mut.unlock();
    this_thread::sleep_for(2s);
    return x;
}


int main() {
    ThreadPool threadPool;
    for (int i = 0; i < 20; ++i) auto ret = threadPool.commit(func, i);
    this_thread::sleep_for(20s);  // 主執行緒等待,因為現在子執行緒是脫離狀態,如果主執行緒關閉,則看不到列印
}

2.4 測試結果

可以看到我們的執行緒是併發執行的,總共用時從44分20秒,到44分26秒,總共6秒,加上我們最後一次列印沒有停留2秒,總共是8秒,每次列印的執行緒號也相同,可以看出,我們實現了執行緒的複用

總結

這只是多執行緒的一個簡單實現,很多東西沒有考慮到,比如任務超時,任務優先順序等,當然,我們會了簡單的之後就能慢慢摸索更復雜的功能。感謝閱讀

相關文章