Java 21 Virtual Thread Configuration on spring-boot 3.2.x

이 글을 읽기 전에 아래 글을 먼저 읽기를 추천드립니다. 먼저 Virtual Thread를 적용하기 전에 제약에 대한 이해가 필요합니다.

ISSUE: Pinning inside synchronized

spring-boot 3.2.x 에서 드디어 JEP-444 Virtual Thread 기능을 활성화 시킬 수 있습니다.
Virtual Thread 와 관련된 자세한 내용을 알고 싶으시면, Oracle 공식 문서를 확인하는 것을 추천합니다.
(기존 Thread는 Platform Thread라고 부르네요.)

결론 부터 말씀드리면 서비스가 DB(jdbc-drivere)에 의존하고 있다면 아직은 Virtual Thread를 사용하기에는 무리입니다.

기본 전략으로는 Platform Thread(기존 방식)을 그대로 사용하시고, I/O 대기가 걸리는 부분만 Virtual Thread를 사용하는 것을 권장합니다.

Virtual Thread

spring:
  threads:
    virtual:
      enabled: true                   # virtual thread 활성화
YAML

위 옵션으로 활성화 시키면 애플리케이션 내 모든 Thread 들이 Virtual thread로 활성화 됩니다. Tomcat 웹서버, Async, Scheduling 등 다 포함됩니다. 기존에 Platform Thread를 사용하는 경우에는 Thread 생성 비용이 비싸기 때문에 기본적으로 Thread Pool 로 설정해야합니다. 하지만 Virtual Thread를 사용한다면 Thread Pool 설정은 오히려 낭비가 됩니다. 그러므로 Thread Pool 설정을 비활성화 해야합니다. 혹시나 커스텀으로 설정된 것이 있다면 제거해야합니다.

TomcatWebServerFactoryCustomizer 코드 일부

	@Configuration(proxyBeanMethods = false)
	@ConditionalOnClass({ Tomcat.class, UpgradeProtocol.class })
	public static class TomcatWebServerFactoryCustomizerConfiguration {

		@Bean
		public TomcatWebServerFactoryCustomizer tomcatWebServerFactoryCustomizer(Environment environment,
				ServerProperties serverProperties) {
			return new TomcatWebServerFactoryCustomizer(environment, serverProperties);
		}

		@Bean
		@ConditionalOnThreading(Threading.VIRTUAL)
		TomcatVirtualThreadsWebServerFactoryCustomizer tomcatVirtualThreadsProtocolHandlerCustomizer() {
			return new TomcatVirtualThreadsWebServerFactoryCustomizer();
		}
	}
Java

Async

spring:
  task:
    execution:
      shutdown:
        await-termination: true       # 스레드가 종료될 때 까지 대기할 것인가?
        await-termination-period: 30s # 종료 시 최대 대기 기간
      simple:
        concurrency-limit: -1         # -1: unllimited
YAML

Schedulinng 관련 설정은 없습니다.

Virtual Thread를 활성화 시키면 spring task(@Async) 에서는 기본적으로 SimpleAsyncTaskExecutor를 사용하게 됩니다. 고로 필요한 설정은 아래 4개의 속성만 설정할 수 있습니다.

  • spring.task.execution.thread-name-prefix
  • spring.task.execution.simple.concurrency-limit
  • spring.task.execution.shutdown.await-termination
  • spring.task.execution.shutdown.await-termination-period

저는 그 중에서 spring.task.execution.thread-name-prefix 를 사용하지 않았습니다.

이 내용은 spring-boot 문서에도 그렇게 적혀져 있으며, 실제 코드를 확인해도 그렇습니다. 아래 코드를 확인해주세요.

  @Bean(
      name = {"simpleAsyncTaskExecutorBuilder"}
  )
  @ConditionalOnMissingBean
  @ConditionalOnThreading(Threading.VIRTUAL)
  SimpleAsyncTaskExecutorBuilder simpleAsyncTaskExecutorBuilderVirtualThreads() {
      SimpleAsyncTaskExecutorBuilder builder = this.builder();
      builder = builder.virtualThreads(true);
      return builder;
  }

  private SimpleAsyncTaskExecutorBuilder builder() {
      SimpleAsyncTaskExecutorBuilder builder = new SimpleAsyncTaskExecutorBuilder();
      // spring.task.execution.thread-name-prefix
      builder = builder.threadNamePrefix(this.properties.getThreadNamePrefix());
      Stream var10001 = this.taskExecutorCustomizers.orderedStream();
      Objects.requireNonNull(var10001);
      builder = builder.customizers(var10001::iterator);
      builder = builder.taskDecorator((TaskDecorator)this.taskDecorator.getIfUnique());
      TaskExecutionProperties.Simple simple = this.properties.getSimple();
      // spring.task.execution.simple.concurrency-limit
      builder = builder.concurrencyLimit(simple.getConcurrencyLimit());
      TaskExecutionProperties.Shutdown shutdown = this.properties.getShutdown();
      // spring.task.execution.shutdown.await-termination
      if (shutdown.isAwaitTermination()) {
          // spring.task.execution.shutdown.await-termination-period
          builder = builder.taskTerminationTimeout(shutdown.getAwaitTerminationPeriod());
      }

      return builder;
  }
Java

조심할 점

  • ISSUE: Pinning inside synchronized: ReentrantLock를 사용하자. 라이브러리 지원을 기다리자.
  • 과도한 ThreadLocal: ThreadLocal 내 무거운 객체를 생성하면 Platform Thread보다 느려질 수 있다.
  • native 메서드 호출: 이건 답이 없…

Case Study: Circuit Breaker에만 Virtual Thread 적용

레거시 jdbc-driver과 같이 Pinning 이슈가 있는 경우에는 기본적으로는 Platform Thread를 적용하는 것이 좋습니다.
하지만 그럴지라도 부분적으로 Virtual Thread를 적용할 수 있죠. 아시다시피 I/O 지연이 있는 api-client를 구현하는 경우에는 부분에 Virtual Thread를 적용하면 아주 좋을 것 같네요.

현재 제가 운영하는 서비스는 api 호출 시 open-fein을 사용하고 있습니다. 거기에 resilience4j의 circuit-breaker까지 적용한 상태이죠. 대부분의 Java 월드에서 circuit-breaker 설정 시 세마포어 보다는 Thread를 기반으로 격리하는 것을 추천합니다. 물론 일부 상황 ex: 로컬 메모리 캐시 조회와 같이 I/O 대기가 적은 경우 에서 Thread 생성 비용 때문에 세마포어가 유리한 경우도 있지습니다. 이런 경우라면 굳이 Virtual Thread 일지라도 사용 안하는 것이 낫습니다.

출처: https://netflixtechblog.com/fault-tolerance-in-a-high-volume-distributed-system-91ab4faae74a

Resilence4jConfig

Customize<Resilience4JCircuitBreakerFactory> 타입으로 스프링 Bean을 생성합니다.
격리 시 Virtual Thread 를 이용하게 커스터마이징 하겠습니다.

    @Bean
    public Customizer<Resilience4JCircuitBreakerFactory> circuitBreakerCustomizer() {
        return factory -> {
            VirtualThreadExecutor executor = new VirtualThreadExecutor("circuit-breaker-");
            factory.configureExecutorService(executor);
        };
    }
Java

하지만…

[question] Is circuitbreaker virtual thread / loom friendly?

Resilence4j github 저장소에 위와 같은 이슈가 등록되어 있는데, 답변을 보니 현재 Virtual Thread 지원이 힘들다고 하네요. 시간이 없어서 오픈소스 커뮤니티의 도움을 요청하고 있습니다.

Hi, there is no release planned. I currently don't have time to develop anymore.
We need contributions from the community to make it Loom friendly

적용하는 모습을 보여드리고 싶었는데 너무 아쉽네요. ㅠㅠ 제가 시간이 있다면 Pull Request라도 올려서 기여하고 싶네요.

아쉽지만 -Djdk.tracePinnedThreads=short JVM 옵션으로 기동해서 synchronized 이슈가 발생하는 부분을 확인하면서 마무리 하겠습니다.

Thread[#87,ForkJoinPool-1-worker-1,5,CarrierThreads]
  com.example.controller.internal.InfraInternalController.infra(InfraInternalController.java:95) <== monitors:1
    org.springframework.web.filter.DelegatingFilterProxy.doFilter(DelegatingFilterProxy.java:268) <== monitors:1
Java

InfraInternalController.java

    @Operation(summary = "Infra, Service 접속 여부 확인 API")
    @GetMapping
		// synchronized
    public synchronized InfraResponse infra() {
        // InfraInternalController.java:95
        return new InfraResponse(isConnect1(), isConnect2(), isConnect3(), isConnect4(),
                                 isConnect5(), isConnectS3(), isConnectSes(), isConnectPrimaryDb(), isConnectOtherDb());
    }
Java

DelegatingFilterProxy.java

	@Override
	public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain)
			throws ServletException, IOException {

		Filter delegateToUse = this.delegate;
		if (delegateToUse == null) {
		  // synchronized
			synchronized (this.delegateMonitor) {
				delegateToUse = this.delegate;
				if (delegateToUse == null) {
					WebApplicationContext wac = findWebApplicationContext();
					if (wac == null) {
						throw new IllegalStateException("No WebApplicationContext found: " +
								"no ContextLoaderListener or DispatcherServlet registered?");
					}
					delegateToUse = initDelegate(wac);
				}
				this.delegate = delegateToUse;
			}
		}

		// DelegatingFilterProxy.java:268
		invokeDelegate(delegateToUse, request, response, filterChain);
	}
Java

Leave a Comment