Virtual Thread

appti·2024년 5월 15일
1

분석

목록 보기
23/25

서론

자바 21에서 새롭게 추가된 기능 Virtual Thread를 이해하기 위한 사전 지식을 살펴보고, Virtual Thread가 무엇인지 알아보도록 하겠습니다.

선요약

내용이 길기 때문에 먼저 요약을 하도록 하겠습니다.

Platform Thread

  • 자바의 기본 스레드입니다.
  • 멀티 스레드 모델 중 일대일 스레드 모델을 사용하기 때문에, Kernel Level Therad와 일대일로 매핑됩니다.
    • 스케줄링, 컨텍스트 스위칭 등을 수행하기 위해 Kernel Mode로 접근해야 하므로, 비용이 비쌉니다.
    • Thread Per Request 방식에서 I/O 작업으로 인해 Blocking 되는 경우, 해당 스레드는 I/O 작업이 끝날 때 까지 대기해야 합니다.

Virtual Thread

  • 자바 21에서 새롭게 추가된 경량 스레드입니다.
  • Blocking 상황이 발생할 때, 스레드가 대기하는 상황을 줄이기 위한 패러다임입니다.
  • CPU Bound 시에는 효율이 떨어지기 때문에, Platform Thread를 대체하기 위한 대상은 아닙니다.
    • Platform Thread와 Virtual Thread 동시 사용이 가능합니다.
  • Continuation Passing Style, Coroutine 개념을 활용했습니다.
    • Blocking 상황이 발생하면 suspend 후, 다른 Virtual Thread의 작업을 수행하는 식으로 동작합니다.
  • Thread를 확장했기 때문에 적용 자체는 쉽지만 BackPressure가 없고, PINNED 상태를 방지하기 위해 synchronized나 JNI 호출을 없애야 하며, ThreadLocal을 지원하지만 특성 상 무거운 객체는 저장해서는 안 되는 등 많은 주의가 필요합니다.
    • 성능 테스트를 통해 기준을 파악할 필요가 있습니다.

다른 방식과의 비교

  • Virtual Thread
    • 스레드 레벨의 비동기 지원 방식
    • 스레드의 대기 시간을 줄이기 위한 패러다임
  • Coroutine
    • 함수(메서드) 레벨의 비동기 지원 방식
    • 함수(메서드)의 대기 시간을 줄이기 위한 패러다임
  • Spring WebFlux
    • 아키텍처 레벨의 비동기 지원 방식
    • 적은 스레드로 여러 I/O 작업을 처리하기 위한 패러다임

여러 방식을 동시에 사용하는 경우

  • Spring WebFlux + Coroutine
    • 패러다임이 충돌하지 않습니다.
    • Coroutine의 특징인 Continuation Passing Style와 같은 방식을 Direct Style로도 표현 가능하기 때문에, 가독성을 위해 같이 사용하는 경우가 많습니다.
      • 그 외에도 디버깅, 예외 처리 등에 유리합니다.
  • Spring WebFlux + Virtual Thread
    • 패러다임이 충돌합니다.
      • 적은 수의 스레드로 여러 I/O 작업을 처리하는 Spring WebFlux와 I/O 작업 발생 시 새로운 스레드를 생성하는 Virtual Thread는 서로 이질적입니다.
        • Spring WebFlux는 클라이언트 요청(= 네트워크 I/O)을 제외한 나머지 Blocking I/O를 제거해야 원하는 성능을 노릴 수 있기 때문에 더욱 어울리지 않습니다.
    • 동시에 사용 시 성능 저하가 발생할 수 있습니다.

결론

  • Blocking 작업이 많은 웹 애플리케이션의 경우 Virtual Thread를 통해 Spring WebFlux, Coroutine에 대한 학습 없이 빠르게 성능을 향상시킬 수 있습니다.
  • 적용이 간단하기 때문에 주의 사항을 고려하며 적용해야 합니다.
    • 성능 테스트를 통해 현재 애플리케이션의 상황을 파악한 뒤 적절한 설정을 반드시 적용해야 합니다.

스레드

스레드는 다음과 같은 두 가지 유형으로 구분이 가능합니다.

  • User Level Thread
  • Kernel Level Thread

하나씩 살펴보도록 하겠습니다.

User Level Thread

User Level Thread는 응용 프로그램 내에서 관리되는 스레드입니다.

다음과 같은 특징을 가지고 있습니다.

  • 사용자 영역에서 Pthreads, Windows Threads, Java Threads 등 스레드 라이브러리에 의해 생성, 스케줄링, 관리되는 특징을 가지고 있습니다.
  • 커널은 User Level Thread를 알지 못하며, 커널은 User Level Thread를 마치 단일 스레드 프로세스처럼 관리합니다.
    • Kernel Level에서의 스케줄링이 불가능합니다.
  • 생성 및 컨텍스트 스위칭 비용이 Kernel Level Thread보다 적습니다.
    • Kernel Level Thread보다 많은 수의 스레드를 생성할 수 있습니다.
  • 스레드 라이브러리에 의존적이므로 실행 환경에 영향을 받습니다.

Kernel Level Thread

Kernel Level Thread는 Kernel 내에서 관리되는 스레드입니다.

다음과 같은 특징을 가지고 있습니다.

  • Kernel에 의해 생성, 스케줄링, 관리됩니다.
    • Kernel에서 PCB와 TCB를 모두 관리합니다.
    • OS 스케줄러에 의해 스케줄링 됩니다.
  • CPU는 Kernel Level Thread의 실행만을 담당합니다.
    • Kernel Level Thread만이 CPU를 할당받을 수 있습니다.
  • 컨텍스트 스위칭 비용이 비쌉니다.

멀티 스레딩 모델

CPU는 스레드 중 Kernel Level Thread만이 할당받을 수 있습니다.

그렇기 때문에 User Level Thread가 CPU를 할당받아 동작하기 위해서는 어떤 방식으로든 Kernel Level Thread와 매핑이 되어야 합니다.

이를 멀티 스레딩 모델이라고 표현합니다.

이러한 모델을 다음과 같이 세 가지가 있습니다.

  • 일대일 스레드 모델
  • 다대일 스레드 모델
  • 다대다 스레드 모델

하나씩 살펴보도록 하겠습니다.

일대일 스레드 모델

하나의 Kernel Level Thread가 하나의 User Level Thread와 매핑되는 형식입니다.

현재 자바(Native Thread)가 사용하고 있는 모델입니다.

일대일로 매핑된 형식이기 때문에 Kernel 입장에서는 동일한 프로세스 내에 있는 각 스레드를 별도의 단일 스레드를 가진 단일 프로세스로 인식합니다.

Kernel 영역에서 User Level Thread를 관리하게 되므로, Kernel 영역에서 PCB 뿐만 아니라 TCB도 같이 관리하게 됩니다.

이 외에도 다음과 같은 특징을 가집니다.

  • Kernel 영역에서 모든 정보(PCB, TCB)를 관리하므로 스케줄링, 컨텍스트 스위칭 시 Kernel 전환 시 발생하는 오버헤드로 인해 비용이 비쌉니다.
  • User Level Thread를 하나의 단일 스레드를 가진 단일 프로세스로 인식하므로 멀티 코어를 활용한 병렬 처리가 가능합니다.
    • OS 입장에서는 프로세스 단위로 프로세서를 할당하기 때문입니다.
    • 이와 동일한 이유로 User Level Thread 중 하나가 Block 되더라도 다른 스레드를 실행할 수 있는, 멀티 스레드의 동시성을 활용할 수 있습니다.
  • 비교적 적은 양의 스레드만이 생성 가능합니다.

다대일 스레드 모델

하나의 Kernel Level Thread가 여러 User Level Thread와 매핑되는 형식입니다.

초기 버전의 자바(Green Thread)에서 사용했던 모델입니다.

User Level Thread는 Thread Libraries를 통해 스레드를 관리하므로, 스레드의 TCB는 프로세스에서 관리됩니다.

Kernel 입장에서는 User Level Thread를 알지 못하므로, 여러 개의 User Level Thread가 있다고 하더라도 단일 스레드를 가진 단일 프로세스라고 인식하게 됩니다.

이 외에도 다음과 같은 특징을 가집니다.

  • Kernel 영역에서 스케줄링 및 컨텍스트 스위칭이 이루어지지 않으므로, Kernel 영역까지 갈 일이 적어 Kernel 전환 시 발생하는 오버헤드가 줄어듭니다.
  • Kernel 입장에서는 단일 스레드를 가진 단일 프로세스로 인식하므로, 하나의 스레드가 Block되면 프로세스 자체가 Block 될 수 있습니다.
    • User Level에서 보면 하나의 스레드를 제외한 나머지 스레드는 정상 동작할 수 있지만, Kernel 입장에서는 단일 스레드가 Block 된 것이기 때문에 프로세스 자체가 Block 되었기 때문에 나머지 스레드도 CPU 할당을 받을 수 없습니다.
  • 비교적 많은 양의 스레드를 생성할 수 있습니다.
  • Kernel 입장에서는 단일 스레드를 가진 단일 프로세스로 인식하므로, 멀티 코어를 활용한 병렬 처리가 불가능합니다.
    • OS 입장에서는 프로세스 단위로 프로세서를 할당하기 때문입니다.

다대다 스레드 매핑

여러 User Level Thread를 그 개수 이하의 Kernel Level Thread와 매핑하는 방식입니다.

Kernel Level Thread는 하나 이상의 User Level Thread와 매핑되며, PCB와 TCB는 Kernel 영역에서 관리됩니다.

일대일, 다대일 스레드 매핑의 단점을 어느 정도 극복한 방식입니다.

이 외에도 다음과 같은 특징을 가집니다.

  • 필요한 만큼 많은 User Level Thread를 생성할 수 있습니다.
    • 생성한 User Level Thread는 모두 Kernel Level Thread로 인해 멀티 프로세서를 통해 병렬로 수행될 수 있습니다.
  • 스레드 매핑 관리가 복잡하며, Kernel 리소스 사용이 많아질 수 있습니다.

자바 Platform Thread

자바는 일대일 스레드 매핑을 사용합니다.

위는 기본적인 멀티 스레딩 모델의 일대일 스레드 모델입니다.
이를 자바에서 사용하는 방식으로 변경하면 다음과 같습니다.

위 그림에서 언급된 Platform Thread와 JNI에 대해서 간단히 알아보겠습니다.

Platform Thread

Platform Thread란 Kernel Level Thread와 일대일로 매핑되는, JVM에 속한 User Level Thread를 의미합니다.

멀티 스레딩 모델 중 일대일 스레드 모델을 사용한 것이므로, Platform Thread는 JVM에서 관리되는 것이 아닌 Kernel 영역에서 관리하게 됩니다.

Kernel 영역에서 관리하기 때문에, Kernel Mode에서 실행이 가능한 System Call을 직접 호출할 수 있습니다.

JNI(Java Native Interface)

JNI는 JVM과 네이티브 애플리케이션(C, C++ 등으로 작성된 코드) 간의 통합을 가능하게 하는 인터페이스입니다.

JNI를 통해 자바 코드에서 네이티브 코드를 호출하고, 반대로 네이티브 코드에서 자바 코드를 호출할 수 있습니다.

다음과 같은 특징을 가지고 있습니다.

  • 플랫폼 독립성
    • JNI는 플랫폼에 독립적이며, Java 가상 머신이 존재하는 모든 플랫폼에서 작동합니다.
  • 임베디드 시스템 지원
    • JNI를 사용하면 Java 코드를 임베디드 시스템에 통합할 수 있습니다.

다음과 같은 상황에서 사용됩니다.

  • 기존 네이티브 코드/라이브러리 재사용 시
  • OS, 하드웨어 등 플랫폼 종속적인 기능 사용 시

자바는 멀티 스레딩 모델로 일대일 스레드 모델을 사용하기 때문에, Platform Thread를 실행시키기 위해서 JNI를 사용하게 됩니다.

JNI을 통해 다음과 같은 스레드 관련 기능을 제공합니다.

  • 스레드 생성 및 Platform Thread와 Kernel Level Thread 연결
  • 스레드 동기화
  • 스레드 중단 및 예외 처리
  • 스레드 우선순위 및 Daemon 설정

Thread 클래스를 확인해보면, Kernel Mode가 필요한 메서드들에 모두 native 키워드가 명시된 것을 확인할 수 있습니다.

동작 방식

Thread.Builder.ofPlatform() 메서드를 통해 Thread 객체를 생성합니다.

Thread.start()를 호출해 JNI에게 Kernel Level Thread를 생성할 것을 요청합니다.

OS는 Kernel Level Thread를 생성합니다.

이렇게 생성된 Kernel Level Thread는 OS 스케줄러에 의해 스케줄링되어 CPU를 할당받게 됩니다.

문제점

자바 Platform Thread는 다음과 같은 문제점이 있습니다.

  • 값비싼 리소스 비용 및 제한적인 스레드 개수
  • 제한적인 Throughput
  • Blocking 동작 시 리소스 낭비 발생

일대일 스레드 모델

값비싼 리소스 비용 및 제한적인 스레드 개수는 Platform Thread가 일대일 스레드 모델이기 때문에 발생합니다.

Kernel Level Thread는 하드웨어의 한계로 인해 스레드 개수에 제한이 있습니다.
scale-up, scale-out으로 해결할 수 있지만, 이 경우 비용이 발생합니다.

또한, Platform Thread를 생성/제어하기 위해서는 결국 Kernel Level Thread를 제어해야 합니다.
그러므로 생성 비용, 컨텍스트 스위칭 시 User Mode에서 Kernel Mode으로 전환하기 위한 오버헤드 등 리소스 비용이 많이 발생합니다.

이러한 문제점을 개선하기 위해 스레드 풀을 통해 미리 사용할 Platform Thread를 생성하지만, 한계가 뚜렷합니다.

Thread Per Request, Blocking 기반 동작

제한적인 Throughput은 Thread Per Request, Blocking 동작으로 인해 일어나며, 이로 인해 리소스 낭비 또한 발생합니다.

문제가 되는 동작 방식은 다음과 같습니다.

사용할 수 있는 Platform Thread가 하나인 경우, Client의 요청을 처리하기 위해 외부 서비스와 통신하기 위해 Blocking 된 상태라고 가정하겠습니다.

이 때 추가적인 클라이언트가 요청을 하는 경우, 이 요청을 수행할 Platform Thread가 없기 때문에 대기하게 됩니다.

하드웨어 한계로 인해 Platform Thread를 무한정 만들 수 없기 때문에 Throughput이 제한적일 수 밖에 없습니다.

또한 Blocking된 Plaftorm Thread는 아무런 동작 없이 외부 서비스의 결과를 기다리고 있기 때문에 리소스의 낭비가 발생합니다.

Spring WebFlux

Spring WebFlux는 Reactive Programming을 지원하는 웹 프레임워크 입니다.

기존의 Spring MVC와는 달리 비동기, Non-Blocking 방식으로 동작합니다.

Platform Thread를 사용했을 때의 세 가지 문제점 중 두 가지 문제점이 모두 Thread Per Request, Blocking 기반 동작으로 인해 발생했기 때문에, 비동기 Non-Blocking 방식으로 동작하는 WebFlux를 통해서 이를 해소할 수 있습니다.

위와 같이 동작합니다.

Event Loop는 Platform Thread 기반으로 동작하며, 싱글 스레드로 동작합니다.
Event Loop 수는 CPU 코어 수와 비례합니다.

DownStream과 같은 경우 비동기 Blocking I/O 작업이 필요한 이벤트를 수행하는데 사용되는 별도의 스레드 풀입니다.
이 또한 Platform Thread로 동작합니다.

Spring WebFlux를 사용하면 Event Loop 기반의 비동기 Non-Blocking으로 동작하므로, Spring MVC를 사용할 때 보다 현저히 작은 수의 Platform Thread를 사용해 Spring MVC보다 많은 트래픽을 처리할 수 있습니다.

문제점

기존 자바 프로그램은 스레드 기반으로 작성됩니다.

그러나 Spring WebFlux, 리액티브 프로그래밍은 스레드 기반이 아닌 이벤트 기반으로 동작하기 때문에 이와 어긋납니다.
즉, 기존 프로그래밍 방식과 다른 방식을 사용하기 때문에 다음과 같은 다양한 문제점을 가지게 됩니다.

  • 높은 러닝 커브
  • 디버깅의 어려움
  • 스택 트레이스 단절
  • 예외 처리의 어려움
  • 기존 애플리케이션 코드 변경 필수
  • Blocking 작업이 있는 경우 목표했던 성능을 달성하기 어려움

Virtual Thread

Virtual Thread는 자바 21부터 추가된 경량 스레드입니다.

경량 스레드

경량 스레드인 만큼, 기존 스레드와 비교했을 때 용량과 같은 부분에서 많은 차이를 보입니다.
또한, Kernel Level Thread와 직접적으로 매핑하지 않고 JVM에서 관리하기 때문에 생성 시간과 컨텍스트 스위칭 시간이 큰 차이를 보이는 것을 확인할 수 있습니다.

항목ThreadVirtual Thread
Stack 용량~2MB~10KB
생성 시간~1ms~1µs
컨텍스트 스위칭~100µs~10µs

이러한 특징으로 인해, Virtual Thread는 Pooling 하지 않고 필요할 때 마다 생성할 것을 권장하고 있습니다.

이로 인해 Virtual Thread는 그 수가 쉽게 증가할 수 있기 때문에, 무거운 객체를 ThreadLocal에 저장하지 않을 것을 권장합니다.

ThreadLocal

Virtual Thread가 ThreadLocal을 지원하지만, ThreadLocal은 다음과 같은 문제점을 가지게 됩니다.

  • 명확한 생명주기가 없음(unbounded lifetime)
  • 변경 가능성에 대해서 제약이 없음(unconstrained mutability)
  • 메모리 사용에 대해서 제약이 없음(unconstrained memory usage)
  • 값비싼 상속 기능을 사용하는 InheritableThreadLocal의 성능 문제

이러한 문제를 해결하기 위해 ThreadLocal을 대체할 수 있는 ScopedValue가 Preview로 추가되었습니다.

구조

다음과 같은 구조로 동작합니다.

기존 Kernel Level Thread와 매핑되는 스레드인 Carrier Thread에 여러 Virtual Thread가 매핑되는 구조입니다.

내부적으로 ForkJoinPool을 사용해 스케줄링 됩니다.

VirtualThread

OS보다 효율적으로 사용할 수 있는, JVM 내부적으로 스케줄링 되는 경량 스레드입니다.

BaseVirtualThread에서는 park(), unpark()와 같이 어떤 식으로 스레드를 정지하고, 재실행시킬지를 반드시 재정의하도록 추상 메서드로 명시하고 있습니다.

이는 VirtualThread가 기존 자바 스레드처럼 Kernel Level Thread로 동작하지 않기 때문에, 다른 로직으로 수행되도록 하기 위함입니다.

위와 같이 다양한 필드를 가지고 있습니다.

Carrier Thread

Carrier Thread는 이전 Platform Thread와 매칭되는, Kernel Level Thread와 매칭되는 User Level Thread 입니다.

ForkJoinPool에 의해 관리되기 때문에, ForkJoinWorkerThread를 확장하고 있음을 확인할 수 있습니다.

ForkJoinPool

Virtual Thread를 스케줄링하는 Thread Pool 입니다.

Virtual Thread에 사용되는 ForkJoinPool은 Stream의 병렬 처리 등에 사용되는 ForkJoinPool과는 별개의 존재입니다.

ForkJoinPool은 Work Queue에 작업을 수행할 Task를 관리하며, Work-Strealing 방식으로 동작하기 때문에 Work Queue에 Task가 없는 경우 대신 처리하기 때문에 효율적으로 작업을 처리할 수 있습니다.

VirtualThread를 확인해보면, DEFAULT_SCHEDULER라는 static 변수로 ForkJoinPool 타입이 명시되어 있음을 확인할 수 있습니다.

또한, static 변수로 설정되어 있는 것을 통해 모든 VirtualThread는 하나의 ForkJoinPool로 스케줄링 된다는 것을 확인할 수 있습니다.

VirtualThread에서 createDefaultScheduler()를 확인해보면, CarrierThread를 관리하는 ForkJoinPool을 생성하고 있음을 확인할 수 있습니다.

Continuation & Coroutine

Project Loom을 확인해보면 Continuation(혹은 Coroutine)을 추가하는 것을 목표라고 하고 있음을 확인할 수 있습니다.

Virtual Thread에서도 Continuation과 관련된 필드를 확인할 수 있습니다.

Continuation에서는 주석으로 Coroutine에서 사용되는 용어인 Suspend가 언급되고 있음을 확인할 수 있습니다.

즉, Virtual Thread의 동작을 확인하기 전에 Continuation과 Coroutine에 대한 이해가 필요하다는 의미입니다.

그러므로 Virtual Thread의 동작을 확인하기 전에 Continuation과 Coroutine에 대해 확인해보겠습니다.

Continuation

Continuation은 Continuation Passing Style에서 비롯된 명칭으로, 협력적 스케줄링을 구현하기 위해 필요한 작업 흐름입니다.

fetch('http://example.com/result.json')
    .then((response) => response.json())
    .then((data) => console.log(data));

대표적인 Continuation의 예시로는 자바스크립트의 fetch()가 있습니다.
then() 내부에 존재하는 코드가 Continuation이라고 볼 수 있습니다.

fetch('http://example.com/result.json')
    .then((response) => response.json()) // 네트워크 I/O 대기 후 작업 내용
    .then((data) => console.log(data)); // 데이터 변환 I/O 대기 후 작업 내용

위 코드는 각 I/O 작업을 수행한 이후에 동작할 Continuation을 명시하고 있습니다.
즉, I/O 작업 이후 어떠한 작업을 수행해야 하는지를 흐름을 명시해놨다고 볼 수 있습니다.

위와 같이 I/O가 발생한다면 제어권을 호출했던 클라이언트에게 전달합니다.
이 때 작업했던 내용을 별도로 저장하게 됩니다.

그렇기 때문에 클라이언트가 I/O 작업을 수행하기 위해 컨텍스트 스위칭을 하고, I/O 작업이 끝난 이후 그 결과를 토대로 이전 작업 수행 중 진행했던 그 위치로 돌아와 작업을 이어서 할 수 있는 것입니다.

First-class Continuation

First-class Continuation은 위에서 설명했던, 어느 위치에서나 코드의 실행을 중지하고 진행 상황을 저장한 뒤, 다시 중지했던 위치로 돌아올 수 있음을 의미합니다.

Call-with-current-continuation

Call-with-current-continuation은 하나의 Continuation에서 다른 Continuation을 호출할 수 있음을 의미합니다.

Coroutine

Coroutine은 Continuation Passing Style를 기반으로 작성된, 함수를 일시 중지(Suspend)했다가 재개(Resume)할 수 있는 기능을 제공합니다.

Continuation Passing Style의 경우 함수 호출 수준에서 동작하지만, Coroutine은 프로그래밍 언어 수준의 개념이라고 볼 수 있습니다.

동작 방식

동작 방식은 Continuation Passing Style과 유사합니다.

Coroutine이 Caller에게 제어권을 전달하기 위해 코드의 실행을 일시 중지하는 행위를 Suspend, Caller가 다시 Coroutine을 실행시키기 위한 행위를 Resume이라고 표현합니다.

특징

Continuation Passing Style과 특징을 공유합니다.

  • First-class Continuation
  • Call-with-current-continuation

가장 큰 특징은, Continuation Passing Style와 같은 방식을 Direct Style로도 표현할 수 있게 해 준다는 것입니다.

이러한 특징으로 인해 Spring WebFlux와 자주 같이 사용됩니다.

동작

Virtual Thread는 주로 start(), park(), unpark()를 통해 동작합니다.
이에 대해 간략하게 살펴보도록 하겠습니다.


VirtualThread의 경우, Continuation과 VirtualThread를 관리하는 CarrierThread를 필드로 가지고 있음을 표현했습니다.

CarrierThread의 경우, ForkJoinPool에서 관리하므로 내부에 위치시켰으며 WorkQueue를 CarrierThread마다 가지고 있음을 표현했습니다.

start(), unpark()에서는 생략했지만 park()에서는 메모리 영역에서 Continuation이 관리되는 모습을 추가했습니다.

start

VirtualThread.start()를 호출해 동작을 트리거합니다.

VirtualThread.start()는 ForkJoinPool.execute()(VirtualThread에서 공통적으로 사용하는 Scheduler)를 호출해 VirtualThread에서 실행할 작업을 ForkJoinPool에 등록합니다.

ForkJoinPool.execute()는 내부적으로 poolSubmit()을 호출해 관리 중인 CarrierThread의 WorkQueue에 작업 내용을 저장합니다.

이 때 저장되는 것은 VirtualThread의 필드 중 Runnable 타입의 runContinuation 입니다.
즉, VirtualThread에서 실제 작업은 Runnable runContinuation입니다.

이후 ForkJoinPool에서 실행할 Worker Thread(=Carrier Thread)를 지정해 runWorker()를 호출합니다.

CarrierThread가 runContinuation.run()을 호출하게 되고, 이 과정에서 CarrierThread와 VirtualThread를 mount()하게 됩니다.

이 과정에서 VirtualThread가 자신의 작업(=runContinuation)을 수행하는 CarrierThread를 필드로 가지게 됩니다.

또한, CarrierThread.currentThread() 호출 시 VirtualThread를 반환할 수 있도록 설정합니다.

park

park(), unpark()는 스레드를 block/unblock 하는 역할을 수행합니다.

이 중 park() 동작 과정에 대해 살펴보겠습니다.

초기 상태는 위와 같습니다.

VirtualThread A의 작업이 현재 처리 중이며, VirtualThread B의 작업은 CarrierThread A의 WorkQueue에서 대기중입니다.

동작중인 VirtualThread A의 Continuation은 Stack 영역에 존재하며, WorkQueue에서 대기중인 VirtualThread B의 Continuation은 Heap 영역에 존재합니다.

현재 CarrierThread A가 실행중인 VirtualThread A에 park()가 호출되었다고 가정합니다.

VirtualThread.park()를 호출하면 내부적으로 yield()를 호출하게 되며, 이 과정에서 unmount()를 호출합니다.

unmount()를 호출해 VirtualThread A, CarrierThread A의 관계를 초기화합니다.
VirtualThread A가 block 되기 때문에, 여태까지 진행한 작업 상황을 의미하는 Continuation A를 Heap 영역으로 이동시킵니다.

CarrierThread A에서 실행하고 있던 Runnable인 runContinuation A는 작업 완료 처리가 되어 완전히 삭제됩니다.

이후 CarrierThread A는 WorkQueue에 있던 runContinuation B를 실행시키게 되고, mount()를 호출해 CarrierThread A와 VirtualThread B가 매핑됩니다.

VirtuatlThread B가 동작하므로 Continuation B가 Heap 영역에서 Stack 영역으로 이동합니다.

결과적으로 위 그림과 같은 상태가 됩니다.

unpark

unpark()의 경우 사실상 start()와 동일하게 동작합니다.

VirtualThread A의 block 작업이 끝나 unpark()을 호출했다고 가정하겠습니다.

ForkJoinPool.execute()를 호출합니다.

execute()는 내부적으로 ForkJoinPool.poolSubmit()을 호출하게 되며, 적절한 Worker Thread(그림에서는 CarrierThread A)의 WorkQueue에서 대기하게 됩니다.

상태

VirtualThread는 위와 같은 상태를 가질 수 있습니다.

위와 같은 식으로 상태가 변경됩니다.

그림에서 확인할 수 있는 것처럼, Virtual Thread의 상태는 주로 Thread.yield()를 통해 상태가 변화하고 있음을 확인할 수 있습니다.

PINNED

Virtual Thread의 상태 PINNED는 표현 그대로 고정된 상태라는 의미입니다.

JEP 444에서 pinned의 의미는 위와 같이 표현하고 있습니다.

  • Carrier Thread에 고정되어 unmount 될 수 없는 상태
  • synchronized 사용 시 발생
  • 네이티브 메서드를 포함한 외부 함수 호출 시 발생

Virtual Thread가 PINNED 상태가 되는 경우, 애플리케이션 자체가 다운되는 등 큰 이슈는 없지만 성능에 부정적인 영향을 줄 수 있습니다.

synchronized의 경우 ReentrantLock으로 대체해 이를 해결할 수 있습니다.

스프링 부트의 경우 내부 로직에서 synchronized를 많이 사용하는 편이었는데, 릴리즈 노트를 확인하면 이를 ReentrantLock으로 대체하는 방향으로 마이그레이션을 적용하고 있음을 확인할 수 있습니다.

이 외에도 Mysql, UUID 등 다양한 기술 진영에서 Virtual Thread를 지원하기 위해 ReentrantLock으로 마이그레이션을 진행하고 있습니다.

-Djdk.tracePinnedThreads=full

위 옵션을 통해 애플리케이션 내 synchronized가 포함되어 있는지 확인할 수 있습니다.
(단, 이 옵션을 사용하려면 Virtual Thread를 사용해야 합니다.)

주의 사항

Virtual Thread는 다음과 같은 항목을 주의해야 합니다.

  • No Pooling
    • 경량 스레드이기 때문에 Pooling 보다는 필요할 때 마다 생성할 것을 권장합니다.
  • CPU Bound에서는 비효율적
    • Carrier Thread와 1:N으로 매핑되기 때문에, I/O Bound가 아닌 CPU Bound 시에는 오히려 불필요한 작업이 많아지게 됩니다.
    • CPU Bound에서는 기존 Platform Thread보다 비효율적입니다.
    • 이를 통해 Virtual Thread는 Platform Thread를 대체하기 위한 용도가 아님을 확인할 수 있습니다.
      • Platform Thread와 Virtual Thread 동시 사용 또한 가능합니다.
  • PINNED
    • 하나의 Virtual Thread가 synchronized/JNI 호출 시 해당 Virtual Thread와 매핑된 Carrier Thread가 block 됩니다.
    • 이는 다른 Virtual Thread에게 yield조차 불가능하기 때문에 전체적으로 성능이 하락하게 됩니다.
  • ThreadLocal
    • Virtual Thread는 경량 스레드로, 필요할 때 마다 계속 생성됩니다.
    • 무거운 객체를 ThreadLocal에 저장하게 되면 OOM 등 문제가 발생할 수 있습니다.
  • BackPressure
    • Virtual Thread는 경량 스레드로, 필요할 때 마다 계속 생성됩니다.
    • 이렇게 늘어난 모든 Virtual Thread가 제한된 리소스를 가지고 있는 외부에 요청(DB Connection 등)을 하게 되면 문제가 발생할 수 있습니다.
    • 외부에서 적절히 BackPressure를 제공하거나, 애플리케이션 레벨에서 이를 제어해야 합니다.
      • 애플리케이션 레벨에서 제어할 경우 세마포어를 통해 제어할 것을 권장합니다.
    • 성능 테스트를 통해 BackPressure의 기준을 결정해야 합니다.

다른 방식과의 비교

Virtual Thread, Coroutine, Spring WebFlux을 간략하게 비교하면 다음과 같습니다.

  • Virtual Thread
    • 스레드 레벨의 비동기 지원 방식
    • 스레드의 대기 시간을 줄이기 위한 패러다임
  • Coroutine
    • 함수(메서드) 레벨의 비동기 지원 방식
    • 함수(메서드)의 대기 시간을 줄이기 위한 패러다임
  • Spring WebFlux
    • 아키텍처 레벨의 비동기 지원 방식
    • 적은 스레드로 여러 I/O 작업을 처리하기 위한 패러다임

세 가지 방법의 패러다임이 모두 다르기 때문에, 상황에 맞춰 적절히 사용해야 합니다.

Spring WebFlux를 기준으로 본다면, Coroutine과 패러다임이 충돌하지 않지만 Virtual Thread와는 패러다임이 충돌하는 것을 확인할 수 있습니다.

Spring WebFlux는 단일(혹은 CPU 기반의 적은) 스레드를 통해 동작하지만, Virtual Thread는 경량 스레드로 여러 스레드를 필요할 때 마다 생성해서 사용하기 때문입니다.

이 외에도 Virtual Thread에는 Backpressure가 없다는 점, Spring WebFlux의 성능을 끌어올리기 위해서는 Blocking 작업을 최대한 줄여야(사실상 없애야) 하지만 Virtual Thread는 이런 상황에서 성능을 끌어올릴 수 있는 방법 중 하나라는 점 등 많은 부분에서 Spring WebFlux와 충돌됩니다.

그러므로 Coroutine은 이질적인 비동기 코드를 Dynamic Style로 변경할 수 있기 때문에 Spring WebFlux와 함께 사용하지만, Virtual Thread와 Spring WebFlux를 함께 사용한다면 이득을 보기 힘듭니다.

앞서 살펴본 주의 사항에서처럼 Virtual Thread가 Platform Thread를 대체하지 않기 때문에, 더욱 상황에 맞춰 사용할 필요가 있습니다.

profile
안녕하세요

0개의 댓글