이번 포스팅은 아래 영상을 정리한 글입니다.
https://www.youtube.com/watch?v=BZMZIM-n4C0&t=2277s
Virtual Thread
2018년 Project Loom으로 부터 시작된 경량 스레드 모델이다. jdk 21에서 정식 feature로 추가되었다.
기존 Java Thread 단점
- 기존 자바 스레드는 생성 비용이 컸다. 우리가 스레드 풀을 사용하는 이유는 바로 기존 스레드 생성 비용이 크기 때문이다.
- 기존 스레드는 공간적인 비용도 상당히 크다. 최대 2MB까지 사용하며 많은 메모리를 사용한다.
- JVM이 아닌 OS에 의해 스케줄링 된다. 스레드 생성 및 소멸과 같은 스케줄링 과정에서 항상 OS와 통신해야하므로 시스템 콜이 발생하는데, 시스템 콜 오버헤드가 발생한다.
Virtual Thread 장점
- 기존 스레드보다 생성 비용이 작아 스레드 풀을 사용하지 않는다.
- 기존 스레드를 백만개 생성하면 30초가 소요되지만 Virtual Thread는 0.375초가 소요된다.
- 사용 메모리 크기가 작으며 버츄얼 스레드는 수십 KB, 기존 스레드는 수십 MB를 사용한다.
- OS가 아닌 JVM 내 스케줄링되며 시스템 콜로 인한 오버헤드가 없다.
- 스레드 스케줄링을 통해 Non-Blocking I/O를 지원
- 기존 스레드를 상속해 코드 호환이 가능
Thread | Virtual Thread | |
메모리 사이즈 | 2MB | 50KB |
생성 시간 | 1 밀리세컨즈 | 10 마이크로세컨즈 |
컨텍스트 스위칭 시간 | 100 마이크로세컨즈 | 10 마이크로세컨즈(10배 차이) |
NonBlocking I/O 지원
현대 서버 구조에서 네트워크 통신으로 인한 Blocking Time이 증가하고 있다.
Thread per requset 모델에서는 병목이 되는 부분이 Blocking Time이다.
Non Blocking I/O는 Blocking Time을 획기적으로 줄여준다.
대표적으로 이벤트 루프를 사용한 Spring WebFlux & Netty가 있다.
Virtual Thread는 Spring WebFlux와는 다른 방식으로 Non Blocking을 지원한다.
아래 두 개념을 활용한다.
- JVM 스레드 스케줄링
- Continuation 활용
Tomcat 스레드 10개, 10초 걸리는 API 호출을 동시에 100번 진행하면 총 100초가 걸린다.
실험에서는 실제로 130초 걸렸고 Virtual Thread는 10.1초가 걸렸다.
기존 스레드 대비 92.2% 성능 향상이 발생했으며 I/O 블럭킹 요청을 동시에 처리했다. 즉 Non Blocking I/O처럼 동작한다.
기존 스레드 상속
VirtualThread -> BaseBirtualThread -> Thread 상속 구조로 이루어진다. 따라서 리스코프 치환 원칙에 따라 갈아끼우는게 가능하다.
ExecutorService도 VirtualThread Executor로 치환이 가능하다.
크게 3가지만 기억하자.
버츄얼 스레드 장점은 3가지다.
- 스레드 생성/ 스케줄 속도가 빠름
- Non Blocking I/O
- 기존 스레드 상속으로 코드 호환
Virtual Thread 동작 원리
기존 스레드 특징은 다음과 같다.
- 플랫폼 스레드, 유저 스레드라고 불린다.
- OS에 의해 스케줄링
- 커널 스레드와 1:1 매핑
- 작업 단위 Runnable
OS 커널 스레드는 자바 플랫폼 스레드와 JNI (Java Native Interface)를 통한 1대1 매핑이 이루어진다.
기존 스레드는 thread.start()를 호출하면 커널 스레드 생성을 요청한다. 이후 커널 스레드 생성 및 스케줄링이 된다.
Virtual Thread 특징은 아래와 같다.
- 가상 스레드
- JVM에 의해 스케줄링
- 캐리어 스레드와 1:N 매핑
- 작업 단위 Continuation
더 자세히 살펴보자.
- Virtual Thread는 start()를 호출하면 JVM 내의 스케줄러를 호출한다.
- 코드를 보면 scheculder 인스턴스 객체가 execute()를 호출하고 있다. 이 객체는 JVM 내 가상 스케줄러를 담당하며, 타입은 Executor이다. 별도 설정이 없는 경우 DEFAULT_SECHEDULER. DEFAULT_SECHEDULER의 값은 ForkJoinPool이고 static 변수이다. 모든 버츄얼 스레드는 동일한 스케쥴러를 공유한다.
- createDefaultScheduler() 메서드를 더 자세히 살펴보니 CarrierThread를 생성하는 것을 볼 수 있다. 또한 parallelism을 사용 가능한 프로세서의 수로 설정하는데 이게 바로 워커 스레드의 개수다.
- 스케줄러는 ForkJoinPool 타입이다. 프로세서 수의 캐리어 스레드를 워커 스레드로 사용한다. Work Stealing(Fork Join Pool 메커니즘) 방식으로 동작한다.
- Work Stealing 메커니즘은 워커 스레드의 각각 워크 큐를 가지고 있고 그 워크 큐에 태스크를 담아 순차적으로 처리한다.
- 본인의 워크 큐에 작업이 비어있으면 다른 워크 큐의 작업을 훔쳐온다.
JVM 스케줄링 이유는 아래와 같다.
- 스레드는 생성 및 스케줄링 시 커널 영역에 접근하는데 이 과정에서 비용이 발생한다.
- Virtual Thread는 커널 영역 접근 없이 단순 자바 객체를 생성한다. 이 과정에서 시스템 콜을 호출하지 않아 접근 비용이 발생하지 않는다.
Continuation 작업 단위
코루틴도 Continuation 개념을 활용한다.
Continuation 정의는 아래와 같다.
- 실행 가능한 작업 흐름이다.
- 중단 가능하다.
- 중단 지점으로부터 재실행 가능하다. (중단 지점을 기록)
아래는 예시다.
중단 지점을 마주치면 작업을 중단한다.
스택 포인터를 기록하고 이를 힙으로 이동한다.
작업을 다시 실행하면 힙에서 데이터를 꺼내 기록되어 있는 스택포인터를 기준으로 다시 실행한다.
아래는 예시 코드다.
VirtualThread의 필드로 Continuation cont (Task Continuation)을 가지고 있다.
runContinuation(Continuation 실행 람다)도 있음.
VThreadContinuation이라는 타입으로 cont에 값을 할당한다.
Virtual Thread를 run(), start()하면 submitRunContinuation() 메서드를 호출한다.
이때 스케쥴러가 Continuation을 실행한다.
워커 스레드의 워크큐는 runContinuation의 람다가 들어간다.
VirtualThread.start()하면 스케쥴러에 작업을 제출한다.
Virtual Thread가 언제 yield가 될까?
- park() 메서드에서 yield 된
- Virtual Thread의 작업을 중단시키고 싶을 땐 park()다.
- 이건 패키지 private이라 사용이 어렵다.
- 따라서 LockSupport.park()를 사용해야함.
LockSupport.park() 코드를 보면 블락킹되는 시점에서 버츄얼 스레드를 사용하면 Continuation의 yield를 호출한다.
yield되면 작업을 힙 메모리로 넘기고 워크 큐에서 제거한다.
기존에는 Blocking 되면 LockSupport.park() 내부에서 U.park()를 호출했다.
VirtualThread가 생기면서 분기문이 생겨 VirtualThread라면 yield를 호출하는 것을 확인할 수 있다.
Continuation 사용 이유는 아래와 같다.
- Thread를 작업 중단을 위해 커널 스레드를 중단
- Virtual Thread는 작업 중단을 위해 continuation yield
- 작업이 블락되어도 실제 스레드는 중단되지 않고 다른 작업 처리 → NIO
- 커널 스레드 중단이 없으므로 시스템 콜 X → 컨텍스트 스위칭 비용이 낮음
요약하면 Virtual Thread는 JVM에 의해 스케줄링 되고 Continuation 작업 단위를 사용해 스레드 스케줄링 비용을 줄이고 Non Blocking를 가능하게 만들었다.
주의 사항
캐리어 스레드를 블락하면 Virtual Thread 활용 불가
- synchronized
- parallelStream
- Reentrantlock을 대신 사용해야 한다.
- mysql은 영상 기준으로 아직 ReetrantLock을 적용한 Driver가 없다. 몽고디비는 적용된 드라이버가 존재한다.
- VM Option으로 감지 가능
- -Djdk.tracePinnedThreads=short,full
스레드 풀을 사용하면 안된다.
- 생성 비용 저렴하다.
- 사용할때마다 생성하고 버린다.
- 사용 완료 후 GC
- 절대 스레드 풀을 사용하지 말자.
CPU를 많이 사용하는 작업에서 사용하지말자.
추가로 주의사항은 아래와 같다.
- 수백만 개의 스레드 생성 컨셉
- 스레드 로컬을 최대한 가볍게 유지
- 쉽게 생성 및 소멸
- JDK 21 preview ScopedValue (스레드 로컬을 대체하는 개념)
배압
- Virtual Thread는 배압 조절 기능이 없다.
- 유한 리소스의 경우 배압을 조절하도록 설정해야 한다. (DB 커넥션, 파일)
- 충분한 성능 테스트 필요하다.
- 하드웨어적인 문제가 발생할 수 있다.
- 버츄얼 스레드를 사용하면서 디비 커넥션이 부족할 수 있다.
결론
- 버츄얼스레드는 가볍고 빠르고 논블록킹인 경량 스레드
- 버츄얼스레드는 jvm 스케쥴링, continuation 개념 적용
- thread per request 사용중이고, 블락킹 타임이 주된 병목이면 고려해보자
- 쉽게 적용 가능
- 리액티브가 러닝커브로 부담되는 경우 사용
- 코틀린 코루틴이 러닝커브로 부담되는 경우 사용
이상으로 포스팅을 마칩니다. 감사합니다.
댓글