web-dev-qa-db-fra.com

Comment créer un planificateur de tâches multi-thread efficace en C ++?

Je voudrais créer un système de planificateur de tâches très efficace en C++.

L'idée de base est la suivante:

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

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

Derrière Scheduler, il devrait y avoir un pool de threads de taille fixe, qui exécutent les tâches (je ne veux pas créer de thread pour chaque tâche). delayToRun signifie que le task n'est pas exécuté immédiatement, mais delayToRun secondes plus tard (mesure à partir du point où il a été ajouté dans le Scheduler).

(delayToRun signifie une valeur "au moins", bien sûr. Si le système est chargé, ou si nous demandons l'impossible au Planificateur, il ne pourra pas gérer notre demande. Mais il devrait le faire du mieux qu'il peut)

Et voici mon problème. Comment implémenter efficacement la fonctionnalité delayToRun? J'essaie de résoudre ce problème avec l'utilisation de mutex et de variables de condition.

Je vois deux façons:

Avec fil conducteur

Le planificateur contient deux files d'attente: allTasksQueue et tasksReadyToRunQueue. Une tâche est ajoutée dans allTasksQueue à Scheduler::add. Il existe un thread de gestion qui attend le plus petit temps afin de pouvoir placer une tâche de allTasksQueue à tasksReadyToRunQueue. Les threads de travail attendent une tâche disponible dans tasksReadyToRunQueue.

Si Scheduler::add ajoute une tâche devant allTasksQueue (une tâche qui a une valeur de delayToRun donc elle doit précéder la tâche en cours d'exécution la plus proche), puis la tâche du gestionnaire doit être réveillé, afin qu'il puisse mettre à jour le temps d'attente.

Cette méthode peut être considérée comme inefficace, car elle a besoin de deux files d'attente et de deux signaux condvar.sign pour exécuter une tâche (une pour allTasksQueue-> tasksReadyToRunQueue et une pour signaler un thread de travail pour exécuter la tâche)

Sans fil de gestionnaire

Il y a une file d'attente dans le planificateur. Une tâche est ajoutée dans cette file d'attente à Scheduler::add. Un thread de travail vérifie la file d'attente. S'il est vide, il attend sans contrainte de temps. S'il n'est pas vide, il attend la tâche la plus rapide.

  1. S'il n'y a qu'une seule variable de condition pour laquelle les threads de travail attendent: cette méthode peut être considérée comme inefficace, car si une tâche ajoutée devant la file d'attente (front signifie, s'il y a N threads de travail, alors l'index de tâche <N) alors tous les threads de travail doivent être réveillés pour mettre à jour l'heure qu'ils attendent.

  2. S'il y a une variable de condition distincte pour chaque thread, alors nous pouvons contrôler quel thread se réveiller, donc dans ce cas, nous n'avons pas besoin de réveiller tous les threads (nous avons seulement besoin de réveiller le thread qui a le plus long temps d'attente , nous devons donc gérer cette valeur). Je pense actuellement à l'implémenter, mais travailler sur les détails exacts est complexe. Y a-t-il des recommandations/réflexions/documents sur cette méthode?


Existe-t-il une meilleure solution à ce problème? J'essaie d'utiliser des fonctionnalités C++ standard, mais je suis prêt à utiliser des outils dépendants de la plate-forme (ma plate-forme principale est linux) aussi (comme pthreads), ou même des outils spécifiques à linux (comme futexes), s'ils fournissent une meilleure solution.

19
geza

Vous pouvez éviter à la fois d'avoir un thread "gestionnaire" distinct et d'avoir à réveiller un grand nombre de tâches lorsque la tâche suivante à exécuter change, en utilisant une conception où un thread de pool unique attend la tâche "suivant à exécuter" (s'il y en a une) sur une variable de condition, et les threads de pool restants attendent indéfiniment sur une deuxième variable de condition.

Les threads de pool exécuteraient le pseudocode le long de ces lignes:

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);

Si vous modifiez la tâche suivante à exécuter, vous exécutez:

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

avec le queue_lock tenue.

Dans ce schéma, tous les wakeups sont directement sur un seul thread, il n'y a qu'une seule file d'attente prioritaire de tâches et aucun thread de gestionnaire requis.

8
caf

Votre spécification est un peu trop forte:

delayToRun signifie que la tâche n'est pas exécutée immédiatement, mais delayToRun secondes plus tard

Vous avez oublié d'ajouter "au moins":

  • La tâche n'est pas exécutée maintenant, mais au moins delayToRun secondes plus tard

Le fait est que si dix mille tâches sont toutes planifiées avec un 0.1 DelayToRun, elles ne pourront sûrement pas s'exécuter en même temps.

Avec une telle correction, vous maintenez simplement une file d'attente (ou un agenda) de (heure de début programmée, fermeture à exécuter), vous gardez cette file d'attente triée et vous démarrez N (un certain nombre fixe) de threads qui atomiquement pop le premier élément de l'ordre du jour et l'exécuter.

alors tous les threads de travail doivent être réveillés pour mettre à jour l'heure qu'ils attendent.

Non, certains threads de travail seraient réveillés.

Lisez à propos des variables de condition et de la diffusion.

Vous pouvez également utiliser des temporisateurs POSIX, voir timer_create (2) , ou un temporisateur fd spécifique à Linux, voir timerfd_create (2)

Vous éviteriez probablement d'exécuter des appels système bloquants dans vos threads, et vous auriez un thread central les gérant à l'aide d'une boucle d'événement (voir poll (2 ) ...); sinon, si vous avez cent tâches exécutant sleep(100) et une tâche planifiée pour s'exécuter en une demi-seconde, elle ne s'exécutera pas avant cent secondes.

Vous voudrez peut-être en savoir plus sur la programmation style passant-continuation (elle -CPS- est très pertinente). Lisez le article sur Continuation Passing C par Juliusz Chroboczek.

Regardez aussi dans fils Qt .

Vous pouvez également envisager de coder en Aller (avec ses Goroutines).

6

Il s'agit d'un exemple d'implémentation de l'interface que vous avez fournie qui se rapproche le plus de votre description 'With manager thread'.

Il utilise un seul thread (timer_thread) pour gérer une file d'attente (allTasksQueue) triée en fonction de l'heure réelle à laquelle une tâche doit être démarrée (std::chrono::time_point).
La 'file d'attente' est un std::priority_queue (qui conserve son time_point éléments clés triés).

timer_thread est normalement suspendu jusqu'au démarrage de la tâche suivante ou lorsqu'une nouvelle tâche est ajoutée.
Lorsqu'une tâche est sur le point d'être exécutée, elle est placée dans tasksReadyToRunQueue, l'un des threads de travail est signalé, se réveille, le supprime de la file d'attente et commence le traitement de la tâche.

Notez que le pool de threads a une limite supérieure de compilation pour le nombre de threads (40). Si vous planifiez plus de tâches que ce qui peut être distribué aux travailleurs, la nouvelle tâche se bloquera jusqu'à ce que les threads soient à nouveau disponibles.

Vous avez dit que cette approche n'est pas efficace, mais dans l'ensemble, elle me semble raisonnablement efficace. Tout est piloté par les événements et vous ne perdez pas de cycles CPU par un spin inutile. Bien sûr, ce n'est qu'un exemple, des optimisations sont possibles (remarque: std::multimap a été remplacé par std::priority_queue).

L'implémentation est compatible 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

Cela signifie que vous souhaitez exécuter toutes les tâches en continu en utilisant un certain ordre.

Vous pouvez créer un certain type de tri par une pile de délais (ou même une liste liée) de tâches. Lorsqu'une nouvelle tâche arrive, vous devez l'insérer dans la position en fonction d'un temps de retard (il suffit de calculer efficacement cette position et d'insérer efficacement la nouvelle tâche).

Exécutez toutes les tâches en commençant par l'en-tête de la pile de tâches (ou liste).

1
Alex Bod

Code de base pour 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();
    }
};

Code de test:

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));

}
1