ThreadPoolExecutor with corePoolSize 0 should not execute tasks until task queue is full
Asked Answered
P

4

12

I was going through Java Concurrency In Practice and got stuck at the 8.3.1 Thread creation and teardown topic. The following footnote warns about keeping corePoolSize to zero.

Developers are sometimes tempted to set the core size to zero so that the worker threads will eventually be torn down and therefore won’t prevent the JVM from exiting, but this can cause some strange-seeming behavior in thread pools that don’t use a SynchronousQueue for their work queue (as newCachedThreadPool does). If the pool is already at the core size, ThreadPoolExecutor creates a new thread only if the work queue is full. So tasks submitted to a thread pool with a work queue that has any capacity and a core size of zero will not execute until the queue fills up, which is usually not what is desired.

So to verify this I wrote this program which does not work as stated above.

    final int corePoolSize = 0;
    ThreadPoolExecutor tp = new ThreadPoolExecutor(corePoolSize, 1, 5, TimeUnit.SECONDS,
            new LinkedBlockingQueue<>());

    // If the pool is already at the core size
    if (tp.getPoolSize() == corePoolSize) {
        ExecutorService ex = tp;

        // So tasks submitted to a thread pool with a work queue that has any capacity
        // and a core size of zero will not execute until the queue fills up.
        // So, this should not execute until queue fills up.
        ex.execute(() -> System.out.println("Hello"));
    }

Output: Hello

So, does the behavior of the program suggest that ThreadPoolExecutor creates at least one thread if a task is submitted irrespective of corePoolSize=0. If yes, then what is the warning about in the text book.

EDIT: Tested the code in jdk1.5.0_22 upon @S.K.'s suggestion with following change:

ThreadPoolExecutor tp = new ThreadPoolExecutor(corePoolSize, 1, 5, TimeUnit.SECONDS,
                new LinkedBlockingQueue<Runnable>(1));//Queue size is set to 1.

But with this change, the program terminates without printing any output.

So am I misinterpreting these statements from the book?

EDIT (@sjlee): It's hard to add code in the comment, so I'll add it as an edit here... Can you try out this modification and run it against both the latest JDK and JDK 1.5?

final int corePoolSize = 0;
ThreadPoolExecutor tp = new ThreadPoolExecutor(corePoolSize, 1, 5, TimeUnit.SECONDS, new LinkedBlockingQueue<>());

// If the pool is already at the core size
if (tp.getPoolSize() == corePoolSize) {
    ExecutorService ex = tp;

    // So tasks submitted to a thread pool with a work queue that has any capacity
    // and a core size of zero will not execute until the queue fills up.
    // So, this should not execute until queue fills up.
    ex.execute(() -> System.out.println("Hello"));
}
tp.shutdown();
if (tp.awaitTermination(1, TimeUnit.SECONDS)) {
    System.out.println("thread pool shut down. exiting.");
} else {
    System.out.println("shutdown timed out. exiting.");
}

@sjlee Have posted the result in comments.

Permissive answered 10/9, 2018 at 7:42 Comment(8)
@sjlee The output for jdk 1.5 is thread pool shut down. exiting. and for jdk 1.8 is Hello thread pool shut down. exiting.Permissive
OK thanks. As you found out below, it appears that the JDK changed the behavior slightly from 1.5 to 1.6 in this regard.Fjeld
@Fjeld But the code in any java version doesn't infer the text mentioned in the text book. Am I interpreting the text incorrectly? According to my example in the question, even if the task queue is full with corePoolSize =0, the task does not execute. I am not able to understand that bit.Permissive
The "textbook" in this case is Java Concurrency in Practice? Tech books fall out of date all the time unless they're constantly updated. I just think it's something that was true at the time of publication which is no longer the case. What's far more important is javadoc (API doc). And I believe API doc on this when core pool size = 0 is pretty vague so this is not a real break in the contract. As for the task queue being full w/ core pool size = 0, could you post a code example that illustrates that?Fjeld
@Fjeld I already did in my question. Look at the test result of jdk1.5. Here, the core pool size =0,task queue size=1 and num of task submitted = 1.Permissive
Task queue being full means the queue must be full when you submit the task. In other words, queue.offer(o) would return false if the queue is full. In your example, you have a LinkedBlockingQueue of size 1. The queue is not full when the first task is submitted. Only if you submit tasks rapidly to fill up the queue only then do you recreate that condition. An easier way to simulate that is either to use a LinkedBlockingQueue of size 0 or a SynchronousQueue.Fjeld
@Fjeld Unfortunately, LinkedBlockingQueue does not allow to create a queue with size 0 and on the other hand SynchronousQueue indeed serves the purpose but I was trying to validate the text in the book.Permissive
I think another footnote from JCP is also wrong:allowCoreThreadTimeOut allows you to request that all pool threads be able to time out; enable this feature with a core size of zero if you want a bounded thread pool with a bounded work queue but still have all the threads torn down when there is no work to do. "enable this feature with a core size of zero" does not make sense.Pulvinate
B
6

This odd behavior of ThreadPoolExecutor in Java 5 when the core pool size is zero was apparently recognized as a bug and quietly fixed in Java 6.

Indeed, the problem reappeared in Java 7 as a result of some code reworking between 6 and 7. It was then reported as a bug, acknowledged as a bug and fixed.

Either way, you should not be using a version of Java that is affected by this bug. Java 5 was end-of-life in 2015, and the latest available versions of Java 6 and later are not affected. That section of "Java Concurrency In Practice" is no longer apropos.

References:

Bordiuk answered 26/5, 2020 at 15:34 Comment(0)
P
4

While running this program in jdk 1.5,1.6,1.7 and 1.8, I found different implementations of ThreadPoolExecutor#execute(Runnable) in 1.5,1.6 and 1.7+. Here's what I found:

JDK 1.5 implementation

 //Here poolSize is the number of core threads running.

 public void execute(Runnable command) {
    if (command == null)
        throw new NullPointerException();
    for (;;) {
        if (runState != RUNNING) {
            reject(command);
            return;
        }
        if (poolSize < corePoolSize && addIfUnderCorePoolSize(command))
            return;
        if (workQueue.offer(command))
            return;
        Runnable r = addIfUnderMaximumPoolSize(command);
        if (r == command)
            return;
        if (r == null) {
            reject(command);
            return;
        }
        // else retry
    }
}

This implementation does not create a thread when corePoolSize is 0, therefore the supplied task does not execute.

JDK 1.6 implementation

//Here poolSize is the number of core threads running.

  public void execute(Runnable command) {
    if (command == null)
        throw new NullPointerException();
    if (poolSize >= corePoolSize || !addIfUnderCorePoolSize(command)) {
        if (runState == RUNNING && workQueue.offer(command)) {
            if (runState != RUNNING || poolSize == 0)
                ensureQueuedTaskHandled(command);
        }
        else if (!addIfUnderMaximumPoolSize(command))
            reject(command); // is shutdown or saturated
    }
}

JDK 1.6 creates a new thread even if the corePoolSize is 0.

JDK 1.7+ implementation(Similar to JDK 1.6 but with better locks and state checks)

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

JDK 1.7 too creates a new thread even if the corePoolSize is 0.

So, it seems that corePoolSize=0 is a special case in each versions of JDK 1.5 and JDK 1.6+.

But it is strange that the book's explanation doesn't match any of the program results.

Permissive answered 15/9, 2018 at 16:7 Comment(3)
I don’t get how you come to the conclusion “the book's explanation doesn't match any of the program results” when you already acknowledged that Java 5’s result does match the description.Took
@Took I ran the following code in Java 5 and it was expected to print Hello but it didn't final int corePoolSize = 0; ThreadPoolExecutor tp = new ThreadPoolExecutor(corePoolSize, 1, 5, TimeUnit.SECONDS,new LinkedBlockingQueue<>(1));//Queue size is set to 1. if (tp.getPoolSize() == corePoolSize) { ExecutorService ex = tp; ex.execute(() -> System.out.println("Hello")); }Permissive
Exactly as the book says. So why do you claim that “the book's explanation doesn't match any of the program results”?Took
P
0

Seems like it was a bug with older java versions but it doesn't exist now in Java 1.8.

According to the Java 1.8 documentation from ThreadPoolExecutor.execute():

     /*
     * 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.
     * ....
     */

In the second point, there is a recheck after adding a worker to the queue that if instead of queuing the task, a new thread can be started, than rollback the enqueuing and start a new thread.

This is what is happening. During first check the task is queued but during recheck, a new thread is started which executes your task.

Potbelly answered 10/9, 2018 at 8:5 Comment(4)
I ran this program in jdk1.6.0_45 and it is giving the same output.Permissive
Problem was probably in jdk 1.5 it seems. I could only get this link on google which hints towards this issue being fixed in 1.6 : cs.oswego.edu/pipermail/concurrency-interest/2006-December/…Potbelly
I have added test result of jdk1.5.0_22. See the update in the question. IMO, had it been a bug, it would have been mentioned in the textbook itself.Permissive
I have added a plausible explanation. Have a look at it.Permissive
D
0

you can modify an BlockingQueue so that it does not accept any Runnable via offer (used by Executor) and add it via "add" in the reject case. This setup have 0 core threads and fill up 32 running threads before the jobs are queued. This is what i think many people expect that first up to running threads are filled and than queued.

    private static BlockingQueue<Runnable> workQueue = new LinkedBlockingDeque<>()  {
        private static final long serialVersionUID = 1L;
        @Override public boolean offer(Runnable e) { return false; }
    };

    private static ThreadPoolExecutor executor  = new ThreadPoolExecutor(0, 32, 10, TimeUnit.SECONDS, workQueue, (r,e)->workQueue.add(r));
Devise answered 28/8, 2022 at 23:43 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.