Goodbye Future, illustrating structured concurrency of JDK21 virtual threads

Java provides us with many methods to start threads and manage threads. In this article, we will introduce some options for concurrent programming in Java. We'll introducethe concept of structured concurrencyand then discussJava 21 A set of preview classes - It makes it very easy to split a task into subtasks, collect the results, and act on them without accidentally leaving any pending tasks.

1 Basic method

This method of creating threads by starting platform threads through Lambda expressions is the simplest and suitable for simple situations.

// Lambda表达式启动平台线程的一种方法。
Thread.ofPlatform().start(() -> {

    // 在这里执行在独立线程上运行的操作

});

question

  • Creating platform threads is expensive
  • If the application has a large number of users, the number of platform threads may grow beyond the limits supported by the JVM.

Apparently, most application servers discourage this behavior. So, moving on to the next method - Java Futures.

2 Java Future class

With the introduction of JDK 5, developers need to change the way they think. Instead of starting a new thread, think about submitting a "task" to the thread pool for execution. JDK 5 also introduces ExecutorService, to which tasks will be submitted. ExecutorService is an interface that defines a mechanism for submitting tasks and returning Java Futures. The submitted task needs to implement the Runnable or Callable interface.

Tasks are submitted to a thread pool that represents a single thread

// 将Callable任务提交给表示单线程线程池的ExecutorService

ExecutorService service = Executors.newSingleThreadExecutor();
Future<String> future = service.submit(() -> {
    // 进行一些工作并返回数据
    return "Done";
});
// 在这里执行其他任务

// 阻塞直到提交的任务完成
String output = future.get();

// 打印 "Done"
System.out.println(output);

// 继续执行后续任务

Multiple tasks submitted to ExecutorService

try (ExecutorService service = Executors.newFixedThreadPool(3)) {

    Future<TaskResult> future1 = service.submit(() -> { 
          // 执行任务1并返回TaskResult 
    });

    Future<TaskResult> future2 = service.submit(() -> { 
          // 执行任务2并返回TaskResult 
    });  

    Future<TaskResult> future3 = service.submit(() -> { 
          // 执行任务3并返回TaskResult 
    });

    /* 所有异常上抛 */

    // get()将阻塞直到任务1完成
    TaskResult result1 = future1.get();

    // get()将阻塞直到任务2完成
    TaskResult result2 = future2.get();

    // get()将阻塞直到任务3完成
    TaskResult result3 = future3.get();

    // 处理result1、result2、result3
    handleResults(result1, result2, result3);
}

All these tasks will run in parallel, and the parent thread can then retrieve the results of each task using the future.get() method.

3 Problems with the above implementation

If you use Platform threads in the above code, there is a problem. Getting the TaskResult's get() method will block the thread, which can be expensive due to scalability issues associated with blocking Platform threads. However, with Java 21 - if using Virtual Threads, the underlying platform thread will not be blocked during get().

If task2 and task3 are completed before task1, you must wait until task1 is completed, and then process the results of task2 and task3.

If the execution process of task2 or task3 fails, the problem is worse. Assuming that the entire use case should fail if any task fails, the code will wait until task1 completes and then throw an exception. This is not what we expect and it will create a very sluggish experience for the end user.

3.1 Basic questions

ExecutorService class knows nothing about the relationship between the various tasks submitted to it. Therefore, it does not know what will happen if a task fails. That is, the three tasks submitted in the example are considered independent tasks and not part of the use case. This is not a failure of the ExecutorService class, as it is not designed to handle any relationship between submitted tasks.

3.2 Another question

Use around ExecutorServicetry-with-resources block, making sure to call < when the try block exits The close method of /span> method ensures that all tasks submitted to the executor service are terminated before execution continues. close. The ExecutorService

If the use case requires immediate failure when any task fails, we are out of luck. The close method will wait for all submitted tasks to complete.

However, if the try-with-resources block is not used, there is no guarantee that all three tasks will end before the block exits. "Unclearly terminated threads" that are terminated without cleanup will be retained. Any other custom implementation must ensure that other tasks are immediately canceled on failure.

So, while using Java Futures is a good way to handle tasks that can be split into subtasks, it's not perfect yet. Development must encode the "awareness" of the use case into the logic, but it's hard!

Note that one of the problems with Platform threads in Java Futures is the blocking problem - this problem no longer exists when using Virtual threads in Java 21. Because when using Virtual Threads, blocking the thread using the future.get() method will release the underlying Platform thread.

UsingCompletableFuture Pipelines can also solve the blocking problem, but we will not discuss it in depth here. There is an easier way to solve the Java 21 blocking problem, and that’s right, it’s Virtual Threads! But we need to find a better solution for handling tasks that can be split into subtasks and that "know" the use case. This leads to the basic idea of ​​structured concurrency.

4 Structured concurrency

Imagine that a task is submitted to the ExecutorService from within a method, and then the method exits. It is now harder to reason about the code because the possible side effects of this submitted task are not known, and this can lead to problems that are difficult to debug. Diagram of the problem:

Structured ConcurrencyThe basic idea is that all tasks started within a block (method or block) should be terminated before the end of the block. That is:

  • Structural boundaries (blocks) of code

  • and the runtime boundaries of tasks submitted within the block

coincide. This makes application code easier to understand because the execution effects of all tasks submitted within a block are restricted to that block. When viewing code outside of a block, you don't have to worry about whether the task is still running.

ExecutorService'stry-with-resources block is forstructured concurrency< A good attempt at a i=4> where all tasks submitted from within the block are completed when the block exits. But it's not enough as it can cause the parent thread to wait longer than necessary. Its improved version - StructuredTaskScope.

5 StructuredTaskScope

Java 21 Virtual Thread was introduced as a feature that virtually eliminates blocking issues in most cases. But even with Virtual Threads and Futures, there are still problems with "unclean termination of tasks" and "waiting longer than necessary".

The StructuredTaskScope class is provided as a preview feature in Java 21 to solve this problem. It attempts to provide cleaner structured concurrency than the Executor Service's try-with-resources block class knows the relationships between submitted tasks, so it can make smarter assumptions about them. StructuredTaskScopeModel. The

Example of usingStructuredTaskScope

When any task fails, return to the use case immediately.

StructuredTaskScope.ShutdownOnFailure() Returns a reference to StructuredTaskScope, which knows that if one task fails, other tasks must also terminate, because it "knows" between submitted tasks Relationship.

 try(var scope = new StructuredTaskScope.ShutdownOnFailure()) {          

     // 想象一下LongRunningTask实现Supplier
     var dataTask = new LongRunningTask("dataTask", ...);  
     var restTask = new LongRunningTask("restTask", ...); 

     // 并行运行任务
     Subtask<TaskResponse> dataSubTask = scope.fork(dataTask);           
     Subtask<TaskResponse> restSubTask = scope.fork(restTask);           

     // 等待所有任务成功完成或第一个子任务失败。 
     // 如果一个失败,向所有其他子任务发送取消请求
     // 在范围上调用join方法,等待两个任务都完成或如果一个任务失败
     scope.join();                                                       
     scope.throwIfFailed();                                              

     // 处理成功的子任务结果                                
     System.out.println(dataSubTask.get());                              
     System.out.println(restSubTask.get());                              
 }                                                                       

Enterprise use cases

Two of the tasks can run in parallel:

  • A DB task
  • A Rest API task

The goal is to run these tasks in parallel and then combine the results into a single object and return it.

CallShutdownOnFailure() static method to create aStructuredTaskScope kind. Then use the StructuredTaskScopeobjectfork method (replace fork method is considered as submit method) to run two tasks in parallel. Behind the scenes, the StructuredTaskScope class uses Virtual threads by default to run tasks. Each time you fork a task, create a new Virtual thread (Virtual threads are never pooled) and run the task.

Then call thejoin method on the scope, waiting for both tasks to complete or if one task fails. More importantly - if a task fails, the join() method will automatically send a cancellation request to other tasks (remaining running tasks) and wait for their termination. This is important because canceling the request will ensure that there are no unnecessary pending tasks when the block exits.

The same is true if other threads send cancellation requests to the parent thread. At the end, if an exception is thrown anywhere inside the block - StructuredTaskScope's close method will ensure that a cancellation request is sent to the subtask and the task is terminated. StructuredTaskScopeThe beauty is - if the child thread creates its own StructuredTaskScope (subtask itself has its own subtasks), they will all be handled cleanly when canceled.

One responsibility of developers here is to ensure that the tasks they write correctly handle interrupt flags that are set on threads during cancellation. It is the task's responsibility to read this interrupt flag and terminate itself cleanly. If the task does not handle the interrupt flag correctly, the responsiveness of the use case will be affected.

6 Using StructuredTaskScope

UseStructuredTaskScopeis appropriate. The example seen in this article is a use case that needs to return immediately if any subtask fails. But StructuredTaskScope is much more than that.

  • Returns when the first task succeeds
  • Returns when all tasks are completed (success or failure)
  • Make your own version of StructuredTaskScope

6.1 Advantages of StructuredTaskScope

  • Code is easy to read because it looks the same regardless of the use case
  • Child threads that fail are terminated cleanly when appropriate. No unnecessary hanging threads
  • UsingStructuredTaskScope together with Virtual Threads means that scalability issues related to blocking do not exist. It’s no wonder, by default, StructuredTaskScope uses Virtual Threads under the hood

7 Summary

Overall, theStructuredTaskScope class is a good addition in Java to handle use cases of splitting a task into multiple subtasks. Automatic cancellation of child threads on failure, code consistency for different use cases and the ability to better understand the code make it a great choice to implement Structured Concurrency in Java ideal choice.

The Virtual Threads andStructuredTaskScope classes together form a perfect combination. Virtual Threads enable us to create hundreds of thousands of threads in the JVM, and the StructuredTaskScope class enables us to manage these threads efficiently.

Let's wait for it to come out of preview and become an official feature!

This article is published by the blog post platform OpenWrite!

Guess you like

Origin blog.csdn.net/qq_33589510/article/details/134911853