Java thread-cancelation notes

Russell Bateman
August 2024
last update:

I have a long-running ReST service that performs duties for user clients. Sometimes these duties take a while to be carried out and clients don't wish to bounce Tomcat to dislodge long outstanding requests especially since restarting Tomcat would cause the loss of all the other requests currently being processed.

So, the idea was hit upon to enhance the POST request to accept a query parameter stating how long the user is willing to wait. When the request is processed, I need code that will cancel the thread if it takes longer than the permitted time.

I had never written anything like this before, so I had to look around for how to accomplish it. My standard aspiration is to find solutions in Java rather than to begin relying upon and place myself at the mercy of some third-party library. Of course, if what I'm looking for rises to the level of a complicated framework, I may gladly accept someone else's help.

In my trial implementation, I have two classes. The first is a JUnit test. The two test cases...

  1. testKillThreadBecauseNotEnoughTimeToFinish()
  2. testEnoughTimeToFinish()

...cover the basic unicorn, marshmallow, rainbow and fairies behavior of what I want. The names of these test cases reveal it all.

DoTest.java:

This is the JUnit test that acts as the controlling service-implementation layer. Here's the top of the JUnit test suite. If you concatenate the Java code segments below, you'll end up with DoTest.java.

package com.windofkeltia.executor;

import java.util.concurrent.CancellationException;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import org.junit.Test;

/**
 * This test becomes the application piece that makes use of the ExecutorService and Future<?> interfaces
 * to enforce thread limits on the lifecycle underneath it.
 *
 * For example, a customer might say, "If the response isn't back within 30 seconds, cancel the request
 * and continue on with the next one.
 */
public class DoTest
{
  private static final Logger logger   = LoggerFactory.getLogger( DoTest.class );
  private static final boolean VERBOSE = true;

First test case: testEnoughTimeToFinish()—configuration properties

  @Test
  public void testEnoughTimeToFinish()
  {
    final int QUIT_TIME = 30 * 1000;  // let's set a 30-second time limit to complete task...
    final int WAIT_TIME =  1 * 1000;  // wait 1 second before looking at elapsed time again...

Output of testEnoughTimeToFinish()

/home/russ/dev/jdk-11.0.10+9/bin/java -ea -Didea.test.cyclic.buffer.size=67108864 -javaagent:/home/russ/dev/idea-IU-241...
[main] INFO com.windofkeltia.executor.DoTest - Thread finished work in (25002 milliseconds).
This is a test.Lorem ipsum odor amet, consectetuer adipiscing elit. Potenti dis
a interdum egestas condimentum nullam ipsum at. Molestie accumsan...

Process finished with exit code 0

Second test case: testKillThreadBecauseNotEnoughTimeToFinish()—configuration properties

  @Test( expected = CancellationException.class )
  public void testKillThreadBecauseNotEnoughTimeToFinish()
  {
    final int QUIT_TIME = 15 * 1000;  // let's set a 15-second time limit to complete task...
    final int WAIT_TIME =  3 * 1000;  // wait 3 seconds before looking at elapsed time again...

Output of testKillThreadBecauseNotEnoughTimeToFinish()

/home/russ/dev/jdk-11.0.10+9/bin/java -ea -Didea.test.cyclic.buffer.size=67108864 -javaagent:/home/russ/dev/idea-IU-241...
[main] INFO com.windofkeltia.executor.DoTest - Thread canceled for having exceeded the time limit (18000 milliseconds).

Process finished with exit code 0

Common code between the two JUnit test cases:

Just copy this at the end of the configurations above. I didn't isolate it as a separate method. Here's what's happening line by line in the code below...

 2. Instantiation of future by which control is maintained and the state discoverable.
    Also, the work task is instantiated and kicked off.
 4. Note the starting time.
10. Loop until future tells us that the work task has been done.
12. Calculate the elapsed time so far.
13. As long as the elapsed time hasn't exceeded the time limit given, ...
16. Sleep just a bit (this is only to lengthen out how long it takes to perform this silly replacement
    for a real task in order to intervene and cancel it easily enough).
21. Once elapsed time outstrips the time limit, kill the thread whether or not it has finished running
    (noted by the true argument).
25. Now that the loop has been exited, measure the total time taken.
27. One of two possible states exist: the task has either been aborted or successfully accomplished.
33. Whatever the product of any successfully completed task, it's obtained from future.
    In our case, it's just a text (String). We can return it further up.
35. (Catches: I just want to make sure nothing happens that I fail to see.)
40. (ibid)

Yes, there are a few race conditions not covered by explanation or by code here, but I have to leave them as exercises for myself when I begin using this. It's the reason for the catches in the code. We're dealing with two threads, a controling thread and a working thread here. One race condition, for instance, is what happens when the work has really completed but is canceled anyway (lines 14 and 21).

    final ExecutorService  service = Executors.newSingleThreadExecutor();
    final Future< String > future  = new Do( service, "This is a test." ).something();

    long    start    = System.currentTimeMillis();
    boolean canceled = false;

    try
    {
      // cancel the request if taking too long...
      while( !future.isDone() )
      {
        long elapsed = System.currentTimeMillis() - start;

        if( !( elapsed > QUIT_TIME ) )
        {
          Thread.sleep( WAIT_TIME );
          continue;
        }

        // out of time: kill the running thread...
        canceled = future.cancel( true );
        break;
      }

      long stop = System.currentTimeMillis();

      if( canceled )
        logger.info( "Thread canceled for having exceeded the time limit ({} milliseconds).", stop - start );
      else
        logger.info( "Thread finished work in ({} milliseconds).", stop - start );

      if( VERBOSE )
        System.out.println( future.get() );
    }
    catch( InterruptedException e )
    {
      logger.info( "Interrupted exception{}", e.getMessage() );
      throw new RuntimeException( e );
    }
    catch( ExecutionException e )
    {
      logger.info( "Execution exception: {}", e.getMessage() );
      throw new RuntimeException( e );
    }
  }

Do.java:

Calling this class Do was just an off-the-cuff fluency joke with myself (do...something, right?).

This code represents what's below the servlet code or service implementation code underneath which is found the real work that my service has long been doing. Here, instead of that work, I'm just going to copy 5 paragraphs of "Lorem ipsum..." in separate gulps separated by 5 second intervals totaling a bit more than 25 seconds. That will give me ample opportunity to test full completion versus premature cancelation of task.

In an effort to be clearer, let's just say that the code highlighted below inside the method is where I'll put (calls to) my real, lower-layer code.

package com.windofkeltia.executor;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Future;

import static java.util.Objects.nonNull;

public class Do
{
  /* Use a 5-second window between copying five different bits during which our caller can cancel
   * our running thread. The total time to finish will be ever so slightly more than 25 seconds.
   */
  private static final int SLEEP = 5000;

  private final ExecutorService service;
  private final StringBuilder loremIpsum = new StringBuilder();

  public Do( ExecutorService service, final String source )
  {
    this.service = service;

    if( nonNull( source ) && !source.isEmpty() )
      loremIpsum.append( source );
  }

  /**
   * This method will be the one that does real work and, if it runs longer than the configured
   * duration, gets killed.
   */
  public Future< String > something()
  {
    return service.submit( () ->
    {
      for( String paragraph : LOREM_ABSIT )
      {
        loremIpsum.append( paragraph );
        Thread.sleep( SLEEP );   // wait between each copy to extend how long this work takes...
      }

      return loremIpsum.toString();
    } );
  }

  /** Here are five clumps of "Lorem ipsum..." text as fake work. */
  private static final String[] LOREM_IPSUM =
  {
      "Lorem ipsum odor amet, consectetuer adipiscing elit."                   /* 1 */
    + "Potenti dis a interdum egestas condimentum nullam ipsum at. "
    + "Molestie accumsan adipiscing massa duis facilisis. "
  ...
    + "Penatibus vulputate congue porttitor massa euismod ac.\n",
  ...
      "Potenti mattis suscipit a feugiat suspendisse ornare interdum libero. " /* 2 */
    + "Vulputate per fringilla quisque morbi varius mollis eleifend. "
  ...
    + "Porta eros erat etiam cursus magna mi?\n"                               /* end of 5 */
  };
}

Some useful links