Skip to content
Advertisement

ScheduledExecutorService with relative delay between tasks

I’m trying to make a ScheduledExecutorService where only one task is active at a time and only once a task has finished, the next task will begin its delay with an arbitrary delay amount.

As a very simple example of what I mean, take a look at this method. The idea is to schedule 10 Runnables to simulate a countdown from 10-1. Each interval takes one second (imagine this was an arbitrary amount of seconds though, I can’t use scheduleAtFixedRate in my use case).

private ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();

public void startCountdown() {
  for (int i = 10; i > 0; i--) {
    int countdownNumber = i;
    scheduler.schedule(() -> {
      System.out.println(countdownNumber);
    }, 1, TimeUnit.SECONDS);
  }
}

However, this will simply print all 10 numbers at once, instead of waiting for a second between each value. The only way I can circumvent this (to my knowledge) is calculating the ABSOLUTE delay, as opposed to the relative one.

While it’s possible to calculate the absolute time for each item, it would be quite a hassle. Isn’t there some construct in Java that allows me to queue many items at once, but waits in between each item for the delay to finish, rather than processing every delay at once?

Advertisement

Answer

tl;dr

  • Do not use your countdown number to directly schedule your tasks. Have one number for scheduling number of seconds to wait (1,2,3,…) and another number for the countdown (9,8,7,…).
  • Use scheduleAtFixedRate to schedule your tasks for an increasing number of seconds. No need for executor service to be single-threaded.

Details

Task that re-schedules itself

Isn’t there some construct in Java that allows me to queue many items at once, but waits in between each item for the delay to finish, rather than processing every delay at once?

If you have an arbitrary amount of time not known up front when beginning the scheduling, then you should only run one task at a time. Let the task re-schedule itself.

To enable a task to re-schedule itself, pass a reference to the ScheduledExecutorService to the task object (your Runnable or Callable) as an argument in the constructor. After the task completes its main work, it discovers/calculates the amount of time to elapse for the next run. The task then submits itself (this) to the passed executor service, along with the amount of time to elapse before the next task execution.

I have already posted Answers on Stack Overflow with code for tasks that re-schedule themselves. I would expect others have as well. Search to learn more.

Regarding the “countdown” aspect of your Question, read on.

Countdown

You have the right approach in using a scheduled executor service. The problem is that you are calling the wrong method on that class.

Your call to schedule means you are scheduling several tasks to all run after a single second. All those tasks are starting from the moment your call is made. So each runs after one second from your call to schedule. So the ten tasks are all waiting a second from almost the same moment: ten moments a split-second apart, the split-second being the time it takes for your for loop to continue.

scheduleAtFixedRate

The method you are looking for is scheduleAtFixedRate. To quote the doc:

Submits a periodic action that becomes enabled first after the given initial delay, and subsequently with the given period; that is, executions will commence after initialDelay, then initialDelay + period, then initialDelay + 2 * period, and so on.

private ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();

public void countdown( ScheduledExecutorService scheduler ) 
{
    for ( int i = 1 ; i <= 10 ; i++ ) 
    {
        int countdownNumber =  10 - i ;  // For 9 through 0. Add 1 for 10 through 1. 
        scheduler.scheduleAtFixedRate 
        (
            () -> { System.out.println( countdownNumber ) ; } , 
            i ,  // 1 second, then 2 seconds, then 3 seconds, and so on to 10 seconds.
            TimeUnit.SECONDS 
        ) ;
    }
}

… Eventually shut down your scheduled executor service.

Notice how this approach does not require the ScheduledExecutorService to be single-threaded.

Full example

Here is a complete example app.

package work.basil.example.countdown;

import java.time.Duration;
import java.time.Instant;
import java.util.Objects;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;

public class Countdown
{
    public static void main ( String[] args )
    {
        Countdown app = new Countdown();
        app.demo();
    }

    private void demo ( )
    {
        System.out.println( "INFO - Demo start. " + Instant.now() );

        ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();  // Our code does *not* require the executor service to be single-threaded. But for this particular example, we might as well do it that way.
        this.countdown( scheduler );
        this.shutdownAndAwaitTermination( scheduler , Duration.ofMinutes( 1 ) , Duration.ofMinutes( 1 ) );

        System.out.println( "INFO - Demo end. " + Instant.now() );
    }

    public void countdown ( final ScheduledExecutorService scheduler )
    {
        Objects.requireNonNull( scheduler ) ; 

        for ( int i = 1 ; i <= 10 ; i++ )
        {
            int countdownNumber = 10 - i;  // For 9 through 0. Add 1 for 10 through 1.
            scheduler.scheduleAtFixedRate
            (
                    ( ) -> { System.out.println( "Countdown: " + countdownNumber + " at " + Instant.now() ); } ,
                    i ,  // 1 second, then 2 seconds, then 3 seconds, and so on to 10 seconds.
                    TimeUnit.SECONDS
            );
        }
    }

    // My slightly modified version of boilerplate code taken from Javadoc of `ExecutorService`.
    // https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/concurrent/ExecutorService.html
    void shutdownAndAwaitTermination ( final ExecutorService executorService , final Duration waitForWork , final Duration waitForRemainingTasks )
    {
        Objects.requireNonNull( executorService ) ;
        Objects.requireNonNull( waitForWork ) ;
        Objects.requireNonNull( waitForRemainingTasks ) ;

        executorService.shutdown(); // Disable new tasks from being submitted
        try
        {
            // Wait a while for existing tasks to terminate
            if ( ! executorService.awaitTermination( waitForWork.toMillis() , TimeUnit.MILLISECONDS ) )
            {
                executorService.shutdownNow(); // Cancel currently executing tasks
                // Wait a while for tasks to respond to being cancelled
                if ( ! executorService.awaitTermination( waitForRemainingTasks.toMillis() , TimeUnit.MILLISECONDS ) )
                { System.err.println( "ExecutorService did not terminate." ); }
            }
        }
        catch ( InterruptedException ex )
        {
            // (Re-)Cancel if current thread also interrupted
            executorService.shutdownNow();
            // Preserve interrupt status
            Thread.currentThread().interrupt();
        }
        System.out.println( "DEBUG - shutdownAndAwaitTermination ran. " + Instant.now() );
    }
}

When run:

INFO - Demo start. 2023-01-20T21:24:47.379244Z
Countdown: 9 at 2023-01-20T21:24:48.390269Z
Countdown: 8 at 2023-01-20T21:24:49.390045Z
Countdown: 7 at 2023-01-20T21:24:50.389957Z
Countdown: 6 at 2023-01-20T21:24:51.386468Z
Countdown: 5 at 2023-01-20T21:24:52.390168Z
Countdown: 4 at 2023-01-20T21:24:53.386538Z
Countdown: 3 at 2023-01-20T21:24:54.387583Z
Countdown: 2 at 2023-01-20T21:24:55.386705Z
Countdown: 1 at 2023-01-20T21:24:56.389490Z
Countdown: 0 at 2023-01-20T21:24:57.387566Z
DEBUG - shutdownAndAwaitTermination ran. 2023-01-20T21:24:57.391224Z
INFO - Demo end. 2023-01-20T21:24:57.391966Z

By the way, know that scheduled tasks do not always fire exactly on time for a variety of reasons.

Also, be aware that messages sent to System.out across threads do not always appear on the console chronologically. If you care about order, always include and study a timestamp such as Instant#now.

Advertisement