web-dev-qa-db-fra.com

Comment obtenir le ThreadPoolExecutor pour augmenter les threads au maximum avant la mise en file d'attente?

Je suis frustré depuis un certain temps avec le comportement par défaut de ThreadPoolExecutor qui soutient les pools de threads ExecutorService que beaucoup d'entre nous utilisent. Pour citer les Javadocs:

S'il y a plus de threads corePoolSize mais moins que maximumPoolSize en cours d'exécution, un nouveau thread sera créé niquement si la file d'attente est pleine.

Cela signifie que si vous définissez un pool de threads avec le code suivant, il ne démarrera jamais le 2ème thread car le LinkedBlockingQueue est sans bornes.

ExecutorService threadPool =
   new ThreadPoolExecutor(1 /*core*/, 50 /*max*/, 60 /*timeout*/,
      TimeUnit.SECONDS, new LinkedBlockingQueue<Runnable>(/* unlimited queue */));

Ce n'est que si vous avez une file d'attente bornée et que la file d'attente est pleine que les discussions au-dessus du nombre de base ont commencé. Je soupçonne qu'un grand nombre de juniors Java multithread ignorent ce comportement du ThreadPoolExecutor.

Maintenant, j'ai un cas d'utilisation spécifique où ce n'est pas optimal. Je cherche des moyens, sans écrire ma propre classe de TPE, de contourner cela.

Mes exigences concernent un service Web qui effectue des rappels à un tiers potentiellement non fiable.

  • Je ne veux pas faire le rappel de manière synchrone avec la demande Web, donc je veux utiliser un pool de threads.
  • J'en reçois généralement quelques-uns par minute, donc je ne veux pas avoir une newFixedThreadPool(...) avec un grand nombre de threads qui sont pour la plupart dormants.
  • De temps en temps, je reçois une rafale de ce trafic et je veux augmenter le nombre de threads à une valeur maximale (disons 50).
  • Je dois faire une meilleure tentative pour faire tous les rappels, donc je veux mettre en file d'attente d'autres au-dessus de 50. Je ne veux pas submerger le reste de mon serveur web en utilisant une newCachedThreadPool().

Comment contourner cette limitation dans ThreadPoolExecutor où la file d'attente doit être bornée et pleine avant plus de threads ne soient démarrés? Comment puis-je l'obtenir pour démarrer plus de threads avant les tâches de mise en file d'attente?

Modifier:

@Flavio fait un bon point sur l'utilisation de la fonction ThreadPoolExecutor.allowCoreThreadTimeOut(true) pour expirer et quitter les threads principaux. J'ai pensé à cela, mais je voulais toujours la fonctionnalité core-threads. Je ne voulais pas que le nombre de threads dans le pool tombe en dessous de la taille de base si possible.

87
Gray

Comment puis-je contourner cette limitation dans ThreadPoolExecutor où la file d'attente doit être limitée et pleine avant de démarrer d'autres threads.

Je crois que j'ai finalement trouvé une solution quelque peu élégante (peut-être un peu hacky) à cette limitation avec ThreadPoolExecutor. Il s'agit d'étendre LinkedBlockingQueue pour qu'il renvoie false pour queue.offer(...) alors qu'il y a déjà des tâches en file d'attente. Si les threads actuels ne suivent pas les tâches en file d'attente, le TPE ajoutera des threads supplémentaires. Si le pool est déjà au maximum de threads, alors le RejectedExecutionHandler sera appelé. C'est le gestionnaire qui effectue ensuite la put(...) dans la file d'attente.

Il est certainement étrange d'écrire une file d'attente où offer(...) peut retourner false et put() ne bloque jamais, c'est la partie hack. Mais cela fonctionne bien avec l'utilisation de la file d'attente par TPE, donc je ne vois aucun problème à le faire.

Voici le code:

// extend LinkedBlockingQueue to force offer() to return false conditionally
BlockingQueue<Runnable> queue = new LinkedBlockingQueue<Runnable>() {
    private static final long serialVersionUID = -6903933921423432194L;
    @Override
    public boolean offer(Runnable e) {
        /*
         * Offer it to the queue if there is 0 items already queued, else
         * return false so the TPE will add another thread. If we return false
         * and max threads have been reached then the RejectedExecutionHandler
         * will be called which will do the put into the queue.
         */
        if (size() == 0) {
            return super.offer(e);
        } else {
            return false;
        }
    }
};
ThreadPoolExecutor threadPool = new ThreadPoolExecutor(1 /*core*/, 50 /*max*/,
        60 /*secs*/, TimeUnit.SECONDS, queue);
threadPool.setRejectedExecutionHandler(new RejectedExecutionHandler() {
    @Override
    public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
        try {
            /*
             * This does the actual put into the queue. Once the max threads
             * have been reached, the tasks will then queue up.
             */
            executor.getQueue().put(r);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return;
        }
    }
});

Avec ce mécanisme, lorsque je soumets des tâches à la file d'attente, le ThreadPoolExecutor:

  1. Ajustez le nombre de threads jusqu'à la taille de base initialement (ici 1).
  2. Offrez-le à la file d'attente. Si la file d'attente est vide, elle sera mise en file d'attente pour être gérée par les threads existants.
  3. Si la file d'attente a déjà 1 ou plusieurs éléments, la offer(...) renverra false.
  4. Si false est retourné, augmentez le nombre de threads dans le pool jusqu'à ce qu'ils atteignent le nombre maximum (ici 50).
  5. Si au maximum alors il appelle le RejectedExecutionHandler
  6. RejectedExecutionHandler place ensuite la tâche dans la file d'attente pour être traitée par le premier thread disponible dans l'ordre FIFO.

Bien que dans mon exemple de code ci-dessus, la file d'attente soit illimitée, vous pouvez également la définir comme une file d'attente limitée. Par exemple, si vous ajoutez une capacité de 1000 au LinkedBlockingQueue, cela:

  1. redimensionner les fils jusqu'à max
  2. puis file jusqu'à ce qu'il soit plein avec 1000 tâches
  3. puis bloquez l'appelant jusqu'à ce que de l'espace soit disponible dans la file d'attente.

De plus, si vous aviez vraiment besoin d'utiliser offer(...) dans RejectedExecutionHandler, vous pouvez utiliser la méthode offer(E, long, TimeUnit) à la place avec Long.MAX_VALUE Comme délai d'expiration.

Modifier:

J'ai modifié ma substitution de méthode offer(...) selon les commentaires de @ Ralf. Cela n'augmentera le nombre de threads dans le pool que s'ils ne suivent pas la charge.

Modifier:

Un autre ajustement à cette réponse pourrait être de demander au TPE s'il y a des threads inactifs et de ne mettre en file d'attente l'élément que si c'est le cas. Vous devrez créer une vraie classe pour cela et ajouter une méthode ourQueue.setThreadPoolExecutor(tpe); dessus.

Votre méthode offer(...) pourrait alors ressembler à ceci:

  1. Vérifiez si la tpe.getPoolSize() == tpe.getMaximumPoolSize() auquel cas appelez simplement super.offer(...).
  2. Sinon, si tpe.getPoolSize() > tpe.getActiveCount() alors appelez super.offer(...) car il semble y avoir des threads inactifs.
  3. Sinon, retournez false pour bifurquer un autre thread.

Peut être ça:

int poolSize = tpe.getPoolSize();
int maximumPoolSize = tpe.getMaximumPoolSize();
if (poolSize >= maximumPoolSize || poolSize > tpe.getActiveCount()) {
    return super.offer(e);
} else {
    return false;
}

Notez que les méthodes get sur TPE sont coûteuses car elles accèdent aux champs volatile ou (dans le cas de getActiveCount()) verrouillent le TPE et parcourent la liste des threads. En outre, il existe des conditions de concurrence critique ici qui peuvent entraîner une mise en file d'attente incorrecte d'une tâche ou un autre thread bifurqué lorsqu'il y avait un thread inactif.

42
Gray

J'ai déjà deux autres réponses à cette question, mais je pense que celle-ci est la meilleure.

Il est basé sur la technique de la réponse actuellement acceptée , à savoir:

  1. Remplacez la méthode offer() de la file d'attente pour retourner (parfois) false,
  2. ce qui provoque le ThreadPoolExecutor à générer un nouveau thread ou à rejeter la tâche, et
  3. définissez RejectedExecutionHandler sur en fait placez la tâche sur le rejet.

Le problème est quand offer() doit retourner false. La réponse actuellement acceptée renvoie false lorsque la file d'attente comporte quelques tâches, mais comme je l'ai souligné dans mon commentaire, cela provoque des effets indésirables. Alternativement, si vous retournez toujours false, vous continuerez à générer de nouveaux threads même lorsque vous avez des threads en attente dans la file d'attente.

La solution consiste à utiliser Java 7 LinkedTransferQueue et à avoir offer() call tryTransfer(). Lorsqu'il y a un thread consommateur en attente, la tâche sera simplement transmise à ce thread. Sinon, offer() renverra false et ThreadPoolExecutor générera un nouveau thread.

    BlockingQueue<Runnable> queue = new LinkedTransferQueue<Runnable>() {
        @Override
        public boolean offer(Runnable e) {
            return tryTransfer(e);
        }
    };
    ThreadPoolExecutor threadPool = new ThreadPoolExecutor(1, 50, 60, TimeUnit.SECONDS, queue);
    threadPool.setRejectedExecutionHandler(new RejectedExecutionHandler() {
        @Override
        public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
            try {
                executor.getQueue().put(r);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }
    });
25

Définissez la taille du noyau et la taille maximale sur la même valeur et autorisez la suppression des threads du noyau du pool avec allowCoreThreadTimeOut(true).

24
Flavio

Remarque: je préfère et recommande maintenant mon autre réponse .

Voici une version qui me semble beaucoup plus simple: augmenter le corePoolSize (jusqu'à la limite de maximumPoolSize) chaque fois qu'une nouvelle tâche est exécutée, puis diminuer le corePoolSize (jusqu'à la limite de la "taille du pool de base" spécifiée par l'utilisateur) chaque fois qu'un tâche terminée.

Pour le dire autrement, gardez une trace du nombre de tâches en cours d'exécution ou mises en file d'attente et assurez-vous que le corePoolSize est égal au nombre de tâches tant qu'il se situe entre la "taille du pool de base" spécifiée par l'utilisateur et le maximumPoolSize.

public class GrowBeforeQueueThreadPoolExecutor extends ThreadPoolExecutor {
    private int userSpecifiedCorePoolSize;
    private int taskCount;

    public GrowBeforeQueueThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue) {
        super(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue);
        userSpecifiedCorePoolSize = corePoolSize;
    }

    @Override
    public void execute(Runnable runnable) {
        synchronized (this) {
            taskCount++;
            setCorePoolSizeToTaskCountWithinBounds();
        }
        super.execute(runnable);
    }

    @Override
    protected void afterExecute(Runnable runnable, Throwable throwable) {
        super.afterExecute(runnable, throwable);
        synchronized (this) {
            taskCount--;
            setCorePoolSizeToTaskCountWithinBounds();
        }
    }

    private void setCorePoolSizeToTaskCountWithinBounds() {
        int threads = taskCount;
        if (threads < userSpecifiedCorePoolSize) threads = userSpecifiedCorePoolSize;
        if (threads > getMaximumPoolSize()) threads = getMaximumPoolSize();
        setCorePoolSize(threads);
    }
}

Telle qu'elle est écrite, la classe ne prend pas en charge la modification du corePoolSize ou du maximumPoolSize spécifié par l'utilisateur après la construction, et ne prend pas en charge la manipulation de la file d'attente de travail directement ou via remove() ou purge().

7

Nous avons une sous-classe de ThreadPoolExecutor qui prend un creationThreshold supplémentaire et remplace execute.

public void execute(Runnable command) {
    super.execute(command);
    final int poolSize = getPoolSize();
    if (poolSize < getMaximumPoolSize()) {
        if (getQueue().size() > creationThreshold) {
            synchronized (this) {
                setCorePoolSize(poolSize + 1);
                setCorePoolSize(poolSize);
            }
        }
    }
}

peut-être que cela aide aussi, mais le vôtre semble plus artistique bien sûr…

6
Ralf H

La réponse recommandée ne résout qu'un (1) des problèmes avec le pool de threads JDK:

  1. Les pools de threads JDK sont orientés vers la mise en file d'attente. Ainsi, au lieu de générer un nouveau thread, ils mettront la tâche en file d'attente. Ce n'est que si la file d'attente atteint sa limite que le pool de threads génèrera un nouveau thread.

  2. Le retrait du fil ne se produit pas lorsque la charge diminue. Par exemple, si nous avons une rafale de travaux atteignant le pool qui entraîne le pool à atteindre le maximum, suivi par une charge légère de 2 tâches maximum à la fois, le pool utilisera tous les threads pour répondre à la charge légère empêchant le retrait du thread. (seulement 2 threads seraient nécessaires…)

Insatisfait du comportement ci-dessus, je suis allé de l'avant et j'ai mis en place un pool pour surmonter les lacunes ci-dessus.

Pour résoudre 2) L'utilisation de la planification Lifo résout le problème. Cette idée a été présentée par Ben Maurer lors de la conférence applicative ACM 2015: Scale Systems @ Facebook

Une nouvelle implémentation est donc née:

LifoThreadPoolExecutorSQP

Jusqu'à présent, cette implémentation améliore les performances d'exécution asynchrone pour ZEL .

L'implémentation est capable de réduire la surcharge de changement de contexte, offrant des performances supérieures pour certains cas d'utilisation.

J'espère que ça aide...

PS: JDK Fork Join Pool implémente ExecutorService et fonctionne comme un pool de threads "normal", la mise en œuvre est performante, elle utilise LIFO Thread scheduling, mais il n'y a aucun contrôle sur la taille de la file d'attente interne, le délai d'expiration. .., et surtout, les tâches ne peuvent pas être interrompues lors de leur annulation

3
user2179737

Remarque: je préfère et recommande maintenant mon autre réponse .

J'ai une autre proposition, suite à l'idée originale de changer la file d'attente pour retourner false. Dans celui-ci, toutes les tâches peuvent entrer dans la file d'attente, mais chaque fois qu'une tâche est mise en file d'attente après execute(), nous la suivons avec une tâche sentinelle sans opération que la file d'attente rejette, provoquant l'apparition d'un nouveau thread, qui s'exécutera le no-op immédiatement suivi par quelque chose de la file d'attente.

Étant donné que les threads de travail peuvent interroger le LinkedBlockingQueue pour une nouvelle tâche, il est possible qu'une tâche soit mise en file d'attente même lorsqu'il existe un thread disponible. Pour éviter de générer de nouveaux threads même lorsqu'il y a des threads disponibles, nous devons garder une trace du nombre de threads en attente de nouvelles tâches dans la file d'attente et générer un nouveau thread uniquement lorsqu'il y a plus de tâches dans la file d'attente que de threads en attente.

final Runnable SENTINEL_NO_OP = new Runnable() { public void run() { } };

final AtomicInteger waitingThreads = new AtomicInteger(0);

BlockingQueue<Runnable> queue = new LinkedBlockingQueue<Runnable>() {
    @Override
    public boolean offer(Runnable e) {
        // offer returning false will cause the executor to spawn a new thread
        if (e == SENTINEL_NO_OP) return size() <= waitingThreads.get();
        else return super.offer(e);
    }

    @Override
    public Runnable poll(long timeout, TimeUnit unit) throws InterruptedException {
        try {
            waitingThreads.incrementAndGet();
            return super.poll(timeout, unit);
        } finally {
            waitingThreads.decrementAndGet();
        }
    }

    @Override
    public Runnable take() throws InterruptedException {
        try {
            waitingThreads.incrementAndGet();
            return super.take();
        } finally {
            waitingThreads.decrementAndGet();
        }
    }
};

ThreadPoolExecutor threadPool = new ThreadPoolExecutor(1, 50, 60, TimeUnit.SECONDS, queue) {
    @Override
    public void execute(Runnable command) {
        super.execute(command);
        if (getQueue().size() > waitingThreads.get()) super.execute(SENTINEL_NO_OP);
    }
};
threadPool.setRejectedExecutionHandler(new RejectedExecutionHandler() {
    @Override
    public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
        if (r == SENTINEL_NO_OP) return;
        else throw new RejectedExecutionException();            
    }
});
1

La meilleure solution à laquelle je peux penser est d'étendre.

ThreadPoolExecutor propose quelques méthodes de hook: beforeExecute et afterExecute. Dans votre extension, vous pouvez continuer à utiliser une file d'attente limitée pour alimenter les tâches et une deuxième file d'attente non limitée pour gérer le débordement. Lorsque quelqu'un appelle submit, vous pouvez tenter de placer la demande dans la file d'attente limitée. Si vous rencontrez une exception, il vous suffit de coller la tâche dans votre file d'attente de débordement. Vous pouvez ensuite utiliser le crochet afterExecute pour voir s'il y a quelque chose dans la file d'attente de débordement après avoir terminé une tâche. De cette façon, l'exécuteur s'occupera d'abord des choses dans sa file d'attente bornée, et tirera automatiquement de cette file d'attente illimitée si le temps le permet.

Cela semble plus de travail que votre solution, mais au moins cela n'implique pas de donner aux files d'attente des comportements inattendus. J'imagine également qu'il existe un meilleur moyen de vérifier l'état de la file d'attente et des threads plutôt que de s'appuyer sur des exceptions, qui sont assez lentes à lancer.

0
bstempi