web-dev-qa-db-ja.com

C ++で効率的なマルチスレッドタスクスケジューラを作成する方法は?

C++で非常に効率的なタスクスケジューラシステムを作成したいと思います。

基本的な考え方は次のとおりです。

class Task {
    public:
        virtual void run() = 0;
};

class Scheduler {
    public:
        void add(Task &task, double delayToRun);
};

Schedulerの後ろには、タスクを実行する固定サイズのスレッドプールが必要です(各タスクにスレッドを作成したくない)。 delayToRunは、taskがすぐには実行されないが、delayToRun秒後(Schedulerに追加された時点から測定)を意味します。

delayToRunは、もちろん「少なくとも」値を意味します。システムがロードされている場合、またはスケジューラに不可能を要求した場合、リクエストを処理することはできません。できる限り最高)

そして、これが私の問題です。 delayToRun機能を効率的に実装する方法は?ミューテックスと条件変数を使用してこの問題を解決しようとしています。

次の2つの方法があります。

マネージャースレッド付き

スケジューラには、allTasksQueuetasksReadyToRunQueueの2つのキューが含まれています。 Scheduler::addallTasksQueueにタスクが追加されます。マネージャースレッドがあり、allTasksQueueからtasksReadyToRunQueueにタスクを配置できるように、最短時間待機します。ワーカースレッドは、tasksReadyToRunQueueで利用可能なタスクを待ちます。

Scheduler::addallTasksQueueの前にタスクを追加する場合(delayToRunの値を持つタスクで、現在最も早く実行されるタスクの前に移動する必要があります)、マネージャータスクはウェイクアップする必要があるため、待機時間を更新できます。

このメソッドは2つのキューを必要とし、タスクを実行するために2つのcondvar.signalを必要とするため、非効率であると考えることができます(1つはallTasksQueue-> tasksReadyToRunQueueに、もう1つはワーカースレッドにシグナルを送るために実際にタスクを実行するため)

マネージャースレッドなし

スケジューラには1つのキューがあります。タスクはScheduler::addでこのキューに追加されます。ワーカースレッドがキューをチェックします。空の場合、時間制限なしで待機します。空でない場合、最も早いタスクを待ちます。

  1. 作業スレッドが待機する条件変数が1つのみの場合:このメソッドは非効率と見なすことができます。タスクがキューの前に追加された場合(前がNワーカースレッドがある場合はタスクインデックス<N) allワーカースレッドは、待機時間を更新するためにウェイクアップする必要があります。

  2. 各スレッドに個別の条件変数がある場合、どのスレッドをウェイクアップするかを制御できるため、この場合、すべてのスレッドをウェイクアップする必要はありません(待機時間が最大のスレッドのみをウェイクアップする必要があります) 、この値を管理する必要があります)。私は現在、これを実装することを考えていますが、正確な詳細を計算することは複雑です。この方法に関する推奨事項/考え/文書はありますか?


この問題のより良い解決策はありますか?私は標準のC++機能を使用しようとしていますが、プラットフォームに依存する(私のメインプラットフォームはlinux)ツール(pthreadsなど)、またはLinux固有のツール(futexなど)を使用したいと考えています。

19
geza

単一のプールスレッドが「実行する次の」タスクを待機する設計を使用することにより、別個の「マネージャー」スレッドを持つことと、次に実行するタスクが変更されたときに多数のタスクを起動することの両方を回避できます(ある場合)1つの条件変数で、残りのプールスレッドは2番目の条件変数で無期限に待機します。

プールスレッドは、次の行に沿って擬似コードを実行します。

pthread_mutex_lock(&queue_lock);

while (running)
{
    if (head task is ready to run)
    {
        dequeue head task;
        if (task_thread == 1)
            pthread_cond_signal(&task_cv);
        else
            pthread_cond_signal(&queue_cv);

        pthread_mutex_unlock(&queue_lock);
        run dequeued task;
        pthread_mutex_lock(&queue_lock);
    }
    else if (!queue_empty && task_thread == 0)
    {
        task_thread = 1;
        pthread_cond_timedwait(&task_cv, &queue_lock, time head task is ready to run);
        task_thread = 0;
    }
    else
    {
        pthread_cond_wait(&queue_cv, &queue_lock);
    }
}

pthread_mutex_unlock(&queue_lock);

実行する次のタスクを変更すると、次を実行します。

if (task_thread == 1)
    pthread_cond_signal(&task_cv);
else
    pthread_cond_signal(&queue_cv);

とともに queue_lock開催。

このスキームでは、すべてのウェイクアップは直接単一のスレッドでのみ行われ、タスクの優先度キューは1つだけで、マネージャースレッドは不要です。

8
caf

あなたの仕様が少し強すぎます:

delayToRunは、タスクがすぐには実行されないが、delayToRun秒後に実行されることを意味します

「少なくとも」追加するのを忘れました:

  • タスクは今は実行されませんが、少なくとも少なくともdelayToRun秒後

ポイントは、1万個のタスクがすべて_0.1_ delayToRunでスケジュールされている場合、確実に同時に実行することが実際にできないことです。

このような修正を行うと、(スケジュールされた開始時間、実行するクロージャ)のいくつかのキュー(またはアジェンダ)を維持し、そのキューをソートしたままにして、アトミックにスレッドのN(いくつかの固定数)を開始しますアジェンダの最初の要素をポップして実行します。

その後、すべてのワーカースレッドをウェイクアップして、待機時間を更新する必要があります。

いいえ、someワーカースレッドが起動されます。

条件変数とブロードキャストについて読んでください。

また、POSIXタイマーを使用する場合は timer_create(2) を参照するか、Linux固有のfdタイマーを参照する場合は timerfd_create(2) を参照してください

あなたはおそらくあなたのスレッドでブロッキングシステムコールを実行することを避け、いくつかのイベントループを使用してそれらを管理するいくつかのcentralスレッドを持っているでしょう( poll(2 ) ...);そうでなければ、sleep(100)を実行している100のタスクと0.5秒で実行するようにスケジュールされた1つのタスクがある場合、100秒前に実行されません。

continuation-passing style programming(it -CPS-は非常に関連性が高い)について読みたいかもしれません。 Juliusz Chroboczekによる Continuation Passing C を読んでください。

Qt threads も調べてください。

Go (ゴルーチンを使用)でのコーディングも検討できます。

これは、 'With manager thread'の説明に最も近いインターフェイスのサンプル実装です。

シングルスレッド(timer_thread)を使用して、タスクを開始する必要がある実際の時間(std::chrono::time_point)に基づいて並べ替えられたキュー(allTasksQueue)を管理します。
「キュー」はstd::priority_queueです(time_pointキー要素はソートされたままです)。

timer_threadは通常、次のタスクが開始されるか、新しいタスクが追加されるまで中断されます。
タスクが実行されようとしているとき、タスクはtasksReadyToRunQueueに配置され、ワー​​カースレッドの1つが通知され、起動し、キューから削除して、タスクの処理を開始します。

スレッドプールには、スレッド数のコンパイル時の上限があることに注意してください(40)。ワーカーにディスパッチできるよりも多くのタスクをスケジュールしている場合、スレッドが再び使用可能になるまで新しいタスクがブロックされます。

このアプローチは効率的ではないとおっしゃいましたが、全体としては、合理的には効率的だと思われます。それはすべてイベント駆動型であり、不必要なスピンによってCPUサイクルを浪費することはありません。もちろん、これは単なる例であり、最適化が可能です(注:std::multimapstd::priority_queueに置き換えられました)。

実装はC++ 11に準拠しています

#include <iostream>
#include <chrono>
#include <queue>
#include <unistd.h>
#include <vector>
#include <thread>
#include <condition_variable>
#include <mutex>
#include <memory>

class Task {
public:
    virtual void run() = 0;
    virtual ~Task() { }
};

class Scheduler {
public:
    Scheduler();
    ~Scheduler();

    void add(Task &task, double delayToRun);

private:
    using timepoint = std::chrono::time_point<std::chrono::steady_clock>;

    struct key {
        timepoint tp;
        Task *taskp;
    };

    struct TScomp {
        bool operator()(const key &a, const key &b) const
        {
            return a.tp > b.tp;
        }
    };

    const int ThreadPoolSize = 40;

    std::vector<std::thread> ThreadPool;
    std::vector<Task *> tasksReadyToRunQueue;

    std::priority_queue<key, std::vector<key>, TScomp> allTasksQueue;

    std::thread TimerThr;
    std::mutex TimerMtx, WorkerMtx;
    std::condition_variable TimerCV, WorkerCV;

    bool WorkerIsRunning = true;
    bool TimerIsRunning = true;

    void worker_thread();
    void timer_thread();
};

Scheduler::Scheduler()
{
    for (int i = 0; i <ThreadPoolSize; ++i)
        ThreadPool.Push_back(std::thread(&Scheduler::worker_thread, this));

    TimerThr = std::thread(&Scheduler::timer_thread, this);
}

Scheduler::~Scheduler()
{
    {
        std::lock_guard<std::mutex> lck{TimerMtx};
        TimerIsRunning = false;
        TimerCV.notify_one();
    }
    TimerThr.join();

    {
        std::lock_guard<std::mutex> lck{WorkerMtx};
        WorkerIsRunning = false;
        WorkerCV.notify_all();
    }
    for (auto &t : ThreadPool)
        t.join();
}

void Scheduler::add(Task &task, double delayToRun)
{
    auto now = std::chrono::steady_clock::now();
    long delay_ms = delayToRun * 1000;

    std::chrono::milliseconds duration (delay_ms);

    timepoint tp = now + duration;

    if (now >= tp)
    {
        /*
         * This is a short-cut
         * When time is due, the task is directly dispatched to the workers
         */
        std::lock_guard<std::mutex> lck{WorkerMtx};
        tasksReadyToRunQueue.Push_back(&task);
        WorkerCV.notify_one();

    } else
    {
        std::lock_guard<std::mutex> lck{TimerMtx};

        allTasksQueue.Push({tp, &task});

        TimerCV.notify_one();
    }
}

void Scheduler::worker_thread()
{
    for (;;)
    {
        std::unique_lock<std::mutex> lck{WorkerMtx};

        WorkerCV.wait(lck, [this] { return tasksReadyToRunQueue.size() != 0 ||
                                           !WorkerIsRunning; } );

        if (!WorkerIsRunning)
            break;

        Task *p = tasksReadyToRunQueue.back();
        tasksReadyToRunQueue.pop_back();

        lck.unlock();

        p->run();

        delete p; // delete Task
    }
}

void Scheduler::timer_thread()
{
    for (;;)
    {
        std::unique_lock<std::mutex> lck{TimerMtx};

        if (!TimerIsRunning)
            break;

        auto duration = std::chrono::nanoseconds(1000000000);

        if (allTasksQueue.size() != 0)
        {
            auto now = std::chrono::steady_clock::now();

            auto head = allTasksQueue.top();
            Task *p = head.taskp;

            duration = head.tp - now;
            if (now >= head.tp)
            {
                /*
                 * A Task is due, pass to worker threads
                 */
                std::unique_lock<std::mutex> ulck{WorkerMtx};
                tasksReadyToRunQueue.Push_back(p);
                WorkerCV.notify_one();
                ulck.unlock();

                allTasksQueue.pop();
            }
        }

        TimerCV.wait_for(lck, duration);
    }
}
/*
 * End sample implementation
 */



class DemoTask : public Task {
    int n;
public:
    DemoTask(int n=0) : n{n} { }
    void run() override
    {
        std::cout << "Start task " << n << std::endl;;
        std::this_thread::sleep_for(std::chrono::seconds(2));
        std::cout << " Stop task " << n << std::endl;;
    }
};

int main()
{
    Scheduler sched;

    Task *t0 = new DemoTask{0};
    Task *t1 = new DemoTask{1};
    Task *t2 = new DemoTask{2};
    Task *t3 = new DemoTask{3};
    Task *t4 = new DemoTask{4};
    Task *t5 = new DemoTask{5};

    sched.add(*t0, 7.313);
    sched.add(*t1, 2.213);
    sched.add(*t2, 0.713);
    sched.add(*t3, 1.243);
    sched.add(*t4, 0.913);
    sched.add(*t5, 3.313);

    std::this_thread::sleep_for(std::chrono::seconds(10));
}
3
LWimsey

これは、何らかの順序を使用してすべてのタスクを継続的に実行することを意味します。

タスクの遅延スタック(またはリンクリスト)で並べ替えられた何らかのタイプを作成できます。新しいタスクが来たら、遅延時間に応じた位置にそれを挿入する必要があります(その位置を効率的に計算し、新しいタスクを効率的に挿入するだけです)。

タスクスタック(またはリスト)の先頭から始まるすべてのタスクを実行します。

1
Alex Bod

C++ 11のコアコード:

#include <thread>
#include <queue>
#include <chrono>
#include <mutex>
#include <atomic>
using namespace std::chrono;
using namespace std;
class Task {
public:
    virtual void run() = 0;
};
template<typename T, typename = enable_if<std::is_base_of<Task, T>::value>>
class SchedulerItem {
public:
    T task;
    time_point<steady_clock> startTime;
    int delay;
    SchedulerItem(T t, time_point<steady_clock> s, int d) : task(t), startTime(s), delay(d){}
};
template<typename T, typename = enable_if<std::is_base_of<Task, T>::value>>
class Scheduler {
public:
    queue<SchedulerItem<T>> pool;
    mutex mtx;
    atomic<bool> running;
    Scheduler() : running(false){}
    void add(T task, double delayMsToRun) {
        lock_guard<mutex> lock(mtx);
        pool.Push(SchedulerItem<T>(task, high_resolution_clock::now(), delayMsToRun));
        if (running == false) runNext();
    }
    void runNext(void) {
        running = true;
        auto th = [this]() {
            mtx.lock();
            auto item = pool.front();
            pool.pop();
            mtx.unlock();
            auto remaining = (item.startTime + milliseconds(item.delay)) - high_resolution_clock::now();
            if(remaining.count() > 0) this_thread::sleep_for(remaining);
            item.task.run();
            if(pool.size() > 0) 
                runNext();
            else
                running = false;
        };
        thread t(th);
        t.detach();
    }
};

テストコード:

class MyTask : Task {
public:
    virtual void run() override {
        printf("mytask \n");
    };
};
int main()
{
    Scheduler<MyTask> s;

    s.add(MyTask(), 0);
    s.add(MyTask(), 2000);
    s.add(MyTask(), 2500);
    s.add(MyTask(), 6000);
    std::this_thread::sleep_for(std::chrono::seconds(10));

}