2024. 11. 30. 18:37ㆍ프로그래밍언어/Java&Servlet
오늘 알아볼 주제는 JDK 21부터 정식으로 도입된 Virtual Thread이다. 불가 얼마전까지만 해도 Webflux나 Kotlin의 Coroutine을 이용해 애플리케이션을 개발하였는데, 최근에 Virtual Thread를 도입하면서 조금더 깊은 이해를 가지고 사용하는 것이 좋다 생각하여 정리해본다.
우선 Virtual Thread를 알아보기전에 Java의 전통적인 Thread 모델에 대해 다시한번 되짚어보자.
Thread란?
위 그림과 같이 우선 Thread에는 유저 레벨 쓰레드, 커널 레벨 쓰레드, 혼합형 쓰레드가 있다.
유저 레벨 쓰레드
사용자 스레드는 커널 영역의 상위에서 지원되며 일반적으로 사용자 레벨의 라이브러리를 통해 구현되며, 라이브러리는 스레드의 생성 및 스케줄링 등에 관한 관리 기능을 제공하기 때문에 커널이 쓰레드의 존재를 모른다.
사용자 레벨 쓰레드에서는 쓰레드 context switcing에 커널이 개입하지 않아 커널에서 사용자 영역으로 전환할 필요가 없다. 그리고 커널은 쓰레드가 아닌 프로세스를 한 단위로 안식하고 프로세서를 할당한다.
동일한 메모리 영역에서 스레드가 생성 및 관리되므로 속도가 빠른 장점이 있는 반면, 여러 개의 사용자 스레드 중 하나의 스레드가 시스템 호출(system call) 등으로 중단되면 나머지 모든 스레드 역시 중단되는 단점이 있다. 이는 커널이 프로세스 내부의 스레드를 인식하지 못하며 해당 프로세스를 대기 상태로 전환시키기 때문이다.
커널 레벨 쓰레드
커널 스레드는 사용자 레벨 쓰레드의 한계를 극복하는 방법으로, 커널이 쓰레드와 관련된 모든 작업을 관리한다.(PCB, TCB 유지) 한 프로세스에서 다수의 쓰레드가 프로세서를 할당받아 병행으로 수행하고, 쓰레드 한 개가 대기 상태가 되면 동일한 프로세스에 속한 다른 쓰레드로 context switching이 가능하다. 이때 커널이 개입하므로 사용자 영역에서 커널 영역으로 전환이 필요하다. 위 그림처럼 유저 레벨 쓰레드 하나를 생성할때 커널 쓰레드가 1:1로 매핑된다.
운영체제가 지원하는 스레드 기능으로 구현되며, 커널이 스레드의 생성 및 스케줄링 등을 관리한다. 스레드가 시스템 호출(system call) 등으로 중단되더라도, 커널은 프로세스 내의 다른 스레드를 중단시키지 않고 계속 실행시켜준다. 멀티프로세싱 환경에서 커널은 여러 개의 스레드를 각각 다른 프로세서에 할당할 수 있다. 다만, 사용자 스레드에 비해 생성 및 관리하는 것이 느리다.
혼합형 쓰레드
혼합형 쓰레드는 사용자 레벨 쓰레드와 커널 수준 쓰레드를 혼합한 구조이다.
그렇다면 자바는 어떤 쓰레드를 사용할까?
사실 여러 글을 찾아봐도 100% 해답을 찾지 못했지만, 결론적으로 Java는 1:1 매핑의 커널 레벨 쓰레드 모델을 사용하고 있는 것으로 보인다.(이미 생성된 커널쓰레드가 놀고 있다면 자바 쓰레드와 unmount되고 새로 생성된 자바 쓰래드와 1:1 매핑되는것 같다. 즉, 실행 시점에 유저쓰레드:커널 쓰레드 = 1:1이 맞는 표현 같다.) 실제로 Java의 Thread 코드를 보면 Thread.start() 시점에 native method(c++로 작성된)를 사용하여 커널 레벨 쓰레드를 생성하고 Java의 Thread local variable에 커널 레벨 쓰레드를 멤버 변수가 가지고 있는다.
다음으로는 Java에서 I/O가 발생했을 때 쓰레드의 상태를 알아보자.
전통적인 Java Thread I/O
Java의 Thread에서 I/O(ex. file read/write, network i/o)가 발생하면, Thread는 system call을 하게된다. system call을 하게 되면 유저 영역에서 커널 영역으로 전환이 되고 커널 쓰레드는 요청온 I/O 작업을 진행하게 된다.(해당 과정에서 인터럽트가 일어나 CPU에게 I/O 작업이 있다고 알린다.) 이때 커널 쓰레드는 Block 상태가 되고, 그에 따라 유저 레벨의 Java Thread도 아무일도 하지 못한채 Block이 되고 만다. 여기서 Java는 커널 레벨 쓰레드 모델을 사용하기 때문에 CPU는 I/O 요청이 온 쓰레드를 대기 큐에 넣고, 준비 큐에 있는 다른 쓰레드의 작업을 진행하게 된다.(context switching 발생) I/O 작업이 완료되었다면 CPU에게 인터럽트를 발생시켜 작업이 완료되었다는 것을 알리고 대기 큐에 있던 Block된 Thread의 작업을 제개하게 된다.
여기까지 Java에서 I/O가 발생했을 때는 대략적인 흐름인데, Spring MVC에서는 그 많은 요청을 어떻게 계속 받을 수 있게 되는 것일까? 그것은 보통 우리는 WAS의 servlet Thread pool을 만들어 사용하기 때문에 최소한 pool에 담긴 thread 개수 만큼은 동시에 I/O가 발생하여도 동시 요청을 받을 수 있게 되는 것이다.
하지만 문제는 무엇일까? 바로 자원은 유한하기 때문에 pool의 thread를 무한히 늘릴 수 없는 것이다. 또한 Java의 thread는 커널 레벨의 쓰레드와 1:1 매핑되기 때문에 I/O 블록킹이 발생하면 CPU의 준비큐에 있는 다른 Thread의 작업을 진행하게 위한 Context switching 비용이 비싸기 때문에 무한히 thread를 늘린다 한들 linear하게 성능이 올라가지 않는 것이다.
1. 시스템 콜 호출: 애플리케이션의 스레드가 파일 읽기나 소켓 통신을 요청하면, 해당 요청은 시스템 콜을 통해 운영 체제의 커널에 전달된다.
2. 커널 모드 전환: 시스템 콜이 호출되면, 프로세스는 커널 모드로 전환되어 커널이 해당 I/O 작업을 처리할 수 있게 된다.
3. I/O 작업 처리: 커널은 디바이스 드라이버를 통해 실제 I/O 작업을 수행한다. 이때, 작업의 완료까지 시간이 소요될 수 있.
4. 스레드 context switching: I/O 작업이 진행되는 동안, 해당 스레드는 BLOCKED 상태로 전환되어 CPU를 다른 작업에 할당할 수 있게 된다.
5. I/O 완료 및 인터럽트 발생: I/O 작업이 완료되면, 디바이스는 인터럽트를 발생시켜 커널에 작업 완료를 알린다.
6. 스레드 재개: 커널은 BLOCKED 상태에 있던 스레드를 READY 상태로 전환하고, 스케줄러는 해당 스레드에 CPU를 재할당하여 작업을 이어서 수행할 수 있게 한다.
Reactive(Webflux)
위와 같이 I/O가 발생될 때마다 쓰레드가 블록킹되고 그에 따른 컨텍스트 스위칭에 대한 비용이 크기 때문에 non-blocking reactive 프로그래밍 방식이 등장하였다.(Webflux에서 네트워크 I/O가 발생하면 파일 디스크립터 감시를 위해 epoll을 사용한다.)
epoll
epoll은 리눅스에서 대규모 파일 디스크립터를 효율적으로 모니터링하기 위해 설계된 I/O 이벤트 통지 메커니즘입니다. epoll의 동작은 설정에 따라 블로킹 또는 논블로킹 모드로 작동할 수 있습니다.
동작 모드:
1. 블로킹 모드: epoll_wait() 함수를 호출할 때, 지정된 파일 디스크립터에 이벤트가 발생할 때까지 호출한 스레드는 대기 상태에 머무릅니다. 이때, epoll_wait()는 이벤트가 발생하거나 타임아웃이 도래하면 반환됩니다.
2. 논블로킹 모드: epoll_wait() 호출 시 타임아웃을 0으로 설정하면, 함수는 즉시 반환하며, 이때 이벤트가 준비되지 않았으면 빈 결과를 반환합니다. 또한, 파일 디스크립터를 논블로킹 모드로 설정하면, I/O 작업이 즉시 완료되지 않더라도 스레드는 블로킹되지 않고 다른 작업을 계속 수행할 수 있습니다.
레벨 트리거(Level-Triggered)와 엣지 트리거(Edge-Triggered):
epoll은 두 가지 트리거 모드를 지원합니다:
• 레벨 트리거(Level-Triggered): 기본 모드로, 파일 디스크립터가 읽기 또는 쓰기 가능한 상태일 때마다 이벤트를 지속적으로 보고합니다. 이 모드에서는 이벤트를 처리하지 않으면 동일한 이벤트가 반복적으로 전달될 수 있습니다.
• 엣지 트리거(Edge-Triggered): 상태의 변화를 감지하여 이벤트를 보고합니다. 예를 들어, 새로운 데이터가 도착했을 때 한 번만 이벤트를 전달하며, 추가적인 데이터가 도착하지 않으면 추가 이벤트는 발생하지 않습니다. 이 모드에서는 I/O 작업을 완료하기 위해 반복적으로 읽기 또는 쓰기 작업을 수행해야 하며, 그렇지 않으면 데이터가 남아있어도 추가 이벤트를 받지 못할 수 있습니다.
참고사항:
epoll을 사용할 때, 파일 디스크립터를 논블로킹 모드로 설정하는 것이 일반적입니다. 이를 통해 I/O 작업이 즉시 완료되지 않더라도 스레드가 블로킹되지 않고 다른 작업을 수행할 수 있습니다. 이러한 설정은 높은 동시성을 필요로 하는 서버 애플리케이션에서 효율적인 자원 활용을 가능하게 합니다.
따라서, epoll의 블로킹 여부는 함수 호출 시 설정한 타임아웃 값과 파일 디스크립터의 모드에 따라 결정됩니다. 적절한 설정을 통해 원하는 동작 방식을 구현할 수 있습니다.
Webflux는 I/O가 발생하여도 쓰레드를 블록킹하지 않기 때문에 적은 비용으로 thread per request 모델보다 더 좋은 성능을 낼 수 있다. 하지만 아래와 같은 단점이 있다.
- 러닝 커브: 기존의 동기식 프로그래밍과 다른 패러다임을 사용하므로, 개발자들이 새로운 개념과 패턴을 익혀야 한다.
- 제한된 블로킹 라이브러리 호환성: 일부 블로킹 방식으로 동작하는 라이브러리나 데이터베이스 드라이버와의 호환성 문제가 발생할 수 있다.
- 디버깅의 어려움: 비동기 흐름으로 인해 전통적인 디버깅 기법이 효과적이지 않을 수 있다.
- 코드 복잡성 증가: 비동기 및 리액티브 프로그래밍은 코드의 복잡성을 높이고, 디버깅과 오류 처리를 어렵게 만들 수 있다.
Virtual Thread
기존의 Java 스레드와 Webflux를 알아보았으니 이제 JDK 21에 새로 도입된 Virtual Thread를 알아보자.
Heap에 존재하는 많은 ULT 중 하나가 JVM의 스케줄링에 따라 KLT에 매핑되어 실행하는 형태가 기존의 Java 스레드 모델이다.
Virtual Thread concepts
출처: https://jenkov.com/tutorials/java-concurrency/java-virtual-threads.html
Virtual Thread는 기존 KLT(1) : ULT(1)의 구조가 아닌 KLT(1) : ULT(1) : Virtual Thread(N)의 구조로 사용된다. KLT와 Virtual Thread 사이의 ULT는 플랫폼 스레드라고 한다.
위 그림과 같이 Heap에 수많은 Virtual Thread를 할당해놓고, 플랫폼 스레드에 대상 Virtual Thread를 마운트/언마운트하여 컨텍스트 스위칭을 수행한다. 따라서 컨텍스트 스위칭 비용이 작아질 수 밖에 없다.
스레드의 크기와 컨텍스트 스위칭 비용이 많이 감소한 모델이기 때문에 Spring MVC/Tomcat 등의 모델이 Netty/WebFlux에 비해 가진 단점이 많이 희석되었다.
Virtual Thread states
Virtual Thread에는 9개의 상태가 있다.
/*
* Virtual thread state and transitions:
*
* NEW -> STARTED // Thread.start
* STARTED -> TERMINATED // failed to start
* STARTED -> RUNNING // first run
*
* RUNNING -> PARKING // Thread attempts to park
* PARKING -> PARKED // cont.yield successful, thread is parked
* PARKING -> PINNED // cont.yield failed, thread is pinned
*
* PARKED -> RUNNABLE // unpark or interrupted
* PINNED -> RUNNABLE // unpark or interrupted
*
* RUNNABLE -> RUNNING // continue execution
*
* RUNNING -> YIELDING // Thread.yield
* YIELDING -> RUNNABLE // yield successful
* YIELDING -> RUNNING // yield failed
*
* RUNNING -> TERMINATED // done
*/
private static final int NEW = 0;
private static final int STARTED = 1;
private static final int RUNNABLE = 2; // runnable-unmounted
private static final int RUNNING = 3; // runnable-mounted
private static final int PARKING = 4;
private static final int PARKED = 5; // unmounted
private static final int PINNED = 6; // mounted
private static final int YIELDING = 7; // Thread.yield
private static final int TERMINATED = 99; // final state
출처: https://github.com/openjdk/jdk21/blob/master/src/java.base/share/classes/java/lang/VirtualThread.java#L91
다음과 같이 Virtual Thread의 상태에 따라 플랫폼 스레드에 마운트/언마운트해 실행을 관리한다.
플랫폼 스레드에 언마운트/마운트할 때에는 park/unpark 메서드를 사용한다.
sealed abstract class BaseVirtualThread extends Thread
permits VirtualThread, ThreadBuilders.BoundVirtualThread {
/**
* Initializes a virtual Thread.
*
* @param name thread name, can be null
* @param characteristics thread characteristics
* @param bound true when bound to an OS thread
*/
BaseVirtualThread(String name, int characteristics, boolean bound) {
super(name, characteristics, bound);
}
/**
* Parks the current virtual thread until the parking permit is available or
* the thread is interrupted.
*
* The behavior of this method when the current thread is not this thread
* is not defined.
*/
abstract void park();
/**
* Parks current virtual thread up to the given waiting time until the parking
* permit is available or the thread is interrupted.
*
* The behavior of this method when the current thread is not this thread
* is not defined.
*/
abstract void parkNanos(long nanos);
/**
* Makes available the parking permit to the given this virtual thread.
*/
abstract void unpark();
}
출처: https://github.com/openjdk/jdk21/blob/master/src/java.base/share/classes/java/lang/BaseVirtualThread.java#L30
위 상태 그림처럼 Virtual Thread의 state를 변경시켜가며 상태를 관리한다.
@Override
void park() {
... 생략
// park on the carrier thread when pinned
if (!yielded) {
parkOnCarrierThread(false, 0);
}
}
private void parkOnCarrierThread(boolean timed, long nanos) {
assert state() == RUNNING;
... 생략
setState(PINNED); RUNNING -> PINNED 로 전환
... 생략
}
출처: https://github.com/openjdk/jdk21/blob/master/src/java.base/share/classes/java/lang/VirtualThread.java#L581
플랫폼 스레드에 마운트하여 실행하는 unpark 메서드를 보자.
void unpark() {
... 생략
if (s == PARKED && compareAndSetState(PARKED, RUNNABLE)) {
if (currentThread instanceof VirtualThread vthread) {
vthread.switchToCarrierThread();
try {
submitRunContinuation();
} finally {
switchToVirtualThread(vthread);
}
} else {
submitRunContinuation();
}
}
... 생략
}
}
private void submitRunContinuation() {
try {
scheduler.execute(runContinuation);
} catch (RejectedExecutionException ree) {
submitFailed(ree);
throw ree;
}
}
출처: https://github.com/openjdk/jdk21/blob/master/src/java.base/share/classes/java/lang/VirtualThread.java#L733
보다시피 scheduler로 실제 실행을 넘기며, scheduler는 ForkJoinPool이다.
private static ForkJoinPool createDefaultScheduler() {
ForkJoinWorkerThreadFactory factory = pool -> {
PrivilegedAction<ForkJoinWorkerThread> pa = () -> new CarrierThread(pool);
return AccessController.doPrivileged(pa);
};
... 생략
출처: https://github.com/openjdk/jdk21/blob/master/src/java.base/share/classes/java/lang/VirtualThread.java#L1113
Virtual Thread는 플랫폼 스레드를 참조하고 있으며 이는 carrierThread라고 한다.
// carrier thread when mounted, accessed by VM
private volatile Thread carrierThread;
출처: https://github.com/openjdk/jdk21/blob/master/src/java.base/share/classes/java/lang/VirtualThread.java#L131
즉, JVM이 직접 접근하는 스레드는 플랫폼 스레드이며, 플랫폼 스레드에 마운트하여 실행하는 과정은 carrierThread에 실행 대상 Virtual Thread를 할당하는 방식이다.
private void mount() {
... 생략
carrier.setCurrentThread(this); -> 플랫폼 스레드에 실행할 Virtual Thread 할당
... 생략
}
private void unmount() {
Thread carrier = this.carrierThread;
carrier.setCurrentThread(carrier);
synchronized (interruptLock) {
setCarrierThread(null); -> Virtual Thread에서 Virtual Thread 제거
}
carrier.clearInterrupt();
}
출처: https://github.com/openjdk/jdk21/blob/master/src/java.base/share/classes/java/lang/VirtualThread.java#L351
Virtual Thread는 플랫폼 스레드를 참조하고 있으며 실제 실행 시에는 플랫폼 스레드에 마운트되어 ForkJoinPool의 큐에 들어가 스케줄링된다.
private <T> ForkJoinTask<T> poolSubmit(boolean signalIfEmpty,
ForkJoinTask<T> task) {
WorkQueue q; Thread t; ForkJoinWorkerThread wt;
U.storeStoreFence(); // ensure safely publishable
if (task == null) throw new NullPointerException();
if (((t = Thread.currentThread()) instanceof ForkJoinWorkerThread) &&
(wt = (ForkJoinWorkerThread)t).pool == this)
q = wt.workQueue;
else {
task.markPoolSubmission();
q = submissionQueue(true);
}
q.push(task, this, signalIfEmpty);
return task;
}
Virtual Thread pinning
Virtual Thread의 장점은, JVM이 자체적으로 Virtual Thread를 스케줄링하고 컨텍스트 스위칭 비용이 줄어들어 효율적으로 운영할 수 있다는 것이다. 하지만 Virtual Thread가 플랫폼 스레드에 고정되어 장점을 활용할 수 없는 경우가 있다. Virtual Thread 내에서 synchronized block을 사용하거나, JNI를 통해 네이티브 메서드를 사용하는 경우다.
출처: https://docs.oracle.com/en/java/javase/21/core/virtual-threads.html#GUID-704A716D-0662-4BC7-8C7F-66EE74B1EDAD
Virtual Thread는 Spring Boot 3.2.x에서 공식적으로 지원하지만(https://spring.io/blog/2023/09/09/all-together-now-spring-boot-3-2-graalvm-native-images-java-21-and-virtual) 2.x에서도 별도로 설정해서 사용할 수 있다(https://spring.io/blog/2022/10/11/embracing-virtual-threads).
@Bean
public TomcatProtocolHandlerCustomizer<?> protocolHandlerVirtualThreadExecutorCustomizer() {
return protocolHandler -> {
protocolHandler.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
};
}
다만, 공식 블로그에 따르면 Spring 로직 내에 많은 synchronized가 있어 효율이 좋지 않다.
출처: https://spring.io/blog/2022/10/11/embracing-virtual-threads#mitigating-limitations
실제로 Spring Boot 2.7.17에서 Virtual Thread를 사용하도록 설정하고 -Djdk.tracePinnedThreads=short 옵션과 함께 구동한 후 synchronized를 사용하는 컨트롤러를 호출하면 다음과 같은 로그를 많이 볼 수 있다.
@GetMapping("/test")
@Operation(summary = "테스트", description = "테스트")
public String test() throws Exception {
synchronized (this){
Thread.sleep(1000l);
log.info("HELLO");
}
return "OK";
}
Thread[#185,ForkJoinPool-1-worker-1,5,CarrierThreads]
com.example.test.TestController.test(TestController.java:22) <== monitors:1
또한 Spring 구동 시 다음과 같은 로그도 볼 수 있다.
Thread[#184,ForkJoinPool-1-worker-2,5,CarrierThreads]
com.mysql.cj.protocol.ReadAheadInputStream.read(ReadAheadInputStream.java:180) <== monitors:1
com.mysql.cj.jdbc.ConnectionImpl.commit(ConnectionImpl.java:791) <== monitors:1
MySQL 패키지에 사용된 synchronized가 pinning을 유발하고 있는 것이다.(최신 spring 3.4.x 버전부터는 mysql의 버전이 pinning관련된 이슈가 해결된 버전이다.)
따라서 Spring은 synchronized를 ReentrantLock으로 마이그레이션하는 방향으로 가고 있다.
출처: https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-3.2.0-M2-Release-Notes#support-for-virtual-threads
그 밖에도 많은 진영에서 Virtual Thread를 지원하기 위해 synchronized에서 ReentrantLock으로 마이그레이션이 진행되고 있다.
- MySQL: https://github.com/mysql/mysql-connector-j/pull/95
- UUId: https://github.com/f4b6a3/uuid-creator/commit/3e684b1dec472b51a641bbd1762b33c9ea62bc77
synchronized가 많이 남아있는 Spring Boot 2.x에서는 Virtual Thread를 잘 사용하기 위해서는 여러 의존 모듈의 마이그레이션이 선행되어야 할 것 같다. 앞으로 미래 Java 버전에서는 synchronized는 점점 사라질 것으로 예상한다.
Virtual Thread blocking
기존 Java 스레드는 sleep 실행 시 blocking 상태가 되며 다른 스레드와 컨텍스트 스위칭을 한다. Virtual Thread의 sleep을 살펴보자.
public static void sleep(long millis) throws InterruptedException {
if (millis < 0) {
throw new IllegalArgumentException("timeout value is negative");
}
long nanos = MILLISECONDS.toNanos(millis);
ThreadSleepEvent event = beforeSleep(nanos);
try {
if (currentThread() instanceof VirtualThread vthread) {
vthread.sleepNanos(nanos);
} else {
sleep0(nanos);
}
} finally {
afterSleep(event);
}
}
출처: https://github.com/openjdk/jdk21/blob/master/src/java.base/share/classes/java/lang/Thread.java#L498
기존 스레드의 경우 sleep0 JNI 호출로 KLT와 함께 block 상태로 변경되고 Virtual Thread의 경우 다른 동작을 하는 것을 볼 수 있다.
void sleepNanos(long nanos) throws InterruptedException {
... 생략
parkNanos(remainingNanos);
... 생략
}
@Override
void parkNanos(long nanos) {
... 생략
boolean yielded = false;
Future<?> unparker = scheduleUnpark(this::unpark, nanos);
setState(PARKING);
try {
yielded = yieldContinuation(); // may throw
... 생략
}
private boolean yieldContinuation() {
// unmount
notifyJvmtiUnmount(/*hide*/true);
unmount();
try {
return Continuation.yield(VTHREAD_SCOPE);
} finally {
// re-mount
mount();
notifyJvmtiMount(/*hide*/false);
}
}
출처: https://github.com/openjdk/jdk21/blob/master/src/java.base/share/classes/java/lang/VirtualThread.java#L628
스레드를 언마운트/park하고 다시 마운트/unpark하는 것은 Future로 돌리는 것을 알 수 있다. 즉, 명시적인 KLT의 sleep/block을 수행하지 않는다.
Spring MVC Tomcat 하에서 테스트를 해보자. Virtual Thread를 사용하지 않는 Tomcat의 threads를 1로 설정하여 커널 스레드를 하나만 사용하게 하고, Virtual Thread에서도 커널 스레드를 하나만 사용하게 하여 처리량을 비교해 보겠다. 또한 호출은 100개의 요청을 동시에 보내 보겠다.
다음 컨트롤러를 호출한다.
@GetMapping("/test")
@Operation(summary = "테스트", description = "테스트")
public String test() throws Exception {
Thread.sleep(1000l);
log.info("{}", Thread.currentThread());
return "OK";
}
Tomcat은 다음 설정으로 스레드를 제한한다.
server:
tomcat:
threads:
max: 1
Virtual Thread는 가이드에 따라 다음 환경변수를 통해 스레드를 제한한다.
출처: https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/Thread.html
Virtual Thread를 사용하지 않은 환경에서는100개의 호출이 동시에 발생했으나, Tomcat 스레드가 1이므로 호출 처리에 최대 1000밀리초 * 100의 처리 시간이 걸리고 1TPS의 처리량을 넘지 못한다. 즉, 동시성이 거의 없는 것을 볼 수 있다.
Name | # reqs | # fails | Avg | Min | Max | Median | req/s | failures/s |
GET /test | 23 | 0(0.00%) | 11986 | 1021 | 22943 | 12000 | 0.99 | 0.00 |
Virtual Thread를 사용한 환경에서는 높은 TPS 처리량을 보인다. 100개의 호출이 동시에 발생했으나, non-blocking 방식으로 처리되어 최대 처리 시간 또한 1000l 정도다. 또한 로그에서 커널 스레드는 하나만 사용하는 것을 알 수 있다.
Name | # reqs | # fails | Avg | Min | Max | Median | req/s | failures/s |
GET /test | 928 | 0(0.00%) | 1005 | 1001 | 1031 | 1001 | 89.19 | 0.00 |
2024-02-05 13:17:26.329 INFO 70581 --- [ ] VirtualThread[#312]/runnable@ForkJoinPool-1-worker-1
2024-02-05 13:17:26.336 INFO 70581 --- [ ] VirtualThread[#313]/runnable@ForkJoinPool-1-worker-1
2024-02-05 13:17:26.339 INFO 70581 --- [ ] VirtualThread[#314]/runnable@ForkJoinPool-1-worker-1
2024-02-05 13:17:26.349 INFO 70581 --- [ ] VirtualThread[#315]/runnable@ForkJoinPool-1-worker-1
따라서 Tomcat, Spring MVC 하에서도 Netty/WebFlux와 처리 방식과 효율이 같으며, 네트워크 I/O처럼 CPU를 사용하지 않는 스레드 blocking 환경에서 사용하면 좋은 효율을 보여줄 수 있다고 판단할 수 있다.
참고
https://techblog.woowahan.com/15398/
https://d2.naver.com/helloworld/1203723
https://wikidocs.net/232300
https://f-lab.kr/insight/understanding-non-blocking-io-and-threads
https://letsmakemyselfprogrammer.tistory.com/98
https://konghana01.tistory.com/649
https://blog.naver.com/PostView.naver?blogId=thisryan97&logNo=223457538567
'프로그래밍언어 > Java&Servlet' 카테고리의 다른 글
Java - 자바 날짜&시간 java.time 패키지(LocalDateTime, ZoneDateTime) (0) | 2020.07.30 |
---|---|
Java - Reactor switchIfEmpty 사용시 주의점(Lambda, 람다 Lazy Evaluation) (2) | 2020.07.29 |
Java - 자바 클로져(Closure) & 커링(currying) (0) | 2020.07.22 |
Java - jdk 14 record(레코드) 란?! Data class(데이터 클래스) (0) | 2020.05.14 |
Java - Model(Object) mapping을 위한 Mapstruct (맵스트럭트)! (0) | 2020.04.29 |