Java ThreadPoolExecutor:コアプールサイズを更新すると、着信タスクが断続的に動的に拒否される


13

ThreadPoolExecutorプールの作成後にのコアプールサイズを別の数値に変更しようとすると、タスクの数をRejectedExecutionException超えて送信しなくても、一部のタスクが断続的に拒否されるという問題が発生していますqueueSize + maxPoolSize

私が解決しようとしている問題はThreadPoolExecutor、スレッドプールのキューにある保留中の実行に基づいてコアスレッドのサイズを変更して拡張することです。デフォルトでThreadPoolExecutorThread、キューがいっぱいの場合にのみ新しいが作成されるため、これが必要です。

これは、問題を示す小さな自己完結型のPure Java 8プログラムです。

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

queueCapacity + maxThreadsを超えて送信しないと、プールがRejectedExecutionExceptionをスローするのはなぜですか。最大スレッドを変更することはないので、ThreadPoolExecutorの定義により、スレッド内のタスクまたはキューにタスクを収容する必要があります。

もちろん、プールのサイズを変更しない場合、スレッドプールは送信を拒否しません。提出に何らかの遅延を追加すると問題が解消されるため、これもデバッグが困難です。

RejectedExecutionExceptionを修正する方法に関する指針はありますか?


ExecutorServiceサイズ変更が原因で送信に失敗したタスクを再送信する既存の実装をラップして、独自の実装を提供してみませんか?
daniu

@daniuは回避策です。質問の要点は、queueCapacity + maxThreadsを超えて送信したことがない場合に、プールがRejectedExecutionExceptionをスローする理由です。最大スレッドを変更することはないので、ThreadPoolExecutorの定義により、スレッド内のタスクまたはキューにタスクを収容する必要があります。
スワランガサルマ

わかりました、あなたの質問を誤解したようです。それは何ですか?動作が発生する理由、または問題の原因となる回避方法を知りたいですか?
daniu

はい、コードの多くがThreadPoolExecutorを参照しているため、実装をexecutorサービスに変更することはできません。したがって、サイズ変更可能なThreadPoolExecutorが必要な場合は、修正方法を知る必要があります。このようなことを行う正しい方法は、ThreadPoolExecutorを拡張し、その保護された変数の一部にアクセスして、スーパークラスによって共有されるロックの同期ブロック内のプールサイズを更新することです。
スワランガサルマ

拡張ThreadPoolExecutorはおそらく悪い考えであり、この場合も既存のコードを変更する必要はありませんか?実際のコードがエグゼキュータにアクセスする方法のいくつかの例を提供するのが最善です。固有でThreadPoolExecutorはない(つまり以外のExecutorService)メソッドを多く使用しているとしたら、私は驚きます。
daniu

回答:


5

これが発生している理由は次のとおりです。

私の例では、minThreads = 0、maxThreads = 2、queueCapacity = 2を使用して短くしています。最初のコマンドが送信されます。これは、メソッド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);
}

このコマンドでは、addWorker(null、false)よりもworkQueue.offer(command)が実行されます。ワーカースレッドは最初にこのコマンドをスレッド実行メソッドのキューから取り出します。そのため、この時点でもキューには1つのコマンドがあります。

2番目のコマンドは、workQueue.offer(command)が実行されるときに送信されます。キューがいっぱいになりました

これで、ScheduledExecutorServiceは、maxThreadsでsetCorePoolSizeを呼び出すresizeThreadPoolメソッドを実行します。次に、メソッド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;
        }
    }
}

このメソッドは、addWorker(null、true)を使用して1つのワーカーを追加します。実行中の2つのワーカーキューはありません。最大で、キューがいっぱいです。

3番目のコマンドは送信され、workQueue.offer(command)およびaddWorker(command、false)が失敗するために失敗し、例外が発生します。

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)

この問題を解決するには、キューの容量を実行するコマンドの最大数に設定する必要があると思います。


正しい。コードを自分のクラスにコピーしてロガーを追加することで再現できました。基本的に、キューがいっぱいになり、新しいタスクを送信すると、新しいワーカーが作成されます。一方、その時点で、リサイザがsetCorePoolSizeを2に呼び出すと、新しいWorkerも作成されます。この時点で、2人のワーカーが追加されることを競っていますが、max-pool-size制約に違反して新しいタスクの送信が拒否されるため、両方を追加することはできません。これは競合状態だと思い、OpenJDKにバグレポートを提出しました。どれどれ。しかし、あなたは私の質問に答えたので、あなたは賞金を手に入れました。ありがとうございました。
スワランガサルマ

2

これがバグであるかどうかはわかりません。これは、キューがいっぱいになった後に追加のワーカースレッドが作成されるときの動作ですが、Javaのドキュメントでは、呼び出し側が拒否されたタスクを処理する必要があることが指摘されています。

Javaドキュメント

新しいスレッドのファクトリ。すべてのスレッドは、このメソッドを使用して(メソッドaddWorkerを介して)作成されます。すべての呼び出し元は、addWorkerが失敗する準備をする必要があります。これは、スレッド数を制限するシステムまたはユーザーのポリシーを反映している場合があります。エラーとして扱われなくても、スレッドの作成に失敗すると、新しいタスクが拒否されるか、既存のタスクがキューに残ったままになる可能性があります。

コアプールのサイズを変更すると、たとえば、増加すると、追加のワーカーが作成され(のaddWorkerメソッドsetCorePoolSize)、追加の作業を作成するための呼び出し(のaddWorkerメソッドexecute)がaddWorkerfalse(add Worker最後のコードスニペット)を返すと拒否されます。によって作成されましたsetCorePoolSize が、まだ実行されていないため、キューの更新が反映されません

関連部品

比較する

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

カスタム再試行拒否実行ハンドラーを使用します(これは、最大プールサイズに上限があるため、このケースで機能するはずです)。必要に応じて調整してください。

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

また、送信されたタスクの実行が完了するのを待たず、awaitTermination代わりにwith を使用するため、shutdownの使用は正しくありません。


JavaDocによると、シャットダウンはすでに送信されたタスクを待機していると思います。shutdown()以前に送信されたタスクが実行される通常のシャットダウンを開始しますが、新しいタスクは受け入れられません。
トーマスクリーガー

@ThomasKrieger-すでに送信されたタスクを実行しますが、完了するまで待機しません-docs docs.oracle.com/javase/7/docs/api/java/util/concurrent/から -このメソッドは以前に送信されたのを待ちません実行を完了するためのタスク。それにはawaitTerminationを使用します。
Sagar Veeram
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.