Java ThreadPoolExecutor: l'aggiornamento delle dimensioni del pool principale rifiuta in modo dinamico le attività in arrivo in modo intermittente


13

Sto riscontrando un problema in cui se provo a ridimensionare la ThreadPoolExecutordimensione del pool principale di un numero diverso dopo la creazione del pool, quindi a intermittenza, alcune attività vengono rifiutate con un RejectedExecutionExceptionanche se non invio mai più del queueSize + maxPoolSizenumero di attività.

Il problema che sto cercando di risolvere è l'estensione ThreadPoolExecutorche ridimensiona i thread principali in base alle esecuzioni in sospeso che si trovano nella coda del pool di thread. Ne ho bisogno perché di default a ThreadPoolExecutorcreerà un nuovo Threadsolo se la coda è piena.

Ecco un piccolo programma autonomo Pure Java 8 che dimostra il problema.

import static java.lang.Math.max;
import static java.lang.Math.min;

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executors;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

public class ThreadPoolResizeTest {

    public static void main(String[] args) throws Exception {
        // increase the number of iterations if unable to reproduce
        // for me 100 iterations have been enough
        int numberOfExecutions = 100;

        for (int i = 1; i <= numberOfExecutions; i++) {
            executeOnce();
        }
    }

    private static void executeOnce() throws Exception {
        int minThreads = 1;
        int maxThreads = 5;
        int queueCapacity = 10;

        ThreadPoolExecutor pool = new ThreadPoolExecutor(
                minThreads, maxThreads,
                0, TimeUnit.SECONDS,
                new LinkedBlockingQueue<Runnable>(queueCapacity),
                new ThreadPoolExecutor.AbortPolicy()
        );

        ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
        scheduler.scheduleAtFixedRate(() -> resizeThreadPool(pool, minThreads, maxThreads),
                0, 10, TimeUnit.MILLISECONDS);
        CompletableFuture<Void> taskBlocker = new CompletableFuture<>();

        try {
            int totalTasksToSubmit = queueCapacity + maxThreads;

            for (int i = 1; i <= totalTasksToSubmit; i++) {
                // following line sometimes throws a RejectedExecutionException
                pool.submit(() -> {
                    // block the thread and prevent it from completing the task
                    taskBlocker.join();
                });
                // Thread.sleep(10); //enabling even a small sleep makes the problem go away
            }
        } finally {
            taskBlocker.complete(null);
            scheduler.shutdown();
            pool.shutdown();
        }
    }

    /**
     * Resize the thread pool if the number of pending tasks are non-zero.
     */
    private static void resizeThreadPool(ThreadPoolExecutor pool, int minThreads, int maxThreads) {
        int pendingExecutions = pool.getQueue().size();
        int approximateRunningExecutions = pool.getActiveCount();

        /*
         * New core thread count should be the sum of pending and currently executing tasks
         * with an upper bound of maxThreads and a lower bound of minThreads.
         */
        int newThreadCount = min(maxThreads, max(minThreads, pendingExecutions + approximateRunningExecutions));

        pool.setCorePoolSize(newThreadCount);
        pool.prestartAllCoreThreads();
    }
}

Perché il pool dovrebbe mai lanciare un RejectedExecutionException se non invio mai più di queueCapacity + maxThreads. Non sto mai cambiando il numero massimo di thread, quindi dalla definizione di ThreadPoolExecutor, dovrebbe adattarsi all'attività in un thread o alla coda.

Naturalmente, se non ridimensiono mai il pool, il pool di thread non rifiuta mai alcun invio. Questo è anche difficile da eseguire il debug poiché l'aggiunta di qualsiasi tipo di ritardo negli invii risolve il problema.

Qualche suggerimento su come riparare RejectedExecutionException?


Perché non fornire la propria implementazione ExecutorServiceavvolgendone una esistente, che reinvia attività che non sono riuscite a inviare a causa del ridimensionamento?
daniu,

@daniu è una soluzione alternativa. Il punto delle domande è perché il pool dovrebbe mai lanciare un RejectedExecutionException se non invio mai più di queueCapacity + maxThreads. Non sto mai cambiando il numero massimo di thread, quindi dalla definizione di ThreadPoolExecutor, dovrebbe adattarsi all'attività in un thread o alla coda.
Swaranga Sarma,

Ok, sembra che abbia frainteso la tua domanda. Che cos'è? Vuoi sapere perché il comportamento si svolge o come aggirarlo causando problemi per te?
Dani

Sì, non è possibile modificare la mia implementazione in un servizio di esecuzione poiché gran parte del codice fa riferimento a ThreadPoolExecutor. Quindi, se volevo ancora avere un ThreadPoolExecutor ridimensionabile, devo sapere come posso ripararlo. Possa il modo giusto per fare qualcosa del genere è estendere ThreadPoolExecutor e ottenere l'accesso ad alcune delle sue variabili protette e aggiornare la dimensione del pool all'interno di un blocco sincronizzato su un blocco condiviso dalla superclasse.
Swaranga Sarma,

L'estensione ThreadPoolExecutorè molto probabilmente una cattiva idea, e in questo caso non avresti bisogno di cambiare anche il codice esistente? Sarebbe meglio che tu fornissi alcuni esempi di come il tuo codice effettivo accede all'esecutore. Sarei sorpreso se usasse molti metodi specifici per ThreadPoolExecutor(cioè non in ExecutorService).
Dani

Risposte:


5

Ecco uno scenario per cui questo sta accadendo:

Nel mio esempio uso minThreads = 0, maxThreads = 2 e queueCapacity = 2 per renderlo più breve. Il primo comando viene inviato, questo viene eseguito nel metodo execute:

public void execute(Runnable command) {
    if (command == null)
        throw new NullPointerException();
    /*
     * Proceed in 3 steps:
     *
     * 1. If fewer than corePoolSize threads are running, try to
     * start a new thread with the given command as its first
     * task.  The call to addWorker atomically checks runState and
     * workerCount, and so prevents false alarms that would add
     * threads when it shouldn't, by returning false.
     *
     * 2. If a task can be successfully queued, then we still need
     * to double-check whether we should have added a thread
     * (because existing ones died since last checking) or that
     * the pool shut down since entry into this method. So we
     * recheck state and if necessary roll back the enqueuing if
     * stopped, or start a new thread if there are none.
     *
     * 3. If we cannot queue task, then we try to add a new
     * thread.  If it fails, we know we are shut down or saturated
     * and so reject the task.
     */
    int c = ctl.get();
    if (workerCountOf(c) < corePoolSize) {
        if (addWorker(command, true))
            return;
        c = ctl.get();
    }
    if (isRunning(c) && workQueue.offer(command)) {
        int recheck = ctl.get();
        if (! isRunning(recheck) && remove(command))
            reject(command);
        else if (workerCountOf(recheck) == 0)
            addWorker(null, false);
    }
    else if (!addWorker(command, false))
        reject(command);
}

per questo comando viene eseguito workQueue.offer (comando) di addWorker (null, false). Il thread di lavoro prima estrae questo comando dalla coda nel metodo di esecuzione del thread, quindi in questo momento la coda ha ancora un comando,

Il secondo comando viene inviato questa volta viene eseguito workQueue.offer (comando). Ora la coda è piena

Ora ScheduledExecutorService esegue il metodo resizeThreadPool che chiama setCorePoolSize con maxThreads. Ecco il metodo setCorePoolSize:

 public void setCorePoolSize(int corePoolSize) {
    if (corePoolSize < 0)
        throw new IllegalArgumentException();
    int delta = corePoolSize - this.corePoolSize;
    this.corePoolSize = corePoolSize;
    if (workerCountOf(ctl.get()) > corePoolSize)
        interruptIdleWorkers();
    else if (delta > 0) {
        // We don't really know how many new threads are "needed".
        // As a heuristic, prestart enough new workers (up to new
        // core size) to handle the current number of tasks in
        // queue, but stop if queue becomes empty while doing so.
        int k = Math.min(delta, workQueue.size());
        while (k-- > 0 && addWorker(null, true)) {
            if (workQueue.isEmpty())
                break;
        }
    }
}

Questo metodo aggiunge un lavoratore utilizzando addWorker (null, true). No, ci sono 2 code di lavoro in esecuzione, il massimo e la coda è piena.

Il terzo comando viene inviato e non riesce perché workQueue.offer (comando) e addWorker (comando, falso) falliscono, portando all'eccezione:

java.util.concurrent.RejectedExecutionException: Task java.util.concurrent.FutureTask@24c22fe rejected from java.util.concurrent.ThreadPoolExecutor@cd1e646[Running, pool size = 2, active threads = 2, queued tasks = 2, completed tasks = 0]
at java.util.concurrent.ThreadPoolExecutor$AbortPolicy.rejectedExecution(ThreadPoolExecutor.java:2047)
at java.util.concurrent.ThreadPoolExecutor.reject(ThreadPoolExecutor.java:823)
at java.util.concurrent.ThreadPoolExecutor.execute(ThreadPoolExecutor.java:1369)
at java.util.concurrent.AbstractExecutorService.submit(AbstractExecutorService.java:112)
at ThreadPoolResizeTest.executeOnce(ThreadPoolResizeTest.java:60)
at ThreadPoolResizeTest.runTest(ThreadPoolResizeTest.java:28)
at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.lang.reflect.Method.invoke(Method.java:498)
at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:44)
at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:15)
at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:41)
at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:20)
at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:263)
at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:69)
at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:48)
at org.junit.runners.ParentRunner$3.run(ParentRunner.java:231)
at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:60)
at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:229)
at org.junit.runners.ParentRunner.access$000(ParentRunner.java:50)
at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:222)
at org.junit.runners.ParentRunner.run(ParentRunner.java:292)
at org.apache.maven.surefire.junit4.JUnit4Provider.execute(JUnit4Provider.java:365)

Penso che per risolvere questo problema dovresti impostare la capacità della coda sul massimo dei comandi che desideri eseguire.


Corretta. Sono stato in grado di riprodurre copiando il codice nella mia classe e aggiungendo logger. Fondamentalmente, quando la coda è piena e invio una nuova attività, proverà a creare un nuovo lavoratore. Nel frattempo, se in quel momento, il mio resizer chiama anche setCorePoolSize su 2, creando anche un nuovo lavoratore. A questo punto, due Lavoratori sono in competizione per essere aggiunti, ma entrambi non possono esserlo perché violerebbe il vincolo di dimensione massima del pool in modo da rifiutare l'inoltro della nuova attività. Penso che questa sia una condizione di gara e ho presentato una segnalazione di bug a OpenJDK. Vediamo. Ma hai risposto alla mia domanda in modo da ottenere la taglia. Grazie.
Swaranga Sarma,

2

Non sono sicuro se questo si qualifica come bug. Questo è il comportamento quando i thread di lavoro aggiuntivi vengono creati dopo che la coda è piena ma questo è stato in qualche modo notato nei documenti java che il chiamante deve gestire le attività rifiutate.

Documenti Java

Fabbrica per nuovi thread. Tutti i thread vengono creati utilizzando questa fabbrica (tramite il metodo addWorker). Tutti i chiamanti devono essere preparati affinché fallisca addWorker, il che potrebbe riflettere un criterio di sistema o dell'utente che limita il numero di thread. Anche se non viene trattato come un errore, la mancata creazione di thread può comportare il rifiuto di nuove attività o il blocco di quelle esistenti nella coda.

Quando si ridimensiona la dimensione del pool principale, diciamo aumentare, i lavoratori aggiuntivi vengono creati ( addWorkermetodo in setCorePoolSize) e la chiamata per creare lavoro aggiuntivo ( addWorkermetodo da execute) viene rifiutata quando il valore addWorkerrestituisce false ( add Workerultimo frammento di codice) poiché i lavoratori aggiuntivi sufficienti sono già creato da setCorePoolSize ma non ancora eseguito per riflettere l'aggiornamento nella coda .

Parti rilevanti

Confrontare

public void setCorePoolSize(int corePoolSize) {
    ....
    int k = Math.min(delta, workQueue.size());
    while (k-- > 0 && addWorker(null, true)) {
        if (workQueue.isEmpty())
             break;
    }
}

public void execute(Runnable command) {
    ...
    int c = ctl.get();
    if (workerCountOf(c) < corePoolSize) {
        if (addWorker(command, true))
            return;
        c = ctl.get();
    }
    if (isRunning(c) && workQueue.offer(command)) {
        int recheck = ctl.get();
        if (! isRunning(recheck) && remove(command))
            reject(command);
        else if (workerCountOf(recheck) == 0)
            addWorker(null, false);
    }
    else if (!addWorker(command, false))
        reject(command);
}

private boolean addWorker(Runnable firstTask, boolean core) {
....
   if (wc >= CAPACITY || wc >= (core ? corePoolSize : maximumPoolSize))
     return false;             
}

Utilizzare il gestore dell'esecuzione del rifiuto di un nuovo tentativo personalizzato (dovrebbe funzionare nel caso in cui il limite superiore sia la dimensione massima del pool). Si prega di regolare secondo necessità.

public static class RetryRejectionPolicy implements RejectedExecutionHandler {
    public RetryRejectionPolicy () {}

    public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
        if (!e.isShutdown()) {
           while(true)
            if(e.getQueue().offer(r)) break;
        }
    }
}

ThreadPoolExecutor pool = new ThreadPoolExecutor(
      minThreads, maxThreads,
      0, TimeUnit.SECONDS,
      new LinkedBlockingQueue<Runnable>(queueCapacity),
      new ThreadPoolResizeTest.RetryRejectionPolicy()
 );

Si noti inoltre che l'utilizzo dell'arresto non è corretto in quanto ciò non attenderà il completamento dell'esecuzione dell'attività inoltrata ma verrà utilizzato con awaitTermination.


Penso che lo shutdown sia in attesa di attività già inoltrate, secondo JavaDoc: shutdown () Avvia un arresto ordinato in cui vengono eseguite attività precedentemente inviate, ma non verranno accettate nuove attività.
Thomas Krieger,

@ThomasKrieger - Eseguirà le attività già inoltrate ma non aspetterà che finiscano - da docs docs.oracle.com/javase/7/docs/api/java/util/concurrent/… - Questo metodo non attende l' invio precedente compiti per completare l'esecuzione. Usa waititTermination per farlo.
Sagar Veeram,
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.