C++ を使用してスレッド プールを実装する方法 (C++11 標準)

前文: この記事は CSDN ブログからの転載です: https://blog.csdn.net/xyygudu/article/details/128767928、著者: xyygudu

言うことはあまりありません。最初にコードを記載します

https://github.com/xyygudu/ThreadPool

マルチスレッド プログラムは必ずしも優れているのでしょうか?

必ずしもそうとは限りませんが、現在のプログラムの種類によって異なります。プログラムの種類には、IO 集中型や CPU 集中型などがあります。

  • IO 集約型: デバイス、ファイル、ネットワークなどの一部の IO 操作を伴う命令。これらの IO 操作はプログラムをブロックしやすく、時間のかかる操作です。

  • CPU 集中型: 命令は主に計算に使用されます。

マルチコア コンピューターの場合、これら 2 種類のプログラムはマルチスレッドを使用する必要がありますが、シングルスレッド環境の場合は、IO 集中型のプログラムがマルチスレッドに適しています。ネットワークブロッキング状態、切り替え可能 他のスレッドは他のタスクの処理に使用されます; 複数のスレッドがあっても、これらの計算命令は依然としてこのコアによって処理されるため、CPU 負荷の高いプログラムはマルチスレッドには適していません。命令を実行する際には、スレッド切り替えのオーバーヘッドも発生します。

スレッド数を決定するにはどうすればよいですか?

  • スレッドの作成と破棄は非常に「重い」操作です。

  • スレッド自体は多くのメモリを消費します。各スレッドには独自の独立したスタック領域があります。

  • スレッドのコンテキスト切り替えには時間がかかります。CPU がスレッドの切り替えに多くの時間を費やすと、タスクを処理する時間がなくなります。

  • 多数のスレッドが同時に起動すると、システムに過度の瞬間的な負荷がかかり、ダウンタイムが発生します。

スレッドの数は通常、現在の CPU のコア数に応じて決定されます。

スレッドプールの概要

スレッドプールの基礎

タスクをタイムリーに処理するために (いわゆるタスクは、実行される関数として理解できます)、すべての保留中のタスクはスレッド プールのタスク キューに入れられ、スレッド プール内の複数のスレッドはタスクキューからタスクを取り出して実行します。スレッドがタスクを取り出している間、ユーザーはスレッドプールに実行するタスクを指示するだけでよいかのように、新しいタスクをタスクキューに追加することもできます。スレッド プールは、内部スレッド処理が完了した後、ユーザーの処理結果を返します。一般的な原理は次の図に示すとおりです。
ここに画像の説明を挿入
読者の中には、関数は呼び出された後に実行されるのではないか、どうやってタスク キューに入れることができるのかと疑問に思う人もいるかもしれません。はい、C++11 には関数テンプレートが用意されており、関数を呼び出し可能なオブジェクト (変数として理解できる) として保存したり、呼び出し可能なオブジェクトをコンテナー (キューなど) に保存したりすることができます。適切なタイミングで取り出して実行します。これは、実行する関数を保存し、後で呼び出すことと同じです。

スレッドプールの利点

  • サービスプロセスの起動開始時に、あらかじめスレッドプール内に多数のスレッドが再作成されているため、実行するタスクがある場合、スレッドを直接選択して、追加の時間を費やすことなく、すぐにタスクを実行できます。前と同じようにタスクを作成します。

  • タスクの実行が完了した後、スレッドを解放する必要はありませんが、後続のタスクにサービスを提供するためにスレッドをスレッド プールに戻す必要があります。

スレッドプールが持つべき機能

  • スレッド プールは構成可能である必要があります。たとえば、予約されたスレッドの数、スレッド プールによってオープンできるスレッドの最大数などを構成します。

  • 予約スレッドと動的スレッドがあります。予約スレッドはスレッド プールに常駐します。スレッド プールが終了しない限り、またはリサイクルされない限り、常駐スレッドは終了せず、動的スレッドはタスクのサイズに応じて動的に作成および破棄されます。タスクキュー内のタスクを時間内に処理できない場合は、動的スレッドを増やします。タスクキューが空で、多くの動的スレッドが待機状態にある場合は、動的スレッドを終了します。

  • さまざまなタスク(パラメータの異なる関数)を実行し、実行結果を取得できます

  • 現在のスレッドプール内のスレッドの総数とアイドルスレッドの数を取得できます。

  • タスク キュー内のタスクの数は、多すぎるタスクが大量のメモリを占有することを防ぐために制御できます。

  • スレッドプール機能を有効にするスイッチ。閉じるときは、現在のタスクが実行可能であることを確認し、残りの未実行タスクを破棄するか、タスク キューへの新しいタスクの追加を停止し、残りのタスクが実行された後に終了します。

スレッドプールの実装

予備知識

このスレッド プールは C++11 で記述されており、主に次の C++11 の機能を使用するため、初心者に適しています。

std::functionおよびstd::bind、可変引数関数テンプレート、package_task および future、アトミック型 (アトミック)、条件変数、ミューテックス、スマート ポインター、ラムダ式、移動セマンティクス、完全転送

クラス分け

スレッド プールは、Thread クラスと ThreadPool クラスの 2 つのクラスに分かれており、Thread クラスは主に、スレッド フラグ (予約スレッドか動的スレッドか) など、スレッドのいくつかの基本属性を保存するために使用されます。メソッドはstd::thread主にスレッドを開始するため (開始関数)、ThreadPool クラスは主に Thread クラスとタスク キューの管理に使用され、スレッドの動的追加/削除、タスク キューへのタスクのアクセス、等々。これら 2 つのクラスのヘッダー ファイルのメイン コードは次のとおりです。

class Thread : public std::enable_shared_from_this<Thread>
{
    
    
 
public:
    using ThreadPtr = std::shared_ptr<Thread>;
    using ThreadFunc = std::function<void(ThreadPtr)>; 
    enum class ThreadFlag {
    
     kReserved, kDynamic };
 
    explicit Thread(ThreadFunc func, ThreadFlag flag = ThreadFlag::kReserved);
    ~Thread() = default;
    // 获取线程id
    pid_t getThreadId() {
    
     return threadId_; }  
    void start();               // 启动线程
 
private:
    ThreadFlag threadFlag_;     // 线程标志
    pid_t threadId_;            // 线程id
    std::thread thread_;        // 线程
    ThreadFunc threadFunc_;     // 线程执行函数
};

class ThreadPool
{
    
    
public:
    using Task = std::function<void()>;                     // 定义任务回调函数
    using Seconds = std::chrono::seconds;
    using ThreadPoolLock = std::unique_lock<std::mutex>;
    using ThreadPtr = std::shared_ptr<Thread>;              // 线程指针类型
    using ThreadFlag = Thread::ThreadFlag;
 
    explicit ThreadPool();
    ~ThreadPool();
 
    void start();                                           // 开启线程池
    void stop();                                            // 停止线程池
    template<typename Func, typename... Args>               // 给线程池提交任务
    auto submitTask(Func&& func, Args&&... args) -> std::future<decltype(func(args...))>;
 
private:
    void addThread(ThreadFlag flag = ThreadFlag::kReserved);// 向线程池中添加一个新线程
    void removeThread(pid_t id);
    void threadFunc(ThreadPtr threadPtr);                   // 线程执行函数
 
    std::list<ThreadPtr> workThreads_;                      // 存放工作线程,使用智能指针是为了能够自动释放Thread
    std::mutex threadMutex_;                                // 工作线程互斥锁
    std::queue<Task> tasks_;                                // 任务队列
    std::mutex taskMutex_;                                  // 互斥锁(从队列取出/添加任务用到)
    std::condition_variable notFullCV_;                     // 任务队列不满
    std::condition_variable notEmptyCV_;                    // 任务队列不空
    std::condition_variable exitCV_;                        // 没有任务时方可线程池退出
 
    // std::atomic_int taskNum_;                            // 任务队列中未处理的任务数量
    std::atomic_int waitingThreadNum_;                      // 处于等待中的线程数量
    std::atomic_uint curThreadNum_;                         // 当前线程数量
 
    std::atomic_bool quit_;                                 // 标识是否退出线程池
 
    Config config_;                                         // 存储线程池配置
};

機能実現

ヘッダーの ThreadPool コンストラクターは何をするのでしょうか?

コンストラクターは主に、予約されたスレッドの数、スレッド プール内のスレッドの最大数など、いくつかの構成を初期化します。

スレッド プールは開始 (開始) されると何をしますか?
スレッド プールが start 関数を呼び出した後、設定で指定された予約スレッドの数に従って、予約スレッドをスレッド コンテナ workThreads_ に追加します。予約スレッドを追加すると、実際には Thread オブジェクトが作成され、これらのスレッドが同時に開始されます。このクラスには start 関数もあります。この関数は正式に std::thread オブジェクトを作成し、実行されるコールバック関数 (つまり、ThreadPool クラスの threadFunc 関数) を std::thread にバインドします。が開始されると、この時点では予約されているすべてのスレッドが実行状態にあり、タスクの到着を待っています。Thread クラスをスマート ポインターでカプセル化する理由については、「Thread クラスにスマート ポインターを使用する理由」の回答を参照してください。

void ThreadPool::start()
{
    
    
    quit_ = false;
    size_t reservedThreadNum = config_.RESERVED_THREAD_NUM;
    std::cout << "init thread num: " << reservedThreadNum << std::endl;
    while (reservedThreadNum--)
    {
    
    
        addThread(); // 默认创建保留线程(threadFlag_=kReserved)
    }
}
 
void ThreadPool::addThread(ThreadFlag flag)
{
    
    
    // 默认创建保留线程(threadFlag_=kReserved)
    ThreadPtr newThreadPtr(new Thread(std::bind(&ThreadPool::threadFunc, this, std::placeholders::_1), flag));
    newThreadPtr->start();                  // 启动线程,线程自动执行threadFunc
    // 如果不把newThreadPtr添加到workThread_,函数运行结束后,newThreadPtr的引用计数为0,资源会被释放
    ThreadPoolLock lock(threadMutex_);
    workThreads_.emplace_back(newThreadPtr);
    curThreadNum_++;
}

サブスレッドはどのようにタスクを処理するのでしょうか?

サブスレッドが実行するのは無限ループであり、終了するスレッドの終了フラグがquit_=trueループを終了し、サブスレッドが終了する場合にのみ実行されます。サブスレッドは ThreadPool の threadFunc 関数で実行されます。この関数の動作手順は次のとおりです。

  1. ミューテックスを取得します。複数のスレッドがタスク キューからタスクを取り出す必要があるため、タスク キュー task_ は重要なリソースであり、重要なリソースにアクセスするには子スレッドをロックする必要があります。
  2. タスクキューtasks_が空かどうかを判定する条件変数notEmptyCV_に従って、tasks_が空でスレッドプールが終了フラグを設定している場合(すなわち)のみ、子スレッド自体がスレッドプールのworkThreads_から削除されますquit_=true。その目的は、終了フラグが設定されていても、タスクキューが空でない限りスレッドプールの実行を継続させること、つまり、スレッドプールが未処理のタスクを放置することを防ぐことです。それ以外の場合、タスクはタスクキューから取得されて実行されます。

子スレッドが終了条件を満たした場合、quit_ && tasks_.empty()それは true です。スレッド プールから自身を削除することに加えて、通知条件変数 exitCV_ もあります。目的は、スレッド プールからすべてのスレッドを削除し、スレッド プールを破棄することです。オブジェクト。詳細については、「スレッド プールを安全に終了する方法」を参照してください。メインコードは次のとおりです (例として予約スレッドを取り上げます。動的スレッドはタイムアウトを 1 回リサイクルするだけです)。

void ThreadPool::threadFunc(ThreadPtr threadPtr)
{
    
    
    for(;;)
    {
    
    
        Task task;
        {
    
    
            ThreadPoolLock lock(taskMutex_);
            std::cout << "tid:" << threadPtr->getThreadId() << " try to get one task..." << std::endl;
            waitingThreadNum_++;
            // 只要任务队列不为空或者线程池要退出,就不阻塞在wait
            if (threadPtr->getThreadFlag() == Thread::ThreadFlag::kReserved)
            {
    
    
                notEmptyCV_.wait(lock, [this]() {
    
     return quit_ || !tasks_.empty(); });
                waitingThreadNum_--;
                // 只有当线程池quit_=true并且没有任务要处理的情况下,才会真正退出线程池
                if (quit_ && tasks_.empty())
                {
    
    
                    removeThread(threadPtr->getThreadId());
                    exitCV_.notify_all();
                    std::cout << "Reserved thread<" << threadPtr->getThreadId() << "> exit" << std::endl;
                    return;
                }
                else{
    
    
                    task = std::move(tasks_.front());
                    tasks_.pop();
                    notFullCV_.notify_all();
                }
            }
            else
            {
    
    
                // 动态线程处理过程
            } 
        }
        if (task != nullptr)  task();                    // 执行任务
        else std::cout << "task is null" << std::endl;
       
        std::cout << "thread(id: " << threadPtr->getThreadId() << ") is running task" << std::endl;
    }
}

タスクをスレッド プールに送信するにはどうすればよいですか?

タスクの送信とは、タスクをパッケージ化してタスク キューに追加することです。具体的なプロセスは次のとおりです。

  1. タスクをパッケージ化します。つまり、ユーザーによって送信されたタスクを呼び出し可能なオブジェクトにパッケージ化して、タスク キューに追加します。submitTask 関数は可変パラメーター テンプレートを使用します。その目的は、ユーザーによって渡されたさまざまなタスクを処理できるようにすることです (つまり、パラメーターが固定されていない関数を処理できるようにすることです)。
  2. タスクをタスクキューに追加します。まず、パッケージ化されたタスクをタスクキューtasks_に追加する前にロックを取得し、タスクキュー内のタスクが条件変数notFullCV_によって上限に達しているかどうかを判断し、上限に達している場合は上限に達するまでここで待機します。タスク キューが条件を満たしていること (ここに小さなバグがあるようです。スレッド プールが終了フラグを設定した場合、タスク キューにタスクを追加し続けるべきではありません)。ただし、ここではスレッド プールが終了するかどうかの判断はありません。 、スレッド プールの終了フラグが設定されていても、タスク キューにまだ追加中のタスクがある限り、スレッド プールは終了できません。スレッド プールが正常に終了するための唯一の条件は次のとおりです)、その後、notEmptyCV_ が通知しますquit_=true && tasks_.empty()=true。タスクキューが空ではない他のスレッド。
  3. 動的スレッドを追加します。待機中のスレッドの数が処理されるタスクの数よりも少ない限り、新しい動的スレッドが作成されます。
template <typename Func, typename... Args>
inline auto ThreadPool::submitTask(Func &&func, Args &&...args) -> std::future<decltype(func(args...))>
{
    
    
    using ReturnType = decltype(func(args...));
    auto task = std::make_shared<std::packaged_task<ReturnType()>>(std::bind(std::forward<Func>(func), std::forward<Args>(args)...));
    std::future<ReturnType> result = task->get_future();
    size_t taskSize = 0;
    {
    
      
        // 获取锁
        ThreadPoolLock lock(taskMutex_);
        notFullCV_.wait(lock, [this]()->bool {
    
     
            bool notfull = false;
            if (tasks_.size() < (size_t)config_.MAX_TASK_NUM) notfull = true;
            else std::cout << "task queue is full..." << std::endl;
            return notfull;
        });
        // 如果任务队列不满,则把任务添加进队列
        tasks_.emplace([task]() {
    
    (*task)();});
        taskSize = tasks_.size();
        notEmptyCV_.notify_one();
    }
 
 
    // 根据任务队列中任务的数量以及空闲线程数量决定要不要新增线程
    if ((size_t)waitingThreadNum_ < taskSize && curThreadNum_ < config_.MAX_THREAD_NUM)
    {
    
    
        addThread(ThreadFlag::kDynamic);
    }
    return result;
}

スレッドプールを安全に終了するにはどうすればよいでしょうか?

スレッド プールが終了するときは、すべての子スレッドが終了するまで待ってから、スレッド プール自体を終了することをお勧めします。これは、サブスレッドが、waitingThreadNum_ 変数の変更など、メインスレッド (スレッド プール) のリソースを使用するためです。スレッド プール オブジェクトが破棄され、サブスレッドがまだ実行中の場合、その結果は想像を絶するものになります。この時点で、stop 関数が何をするかを見てみましょう。

  1. 最初に quit_ を true に設定します。このステップは非常に重要です。子スレッドは無限ループで quit_ フラグをチェックします。quit_=trueその時点で対応する終了処理を自ら実行します。
  2. 次に、notEmptyCV_ がすべての子スレッドに通知します。これは、タスク キューが空で実行を続行できないため、一部のスレッドが待機状態になっているためです。したがって、タスク キューが空ではないというわけではありませんが、待機中の子スレッドを起こすためですnotEmptyCV_.notify_all()。スレッドを正常に終了させるため。
  3. すべての子スレッドがスレッド プールから削除されるまで待ちます。各サブスレッドが終了条件を満たした後、自身を workThreads_ から削除します。workThreads_.size()==0そのときすべてのサブスレッドが終了したと言えます。このとき、最後のサブスレッドが workThreads_ から自身を削除した後、次のようにexitCV_.notify_all()伝えます。この時点では workThreads_ のサイズがすでに 0 であるため、スレッド プールは待つ必要がなく、スレッド プールは正常に終了します。

「workThreads_ のサブスレッドごとに join() を呼び出すだけでは十分ではないのですか? なぜ条件変数 exitCV_ を使用するのですか?」という疑問を持つ読者もいるかもしれません。答えについては、「サブスレッドはどのようにリサイクルされるか」を参照してください。

void ThreadPool::stop()
{
    
    
    // 线程池退出时,线程只有两种状态,要么wait要么run,所以要把处于wait状态的线程唤醒
    quit_ = true;
    // 唤醒所有处于kWaiting的线程
    ThreadPoolLock lock(taskMutex_);
    notEmptyCV_.notify_all();     
    // 等待所有线程退出并移出所有线程
    exitCV_.wait(lock, [&]()->bool {
    
     return workThreads_.size() == 0; });
    std::cout << "all thread remove from workThreads_ successfully" << std::endl;  
}

よくある質問

動的スレッドはいつ開始されますか?

タスクが存在する限り、タスク キューが動的スレッドを開始するのは、予約されたスレッドがまだ作成途中である可能性が高いため、不適切であると思われます。この時点で、タスク キューには、動的スレッドを開始できないタスクがいくつかある可能性があります。予約スレッドが作成されると、タスク キュー内の残りのタスクを処理するのに十分な可能性が高いため、動的スレッドを開始するにはタスク キュー内にタスクがいくつあるでしょうか? 回答: タスク キュー内のタスクの数とアイドル状態のサブスレッドの数を比較でき、待機中のスレッドの数がタスク キュー内の数より少ない場合は、動的スレッドを作成できますが、実際には動的スレッドが作成されないようです。また、予約されたスレッドはまだ作成中です このとき、タスクキューの数がすでに待機しているスレッドの数よりも多く、不要な動的スレッドが作成されますが、大きな問題ではないようです。これは単に読者に考えさせるためのものです。

子スレッドはどのようにリサイクルされるのでしょうか?

子スレッドによって実行される関数の実行が終了した後 (ループを終了した後)、スレッドのリソースはいつ解放され (参加または切り離し)、スレッドが属するクラス Thread はいつ解放されますか? 回答: スレッドの開始時にスレッドを分離するためにデタッチが呼び出されます。デタッチは非常に慎重に使用する必要がありますが、ここでは方法がありません。開始関数はジョインを呼び出すことができません。子スレッドが結合されている場合、メインスレッドがジョイン関数でブロックされるためです。スレッドが終了しないため、開始が失敗します。実行を終了できない場合は、ヘアリー タスクを送信してください。では、他の関数で join を呼び出すことはできないのでしょうか。たとえば、stop 関数で workThreads_ のサブスレッドを順番に走査して結合することはできないでしょうか。いいえ、リサイクルされる動的なスレッドが含まれるためです。サブスレッドがタイムアウトを待ってリサイクルする必要がある場合、サブスレッドはそれ自体を workThreads_ から削除し、後続のスレッド プールが workThreads_ を通過するときに、サブスレッドは実行できなくなります。そのため、workThreads_ からスレッドを取り出すことができず、そのスレッドに対して join が呼び出されます。したがって、この記事の start 関数で作成されたスレッドは、スレッドをリサイクルするために detach を呼び出します。一部の読者は、子スレッドが workThreads_ から削除されるときに、子スレッドを結合または切り離すことができるかという別の質問を持っています。いいえ、サブスレッド自体は結合または接続できないため、そうでない場合はエラーが発生します)

Thread クラスがスマート ポインターを使用するのはなぜですか?

回答: スレッドはスレッド プールのコンテナに追加されます。コンテナに Thread クラスを追加するのと比較すると、ポインタを追加するのは明らかに効率的ではありませんが、追加されたネイキッド ポインタ Thread* の場合は、追加する必要があります。 Thread オブジェクトを手動で新規作成すると、削除し忘れるとメモリリークが発生するため、共有ポインタと排他ポインタの両方でスマート ポインタを使用して Thread をラップします。

スレッド プールを破棄する前にすべての子スレッドを終了する必要があるのはなぜですか

子スレッドはメイン スレッド (スレッド プール) のリソース (quit_ 変数など) を使用するため、スレッド プールが最初に破棄された場合、子スレッドはまだ実行されている可能性があります。つまり、子スレッドは、次のリソースにアクセスする可能性があります。が破棄されたため、子スレッドが例外により終了しました。このため、スレッドの結合と切り離しは慎重に行うことをお勧めします。

すべてのスレッドが終了した後にメインスレッドを終了させるにはどうすればよいですか?

  • 方法 1: 新しい条件変数 exitCV_ を導入します。つまり、私が使用する方法: スレッド プールの stop 関数で exitCV_ を使用して、worThreads_ が空かどうかを確認し、空でない場合は待機します。これには、各サブスレッドが直接 Remove Yourself from workThreads_ を配置する必要があります。同時に exitCV_.notify_all(); このとき、サブスレッドの exitCV_ は通知されていますが、worThreads_ が空でない限り、すべてのサブスレッドが自身を削除するまで待機状態になります。ワークスレッド_.
  • 方法 2: kStop であると仮定して、各 Thread クラスに終了フラグを追加します。このメソッドは、子スレッドが終了するときに workThreads_ から自身を削除しませんが、それ自体を kStop 状態としてマークしてイベント ループを終了するだけです。または、各スレッドを切り離してから、workThreads_ をクリアします。メリット:条件変数exitCV_を導入する必要がなく、joinすることでサブスレッドを終了できるので安全 デメリット:サブスレッドは終了するが、サブスレッドが所属するThreadクラスは解放されない状況: 停止状態にある workThreads_ 内のスレッドを最初に有効にする必要があります。それでもスレッドを追加する必要がある場合は、新しいスレッドを作成します。

おすすめ

転載: blog.csdn.net/hallobike/article/details/130260859