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.