Virtual Thread란
Project Loom의 일환으로 JDK21에서 release 된 Virtual Thread는 기존의 OS Thread와는 다르게 훨씬 더 적은 리소스를 소비한다. 기존 Java의 Thread는 OS Thread를 직접 사용하기에 수천 개 이상의 요청 처리 시 효율적이지 못했다. 그러나 경량 스레드 모델인 Virtual Thread의 등장으로 OS Thread를 그대로 사용하지 않고 JVM의 내부 스케줄링을 통해 다수의 스레드를 사용할 수 있게 하였다. 따라서 더 많은 요청처리가 가능해지고, 컨텍스트 스위칭 비용을 줄이며 성능을 높일 수 있었다.
Project Loom has made it into the JDK through JEP 425. It’s available since Java 19 in September 2022 as a preview feature. Its goal is to dramatically reduce the effort of writing, maintaining, and observing high-throughput concurrent applications.
기존 자바의 Thread 모델
기존 방식의 스레드는 Platform Thread와 OS Thread를 1대 1로 매핑한 형태이다. Java 애플리케이션에서 스레드를 사용하면 OS 영역의 스레드를 사용하는 것과 같다. 즉, OS 스레드를 생성하기 위해서는 시스템 호출을 수행해야 하는데, 이는 OS 커널에서 사용할 수 있는 스레드 개수가 제한적이고, 생성과 유지비용이 비싸다. 그렇기 때문에 필요에 따라 스레드를 재할당하거나 해제하는 대신 Thread Pool을 사용한다.
애플리케이션으로부터 하나의 요청을 하나의 스레드가 처리하는 이 방식은 요청량이 급격하게 늘어나면 성능이 저하된다. 메모리의 크기가 제한되어 있어 최대로 생성할 수 있는 스레드 수에 제한이 있고, 스레드의 컨텍스트 스위칭 비용도 기하급수적으로 늘어나기 때문이다.
- OS Thread는 생성 개수가 제한적이고, 생성, 유지하는 비용이 비쌈
- Thread Pool을 통해 스레드 생성비용을 줄였지만 스레드 수를 늘리지는 않기에 아직까지 한계 존재
- 작업 처리량을 높이기 위해서는 스레드 증가가 필수적이지만, 스레드 풀의 크기를 넘어서 무한정으로 늘릴 수 없음 -> Throughput의 한계
- IO 작업 처리 시 Blocking이 발생하면 IO작업이 완료될 때까지 대기하고 있어 성능 저하
- 이 때문에 Non-blocking방식의 Reactive Programming이 발전하였지만, 코드를 작성하고, 디버깅, 유지보수를 하는데 어려움이 있다.
보완하기 위한 방안
비동기 API 사용[참고]
자바에서는 Future와 CompletableFuture 등을 활용하여 비동기 프로그래밍을 구현할 수 있다. 그러나 다음과 같은 이유로 대안책이 될 수 없다.
- 동기식 코드와 결합되기 어려움
- 가독성이 떨어지고 유지보수가 어려움
- 에러핸들링이 복잡함
Coroutine
Coroutine은 경량 스레드와 유사한 개념으로, 동기식 코드의 가독성을 유지하면서 비동기 처리를 쉽게 도와준다. 그러나 자바에서는 Coroutine을 직접적으로 지원하지 않기 때문에 외부 라이브러리나 다른 언어로 Coroutine을 위한 API를 별도로 마련해야 한다. 도입하게 된다면 스레드 용으로 설계된 API와 Coroutine용으로 설계된 API로 나뉘어야 하고, 플랫폼의 모든 계층과 도구에 이러한 구조를 도입해야 한다. 자바의 생태계는 이러한 구조를 채택하는데 오랜 시간이 걸릴 것이며, User mode 스레드만큼 플랫폼 조화를 이루지 못할 것이다. Syntatic Coroutine을 채택한 대부분의 언어는 User mode 스레드를 구현할 수 없거나 레거시 의미 보장 또는 언어별 기술적 제약 때문에 도입했지만 Java는 해당하지 않기에 적용시키지 않았다.
Reactive Programming
또 다른 방안으로는 Webflux가 있다. Webflux는 논블로킹 I/O를 활용하여 스레드가 대기하지 않고 다른 작업을 처리할 수 있다. 하지만 코드를 작성하고 이해하는데 어려움이 있고, Reactive 하게 동작하는 라이브러리 지원을 필요로 한다. 또한 자바라는 프로그래밍 언어의 스타일과 맞지 않아 좋은 대안이 될 수는 없다.
여전히 존재하는 한계
문제점
- OS 스레드 기반
- 스레드 풀의 크기 제한으로 인한 Throughput의 한계
- Blocking 처리 방식의 문제
- Reactive 프로그래밍의 불편함
목표
- Thread-per-request 스타일의 서버 애플리케이션을 최적으로 하드웨어를 사용할 수 있도록 지원
- java.lang.Thread API를 사용하는 기존 모델의 변경 없이 기존 코드가 최소한의 변경만으로 가상 스레드를 채택할 수 있도록 함
- 기존 JDK 도구를 사용하여 가상 스레드의 문제 해결, 디버깅 및 프로파일링을 쉽게 수행할 수 있도록 함
프로파일링 : 여러 스레드의 동작을 시각화하여 성능을 이해하는데 도움을 줌
Virtual Thread 모델
Virtual Thread는 기존 Java의 스레드 모델과 달리 Virtual Thread와 Platform Thread로 나뉜다. 하나의 Platform Thread는 n대 1로 여러 Virtual Thread를 번갈아가며 매핑되어 실행되는 구조이다. Virtual Thread는 JVM에서 관리되기 때문에 스레드 할당 시 시스템 호출이 필요하지 않고, OS 영역에서 컨텍스트 스위칭이 발생하지 않아 성능상 이점이 있다. 또한 IO 블로킹 발생 시 기존의 자바 스레드 구조는 대기상태로 이어졌다면, Virtual Thread 모델에서는 Carrier Thread가 다른 Virtual Thread로 전환하여 다음 작업을 진행하기에 대기 상태가 발생하지 않는다.
Platform Thread | Virtual Thread | |
Metadata size | 약 2kb(OS별 차이 있음) | 200~300Byte |
생성시간 | ~1ms | ~1µs |
Memory | 미리 할당된 스택 사용 | 필요할때마다 Heap 사용 |
Context Switching cost | ~100µs(커널 영역에서 발생) | ~10µs |
Virtual Thread의 동작 방식
- Carrier Thread는 ForkJoinPool 안에서 생성되어 스케줄링이 됨
- 각 Carrier Thread들은 Work Queue를 가지고 있어 Virtual Thread가 Task가 되어 들어감
- 해당 Task(Virtual Thread)가 플랫폼 스레드에 마운트 되어 작업 처리
- Task 처리도중 I/O 또는 Sleep으로 인한 인터럽트나 작업 완료 시, work queue에서 pop 되어 park과정에 의해 힙 메모리로 돌아감
마운팅 : Heap 메모리에 저장된 stack frames를 다시 Virtual Thread로 불러오는 과정
언마운팅 : 블로킹 발생 시 stack frames를 Heap메모리에 저장하는 과정
park()와 unpark() 메서드는 java.util.concurrent.locks 패키지에서 제공되는 스레드 블로킹 및 언블로킹을 수행하는 메서드이다. 이들은 주로 LockSupport 클래스에서 사용되며, 스레드 간의 협력적인 동기화를 가능하게 한다.
왜 Virtual Thread를 사용해야 할까?
이전 방식이라면 높은 처리량을 보이기 위해 Reactive Programming이 대안이 될 수 있었다. 이제는 Virtual Thread를 통해 Non-blocking을 통한 효율적인 자원 사용이 가능해졌고, 가독성을 높이고, 기존의 스레드 모델에 그대로 적용이 가능하기 때문에 쉽게 사용할 수 있다.
- 가상 스레드는 컨텍스트 스위칭에 대한 부하가 적다.
- 플랫폼 스레드보다 가상 스레드가 사용하는 메모리 크기가 작다.
- IO 블로킹 발생 시 대기 상태로 넘어가지 않아 CPU의 낭비가 적다.
Virtual Thread 주의사항
▷ No pooling
기존의 자바 스레드 모델에서는 스레드 풀에서 관리되는 스레드들이 작업을 수행하고, 작업이 완료되면 스레드 풀에 반환하되었다. 이와 달리 Virtual Thread는 쓰레드 풀에서 관리되지 않고, JVM 내부에서 필요할 때마다 동적으로 생성되어 사용된다. 그리고 해당 Virtual Thread가 종료되면 자원이 자동으로 반환된다. 이전의 방식보다 생성에 있어 빠르기 때문에 오히려 풀을 만들어 Virtual Thread를 Pooling 하는 것 자체가 비효율적이다. 따라서 필요할 때마다 생성하고 GC에 의해 소멸되도록 하는 것이 좋다.
▷ CPU Intensive 작업
CPU를 주로 사용하는 작업에는 Virtual Thread가 효과가 없다. 주로 IO 작업, 네트워크 IO 중심 작업일 때 효과가 있고, CPU 연산이 많은 작업에서는 Virtual Thread를 무작정 생성해 놓으면 CPU를 할당받기 위해 계속해서 컨텍스트 스위칭이 발생하여 성능이 떨어질 수밖에 없다. 따라서 컨텍스트 스위칭이 빈번히 발생하지 않는다면, 기존 스레드 모델을 사용하는 것이 바람직하다.
▷ Pinned Issue
Pinned 상태는 Virtual Thread가 캐리어 스레드에 고정된 상태를 말한다. 해당 상태는 Virtual Thread 내에서 Synchronized 블록에서나 parallelStream 혹은 네이티브 메서드 또는 외부 함수에서 IO 블로킹이 발생했을 때 나타나는 상태로, Virtual Thread의 블로킹이 끝날 때까지 플랫폼 스레드도 같이 블로킹된다. 따라서 Synchronized 블록을 최소화하고, java.util.concurrent.locks.ReentrantLock을 사용해서 Pinned 상태를 피하도록 한다.
▷ Thread Local
Virtual Thread는 Thread Local을 지원하므로 Thread Local을 사용할 수 있다. 수백만 개의 많은 Virtual Thread가 수시로 생성되고 소멸되며 스위칭된다. Virtual Thread는 메모리 할당 및 관리가 Heap 영역에서 이루어지기 때문에 Thread Local을 남발하게 되면 메모리 사용이 늘어나 성능에 좋지 않다.
ThreadLocal : Thread 마다 고유한 지역(Local) 변수를 넣어 사용하는 클래스
Virtual Thread에 대한 오해
- Virtual Thread는 기존 Platform Thread를 대체하는 것이 아니다. 서로 상호보완적인 관계.
- Virtual Thread가 무조건적으로 빠른 것은 아니다. IO 작업 시 이점.
- 스레드 풀로 관리하지 않는다.
- Virtual Thread의 추구 방향은 처리량을 증가시키면서, 기존 디자인과 조화를 이루는 것
참조한 사이트
https://techblog.woowahan.com/15398/
https://openjdk.org/jeps/444#Thread-local-variables
https://mangkyu.tistory.com/309
https://findstar.pe.kr/2023/04/17/java-virtual-threads-1/
https://findstar.pe.kr/2023/07/02/java-virtual-threads-2/
https://perfectacle.github.io/2022/12/29/look-over-java-virtual-threads/
https://perfectacle.github.io/2023/07/10/java-virtual-thread-vs-kotlin-coroutine/
https://www.baeldung.com/java-virtual-thread-vs-thread
https://spring.io/blog/2022/10/11/embracing-virtual-threads/
https://www.youtube.com/watch?v=WsCJYQDPrrE
https://www.youtube.com/watch?v=srpOD6WIasM
https://www.youtube.com/watch?v=vQP6Rs-ywlQ
'CS > JAVA' 카테고리의 다른 글
[JAVA] Record (0) | 2023.12.16 |
---|---|
JAVA의 리플렉션 API (0) | 2023.01.08 |
[JAVA] String 그리고 StringBuffer와 StringBuilder (0) | 2022.08.30 |
Abstract Class와 Interface (0) | 2022.08.23 |
Java에서의 Hash (0) | 2022.08.16 |