Virtual Thread
- -
이번 포스트에서는 JDK21에 추가된 Virtual Thread에 대해 살펴보자.
Virtual Thread?
기존의 스레드의 동작 방식
자바에는 이미 잘 동작하는 스레드가 있는데 구지 Virtual Thread(V.T)가 필요했던 이유는 무엇일까? V.T의 필요성을 이해하기 위해서는 먼저 기존 스레드의 동작을 살펴볼 필요가 있다.
일단 스레드는 기본적으로 OS의 자산이다. 자바께 아니다. Java에서 스레드를 사용할 때는 OS의 스레드와 1:1로 통신할 수 있는 스레드를 만들어서 사용한다. 이를 플랫폼 스레드(Platform Thread:P.T)라고 부른다. 그러다 보니 스레드의 주도권은 OS에 있다. 자바에서 스레드로 뭔가를 하려고 하면 OS와 소통해야 한다는 말이다.
기존의 Thread의 문제점과 Virtual Thread의 목적
애초에 OS의 스레드를 빌려서 사용하다보니 여러가지 문제점이 발생한다.
첫째. 스레드의 스케줄링은 OS에 의해서 진행된다. 결국 스레드를 제어하기 위해 빈번한 시스템 호출이 발생하게 되고 이는 상당한 오버헤드를 유발하며 애플리케이션의 성능 저하를 초래할 수 있다.
둘째. OS의 스레드는 생성 개수가 제한적이고 생성, 운영에 드는 비용이 비싸다. 기본적으로 일반 스레드는 별도의 스택을 구성하는데 이걸 계속 만들었다가 지웠다가 하기가 쉽지는 않을 것이다. 그래서 자바에서는 스레드 풀(Thread Pool)을 이용해서 스레드를 한번 쓰고 버리지 않고 재사용 하는 등 조금이나마 스레드를 효율적으로 사용하기 위해서 노력해왔다. 하지만 스레드 풀을 쓰더라도 OS의 스레드를 무한정 늘릴 수 없기 때문에 생성할 수 있는 스레드 양은 제한적이었다. 결국 Spring MVC와 같이 요청 마다 스레드를 생성하는(Thread Per Request) 형식의 애플레케이션에서는 처리량(throughput)을 스레드 풀에서 감당할 수 있는 범위 내에서만 가져갈 수 밖에 없는 한계가 있었다.
Thread Per Request 형태에서는 사용자의 짧은 요청에 기민하게 반응할 수 있는 가벼운 1회용의 스레드가 필요하다!
셋째. 전통적인 Thread는 I/O과정에서 블로킹이 발생한다. 즉 스레드가 동작 중 I/O를 만나게 되면 I/O가 끝날 때 까지 멍때리게 된다. 어쩌면 열일하는 시간보다 멍때리는 시간이 더 길 수도 있다. 그나마 스레드의 개수가 제한된 상태에서 매우 아쉬운 대목이다.
Non blocking IO를 위해서 RxJava나 WebFlux 같은 비동기 기술들이 사용되었지만 사용법이 살짝 난해하고 학습곡선이 높았다. 좀더 쉽게 쓸 수 없을까?
Virtual Thread의 등장
Virtual Thread(이하 V.T)는 JDK21(LTS)에 포함된 신 기능으로 말 그대로 가상의 스레드이다. 즉 OS에서 동작하는 그런 진짜 스레드가 이니다. V.T는 일반 스레드와 달리 매우 경량이어서 수백만개의 V.T를 순간적으로 만들 수 있고 비용이 싸기 때문에 한번 쓰고 바로 버릴 수 있는 1회용이다. 따라서 스레드 풀 같은 것도 필요 없다.
또한 IO 상황에서도 블로킹이 발생하지 않기 때문에 대기시간을 줄일 수 있고 JVM에서 직접 스케줄링하기 때문에 운영상 발생하는 오버해드도 없다.
그럼에서 V.T는 일반 스레드를 상속 받기 때문에 기존의 스레드를 다루듯이 쉽게 접근할 수 있게 디자인 되었다.
sealed abstract class BaseVirtualThread extends Thread
permits VirtualThread, ThreadBuilders.BoundVirtualThread {}
final class VirtualThread extends BaseVirtualThread {
private static final Unsafe U = Unsafe.getUnsafe();
private static final ContinuationScope VTHREAD_SCOPE = new ContinuationScope("VirtualThreads");
// 기본 Scheduler - static이므로 모든 Virtual Thread는 이 Scheduler를 사용한다.
private static final ForkJoinPool DEFAULT_SCHEDULER = createDefaultScheduler();
private static final ScheduledExecutorService UNPARKER = createDelayedTaskScheduler();
// scheduler and continuation
private final Executor scheduler; // JVM 내 가상 스레드의 스케줄링 담당
private final Continuation cont;
private final Runnable runContinuation;
private volatile Thread carrierThread; // V.T가 mount 될 carrierThread
}
기본적으로 V.T도 스레드를 상속 받았기 때문에 Thread나 ExecutorService를 통해서 일반 스레드와 동일하게 사용할 수 있다.
// 1. 직접 virtual thread 만들기
Thread virtualThread = Thread.startVirtualThread(() -> {
System.out.println("Virtual Thread 1");
});
// 2. 일반 Runnable을 Virtual Thread로 실행하기
virtualThread = Thread.ofVirtual().start(() -> System.out.println("Virtual Thread 2"));
// 3. ExecutorService를 이용한 Virtual Thread 실행
Runnable runnable = ()-> System.out.println("Hi Virtual Thread");
ExecutorService eService = Executors.newVirtualThreadPerTaskExecutor();
IntStream.range(0, 3).forEach(i -> eService.submit(runnable));
VirtualThread 클래스의 코드를 살펴보면 몇 가지 특이한 녀석들이 나오는데 Thread 타입의 carrierThread와 Executor 타입의 scheduler, Continuation cont 3녀석이다.
이녀석들이 어떻게 V.T의 목적을 달성하게 해주는지 살펴보자.
Virtual Thread의 동작과 스케줄링
Carrier Thread와 Virtual Thread
V.T에서는 OS의 Thread와 1:1로 대응하는 Platform Thread로 Carrier Thread(C.T)라는 것을 사용한다. V.T들이 C.T와 N:1로 대응하며 C.T가 OS의 스레드와 통신하는 구조이다. 실제 작업은 V.T에서 진행하고 C.T는 OS의 스레드와 연결해주는 역할만 수행한다.
C.T가 생성되고 소멸될 때는 시스템 콜이 발생해서 시스템에 부하를 줄 수 있지만 V.T는 커널영역과 전혀 상관이 없다. 이처럼 V.T는 생성 비용이 매우 작기 때문에 풀링 하지 않고 1회용으로 한번 쓰고 용도가 끝나면 바로 파기해버린다.
위 상황에서 만약 V.T C가 C.T B를 할당(mount) 받아서 사용하다가 blocking이 발생하게 되면 기존에는 C.T B가 대기했지만 이제 단순히 V.T C만 unmount 시켜버리고 V.T D를 할당해서 C.T B는 계속해서 동작하게 된다. 이렇게 되면 굳이 C.T를 계속 증가시키지 않아도 많은 V.T를 사용할 수 있게 된다.
이제 C.T에 여러 V.T가 mount/unmount 될 수 있기 때문에 V.T가 블록된다면 그냥 unmount시키고 다른 V.T를 mount 시키면 된다.
C.T에서 어떤 V.T를 사용할 것인가에 대한 스케줄링은 OS가 아닌 JVM 내에서 스캐줄러가 담당하는데 기본 스케줄러의 타입은 ForkJoinPool이다.
ForkJoinPool
ForkJoinPool을 여러 스레드(Carrier Thread 들)를 이용해서 작업을 병렬로 처리할 수 있도록 설계된 스레드 풀인데 관리되는 스레드는 자신의 작업 큐를 가지고 있다. ForkJoinPool은 큰 작업을 더 작은 하위 작업으로 나누고 작은 작업을 각 스레드가 fork해서 자신의 큐에 추가한 후 병렬로 작업한다. 이후 완료된 작업을 "join" 하여 결과를 합한다.
만약 스레드가 처리할 작업이 없어서 놀고 있을 때는 다른 스레드의 작업 큐에서 작업을 훔쳐와서(work stealing) 처리할 수 있다. 다른 스레드에서 작업을 훔쳐올 때는 큐의 끝에서 작업을 가져오므로 상대적으로 나중에 추가된 작업이 먼저 처리될 수도 있다. 아무튼 work stealing 메커니즘으로 놀고있는 스레드 없이 작업을 효율적으로 처리할 수 있다.
Continuation을 이용한 Non IO Blocking
Continuation이란?
V.T의 장점중 하나는 Non IO Blocking이다. 이는 V.T가 블로킹되지 않는 것이 아니라 V.T가 블로킹 될 때 C.T에서 unmount 시키고 다른 V.T를 mount 하기 때문에 C.T가 블로킹 되지 않는다는 말이다. 그럼 V.T가 I/O 상황에서 중지 되었다가 다시 중단점에서 시작해야 한다. 어떻게 이런 일이 가능할까?
이를 위해 Continuation이 사용된다. Continuation은 사전적인 의미는 "계속함", "연속"이며 어떤 것이 중단된 후에 다시 이어지는 것을 의미한다. 프로그래밍에서는 특정 작업이나 프로세스의 흐름을 일시 중단했다가 나중에 다시 이어서 실행할 수 있는 개념을 말하는데 다른 언어에서는 coroutine이라는 이름으로 사용중이다.
다음은 c++20에 소개된 coroutine에 대한 이미지이다.
일반적으로 우리가 사용하던 함수는 좌측의 이미지처럼 호출 후 return을 만나면 비로소 종료된다. 하지만 coroutine이 적용된 함수는 실행하다가 특정 상황에서 일시 정지(suspend)를 호출하고 제어권을 다시 caller로 반환한다. 제어권을 받은 caller는 다시 작업을 수행하다가 다시 coroutine을 호출하게 되면 중단했던 지점에서 다시 재시작(resume)하게 된다.
Java의 Continuation은 위와 같이 coroutine이 적용된 작업단위로 실제 작업인 Runnable과 중단점 정보를 갖는다. Continuation은 yield()메서드가 호출되면 중단된다. Continuation의 yield를 호출하기 위해서는 V.T의 park()를 호출하면 된다.
Continuation Scope는 여러 Continuation들이 동작하는 공간이라고 보면 된다. Stack 영역은 Continuation들이 실행되는 Carrier Thread Stack을 의미한다.
- Continuation 1이 먼저 실행되면 Stack에 Continuation 1이 자리잡고 실행한다.
- Continuation 1의 yield()가 호출되면 중단점을 기록하고 Heap 영역으로 이동한다.
- Continuation 2가 실행되면 역시 Stack 영역에 자리 잡는다.
- Continuation 2의 yield()가 실행되면 역시 중단점을 기록하고 Heap으로 이동하고 Heap에 있던 Continuation 2이 다시 Stack으로 이동해서 작업을 계속한다.(이미지 상태)
Continuation은 공개되지 않은 내부 모듈이어서 직접적으로 사용하지 않는 것이 권장된다. 아래 코드는 단지 Continuation에 대한 테스트를 위한 코드이다. Continuation을 사용하기 위해서는 다음과 같은 설정이 필요하다. (Intellij 기준)
Settings -> Build, Execution, Deployment -> Compiler -> Java Compiler 에서 JDK 파라미터 설정
실행하려는 어플리케이션의 Run/Debug Configurations에서 Build and run에 실행 옵션 추가
package com.doding;
import jdk.internal.vm.*;
public class Main {
public static void main(String[] args) {
ContinuationScope sc = new ContinuationScope("test_configuration_scope");
Continuation cont1 = new Continuation(sc, ()->{
System.out.println("cont1 실행 중 1");
Continuation.yield(sc);
System.out.println("cont1 실행 중 2");
});
Continuation cont2 = new Continuation(sc, ()->{
System.out.println("cont2 실행 중 1");
Continuation.yield(sc);
System.out.println("cont2 실행 중 2");
});
cont1.run();
cont2.run();
cont1.run();
cont2.run();
}
}
// 실행 결과: 놀랍게도 cont1이 계속 실행되지 않고 실행 흐름을 cont2에게 넘겨주고 있다.
cont1 실행 중 1
cont2 실행 중 1
cont1 실행 중 2
cont2 실행 중 2
Virtual Thread의 상태 관리
V.T는 다음과 같은 상태를 갖는다.
/*
* Virtual thread state transitions:
*
* NEW -> STARTED // Thread.start, schedule to run
* STARTED -> TERMINATED // failed to start
* STARTED -> RUNNING // first run
* RUNNING -> TERMINATED // done
*
* RUNNING -> PARKING // Thread parking with LockSupport.park
* PARKING -> PARKED // cont.yield successful, parked indefinitely
* PARKING -> PINNED // cont.yield failed, parked indefinitely on carrier
* PARKED -> UNPARKED // unparked, may be scheduled to continue
* PINNED -> RUNNING // unparked, continue execution on same carrier
* UNPARKED -> RUNNING // continue execution after park
*
* RUNNING -> TIMED_PARKING // Thread parking with LockSupport.parkNanos
* TIMED_PARKING -> TIMED_PARKED // cont.yield successful, timed-parked
* TIMED_PARKING -> TIMED_PINNED // cont.yield failed, timed-parked on carrier
* TIMED_PARKED -> UNPARKED // unparked, may be scheduled to continue
* TIMED_PINNED -> RUNNING // unparked, continue execution on same carrier
*
* RUNNING -> YIELDING // Thread.yield
* YIELDING -> YIELDED // cont.yield successful, may be scheduled to continue
* YIELDING -> RUNNING // cont.yield failed
* YIELDED -> RUNNING // continue execution after Thread.yield
*/
private static final int NEW = 0;
private static final int STARTED = 1;
private static final int RUNNING = 2; // runnable-mounted
// untimed and timed parking
private static final int PARKING = 3;
private static final int PARKED = 4; // unmounted
private static final int PINNED = 5; // mounted
private static final int TIMED_PARKING = 6;
private static final int TIMED_PARKED = 7; // unmounted
private static final int TIMED_PINNED = 8; // mounted
private static final int UNPARKED = 9; // unmounted but runnable
// Thread.yield
private static final int YIELDING = 10;
private static final int YIELDED = 11; // unmounted but runnable
private static final int TERMINATED = 99; // final state
매우 복잡해보이지만 사실 V.T의 상태는 일반 스레드의 상태로도 표시된다.
@Override
Thread.State threadState() {
int s = state();
switch (s & ~SUSPENDED) {
case NEW:
return Thread.State.NEW;
case STARTED:
// return NEW if thread container not yet set
if (threadContainer() == null) {
return Thread.State.NEW;
} else {
return Thread.State.RUNNABLE;
}
case UNPARKED:
case YIELDED:
// runnable, not mounted
return Thread.State.RUNNABLE;
case RUNNING:
// if mounted then return state of carrier thread
synchronized (carrierThreadAccessLock()) {
Thread carrierThread = this.carrierThread;
if (carrierThread != null) {
return carrierThread.threadState();
}
}
// runnable, mounted
return Thread.State.RUNNABLE;
case PARKING:
case TIMED_PARKING:
case YIELDING:
// runnable, in transition
return Thread.State.RUNNABLE;
case PARKED:
case PINNED:
return State.WAITING;
case TIMED_PARKED:
case TIMED_PINNED:
return State.TIMED_WAITING;
case TERMINATED:
return Thread.State.TERMINATED;
default:
throw new InternalError();
}
}
V.T의 상태 | 스레드의 상태 | V.T의 상태 | 스레드의 상태 |
NEW | NEW | STARTED | NEW or RUNNABLE |
UNPARKED, YIELDED | RUNNABLE | RUNNING | RUNNABLE |
PARKING, YIELDING | RUNNABLE | PARKED, PINNED | WAITING |
위 상태도에서 오렌지색은 V.T가 C.T에 mount 되서 C.T의 스택에서 동작하는 상태이고 보라색은 V.T가 C.T에서 unmounted되서 heap 메모리에 존재하는 상태이다.
전체적인 설명은 V.T의 주석에 상태의 흐름이 잘 나와있기 때문에 생략하고 일반 스레드의 상태와 비교해서 생소한 상태는 PARKING, PARKED, PINNED 상태에 대해 살펴보자.
- RUNNING 중인 V.T에서 LockSupport.park()메서드가 호출되면 V.T는 PARKING상태가 된다. 이 상태는 대기 상태로 전환되는 과정으로 아직은 C.T에 mount 되어있다. PARKING에서는 내부적으로 Continuation.yield()를 호출한다.
- 만약 Continuation.yield()가 성공하면 PARKED 상태가 되는데 이는 대기 중인 상태로 V.T가 실행되지 않으므로 C.T에서 unmounted된 상태다.
- yield() 호출이 실패하면 V.T는 계속해서 작업을 해야 하기 때문에 C.T에 고정된다. 이 상황이 PINNED상태이다. 특히 V.T가 블로킹 작업을 수행(V.T 내에서 synchronized나 parallelStream을 사용하는 경우)하고 있는 경우 yield()가 실패할 수 있다.
다음으로 ForkJoinPool을 이용한 자체 스케줄링과 Continuation을 이용한 Non IO Blocking으로 무장한 V.T를 사용할 때 주의 사항에 대해 살펴보자.
주의사항
V.T는 1회용
일반 스레드는 만드는데 정성이 많이 들기 때문에 만들고 쉽게 버리기에 아깝다. 따라서 스레드 풀을 이용해서 재사용하는 편이다. 이 과정에서는 스레드를 유지하는 비용이 발생한다.
하지만 V.T는 생성하는데 드는 비용이 매우 적고 오히려 재사용하기 위한 유지보수 비용이 더 클 수 있다. 따라서 V.T는 1회용으로만 쓰로 재사용하지 않는 것이 유리하다. V.T는 일회용의 성격이 강하기 때문에 ThreadLocal(스레드 별로 독립적으로 사용되는 데이터를 저장하는 클래스)의 크기가 크다면 오히려 일반 스레드를 사용하는 편이 유리하다.
V.T에게 유리한 작업들을 맡기기
V.T의 주요 목적은 Non IO Blocking이다. CPU 작업만 주로 한다면 V.T의 장점을 살리기 쉽지 않다. 만약 Context Switching이 빈번하게 발생하지 않는 상황이라면 오히려 일반 스레드가 유리하다.
Synchronized 사용 시 주의가 필요하다. 왜냐하면 carrier thread가 blocking 될 수 있기 때문이다. 이런 경우는 ReentrantLock을 사용할 수 있다.
import java.util.concurrent.locks.ReentrantLock;
public class ReentrantLockExample {
private static ReentrantLock lock = new ReentrantLock();
private static int counter = 0;
public static void main(String[] args) {
Runnable task = () -> {
for (int i = 0; i < 1000; i++) {
lock.lock(); // 잠금 획득
try {
counter++; // 공유 자원 수정
} finally {
lock.unlock(); // 잠금 해제
}
}
};
Thread thread1 = new Thread(task);
Thread thread2 = new Thread(task);
thread1.start();
thread2.start();
try {
thread1.join();
thread2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Final counter value: " + counter);
}
}
배압 기능 없음
배압(Back Pressure)는 데이터의 흐름을 제어하는 메커니즘으로 주로 비동기나 스트림 처리에서 사용된다. 배압은 데이터를 생성하는 생산자가 데이터를 소비하는 소비자의 처리 능력에 맞추어 데이터 전송 속도를 조절해주는 것이다.
만약 3000명의 사용자가 WAS에 요청을 날리고 WAS는 이를 V.T로 처리한다고 생각해보자. 각각의 V.T가 DB 작업을 하는데 DB에서 1초씩의 시간이 소비된다고 하면 WAS의 요청 처리는 눈부시게 빠른 속도로 처리가 되겠지만 DB 작업에 오랜 시간이 걸리므로 connection pool에서 오히려 timeout 이 발생할 수 있다. 이런 상황을 overwhelming(압도적인: 앞은 다 처리가 끝났는데 뒤에서 처리를 못해주는 상황)이라고 한다. 이런 경우는 오히려 Thread Pool을 이용해서 Thread의 개수를 제한하고 사용하는 것이 훨씬 바람직할 수 있다.
이제까지 살펴본 대로 Virtual Thread는 효율적으로 보이기는 하지만 우리가 사용하는 모든 리소스들은 아직 Virtual Thread와 완벽하게 융합되지는 않는다. 따라서 Virtual Thread를 적용하기 위해서는 정말 가능한지, 문제가 없는지 충분한 테스트를 해본 후 적용 해야 한다.
적용
SpringBoot에서 Virtual Thread 적용하기
만약 Spring Boot(3.2 이상)에서 Virtual Thread를 적용하기 위해서는 spring.threads.virtual.enabled 속성을 true로 설정하면 된다.
#application.yml
spring:
threads:
virtual:
enabled: true
위와 같은 설정 하나만으로 tomcat에서의 요청 처리등 내부적인 스레드의 동작을 Virtual Thread로 변경해서 처리해준다.
'자바(SE)' 카테고리의 다른 글
[JDK] 버전별 특징 - JDK21 (0) | 2024.09.20 |
---|---|
[Stream] 스트림의 병렬 처리 (0) | 2024.07.08 |
[Thread] 05. 스레드 풀 (1) | 2024.07.07 |
[Thread] 04. 멀티 스레드와 Collection (0) | 2024.07.06 |
[Thread] 03.데이터의 공유와 동기화 (0) | 2024.07.05 |
소중한 공감 감사합니다