C ++でのマルチスレッドとスレッドプール構築の使用。150行のコード、手書きのスレッドプール

C ++ 11では、マルチスレッドの開発を容易にするstd :: thread標準ライブラリが導入されています。

マルチスレッド開発に関しては、新しいスレッドを作成するだけでなく、スレッドの同期を伴うことは避けられません。

スレッドの同期を確保し、スレッドセーフを実現するには、セマフォ、ミューテックス、条件変数、アトミック変数などの関連ツールを使用する必要があります。

これらの名詞の概念はオペレーティングシステムから派生したものであり、どのプログラミング言語に固有のものではありません。言語ごとに異なる表現形式がありますが、その背後にある原則は同じです。

スレッドプールの150行のコードのビデオ説明、学ぶ必要がある友人はクリックして見ることができます:150行のコード、手書きのスレッドプール

C ++ 11では、ミューテックス、condition_variable、future、およびその他のスレッドセーフクラスも導入されています。それらについて1つずつ学習していきましょう。

ミューテックス

ミューテックスとして、ミューテックスは排他的所有権の特性を提供します。

ロック解除が呼び出され、スレッドがロックを所有し、ロックされたミューテックスにアクセスする他のスレッドがブロックされるまで、スレッドはミューテックスをロックします。

使用例:

#include <thread>
#include <iostream>

int num = 0;
std::mutex mutex;

void plus(){
    std::lock_guard<std::mutex> guard(mutex);
    std::cout << num++ <<std::endl;
}

int main(){
    std::thread threads[10];
    for (auto & i : threads) {
        i = std::thread(plus);
    }
    for (auto & thread : threads) {
        thread.join();
    }
    return 0;
}

上記のコードでは、10個のスレッドが作成され、各スレッドは最初にnumの値を出力し、次にnum変数を1つインクリメントして、0から9を順番に出力します。

ご存知のとおり、+ 1操作はスレッドセーフではありません。実際には、最初に読み取り、次に1つ追加し、最後に値を割り当てるという3つのステップが含まれます。ただし、排他性を確保するためにミューテックスが使用されるため、結果は昇順で印刷されます。

ミューテックスが使用されていない場合、前のスレッドがまだ割り当てを完了しておらず、後者のスレッドがそれを読み取った可能性があり、最終結果はランダムで予期しないものになります。

condition_variable

条件変数として、condition_variableは、特定の条件が満たされるのを待つためにwait関数を呼び出します。満たされない場合、unique_lockを介して現在のスレッドをロックし、他のスレッドが呼び出すまで、現在のスレッドはブロックされた状態になります。ウェイクアップする条件変数のnofity関数。

使用例:

#include <iostream>
#include <thread>

int num = 0;
std::mutex mutex;
std::condition_variable cv;

void plus(int target){
    std::unique_lock<std::mutex> lock(mutex);
    cv.wait(lock,[target]{return num == target;});
    num++;
    std::cout << target <<std::endl;
    cv.notify_all();
}

int main(){
    std::thread threads[10];
    for (int i = 0; i < 10; ++i) {
        threads[i] = std::thread(plus,9-i);
    }
    for (auto & thread : threads) {
        thread.join();
    }
    return 0;
}

10個のスレッドも作成され、各スレッドには、スレッドによって出力される値を表すターゲットパラメーターがあります。スレッドは9-> 0の順序で作成され、最終的な実行結果は0->を出力します。順番に9。

各スレッドが実行されると、最初に待機関数を呼び出して、num == targetの条件が満たされるのを待ちます。満たされると、numが1つ増え、ターゲット値が出力されてから、次のスレッドがウェイクアップします。条件を満たす値。

条件変数の待機関数を変更して条件をウェイクアップすることにより、一般的な生産者/消費者モデルなど、さまざまなマルチスレッドモードを実現できます。

Linux、Nginx、ZeroMQ、MySQL、 Redis、スレッドプール、MongoDBを含む学習資料、完全なテクノロジースタック、コンテンツ知識を強化するための、知識と学習ネットワークの基礎となる原則のC / C ++ Linuxバックエンド開発についてもっと共有する、ZK、ストリーミングメディア、オーディオとビデオ、Linuxカーネル、CDN、P2P、epoll、Docker、TCP / IP、coroutine、DPDKなど。

ビデオ学習資料をクリックしてください:C / C ++ Linuxサーバー開発/ Linuxバックグラウンドアーキテクト-学習ビデオ

condiation_variableは、本番コンシューマーモードを実装します

#include <iostream>
#include <thread>
#include <queue>

int main(){
    std::queue<int> products;
    std::condition_variable cv_pro,cv_con;
    std::mutex mtx;

    bool end = false;
    std::thread producer([&]{
        for (int i = 0; i < 10; ++i) {
            std::unique_lock<std::mutex> lock(mtx);
            cv_pro.wait(lock,[&]{return products.empty();});
            products.push(i);
            cv_con.notify_all();
        }
        cv_con.notify_all();
        end = true;
    });

    std::thread consumer([&]{
        while (!end){
            std::unique_lock<std::mutex> lock(mtx);
            cv_con.wait(lock,[&]{return !products.empty();});
            int d = products.front();
            products.pop();
            std::cout << d << std::endl;
            cv_pro.notify_all();
        }
    });

    producer.join();
    consumer.join();
    return 0;
}

未来と約束

将来の機能は、日常の開発ではめったに使用されません。非同期タスクの結果を取得するために使用できます。また、スレッド間の同期手段としても使用できます。

プログラムが時間のかかる操作を実行するスレッドを作成し、時間のかかる操作が終了した後に戻り値を取得する必要があると仮定すると、condiation_variableによって実現できます。非同期スレッドが実行された後、notifyメソッドが呼び出されます。メインスレッドをウェイクアップします。同じことが当てはまります。これは先物を通じて達成できます。

プログラムが特定のメソッドを介して非同期操作を作成すると、非同期スレッドの状態にアクセスできるfutureが返されます。

非同期スレッドで共有状態の値を設定すると、共有状態に関連付けられたfutureは、getメソッドを介して結果を取得できます。get()メソッドは、呼び出し元のスレッドをブロックし、非同期スレッドが設定を完了するのを待ちます。

futureのgetメソッドは、実際にはcondiation_variableのwaitメソッドと同等であり、非同期スレッドによって共有状態の値を設定するメソッドは、condiation_variableのnotifyメソッドと同等です。

未来を創造する3つの方法があります:

std :: promise

Promiseは、その文字通りの意味と同様に、promiseを表し、非同期スレッドで共有状態を設定し、futureは辛抱強く待つことを示します。

次の図に、promiseとfutureの呼び出しプロセスを示します。

コード例は次のとおりです。

#include <iostream>
#include <thread>
#include <chrono>
#include <future>

void task(std::promise<int>& promise){
    std::this_thread::sleep_for(std::chrono::seconds(1));
    promise.set_value(10);
}

int main(){
    std::promise<int> promise;
    std::future<int> future = promise.get_future();
    std::thread t(task,std::ref(promise));
    int result = future.get();
    std::cout << "thread result is " << result << std::endl;
    t.join();
    return 0;
}
复制代码

promiseは、get_futureメソッドを介してpromiseに関連付けられたfutureオブジェクトを取得し、set_valueメソッドを介して共有状態の値を設定します。

std :: packaged_task

packaged_taskは、呼び出し可能なオブジェクトをパッケージ化するために使用でき、std :: functionに少し似たスレッドの実行関数として使用できます。

ただし、違いは、状態の共有を実現するために、ラップする呼び出し可能オブジェクトの実行結果を関連するfutureに渡し、futureはgetメソッドを介して呼び出し可能オブジェクトの実行の終了を待機することです。

次のコードに示すように:

#include <iostream>
#include <thread>
#include <chrono>
#include <future>

int task(){
    std::this_thread::sleep_for(std::chrono::seconds(1));
    return 10;
}

int main(){
    std::packaged_task<int(void)> packaged_task(task);
    std::future<int> future = packaged_task.get_future();
    std::thread thread(std::move(packaged_task));
    int result = future.get();
    std::cout << "thread result is " << result << std::endl;
    thread.join();
    return 0;
}

packaged_taskは、get_futureメソッドを介して関連するfutureオブジェクトを取得します。

std :: async

Asyncはfutureを作成することもでき、std :: thread、std :: packaged_task、およびstd :: promiseのカプセル化に似ています。

次のコードに示すように:

#include <iostream>
#include <thread>
#include <chrono>
#include <future>

int task(){
    std::this_thread::sleep_for(std::chrono::seconds(1));
    return 10;
}

int main(){
    std::future<int> future = std::async(std::launch::async,task);
    int result = future.get();
    std::cout << "thread result is " << result << std::endl;
    return 0;
}
复制代码

asyncを使用して非同期スレッドを直接作成し、関連するfutureオブジェクトを取得することで、スレッド作成操作も保存されます。

asyncには、launch :: asyncとlaunch :: deferredの2つの実行戦略があります。前者はすぐに実行され、後者はfuture.get()メソッドが呼び出されてタスクを実行するスレッドを作成するまで待機します。

スレッドプールの構築

上記のスレッド関連の操作クラスを理解したら、さらに進んでそれらを使用してスレッドプールを作成できます。

スレッドプールの構築に関しては、特定のビジネスや使用シナリオに応じてさまざまなサポートがありますが、一部の重要なコンテンツは変更されません。

スレッドプールの出発点はもちろん、スレッドの頻繁な作成と破棄に費やされる時間とシステムリソースのオーバーヘッドを削減することです。式の形式では、スレッドプールにタスクを送信し、最終的に割り当てるスレッドのプールがあります。それらを実行のためにスレッドに送ります。

下の図に示すように、これは単純なスレッドプールのプロトタイプです。タスク、タスクを分散するスレッドプール、および最終的にタスクを実行するワーカースレッドがあります。スズメは小さくて完全ですが。

次に、上記の部品を詳しく分解します。

Linux、Nginx、ZeroMQ、MySQL、 Redis、スレッドプール、MongoDBを含む学習資料、完全なテクノロジースタック、コンテンツ知識を強化するための、知識と学習ネットワークの基礎となる原則のC / C ++ Linuxバックエンド開発についてもっと共有する、ZK、ストリーミングメディア、オーディオとビデオ、Linuxカーネル、CDN、P2P、epoll、Docker、TCP / IP、coroutine、DPDKなど。

ビデオ学習資料をクリックしてください:C / C ++ Linuxサーバー開発/ Linuxバックグラウンドアーキテクト-学習ビデオ

タスクタイプ

タスクタイプは、ビジネス要件に基づいて複数の定義を持つことができます。主な違いは、タスクに必要なパラメーターと戻り値のタイプにあります。

さらに、タスク自体には、属性のタイプ、実行のためにどのスレッドを配置する必要があるかなどを識別するためのいくつかの属性を含めることもできます。

簡単にするために、パラメーターと戻り値タイプのない単純なタスクタイプを定義します。

using task = std::function<void()>;

スレッド数

スレッドプールにはいくつのスレッドが必要ですか?数が多すぎると、リソースが無駄になり、一部のスレッドが完全に使用されない可能性があります。少なすぎると、新しいスレッドが頻繁に作成されます。

柔軟なスレッドプールは、スレッドの数を動的に変更できる必要があります。Javaスレッドプールの実装原則と、Meituanのビジネスにおけるその実践を参照してください。

JavaのThreadPoolExecutorでは、corePoolSizeとmaximumPoolSizeを使用してスレッド数を制限します。スレッド数は、[0〜corePoolSize]と[corePoolSize〜maximumPoolSize]の間で変動します。

タスクがタイトで、スレッドとキャッシュがいっぱいになると、スレッドに適用され、数は[corePoolSize〜maximumPoolSize]の範囲に達します。タスクが緩むと、いくつかのアイドルスレッドが解放され、数がフォールバックします。 [0〜corePoolSize]の範囲まで。、その後、タスクは拒否されます。

もちろん、CPUマルチコアに基づいてスレッド数を決定するなど、特定のビジネス要件に従って決定されるスレッド数を決定するための他の戦略があります。

簡単にするために、ここでは固定数のスレッドをデモンストレーションとして使用します。

size_t N = std::thread::hardware_concurrency();

タスクキャッシュ

N個のスレッドが修正され、各スレッドに実行中のタスクがあり、この時点で新しいタスクが到着したとすると、どのように対処する必要がありますか?現時点では、タスクのキャッシュメカニズムが必要です(もちろん、タスクを直接拒否することもできます)。

タスクキャッシングも多くの形式に分けられます。

  1. グローバルキャッシュ
  2. スレッドキャッシュ
  3. グローバルキャッシュ+スレッドキャッシュ

グローバルキャッシュ

グローバルキャッシュは、その名前が示すように、スレッドプール内のグローバルキャッシュキューです。スレッドプールに入るすべてのタスクはグローバルキャッシュに進み、グローバルキャッシュがタスクを分散し、最後に別のワーカースレッドが実行します。タスク。

スレッドキャッシュ

スレッドキャッシングは、その名前が示すように、各ワーカースレッドにキャッシュキューを設定することです。その後、スレッドは継続的にループして、独自のキャッシュキューでタスクを処理します。スレッドプールに入るすべてのタスクは、スレッドプールからディスパッチおよび分散され、ワー​​カースレッドに対応するキャッシュキューに入れられ、最後に実行されます。

グローバルキャッシュ+スレッドキャッシュ

グローバルキャッシュ+スレッドキャッシュは上記の2つの組み合わせです。次の図を使用して、デモンストレーションを要約します。

この種のキャッシング方法は、より複雑な状況と見なすことができ、大量の計算と高速実行の状況に適しています。一般的に、グローバルキャッシングの方が一般的です。

キャッシュキュー

タスクキャッシュを使用して、キャッシュキューも定義する必要があります。キャッシュキューは複数のワーカースレッド間でタスクを共有するため、スレッドセーフである必要があることは間違いありません。

キャッシュキューには、ブロックキュー、二重リンクリストのブロックキューなど、さまざまな形式があります。ここでは、単純なキューを定義し、std :: queueをスレッドセーフなカプセル化します。

#pragma once

#include <mutex>
#include <queue>

// Thread safe implementation of a Queue using an std::queue
template <typename T>
class SafeQueue {
private:
  std::queue<T> m_queue;
  std::mutex m_mutex;
public:
  SafeQueue() {

  }

  bool empty() {
    std::unique_lock<std::mutex> lock(m_mutex);
    return m_queue.empty();
  }
  
  int size() {
    std::unique_lock<std::mutex> lock(m_mutex);
    return m_queue.size();
  }

  void enqueue(T& t) {
    std::unique_lock<std::mutex> lock(m_mutex);
    m_queue.push(t);
  }
  
  bool dequeue(T& t) {
    std::unique_lock<std::mutex> lock(m_mutex);

    if (m_queue.empty()) {
      return false;
    }
    t = std::move(m_queue.front());
    
    m_queue.pop();
    return true;
  }
};

エンキューメソッドとデキューメソッドは、タスクをキューに詰め込み、タスクをフェッチするように定義されており、スレッドの安全性を確保するためにロックが使用されます。

スレッドスケジューリング

スレッドプールの中核部分はスレッドスケジューリングです。グローバルキャッシュが使用されていると仮定すると、グローバルキャッシュ内のタスクをアイドル状態のスレッドに分散する方法は?

実際、特定の観点から、グローバルキャッシュのスレッドプールは、単一のプロデューサー-マルチコンシューマーモデルと見なすこともできます。グローバルキャッシュはプロデューサーであり、複数のスレッドは複数のコンシューマーです。

以前のコードプラクティスでは、単一プロデューサー-単一コンシューマーモデルが記述されていました。プロデューサーがタスクを生成した後、コンシューマーはnotifyメソッドによってウェイクアップされ、タスクは実行のためにコンシューマーに割り当てられます。消費者は1人だけなので、目覚めるのはそれだけです。

複数の消費者がいる場合、どれが目覚めますか?答えはランダムです。notify_oneメソッドを呼び出すと、1つのスレッドがランダムにウェイクアップし、notify_allを呼び出すと、すべてのスレッドがウェイクアップします。

ただし、ウェイクアップは、スレッドがタスクを消費することを意味するわけではありません。1つのタスクは複数のスレッドに対応します。スレッドがウェイクアップすると、グローバルキャッシュに移動して、タスクタスクを取得します。成功すると、実行されます。他のスレッドは、つかまれていない場合は、ハングし続け、次回を待ちます。

スレッドプール内のスレッドの実行ステータスを次の図に示します。

本質的に、スレッドプールはnotifyメソッドを介してスレッドをウェイクアップし、タスクの分散とスケジューリングを実現します。

このメソッドにはある程度のランダム性があり、どのスレッドがウェイクアップされるかを保証できません。共通の属性を持つ特定のスレッドのみをウェイクアップしたり、タスクの要件に従って特定のスレッドをウェイクアップしたりするなど、ビジネスニーズに応じて関連するスケジューリングロジックをカスタマイズできます。タスクなどnotifyメソッドを使用せずに、タスクを対応するスレッドに直接ディスパッチして実行できます。

上記のフローチャートによれば、ワーカースレッドによって実行されるコードは次のようになります。

class WorkerThread {
private:
    int m_id;
    ThreadPool *m_pool;
public:
    WorkerThread(ThreadPool *pool, int id) : m_pool(pool), m_id(id) {

    }

    void operator()() {
        task func;
        bool dequeued;
        while (!m_pool->m_shutdown) {
            std::unique_lock<std::mutex> lock(m_pool->m_mutex);
            if (m_pool->m_queue.empty()){
                m_pool->m_condition_variable.wait(lock);
            }
            dequeued = m_pool->m_queue.dequeue(func);
            if (dequeued) {
                func();
            }
        }
    }
};

以下は、単純なスレッドプールコードのプラクティスです。


#ifndef THREAD_POOL_THREADPOOL_H
#define THREAD_POOL_THREADPOOL_H

#include <functional>
#include <future>
#include <mutex>
#include <condition_variable>
#include <thread>
#include <queue>
#include "SafeQueue.h"

using task = std::function<void()>;

class ThreadPool {
public:
    ThreadPool(size_t thread_num = std::thread::hardware_concurrency()) : m_threads(
            std::vector<std::thread>(thread_num)), m_shutdown(false) {
    }

    void init() {
        for (int i = 0; i < m_threads.size(); ++i) {
            m_threads[i] = std::thread(WorkerThread(this, i));
        }
    }

    void shutdown() {
        m_shutdown = true;
        m_condition_variable.notify_all();
        for (int i = 0; i < m_threads.size(); ++i) {
            if (m_threads[i].joinable()) {
                m_threads[i].join();
            }
        }
    }


    std::future<void> submit(task t){
        auto p_task = std::make_shared<std::packaged_task<void()>>(t);
        task wrapper_task = [p_task](){
             (*p_task)();
        };
        m_queue.enqueue(wrapper_task);
        m_condition_variable.notify_one();
        return p_task->get_future();
    }

private:
    class WorkerThread {
    private:
        int m_id;
        ThreadPool *m_pool;
    public:
        WorkerThread(ThreadPool *pool, int id) : m_pool(pool), m_id(id) {

        }

        void operator()() {
            task func;
            bool dequeued;
            while (!m_pool->m_shutdown) {
                std::unique_lock<std::mutex> lock(m_pool->m_mutex);
                if (m_pool->m_queue.empty()){
                    m_pool->m_condition_variable.wait(lock);
                }
                dequeued = m_pool->m_queue.dequeue(func);
                if (dequeued) {
                    func();
                }
            }
        }
    };

    bool m_shutdown;
    SafeQueue<task> m_queue;
    std::vector<std::thread> m_threads;
    std::mutex m_mutex;
    std::condition_variable m_condition_variable;

};

#endif //THREAD_POOL_THREADPOOL_H

submitメソッドを使用してタスクをグローバルキャッシュキューに送信し、スレッドをウェイクアップしてタスクの実行を消費します。

概要

C ++マルチスレッドの使用については、まだ多くの知識があります。上記は紹介の一部にすぎず、後で追加される多くの欠点があります。

 

おすすめ

転載: blog.csdn.net/Linuxhus/article/details/114948553