Spring Boot - Virtual Thread!

Virtual Thread

출처
https://techblog.woowahan.com/15398/ https://tech.kakaopay.com/post/ro-spring-virtual-thread/
https://d2.naver.com/helloworld/1203723

Java21 에서 공개된 Virtual Thread 를 통해 Blocking 기반의 코드에서 획기적인 성능향상이 가능하다.

Java21 이전, 기존 JVM 에선 OS에서 제공되는 Thread 와 직접 매핑되는 Platform Thread 를 통해 CPU 자원을 사용해왔다.

1 Java 에서 생성 가능한 Thread 개수는 OS 지원 Thread 를 초과할 수 없고 Request 사용자만큼 Thread 가 생성되는 Spring MVC 서버에선 OS 최대개수만큼 동시접속자 처리가 불가능하다는 뜻이다.

Java21 Virtual Thread 에선 JVM과 OS 사이에서 JNI(Java Native Interface) 가 수행하는 비싼 Operation JVM 내에서 경량 스레드 방식을 통해 최적화 하는 방식을 지원한다.

1

  • Thread 생성 및 Context switch 오버헤드 감소.
  • Thread 당 수 MB에 달하는 메모리 차지 제거.
  • OS Thread 가용 수 상관없이 Thread 생성 가능.

이런 방식이 가능한 이유는 Platform Thread 의 ForkJoinPool 스케줄러애서 시스템콜이 필요한 I/O 작업이 발생할 때 마다 CPU 를 사용하는 스레드를 블록킹하지 않고 스케줄링을 통해 내부 Virtual Thread 들이 CPU 자원을 효율적으로 사용할 수 있도록 지원한다.

park() & unpark()

Virtual Thread 동작방식을 설명할때 아래 3종류의 Thread 로 구분짖는다.

  • Virtual Thread
    • 사용자 공간에서 관리되는 경량 스레드.
  • Platform Thread
    • Java의 기존 스레드, OS Thread 와 같음.
  • Carrier Thread
    • Virtual Thread 가 실행될 때 Platform Thread 로 운반(Carrier) 하여 OS Thread 자원을 사용. Virtual Thread 가 사용을 기다리는 공유자원이다. ForkJoinPool 에서 관리된다.

Virtual Thread 가 실제 자원인 Carrier Thread 를 사용하는 순서는 아래와 같다.

  • Virtual Thread는 일반 메서드처럼 실행되다가 차단 가능한 지점(sleep, I/O, lock)에서 park()를 호출.
  • park() 시점에 실행되던 Virtual Thread 상태를 Continuation으로 저장하고 Carrier Thread를 반환.
  • 이후 적절한 시점에 unpark()로 다시 실행을 예약.

이를 통해 I/O 작업을 Non-blocking I/O 형태로 수행할 수 있다.

Stackful Coroutine 방식이라 부름.

Pinning

만병통치약같은 Virtual Thread 환경에서도 Pinning 을 주의해야한다.

Pinning 블록킹 연산이 특정 조건에서 발생할 때 발생한다. Virtual Thread 가 Carrier Thread에 고정(pinned) 되어있는 상황을 뜻한다.

  • synchronized 블록 안에서 블로킹 I/O 호출
  • 네이티브 메서드 안에서 블로킹 I/O 호출

위와 같은 예에선 블로킹 I/O 가 발생했을 때 suspend 되어 다른 가상스레드가 Carrier Thread 를 사용하지 못하고 블로킹 시간동안 점유하게 된다.

synchronized 키워드는 대부분 라이브러리에서 잘 사용되고 있지 않지만 일부 라이브러리에선 계속 사용되어서 사용시 주의해야한다.

Java24 부턴 synchronized 키워드에서 블로킹 I/O 가 발생해도 동작시키도록 수정되었다. 향후 Java25(LTS) 가 나오면 변경을 추천.

Pinning 이 일어나는지 확인하려면 코드 실행시 -Djdk.tracePinnedThreads=full 옵션을 사용하면 된다.

데모 코드

https://github.com/Kouzie/spring-virtual-thread-test

간단히 spring.threads.virtual.enabled 설정을 on/off 하여 k6s 로 테스트

spring:
  threads:
    virtual:
      enabled: true
@SpringBootApplication
class MvcDemoApplication

fun main(args: Array<String>) {
    runApplication<MvcDemoApplication>(*args)
}

@RestController
class DemoController {

    @GetMapping("/demo")
    fun get(): String {
        Thread.sleep(2000)
        return "Is virtual thread: ${Thread.currentThread().isVirtual}"
    }
}

2000개의 가상사용자가 동시에 한번 호출하는 시나리오

import http from 'k6/http';
import { check, sleep } from 'k6';

export const options = {
  vus: 2000, // 가상 사용자 수 (동시 요청 수)
  iterations: 2000, // 총 요청 수 (각 VU가 1번씩 요청)
  duration: '1m', // 테스트 지속 시간
};

export default function () {
  check(res, {
    'is status 200': (r) => r.status === 200,
  });
}

Tomcat 에선 기본 스레드 개수인 200개씩 2초마다 처리하느라 20초가 걸리고
Virtual Thread 를 사용하는 경우 2초에 2000개의 요청이 한번에 처리 완료되었다.

k6 run get_demo.js

running (0m20.1s), 0000/2000 VUs, 2000 complete and 0 interrupted iterations
default ✓ [======================================] 2000 VUs  0m20.1s/1m0s  2000/2000 shared iters

k6 run get_demo.js

running (0m02.2s), 0000/2000 VUs, 2000 complete and 0 interrupted iterations
default ✓ [======================================] 2000 VUs  0m02.2s/1m0s  2000/2000 shared iters

간단히 Webflux 에서도 테스트 하였는데 Virtual Thread 를 사용하는것과 동일한 결과가 출력되었다.

@SpringBootApplication
class WebfluxDemoApplication

fun main(args: Array<String>) {
    runApplication<WebfluxDemoApplication>(*args)
}

@RestController
class DemoController {

    @GetMapping("/demo")
    fun get(): Mono<String> {
        return Mono.delay(Duration.ofSeconds(2)) // 2초 대기
            .map { "Demo(Thread=${Thread.currentThread().name})" }
    }
}
running (0m02.2s), 0000/2000 VUs, 2000 complete and 0 interrupted iterations
default ✓ [======================================] 2000 VUs  0m02.2s/1m0s  2000/2000 shared iters

카테고리:

업데이트: