Exemplifying thread context switching 2 – Concurrency – Virtual Threads, Structured Concurrency

Example 2

In this example, let’s start by limiting the parallelism to 1 (is like having a single core and a single virtual thread):

System.setProperty(
  “jdk.virtualThreadScheduler.maxPoolSize”, “1”);
System.setProperty(
  “jdk.virtualThreadScheduler.maxPoolSize”, “1”);
System.setProperty(
  “jdk.virtualThreadScheduler.maxPoolSize”, “1”);

Next, let’s consider that we have a slow task (we call it slow because it sleeps for 5 seconds):

Runnable slowTask = () -> {
  logger.info(() -> Thread.currentThread().toString()
    + ” | working on something”);          
  logger.info(() -> Thread.currentThread().toString()
    + ” | break time (blocking)”);
  try { Thread.sleep(Duration.ofSeconds(5)); }
    catch (InterruptedException ex) {} // blocking          
  logger.info(() -> Thread.currentThread().toString()
    + ” | work done”);
};

And, a fast task (similar to the slow task but sleeps only 1 second):

Runnable fastTask = () -> {
  logger.info(() -> Thread.currentThread().toString()
    + ” | working on something”);          
  logger.info(() -> Thread.currentThread().toString()
    + ” | break time (blocking)”);
  try { Thread.sleep(Duration.ofSeconds(1)); }
    catch (InterruptedException ex) {} // blocking          
  logger.info(() -> Thread.currentThread().toString()
    + ” | work done”);
};

Next, we define two virtual threads to execute these two tasks as follows:

Thread st = Thread.ofVirtual()
  .name(“slow-“, 0).start(slowTask);
Thread ft = Thread.ofVirtual()
  .name(“fast-“, 0).start(fastTask);
      
st.join();
ft.join();

If we run this code then the output will be as follows:

[08:38:46] VirtualThread[#22,slow-0]/runnable
           @ForkJoinPool-1-worker-1 | working on something
[08:38:46] VirtualThread[#22,slow-0]/runnable
           @ForkJoinPool-1-worker-1 | break time (blocking)
[08:38:46] VirtualThread[#24,fast-0]/runnable
           @ForkJoinPool-1-worker-1 | working on something
[08:38:46] VirtualThread[#24,fast-0]/runnable
           @ForkJoinPool-1-worker-1 | break time (blocking)
[08:38:47] VirtualThread[#24,fast-0]/runnable
           @ForkJoinPool-1-worker-1 | work done
[08:38:51] VirtualThread[#22,slow-0]/runnable
           @ForkJoinPool-1-worker-1 | work done

If we analyze this output, we notice that the execution starts the slow task. The fast task cannot be executed since worker-1 (the only available worker) is busy executing the slow task.

[08:38:46] VirtualThread[#22,slow-0]/runnable
           @ForkJoinPool-1-worker-1 | working on something

Worker-1 executes the slow task until this task hits the sleeping operation. Since this is a blocking operation, the corresponding virtual thread (#22) is unmounted from worker-1.

[08:38:46] VirtualThread[#22,slow-0]/runnable
           @ForkJoinPool-1-worker-1 | break time (blocking)

JVM takes advantage of the fact that worker-1 is available and pushes for the execution of the fast task.

[08:38:46] VirtualThread[#24,fast-0]/runnable
           @ForkJoinPool-1-worker-1 | working on something

The fast task also hits a sleeping operation and its virtual thread (#24) is unmounted.

[08:38:46] VirtualThread[#24,fast-0]/runnable
           @ForkJoinPool-1-worker-1 | break time (blocking)

But, the fast task sleeps only for 1 second, so its blocking operation is over before the slow task blocking operation which is still sleeping. So, the JVM can schedule the fast task for execution again, and worker-1 is ready to accept it.

[08:38:47] VirtualThread[#24,fast-0]/runnable
           @ForkJoinPool-1-worker-1 | work done

At this moment, the fast task is done and worker-1 is free. But, the slow task is still sleeping. After these long 5 seconds, the JVM schedules the slow task for execution and worker-1 is there to take it.

[08:38:51] VirtualThread[#22,slow-0]/runnable
           @ForkJoinPool-1-worker-1 | work done

Done!

Example 3

This example is just a slight modification of Example 2. This time, let’s consider that the slow task contains a non-blocking operation that runs forever. In this case, this operation is simulated via an infinite loop:

Runnable slowTask = () -> {
  logger.info(() -> Thread.currentThread().toString()
    + ” | working on something”);          
  logger.info(() -> Thread.currentThread().toString()
    + ” | break time (non-blocking)”);
  while(dummyTrue()) {} // non-blocking          
  logger.info(() -> Thread.currentThread().toString()
    + ” | work done”);
};
static boolean dummyTrue() { return true; }

We have a single worker (worker-1) and the fast task is the same as in Example 2. If we run this code, the execution hangs on as follows:

[09:02:45] VirtualThread[#22,slow-0]/runnable
           @ForkJoinPool-1-worker-1 | working on something
[09:02:45] VirtualThread[#22,slow-0]/runnable
           @ForkJoinPool-1-worker-1 | break time(non-blocking)
// hang on

The execution hangs on because the infinite loop is not seen as a blocking operation. In other words, the virtual thread of the slow task (#22) is never unmounted. Since there is a single worker, the JVM cannot push for the execution of the fast task.If we increase the parallelism from 1 to 2 then the fast task will be successfully executed by worker-2, while worker-1 (executing the slow task) will simply hang on to a partial execution. We can avoid such situations by relying on a timeout join such as join(Duration duration). This way, after the given timeout, the slow task will be automatically interrupted. So, pay attention to such scenarios.