이번 시간에는 최근 자바 진영의 뜨거운 감자인 `가상 스레드 (Virtual Thread)`에 대해서 알아보도록 하겠습니다.
가상 스레드란?
Virtual threads are lightweight threads that reduce the effort of writing, maintaining, and debugging high-throughput concurrent applications.
가상 스레드는 높은 동시 처리 애플리케이션 환경을 제공하기 위한 경량 스레드입니다.
Virtual thread isn't tied to a specific OS thread. A virtual thread still runs code on an OS thread. However, when code running in a virtual thread calls a blocking I/O operation, the Java runtime suspends the virtual thread until it can be resumed. The OS thread associated with the suspended virtual thread is now free to perform operations for other virtual threads.
가상 스레드는 특정 OS 스레드에 종속되지 않지만, 여전히 OS 스레드에서 코드를 실행합니다. 가상 스레드에서 실행되는 코드가 블로킹 I/O 작업을 호출하면 Java 런타임은 가상 스레드가 재개될 수 있을 때까지 가상 스레드를 일시 중단합니다. 일시 중단된 가상 스레드와 연결된 OS 스레드는 다른 가상 스레드에 대한 작업을 자유롭게 수행할 수 있습니다.
Virtual threads are not faster threads; they do not run code any faster than platform threads. They exist to provide scale (higher throughput), not speed (lower latency).
가상 스레드는 더 빠른 스레드가 아닙니다. 플랫폼 스레드보다 더 빠르게 코드를 실행하지 않습니다. 처리 속도가 아닌 처리 규모를 높이기 위한 쓰레드입니다.
출처 : 오라클 공식 문서
공식 문서에 따르면 가상 스레드는 애플리케이션의 높은 동시 처리량을 위한 경량 스레드로서, 블로킹 I/O 작업을 논블로킹하게 처리함으로써 처리 규모를 높이기 위해 등장했음을 알 수 있습니다.
가상 스레드의 구조
가상 스레드는 위 그림과 같은 구조를 지녔는데 보다시피 OS 스레드에 가상 스레드가 직접 매핑되는 것이 아니라, `플랫폼 스레드 == 캐리어 스레드` 라는 `ForkJoin Pool`의 워커 스레드에 매핑됩니다. 즉, OS 스레드와 캐리어 스레드가 1:1로 매핑되고, 캐리어 스레드와 가상 스레드가 1:N으로 매핑되는 것입니다.
기본적으로 가상 스레드는 JVM 영역에서 관리되는 경량 스레드이기 때문에 생성 비용 자체도 저렴하고, 시스템 콜과 메모리도 적게 사용하여 컨텍스트 스위칭 비용이 저렴합니다. 때문에 공식문서에서도 경량 스레드의 장점을 살려 고정 풀을 만들지 않고 필요할 때마다 생성해서 사용하기를 권장하고 있는데, 이는 고정된 풀을 사용하여 생성 제한을 거는 순간 처리 규모를 높이기 위한 가상 스레드의 핵심에서 벗어나는 것이기 때문입니다. 다만, 특정 상황에서는 가상 스레드의 배압을 조절해야 할 수도 있는데 이러한 상황에서는 세마포어나 Reetrant Lock을 사용하여 가상 스레드의 배압을 조절하길 권장하고 있습니다.
컨텍스트 스위칭
CPU 코어에서 실행 중이던 프로세스/스레드가 다른 프로세스/스레드로 교체되는 과정으로, 여러 프로세스/스레드를 동시에 실행시키기 위해 사용된다. 실행 중이던 프로세스/스레드를 교체하기 위해서는 현재 상태를 기록하는 동작과 후에 다시 상태를 읽어오는 동작이 존재하기 때문에 비용이 발생한다.
Fork Join Pool
Java7에 등장한 기술로 동일한 작업을 여러 개의 Sub Task로 분리(Fork)하여 각각을 처리하고, 이를 최종적으로 합쳐서(Join) 결과를 만들어내는 방식이다. 하나의 Work Queue를 가지고 있으며, 큐 내부의 작업들을 Fork Join Pool에서 관리하는 워커 스레드들이 작업들을 처리한다. 이때, 워커 스레드들은 Work Queue에서 가져온 작업을 내부 큐(Inbound Queue)에 담아 관리하는데, 이는 놀고 있는 스레드가 존재하지 않도록 각각의 다른 워커 스레드의 내부 큐에 있는 작업을 훔쳐오는 식(Work Stealing)으로 동작하기 위함이다.
가상 스레드의 동작 방식
우선, 가상 스레드가 실제로 작업을 수행하기 위해서는 OS 스레드와 연결된 캐리어 스레드에 위에 올려지는 동작이 필요합니다. 이를 `Mount`라고 하며, 캐리어 스레드에 `Mount`된 가상 스레드가 블로킹 I/O를 만나거나 CPU에서 실행을 멈추고 대기 상태로 전환되면 `Unmount`를 수행하게 됩니다. `Unmout`를 수행하면, 가상 스레드는 더 이상 캐리어 스레드와 연결되지 않고 대기 상태로 돌아갑니다.
가상 스레드는 JVM 영역에서 관리되기 때문에 실행 도중 중단되거나 블로킹 I/O 작업으로 인해 일시 정지 상태가 되면 현재 상태(스택 프레임, 로컬 변수, 호출된 함수 정보 등)를 Heap 영역에 저장하게 됩니다. 이 저장된 상태는 나중에 해당 스레드가 다시 실행될 때 그대로 복원되어 이어서 실행될 수 있어야 하는데, 이런 상태를 관리하는 것이 `Continuation` 객체 입니다. 가상 스레드는 자신의 작업을 `runContinuation`(Runnable) 이라는 객체로 캐리어 스레드의 `Work Queue`에 적재하는데, `Work Queue`에 존재하는 `runContinuation` 작업들은 캐리어 스레드에 의해 `Work Stealing` 방식으로 처리됩니다.
앞서, 오라클 공식 문서 인용 두 번째 문단에서 다음과 같은 내용이 있었습니다.
가상 스레드에서 실행되는 코드가 블로킹 I/O 작업을 호출하면 Java 런타임은 가상 스레드가 재개될 수 있을 때까지 가상 스레드를 일시 중단합니다. 일시 중단된 가상 스레드와 연결된 OS 스레드는 다른 가상 스레드에 대한 작업을 자유롭게 수행할 수 있습니다
이를 동작 방식을 이해하고 다시 보면, 가상 스레드의 `Mount`와 `Unmount`가 진행되며 캐리어 스레드가 가상 스레드의 블로킹에 얽메이지 않고 자유롭게 작업을 수행하고 있으며, `Unmount` 이후에 다시 `Mount`가 이루어지더라도 `Continuation` 객체에 이전 실행 정보들이 들어있기 때문에 일시 정지되었던 작업을 재개하는 데에 문제가 없음을 알 수 있습니다.
정리를 하다보니 동작 방식이 이벤트 루프와 상당히 유사하다는 생각이 들었는데, 기존에 이벤트 루프를 어느정도 이해한 상태였기에 가상 스레드의 동작 방식을 이해하는 데에 많은 도움이 되었던 것 같습니다. 따라서 가상 스레드의 동작 방식을 좀 더 깊게 이해하기 위해 이벤트 루프에 대해서도 간단하게 알아보도록 하겠습니다.
이벤트 루프란?
이벤트 루프는 싱글 스레드를 기반으로 동작합니다. Event Loop가 중심에서 메인 스레드(싱글 스레드)로써 Event Queue에 들어오는 모든 요청들을 처리하게 됩니다. 이때 Event Loop에서 파일 시스템이나 네트워크 통신 등의 블로킹 I/O 작업을 마주하면 이 작업들을 별도의 스레드 풀에서 처리하도록 하고, 블로킹 I/O 작업이 완료되면 Callback을 통해 Event Queue에 다음 작업을 적재하여 다시 Event Loop가 처리하도록 합니다.
때문에 이벤트 루프는 블로킹 I/O가 발생하더라도 멈추지 않고, 논블로킹 하게 동작하여 싱글 스레드임에도 높은 처리량을 보일 수 있습니다. 이를 Java의 가상 쓰레드 환경에 대입하면 캐리어 스레드가 Event Loop가 되는 것이고 가상 스레드가 블로킹 I/O를 처리하기 위한 스레드가 것이라고 볼 수 있습니다.
백문이 불여일견, 코드로 살펴보자
이제 어느 정도 가상 스레드에 대한 지식이 쌓인 것 같으니 코드를 통해서 살펴보도록 하겠습니다.
코드를 살펴보기에 앞서 다음과 같은 상황을 가정해보도록 하겠습니다.
- 톰캣 스레드의 총 개수는 10개
- 처리하는 데 10초가 소요되는 API
해당 상황에서 API를 동시에 14번 호출하면 총 몇초가 소요될까요? 정답은 20초입니다. 톰캣 스레드가 10개이기 때문에 최초에 14개의 요청중 10개의 요청만을 먼저 처리할 수 있고 10초가 흐른 뒤에 다시 스레드가 반납되면 나머지 4개의 요청을 처리할 수 있기 때문입니다.
@GetMapping("")
public void testMethod() throws Exception {
System.out.println("요청 시작 = " + LocalDateTime.now() + " 쓰레드 정보 = " + Thread.currentThread());
ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor();
executorService.submit(() -> {
try {
Thread.sleep(10000);
System.out.println("가상 쓰레드 종료 = " + LocalDateTime.now() + " 쓰레드 정보 = " + Thread.currentThread());
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
executorService.close();
}
});
System.out.println("종료 = " + LocalDateTime.now() + " 쓰레드 정보 = " + Thread.currentThread());
}
같은 조건에서 위와 같이 `Executors.newVirtualThreadPerTaskExecutor()`를 사용하여 가상 쓰레드로 처리하도록 하였을 때 얻은 결과는 다음과 같습니다.
요청 시작 = 2024-09-05T23:40:45.237185900 쓰레드 정보 = Thread[#89,http-nio-8080-exec-10,5,main]
요청 시작 = 2024-09-05T23:40:45.237185900 쓰레드 정보 = Thread[#80,http-nio-8080-exec-1,5,main]
요청 시작 = 2024-09-05T23:40:45.237185900 쓰레드 정보 = Thread[#88,http-nio-8080-exec-9,5,main]
요청 시작 = 2024-09-05T23:40:45.237185900 쓰레드 정보 = Thread[#85,http-nio-8080-exec-6,5,main]
요청 시작 = 2024-09-05T23:40:45.237185900 쓰레드 정보 = Thread[#84,http-nio-8080-exec-5,5,main]
요청 시작 = 2024-09-05T23:40:45.237185900 쓰레드 정보 = Thread[#81,http-nio-8080-exec-2,5,main]
요청 시작 = 2024-09-05T23:40:45.237185900 쓰레드 정보 = Thread[#83,http-nio-8080-exec-4,5,main]
요청 시작 = 2024-09-05T23:40:45.237185900 쓰레드 정보 = Thread[#86,http-nio-8080-exec-7,5,main]
요청 시작 = 2024-09-05T23:40:45.237185900 쓰레드 정보 = Thread[#82,http-nio-8080-exec-3,5,main]
요청 시작 = 2024-09-05T23:40:45.237185900 쓰레드 정보 = Thread[#87,http-nio-8080-exec-8,5,main]
종료 = 2024-09-05T23:40:45.244184200 쓰레드 정보 = Thread[#83,http-nio-8080-exec-4,5,main]
종료 = 2024-09-05T23:40:45.244184200 쓰레드 정보 = Thread[#80,http-nio-8080-exec-1,5,main]
종료 = 2024-09-05T23:40:45.244184200 쓰레드 정보 = Thread[#84,http-nio-8080-exec-5,5,main]
종료 = 2024-09-05T23:40:45.244184200 쓰레드 정보 = Thread[#85,http-nio-8080-exec-6,5,main]
종료 = 2024-09-05T23:40:45.244184200 쓰레드 정보 = Thread[#82,http-nio-8080-exec-3,5,main]
종료 = 2024-09-05T23:40:45.244184200 쓰레드 정보 = Thread[#81,http-nio-8080-exec-2,5,main]
종료 = 2024-09-05T23:40:45.244184200 쓰레드 정보 = Thread[#89,http-nio-8080-exec-10,5,main]
종료 = 2024-09-05T23:40:45.245187700 쓰레드 정보 = Thread[#88,http-nio-8080-exec-9,5,main]
종료 = 2024-09-05T23:40:45.245187700 쓰레드 정보 = Thread[#86,http-nio-8080-exec-7,5,main]
종료 = 2024-09-05T23:40:45.245187700 쓰레드 정보 = Thread[#87,http-nio-8080-exec-8,5,main]
요청 시작 = 2024-09-05T23:40:45.280187300 쓰레드 정보 = Thread[#84,http-nio-8080-exec-5,5,main]
요청 시작 = 2024-09-05T23:40:45.280187300 쓰레드 정보 = Thread[#86,http-nio-8080-exec-7,5,main]
종료 = 2024-09-05T23:40:45.280187300 쓰레드 정보 = Thread[#84,http-nio-8080-exec-5,5,main]
요청 시작 = 2024-09-05T23:40:45.280187300 쓰레드 정보 = Thread[#89,http-nio-8080-exec-10,5,main]
종료 = 2024-09-05T23:40:45.280187300 쓰레드 정보 = Thread[#86,http-nio-8080-exec-7,5,main]
종료 = 2024-09-05T23:40:45.280187300 쓰레드 정보 = Thread[#89,http-nio-8080-exec-10,5,main]
요청 시작 = 2024-09-05T23:40:45.280187300 쓰레드 정보 = Thread[#87,http-nio-8080-exec-8,5,main]
종료 = 2024-09-05T23:40:45.281190100 쓰레드 정보 = Thread[#87,http-nio-8080-exec-8,5,main]
가상 쓰레드 종료 = 2024-09-05T23:40:55.258286900 쓰레드 정보 = VirtualThread[#98]/runnable@ForkJoinPool-1-worker-4
가상 쓰레드 종료 = 2024-09-05T23:40:55.258286900 쓰레드 정보 = VirtualThread[#101]/runnable@ForkJoinPool-1-worker-10
가상 쓰레드 종료 = 2024-09-05T23:40:55.258286900 쓰레드 정보 = VirtualThread[#102]/runnable@ForkJoinPool-1-worker-1
가상 쓰레드 종료 = 2024-09-05T23:40:55.258286900 쓰레드 정보 = VirtualThread[#100]/runnable@ForkJoinPool-1-worker-7
가상 쓰레드 종료 = 2024-09-05T23:40:55.258286900 쓰레드 정보 = VirtualThread[#105]/runnable@ForkJoinPool-1-worker-3
가상 쓰레드 종료 = 2024-09-05T23:40:55.258286900 쓰레드 정보 = VirtualThread[#103]/runnable@ForkJoinPool-1-worker-5
가상 쓰레드 종료 = 2024-09-05T23:40:55.258286900 쓰레드 정보 = VirtualThread[#104]/runnable@ForkJoinPool-1-worker-9
가상 쓰레드 종료 = 2024-09-05T23:40:55.258286900 쓰레드 정보 = VirtualThread[#106]/runnable@ForkJoinPool-1-worker-2
가상 쓰레드 종료 = 2024-09-05T23:40:55.258286900 쓰레드 정보 = VirtualThread[#99]/runnable@ForkJoinPool-1-worker-8
가상 쓰레드 종료 = 2024-09-05T23:40:55.258286900 쓰레드 정보 = VirtualThread[#97]/runnable@ForkJoinPool-1-worker-6
가상 쓰레드 종료 = 2024-09-05T23:40:55.288090900 쓰레드 정보 = VirtualThread[#121]/runnable@ForkJoinPool-1-worker-1
가상 쓰레드 종료 = 2024-09-05T23:40:55.288090900 쓰레드 정보 = VirtualThread[#120]/runnable@ForkJoinPool-1-worker-8
가상 쓰레드 종료 = 2024-09-05T23:40:55.288090900 쓰레드 정보 = VirtualThread[#119]/runnable@ForkJoinPool-1-worker-10
가상 쓰레드 종료 = 2024-09-05T23:40:55.288090900 쓰레드 정보 = VirtualThread[#118]/runnable@ForkJoinPool-1-worker-12
흐름을 살펴보기에 앞서 이해를 돞기 위해 가상 스레드 블럭 (`executorService.submit()` 내부) 을 `Inner`, 가상 스레드 블럭 외부를 `Outer`라고 부르도록 하겠습니다.
1. 톰캣 스레드의 크기(10개)만큼 요청이 시작된다.
2. `Outer`의 첫 번째 로직인 요청 시작 출력문을 출력한다.
3. 가상 스레드를 생성하고 `Inner`로 진입한다. (`Mount`)
4. `Inner`에서 블로킹 작업인 `Thread.sleep()`을 만났기 때문에 `Inner` 작업을 일시 중단하고, 빠져나온다. (`Unmount`)
5. `Outer`의 마지막 로직인 요청 종료 출력문을 출력하고, 캐리어 스레드는 다시 톰캣 스레드 풀에 반납된다.
6. 다시 대기 중인 4개의 요청을 가져가 위와 마찬가지로 1 ~ 5 과정을 수행한다.
7. `Inner` 로직에서 10초간의 블로킹이 종료되면 가상 스레드 작업이 재개되어 가상 스레드 종료문을 출력하고 가상 스레드가 종료된다.
Java에서도 이렇게나 쉽게 NonBlocking을 즐길 수 있다니... 자바공화국 복지가 상당히 좋아지고 있는 것 같습니다...👍
여기서 좀 더 드라마틱하게 톰캣 스레드를 `1개`로 두고 실행하면 어떻게 될까요?
요청 시작 = 2024-09-05T23:49:24.865781600 쓰레드 정보 = Thread[#68,http-nio-8080-exec-1,5,main]
종료 = 2024-09-05T23:49:24.865781600 쓰레드 정보 = Thread[#68,http-nio-8080-exec-1,5,main]
요청 시작 = 2024-09-05T23:49:24.867779600 쓰레드 정보 = Thread[#68,http-nio-8080-exec-1,5,main]
종료 = 2024-09-05T23:49:24.867779600 쓰레드 정보 = Thread[#68,http-nio-8080-exec-1,5,main]
요청 시작 = 2024-09-05T23:49:24.868783 쓰레드 정보 = Thread[#68,http-nio-8080-exec-1,5,main]
종료 = 2024-09-05T23:49:24.869773900 쓰레드 정보 = Thread[#68,http-nio-8080-exec-1,5,main]
요청 시작 = 2024-09-05T23:49:24.871771 쓰레드 정보 = Thread[#68,http-nio-8080-exec-1,5,main]
종료 = 2024-09-05T23:49:24.871771 쓰레드 정보 = Thread[#68,http-nio-8080-exec-1,5,main]
요청 시작 = 2024-09-05T23:49:24.872773700 쓰레드 정보 = Thread[#68,http-nio-8080-exec-1,5,main]
종료 = 2024-09-05T23:49:24.872773700 쓰레드 정보 = Thread[#68,http-nio-8080-exec-1,5,main]
요청 시작 = 2024-09-05T23:49:24.876772100 쓰레드 정보 = Thread[#68,http-nio-8080-exec-1,5,main]
종료 = 2024-09-05T23:49:24.877773400 쓰레드 정보 = Thread[#68,http-nio-8080-exec-1,5,main]
요청 시작 = 2024-09-05T23:49:24.879777200 쓰레드 정보 = Thread[#68,http-nio-8080-exec-1,5,main]
종료 = 2024-09-05T23:49:24.880782 쓰레드 정보 = Thread[#68,http-nio-8080-exec-1,5,main]
요청 시작 = 2024-09-05T23:49:24.882777900 쓰레드 정보 = Thread[#68,http-nio-8080-exec-1,5,main]
종료 = 2024-09-05T23:49:24.882777900 쓰레드 정보 = Thread[#68,http-nio-8080-exec-1,5,main]
요청 시작 = 2024-09-05T23:49:24.884776600 쓰레드 정보 = Thread[#68,http-nio-8080-exec-1,5,main]
종료 = 2024-09-05T23:49:24.884776600 쓰레드 정보 = Thread[#68,http-nio-8080-exec-1,5,main]
요청 시작 = 2024-09-05T23:49:24.887775900 쓰레드 정보 = Thread[#68,http-nio-8080-exec-1,5,main]
종료 = 2024-09-05T23:49:24.887775900 쓰레드 정보 = Thread[#68,http-nio-8080-exec-1,5,main]
요청 시작 = 2024-09-05T23:49:24.889778 쓰레드 정보 = Thread[#68,http-nio-8080-exec-1,5,main]
종료 = 2024-09-05T23:49:24.889778 쓰레드 정보 = Thread[#68,http-nio-8080-exec-1,5,main]
요청 시작 = 2024-09-05T23:49:24.891781300 쓰레드 정보 = Thread[#68,http-nio-8080-exec-1,5,main]
종료 = 2024-09-05T23:49:24.891781300 쓰레드 정보 = Thread[#68,http-nio-8080-exec-1,5,main]
가상 쓰레드 종료 = 2024-09-05T23:49:34.870609800 쓰레드 정보 = VirtualThread[#97]/runnable@ForkJoinPool-1-worker-2
가상 쓰레드 종료 = 2024-09-05T23:49:34.870609800 쓰레드 정보 = VirtualThread[#98]/runnable@ForkJoinPool-1-worker-2
가상 쓰레드 종료 = 2024-09-05T23:49:34.870609800 쓰레드 정보 = VirtualThread[#100]/runnable@ForkJoinPool-1-worker-2
가상 쓰레드 종료 = 2024-09-05T23:49:34.870609800 쓰레드 정보 = VirtualThread[#99]/runnable@ForkJoinPool-1-worker-8
가상 쓰레드 종료 = 2024-09-05T23:49:34.870609800 쓰레드 정보 = VirtualThread[#101]/runnable@ForkJoinPool-1-worker-9
가상 쓰레드 종료 = 2024-09-05T23:49:34.886150800 쓰레드 정보 = VirtualThread[#102]/runnable@ForkJoinPool-1-worker-10
가상 쓰레드 종료 = 2024-09-05T23:49:34.886150800 쓰레드 정보 = VirtualThread[#105]/runnable@ForkJoinPool-1-worker-8
가상 쓰레드 종료 = 2024-09-05T23:49:34.886150800 쓰레드 정보 = VirtualThread[#106]/runnable@ForkJoinPool-1-worker-2
가상 쓰레드 종료 = 2024-09-05T23:49:34.886150800 쓰레드 정보 = VirtualThread[#104]/runnable@ForkJoinPool-1-worker-9
가상 쓰레드 종료 = 2024-09-05T23:49:34.886150800 쓰레드 정보 = VirtualThread[#103]/runnable@ForkJoinPool-1-worker-11
가상 쓰레드 종료 = 2024-09-05T23:49:34.886150800 쓰레드 정보 = VirtualThread[#107]/runnable@ForkJoinPool-1-worker-7
가상 쓰레드 종료 = 2024-09-05T23:49:34.901615300 쓰레드 정보 = VirtualThread[#108]/runnable@ForkJoinPool-1-worker-7
가상 쓰레드 종료 = 2024-09-05T23:49:34.901615300 쓰레드 정보 = VirtualThread[#109]/runnable@ForkJoinPool-1-worker-11
가상 쓰레드 종료 = 2024-09-05T23:49:34.901615300 쓰레드 정보 = VirtualThread[#110]/runnable@ForkJoinPool-1-worker-10
위에서 예시로 들었던 Event Loop처럼 단 하나의 스레드만으로도(출력문 기준 `Thread[#68,...]`)NonBlocking하게 동작하여 요청을 처리하는 것을 볼 수 있었습니다. 실제로는 요청 시작과 종료가 빠르게 10번 수행되는 것이지만, 밖에서 볼 때는 마치 거의 동시에 처리되는 것처럼 보일 것입니다.
그럼 톰캣 쓰레드 자체를 가상 쓰레드로 변경하면 어떻게 될까요?
// application.yml을 수정하는 방법
spring:
threads:
virtual:
enabled: true
// @Configuration 클래스를 통해 수정하는 방법
@Configuration
public class ThreadConfig {
@Bean
public TomcatProtocolHandlerCustomizer<?> protocolHandlerVirtualThreadExecutorCustomizer() {
return protocolHandler ->
protocolHandler.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
}
}
톰캣 스레드 풀의 변경은 `application.yml`을 수정하거나(Spring Boot 3.2 이상) `@Configuration` 클래스에서 직접 톰캣 스레드 풀을 커스터마이징하여 적용할 수 있습니다.
톰캣 스레드 풀 자체를 가상 스레드로 변경하였으니 위에서 사용한 `Executors.newVirtualThreadPerTaskExecutor`는 사용하지 않아도 됩니다. 그리고 가상 쓰레드의 종료가 곧 톰캣 스레드의 종료를 의미하기 때문에 출력문도 가상 쓰레드 종료에서 Thread.sleep 종료로 변경하도록 하였습니다.
요청 시작 = 2024-09-18T16:14:30.316395700 쓰레드 정보 = VirtualThread[#87,tomcat-handler-6]/runnable@ForkJoinPool-1-worker-7
요청 시작 = 2024-09-18T16:14:30.317396400 쓰레드 정보 = VirtualThread[#80,tomcat-handler-0]/runnable@ForkJoinPool-1-worker-1
요청 시작 = 2024-09-18T16:14:30.316395700 쓰레드 정보 = VirtualThread[#98,tomcat-handler-7]/runnable@ForkJoinPool-1-worker-11
요청 시작 = 2024-09-18T16:14:30.316395700 쓰레드 정보 = VirtualThread[#83,tomcat-handler-2]/runnable@ForkJoinPool-1-worker-3
요청 시작 = 2024-09-18T16:14:30.316395700 쓰레드 정보 = VirtualThread[#101,tomcat-handler-10]/runnable@ForkJoinPool-1-worker-10
요청 시작 = 2024-09-18T16:14:30.316395700 쓰레드 정보 = VirtualThread[#82,tomcat-handler-1]/runnable@ForkJoinPool-1-worker-9
요청 시작 = 2024-09-18T16:14:30.317396400 쓰레드 정보 = VirtualThread[#85,tomcat-handler-4]/runnable@ForkJoinPool-1-worker-5
요청 시작 = 2024-09-18T16:14:30.316395700 쓰레드 정보 = VirtualThread[#84,tomcat-handler-3]/runnable@ForkJoinPool-1-worker-6
요청 시작 = 2024-09-18T16:14:30.316395700 쓰레드 정보 = VirtualThread[#99,tomcat-handler-8]/runnable@ForkJoinPool-1-worker-8
요청 시작 = 2024-09-18T16:14:30.316395700 쓰레드 정보 = VirtualThread[#86,tomcat-handler-5]/runnable@ForkJoinPool-1-worker-4
요청 시작 = 2024-09-18T16:14:30.316395700 쓰레드 정보 = VirtualThread[#108,tomcat-handler-13]/runnable@ForkJoinPool-1-worker-14
요청 시작 = 2024-09-18T16:14:30.317396400 쓰레드 정보 = VirtualThread[#103,tomcat-handler-11]/runnable@ForkJoinPool-1-worker-12
요청 시작 = 2024-09-18T16:14:30.316395700 쓰레드 정보 = VirtualThread[#106,tomcat-handler-12]/runnable@ForkJoinPool-1-worker-13
요청 시작 = 2024-09-18T16:14:30.316395700 쓰레드 정보 = VirtualThread[#100,tomcat-handler-9]/runnable@ForkJoinPool-1-worker-2
Thread.sleep() 종료 = 2024-09-18T16:14:40.323196 쓰레드 정보 = VirtualThread[#98,tomcat-handler-7]/runnable@ForkJoinPool-1-worker-7
Thread.sleep() 종료 = 2024-09-18T16:14:40.323196 쓰레드 정보 = VirtualThread[#87,tomcat-handler-6]/runnable@ForkJoinPool-1-worker-8
Thread.sleep() 종료 = 2024-09-18T16:14:40.323196 쓰레드 정보 = VirtualThread[#100,tomcat-handler-9]/runnable@ForkJoinPool-1-worker-12
Thread.sleep() 종료 = 2024-09-18T16:14:40.323196 쓰레드 정보 = VirtualThread[#101,tomcat-handler-10]/runnable@ForkJoinPool-1-worker-5
Thread.sleep() 종료 = 2024-09-18T16:14:40.323196 쓰레드 정보 = VirtualThread[#82,tomcat-handler-1]/runnable@ForkJoinPool-1-worker-11
종료 = 2024-09-18T16:14:40.323196 쓰레드 정보 = VirtualThread[#101,tomcat-handler-10]/runnable@ForkJoinPool-1-worker-10
Thread.sleep() 종료 = 2024-09-18T16:14:40.323196 쓰레드 정보 = VirtualThread[#85,tomcat-handler-4]/runnable@ForkJoinPool-1-worker-13
Thread.sleep() 종료 = 2024-09-18T16:14:40.323196 쓰레드 정보 = VirtualThread[#83,tomcat-handler-2]/runnable@ForkJoinPool-1-worker-1
Thread.sleep() 종료 = 2024-09-18T16:14:40.323196 쓰레드 정보 = VirtualThread[#86,tomcat-handler-5]/runnable@ForkJoinPool-1-worker-13
Thread.sleep() 종료 = 2024-09-18T16:14:40.323196 쓰레드 정보 = VirtualThread[#99,tomcat-handler-8]/runnable@ForkJoinPool-1-worker-11
Thread.sleep() 종료 = 2024-09-18T16:14:40.323196 쓰레드 정보 = VirtualThread[#84,tomcat-handler-3]/runnable@ForkJoinPool-1-worker-5
Thread.sleep() 종료 = 2024-09-18T16:14:40.323196 쓰레드 정보 = VirtualThread[#103,tomcat-handler-11]/runnable@ForkJoinPool-1-worker-13
종료 = 2024-09-18T16:14:40.323196 쓰레드 정보 = VirtualThread[#98,tomcat-handler-7]/runnable@ForkJoinPool-1-worker-7
Thread.sleep() 종료 = 2024-09-18T16:14:40.323196 쓰레드 정보 = VirtualThread[#108,tomcat-handler-13]/runnable@ForkJoinPool-1-worker-1
Thread.sleep() 종료 = 2024-09-18T16:14:40.323196 쓰레드 정보 = VirtualThread[#106,tomcat-handler-12]/runnable@ForkJoinPool-1-worker-11
Thread.sleep() 종료 = 2024-09-18T16:14:40.323196 쓰레드 정보 = VirtualThread[#80,tomcat-handler-0]/runnable@ForkJoinPool-1-worker-3
종료 = 2024-09-18T16:14:40.323196 쓰레드 정보 = VirtualThread[#87,tomcat-handler-6]/runnable@ForkJoinPool-1-worker-8
종료 = 2024-09-18T16:14:40.323196 쓰레드 정보 = VirtualThread[#100,tomcat-handler-9]/runnable@ForkJoinPool-1-worker-12
종료 = 2024-09-18T16:14:40.323196 쓰레드 정보 = VirtualThread[#82,tomcat-handler-1]/runnable@ForkJoinPool-1-worker-12
종료 = 2024-09-18T16:14:40.324202500 쓰레드 정보 = VirtualThread[#85,tomcat-handler-4]/runnable@ForkJoinPool-1-worker-12
종료 = 2024-09-18T16:14:40.324202500 쓰레드 정보 = VirtualThread[#83,tomcat-handler-2]/runnable@ForkJoinPool-1-worker-11
종료 = 2024-09-18T16:14:40.324202500 쓰레드 정보 = VirtualThread[#86,tomcat-handler-5]/runnable@ForkJoinPool-1-worker-8
종료 = 2024-09-18T16:14:40.324202500 쓰레드 정보 = VirtualThread[#99,tomcat-handler-8]/runnable@ForkJoinPool-1-worker-12
종료 = 2024-09-18T16:14:40.324202500 쓰레드 정보 = VirtualThread[#84,tomcat-handler-3]/runnable@ForkJoinPool-1-worker-11
종료 = 2024-09-18T16:14:40.324202500 쓰레드 정보 = VirtualThread[#103,tomcat-handler-11]/runnable@ForkJoinPool-1-worker-8
종료 = 2024-09-18T16:14:40.324202500 쓰레드 정보 = VirtualThread[#108,tomcat-handler-13]/runnable@ForkJoinPool-1-worker-12
종료 = 2024-09-18T16:14:40.324202500 쓰레드 정보 = VirtualThread[#106,tomcat-handler-12]/runnable@ForkJoinPool-1-worker-8
종료 = 2024-09-18T16:14:40.324202500 쓰레드 정보 = VirtualThread[#80,tomcat-handler-0]/runnable@ForkJoinPool-1-worker-3
톰캣 쓰레드 자체를 가상 쓰레드로 변경하니, 톰캣의 최대 쓰레드 개수(10) 설정은 무시되고 14개의 가상 쓰레드가 생성되고 요청이 시작되었습니다. 이는 가상 스레드가 필요할 때마다 생성되기 때문인데, 실제로 요청개수를 400개로 증가시키고 테스트를 해보니 400개의 가상 스레드가 생성되었고 마찬가지로 10초만에 400개의 요청이 처리되는 것을 확인할 수 있었습니다.
가상 스레드 주의할 점
항상 그렇듯 개발에 은 탄환은 없습니다. 가상 스레드 역시 사용시에 유의해야 할 점들이 다수 존재하는데, 그중에서도 가상 쓰레드가 캐리어 쓰레드에 고정되어 버리는 `PINNED` 상태를 가장 조심해야 합니다.
이 상태는 가상 스레드가 캐리어 스레드에서 `Unmount` 되지 못하는 상태인데, 가상 쓰레드 내에서 `synchronized`나 `parallerStream` 혹은 네이티브 메서드를 사용하면 가상 스레드와 연결된 캐리어 스레드에서 블로킹이 발생하기 때문에 발생합니다. 캐리어 스레드가 멈춰버리면 가상 스레드 스케줄러 역시 해당 캐리어 스레드를 관리할 수 없기 때문에 성능 저하가 발생하게 됩니다.
다음 시간에는 가상 스레드의 `PINNED` 상태에 대해서 어떤 상황에서 발생하는지, 그리고 그, 해결 방법은 무엇인지 등에 대해서 다뤄보도록 하겠습니다.
Reference
https://spring.io/blog/2022/10/11/embracing-virtual-threads
https://www.youtube.com/watch?v=BZMZIM-n4C0&t=862s
'Java' 카테고리의 다른 글
OOP 다형성에 대한 고찰 (0) | 2023.07.06 |
---|