virtual thread를 음미해 보자

Lee Jin Hyuk·2025년 11월 16일
post-thumbnail

Blocking I/O 처리량을 획기적으로 높여주는 virtual thread... 쉽게 적용 가능하고 성능이 좋아진다로고만 알고있었는데 이번 기회에 제대로 파헤쳐 보려한다.

virtual thread란?

기존 platform thread(OS thread)의 한계를 해결하기 위해 Java 21에서 등장한 경량 스레드

이 virtual thread는 기존 스레드의 어떤 문제점을 해결하고자 등장한걸까?

기존 플랫폼 스레드의 문제점

스택 메모리 고장 할당

  • 각 스레드는 기본적으로 1-2MB의 스택 메모리를 미리 할당받는다.
  • 할당 받은 메모리는 스레드가 실제로 사용되지 않더라도 예약이된다.
    ex) 1000개의 스레드 -> 최소 1GB 메모리 소요

platform thread = OS thread

  • platform thread는 OS의 native thread와 1:1 대응한다.
  • JVM이 아닌 OS 커널이 직접 스케줄링을 담당한다.
  • 스레드 생성/관리가 시스템 콜을 통해 이루어진다.

이는 컨텍스트 스위칭 및 스케줄링이 OS 레벨에서 발생하는 것을 의미하며 컨텍스트 스위칭의 비용도 많이 들고 OS는 JVM의 애플리케이션 로직을 모르기 때문에 비효율적인 스케줄링을 할 가능성이 있다.

Thread Pool의 딜레마

Thread Pool Sizing 문제

// CPU 집약적 작업: 코어 수만큼
ThreadPoolExecutor cpuPool = new ThreadPoolExecutor(
    Runtime.getRuntime().availableProcessors(), ...);

// I/O 집약적 작업: 어떻게 설정해야 할까?
ThreadPoolExecutor ioPool = new ThreadPoolExecutor(
    ???, ...); // 정답이 없음

Blocking과 처리량의 역설

  • I/O 대기 시간이 길수록 더 많은 스레드 필요
  • 하지만 스레드 증가 → 메모리/스케줄링 오버헤드 증가

virtual thread 특징

그럼 이러한 platform thread의 문제점을 해결한 virtual thread는 어떤 특징을 가지고 있을까?

경량성

Virtual Thread는 Platform Thread 대비 1000분의 1 수준의 메모리를 사용한다. 이는 객체 지향적 설계로 스택을 힙에 저장하고 필요에 따라 동적으로 확장

M:N 스케줄링 모델

Virtual Thread는 실제 OS Thread(Carrier Thread) 위에서 실행되지만, 1:1 고정 관계가 아니다. 하나의 Carrier Thread가 여러 Virtual Thread를 시분할로 처리할 수 있어 OS 자원을 효율적으로 활용 가능하다.

협력적 스케줄링

virtual Thread는 선점형이 아닌 협력형 스케줄링을 사용한다. Blocking 작업(I/O, sleep 등)에서만 자발적으로 CPU를 양보하며, 이는 컨텍스트 스위칭 오버헤드를 극단적으로 줄인다.

구조적 동시성 지원

Virtual Thread는 구조적 동시성 패러다임과 자연스럽게 결합되어 작업의 생명주기를 명확하게 관리할 수 있다. 부모-자식 관계의 작업 계층구조를 통해 예외 전파와 취소를 체계적으로 처리한다.

virtual thread 동작원리

좀 더 virtual thread가 어떻게 동작하길래 높은 동시 처리량을 달성할 수 있는지 알아보자

virtual thread 스케줄링

[출처] : https://youtu.be/AuBHv8NOca4?si=8EH0LkGQ39f3aj-J

  1. virtual thread가 생성되어 ForkJoinPool에 전달되면 WorkStealingQueue에 추가된다.
  2. 큐에 추가된 이 virtual thread는 cpu 코어가 아닌 jvm의 platform thread에 의해 실행된다.
  • 여기서의 platform thread를 다른말로 carrier thread라고 한다.
  1. platform thread는 큐에서 virtual thread를 꺼내 그 내부의 코드를 실행한다.
  2. 이때 Blocking 되는 작업(sleep, I/O 등)이 발생하면 실행을 중단하고 큐에 있는 다른 virtual thread를 가져와 실행한다.
  3. 중단된 virtaul thread는 blocking 이벤트가 완료되어 실행할 수 있는 준비가 되면 다시 ForkJoinPool에 전달되고 미래의 어느 시점에 실행된다.

virtual thread 컨텍스트 스위칭

virtual thread의 컨텍스트는 사실 jvm의 platform thread의 메모리 영역을 조작하는 것이다. 즉, OS kernel이 아닌 application level에서 컨텍스트 스위칭이 일어나는 것이고 이는 Blocking I/O 처리량을 높이는 주된 이유이다.

[출처] : https://youtu.be/AuBHv8NOca4?si=8EH0LkGQ39f3aj-J

platform thread는 자신의 스택과 수행할 바이트 코드의 주소를 저장하는 PC Register를 갖고 있다.
virtaul thread의 작업은 heap에 저장되어 있는데 platform thread가 virtual thread를 실행하기 위해서 heap 저장된 스택을 자신의 스택영역에 복사한다.
그리고 virtual thread가 실행해야할 바이트 코드의 주소를 PC Register에 설정한다.

지금 까지의 내용을 정리해보자면 virtual thread의 실행은 platform thread가 virtrual thread의 Runnable run() 메소드를 호출는 것!

Runnable run을 중단하고 다시 실행 할 수 있나요??

우리가 일반적으로 알고있는 Runnable의 run()은 한번 실행하면 완료 까지 멈출 수 없다. 그런데 virtual thread는 어떻게 중단에 멈출 수 있는 것 일까?

continuation

java는 continuation을 통해 중단과 재개 매커니즘을 구현할 수 있다.
다음 예제를 통해 쉽게 이해 해보도록 하자

이 main의 결과물은 어떻게 될까?

hello!
virtual thread!
spring camp 2025!

먼저 cont의 run에 의해 greeting()이 실행이 된다.
이때 hello!가 출력이 된다.

그리고 coninuation의 yield 함수를 만나면 더이상 greeting()을 실행하지 않고 이전 main 함수로 돌아간다.
이때 virtual thread!가 출력이된다.

그리고 다시 cont.run()이 실행되면 greeting()의 yield 그 다음부터 실행된다.
따라서 마지막으로 spring camp 2025!가 출력

이렇게 run과 yield가 진행될 떄 의 stack 과 heap의 상태를 자세히 봐보도록 하자

greeing()의 continuation.yield()가 실행될 때의 stack과 heap의모습이다. yield에 의해 cont.run() 부터 yield 함수까지의 stack frame들이 stack chunk로 만들어져 heap에 복사가 된다.


복사가 된 이후에는 해당 stack frame들이 stack에서 제거가 된다. 이때의 상황이 마치 main 함수로 되돌아가는 상황처럼 보여진다.


그리고 다시 cont.run()을 만나게 되면 heap에 복사 해두었던 stack frame을 stack 영역으로 다시 복원시킨다.
이때의 상황이 마치 중단된 작업을 재개시키는 상황처럼 보여진다.

복원이 완료되면 yield 이후의 함수 남아있던 명령어 부터 실행한다.

virtual thread에서의 continuation 활용


virtual thread의 상태는 크게 실행, 중단, 재개로 나뉘어진다.

virtual thread 내부의 cont는 이전에 살펴봤던 continuation이고 runContinuation은 virtual thread의 runnable을 실행시키는 runnable이다.

  • runContinuation의 설명이 혼동스러울 수 있는데 다음의 내용을 보면서 이해해보도록 하자



3가지 상태중 실행 상태로 만드는 start 함수를 보면 submitRunContinuation이 있는데 이것은 runContinuation Runnable을 스케줄러에 등록하는 것이다.



runContination을 보면 mount와 continuation run이 보이는데 각각 다음과 같은 역할을 한다.
mount : virtual thread와 platform thread간의 관계를 맺어준다.
run : virtual thread에 할당된 작업들이 실행된다

  • 이때 처음 시작하는 거라면 초기화, 재개되는 경우라면 heap에서 stack frame을 stack으로 복원



중단의 상태로 만드는 park 함수를 보면 yieldContinuation이 있는데 이는 Coninuation yield를 호출한다.

  • 앞서 설명했던 것처럼 이 yield를 통해 stack에 저장된 stack frame을 heap에 복사를 한다.


heap에 stack frame을 복사하고 stack에서 해당 stack frame을 삭제함으로 코드의 흐름은 finally 부분을 실행하는 것이다.

그리고 재개 상태인 unpark를 보면 start와 마찬가지로 runConinuation을 스케줄러에 등록하는 것이다.

정리

  1. virtual thread는 continuation과 실행할 Runnable을 가진 인스턴스이다.
  2. virtual thread의 실행은 ForkJoinPool에 Runnable 타입의 람다 runContinuation()을 등록하는 것이다.
  • 이 runContinuation이 하는 역할은 virtual thread와 platform thread간의 관계를 맺어주고 virtual thread에 할당된 작업들을 실행하는 것
  1. virtual thread는 park, unpark를 통해 continuation을 사용하여 실행의 중단과 재개를 한다.

마치며

virtual thread의 동작원리 부분은 springcamp by KSUG에 올라온 Virtual Thread 어디까지 보고 오셨어요? (박성훈, 정승주)의 영상을 요약 및 정리 한 것이다. 해당 영상은 continuation, virtual thread를 잘 몰랐던 필자도 정말 쉽게 이해할 정도로 잘 만들어진 발표자료, 발표이다. 꼭 영상을 보는 것을 강추한다!

참고로 커널 레벨에서 Blocking I/O가 발생하면 OS thread를 Blocking 해버리는데 이는 platform thread가 Blocking 되버리는 것과 마찬가지이다. (virtual thread의 실행은 사실상 platform thread가 수행하는 것인데 이것이 blocking 되버린다는 뜻) 이러한 문제를 해결하기 위해 virtual thread는 어떤 방법을 채택했는지도 영상에 나오는데 이 내용또한 정말 흥미로우니 영상을 끝까지 보는 것을 추천한다

참고자료

[스프링캠프 2025 [Track 2] 4. Virtual Thread 어디까지 보고 오셨어요? (박성훈, 정승주)] : https://youtu.be/AuBHv8NOca4?si=8EH0LkGQ39f3aj-J

0개의 댓글