Java도 한다 경량 쓰레드 (Virtual Thread)
봄(Spring)은 왔는가?
Java의 위대한 산물인 가상 쓰레드는 분명 JVM 생테계에 엄청난 열풍을 일으켰음에 의심할 여지가 없다.
많은 개발자들이 그 패러다임에 발맞춰서 프레임워크를 개선하고 있듯이, 나도 얼른 쫓아가겠다.
* 구조도를 직접 그린게 이해하기 어려우면 맨 아래의 출처에 가면 다른 이미지도 있습니다
Java의 기존 쓰레드 모델 (JDK 17 이하)
컨텍스트 스위칭을 통해 OS 자체의 리소스를 점유하는 방식으로, JVM 내에서 플랫폼 쓰레드를 생성할때 JNI를 사용해서 OS(Kernel) 쓰레드에 직접 매핑되도록 설계되었다.
Heap에 존재하는 수많은 유저 쓰레드 중 하나가 JVM의 스케줄링에 따라서 커널 쓰레드에 매핑되어 실행되는 기존 구조
스케줄링은 ExecutorService에 의해서 Java 수준에서 진행하되, 실제 실행은 JNI를 통해 커널에서 실행된다
Java Native Interface(JNI)
C,C++ 처럼 인터프리터 없이 OS가 바로 읽을 수 있는 형태의 네이티브 코드를 JVM이 호출할 수 있게 하는 인터페이스
Java가 머신 플랫폼에 상관없이 동작할 수 있다.
자바는 기본적으로 인터프리터 방식이지만, JIT(Just-In-Time) 컴파일러를 통해 코드가 실행되는 동안 자주 호출되는 부분들을 네이티브 코드로 변환하여 성능을 최적화한다.
JNI는 자바와 네이티브 코드(C,C++)를 상호작용하는 메커니즘으로, 자주 쓰는 코드가 아니라 필요에 의해 네이티브로 호출되는 경우에 사용된다.
- Java 수준에서 ExecutorService를 통해 스케줄링 되는 JVM 쓰레드 객체는 JNI를 통해 start() 함수를 호출한다.
- 각 머신 OS에 맞게 설치된 JVM은 커널 쓰레드를 만들어서 실행한다.
- 이 네이티브 메서드 호출은 JVM 내에서 스택과 분리된 네이티브 메서드 스택을 사용
기존 프로세스 모델에서 공통된 부분은 공유하되, 작은 실행단위를 번갈아 실행하도록 해서 비용이 적고 컨텍스트 스위칭 역시 비용이 저렴했기 때문에 주목받았었다.
Java의 쓰레드에서 다른 쓰레드가 커널 쓰레드를 점유하여 작업을 수행하는 것을 컨텍스트 스위치라고 한다.
하지만 요청량이 급증하는 고성능 애플리케이션 서버 환경에서는 갈수록 더 많은 쓰레드 수를 요구하게 되었다.
메모리가 제한된 환경에서 생성할 수 있는 쓰레드는 한계가 있었고, 쓰레드가 많아질수록 컨텍스트 스위칭 비용도 기하급수적으로 늘어났다.
각 쓰레드가 메모리를 많이 차지한다거나 컨텍스트 스위칭 비용, 쓰레드 생성 비용은 차차하고
Spring MVC와 같은 Thread per Request 구조에서 최대 요청 수용량을 제한하게 되는 한계가 있다.
커널 수준이 아니라 런타임 수준에서 스케줄링, 컨텍스트 스위칭을 수행하는 경량 쓰레드(Lightweight Thread)가 트렌드로 자리잡고 있다.
가상 쓰레드(Virtual Thread, JDK 21)
기존 쓰레드 모델이 갖던 Kernel Thread(1) : User Thread(1) 구조가 아니라 Kernel(1) : User(1) : Virtual(N) 구조로 사용된다.
위 이미지처럼 플랫폼 쓰레드는 여러 가상 쓰레드를 번갈아 실행하는 형태로 동작한다.
기존 구조에서 커널 쓰레드와 플랫폼 쓰레드가 1:1로 매핑되었듯이, 이번엔 JVM 안에서 플랫폼 쓰레드가 가상 쓰레드들과 1:N로 매핑되는 셈이다.
기존 쓰레드 구조는 커널 쓰레드를 할당받는 스케줄링시 시스템 호출에서 생성 비용이 부담스러웠다.
하지만 경량화된 이 친구는 JVM 위에서 생성되기 때문에 메모리 크기가 굉장히 작아서 컨텍스트 스위칭 비용이 저렴하다.
가상 쓰레드(Virtual Thread) 톺아보기
경량 쓰레드 모델을 통해서 쓰레드 크기나 컨텍스트 스위칭 비용이 현저히 줄어들기 때문에, Spring MVC/Tomcat 모델이 Webflux/Netty에 비해 갖던 단점들이 많이 희석되었다.
플랫폼 쓰레드의 스케줄러인 ForkJoinPool은 플랫폼 쓰레드 풀을 관리하고, Virtual Thread의 작업을 분배하는 역할을 한다.
- 플랫폼 쓰레드 관리
- 가상 쓰레드의 작업 분배
가상 쓰레드의 동작 원리
실제 작업을 수행하는 플랫폼 쓰레드(Carrier Thread)는 작업 큐(WorkQueue)를 가진다.
플랫폼 쓰레드는 해당 작업 큐에 실제 작업할 내용(Continuation)을 적재하고, pop할때 깊은 고민에 빠지게 된다.
- 가상 쓰레드를 쓸까? 기존 쓰레드를 쓸까?
- 가상 쓰레드 중에서 어떤 녀석한테 작업을 시킬까? (scheduling)
이때 캐리어 쓰레드가 가상 쓰레드를 스케줄링할때 이용되는 기술이 park(), unpark()이다.
가상 쓰레드의 park,unpark
Java에서 park/unpark 동작을 통해서 쓰레드를 컨텍스트 스위칭을 하는 역할을 수행한다.
기존 쓰레드 모델(JDK 17)의 park/unpark
park()와 unpark()은 Java의 동기화 및 쓰레드 제어에 사용되는 메서드 (java.util.concurrent.locks.LockSupport 포함)
- 현재 쓰레드를 차단(block)하는 park() 메서드. 기본적으로 다른 쓰레드가 해당 쓰레드를 깨울 때까지 기다린다.
- park()에 의해 차단된 쓰레드를 깨워서 실행하는 unpark()
JDK 21의 park/unpark
현재 쓰레드가 가상 쓰레드인 경우, 가상 쓰레드의 park/unpark으로 작업하도록 하여 기존 쓰레드 모델과 완벽하게 호환한다.
기존 쓰레드 모델에서 네이티브 기반으로 동작하던 park/unpark 로직에 대해서 가상 쓰레드 분기를 추가해서 특별한 코드 수정 없이 가상 쓰레드 기반의 컨텍스트 스위칭을 가능하게 하였다.
그래서 Spring 애플리케이션에서 어떻게 가상 쓰레드를 사용하나요?
spring.threads.virtual.enabled=true;
IO를 적극적으로 활용하는 구간에 있어 인프라나 다른 서비스로의 트래픽에 영향을 주지 않는 선에서 선택적으로 가상 쓰레드를 이용할 수 있다.
Executor를 생성하여 Bean으로 지정하는 방식
@Bean
public Executor someTaskExecutor() {
var executor = new TaskExecutorAdapter(new VirtualThreadTaskExecutor("some-task-"));
executor.setTaskDecorator(new TaskDecorator());
return executor;
}
가상 쓰레드의 단점
아직 사용하는 인프라나 라이브러리가 완벽히 대응하지 않는 경우를 종종 만날 수 있고, 이는 성능 저하로 이어지게 된다.
특히 아직 많은 JDBC 드라이버들이 내부에서 syncronized를 사용하기 때문에 Pinning 이슈가 있다.
MySQL Bugs: #109346: Add support for Virtual Threads on the JDK
bugs.mysql.com
가상 쓰레드가 플랫폼 쓰레드에 고정되어(pinned) 장점을 활용할 수 없는 경우가 이에 해당한다.
- 가상 쓰레드 내에서 synchronized block을 사용하는 경우
- JNI를 통해 네이티브 메서드를 사용하는 경우
주로 가상 쓰레드 내에서 synchronized 나 parallelStream 혹은 네이티브 메서드를 사용하면 virtual thread가 carrier thread에 park 될 수 없는 상태가 된다. (Pinned 상태)
많은 라이브러리들에서 최신 버전으로 synchronized 메서드를 ReentrantLock으로 대체하는 방식으로 JDK 21의 변화에 적응하고 있다.
가상 쓰레드 사용시 주의사항
Java 공식 문서에서 설명하는 유의사항에 대해 나름 정리한 내용이다.
1. Don't Polling - 쓰레드 생성 비용이 작고 오히려 풀링 관리가 더 낭비
2. Use Semaphore - 제한적인 리소스 접근이나 사용시에는 세마포어 사용
3. Avoid Pinning - 앞서 설명한 Pinned 상태를 지양
4. Avoid thread-local variables
특히 컨텍스트 스위칭이 빈번하지 않는 CPU Bound 작업에서 가상 쓰레드 활용은 비효율적이다.
가상 쓰레드와 다른 모델과 비교
기존 쓰레드 모델 비교
IO Bound 작업에 대해서 제한된 성능에서 가상 쓰레드 모델이 더 높은 처리량, 처리 속도를 보여준다.
CPU Bound 작업에서는 일반 쓰레드 모델이 성능상 우위를 보여준다.
경량 쓰레드가 결국 플랫폼 쓰레드 위에서 동작하기 때문에 CPU Bound 작업시에는 가상 쓰레드 생성이나 스케줄링 비용부터 플랫폼 쓰레드 사용 비용까지 포함되어 낭비가 발생한다.
It is more expensive to run a task in a virtual thread than running it in a platform thread.
Reactive Stream 비교
Webflux는 Netty의 이벤트 루프를 기반으로 동작한다.
이벤트 루프가 중심에서 모든 요청을 처리하고, 처리 구간을 콜백으로 등록해놓고 작업 쓰레드 풀이 작업을 처리하는 형태이다.
이때 Worker 쓰레드가 작업을 처리하는 과정에서 IO를 마주치면 park되면서 컨텍스트 스위칭이 발생한다.
리액티브 역시 마찬가지로 함수의 색 문제를 가지고 있어서, park/unpark가 사용되는 부분마다 reactive가 적용되어야한다. (흐름 전체에 reactive stream을 사용해야하는 문제점)
스트림 연산자 체이닝 과정에서 비동기적 작업이 많아지면 함수 흐름이 어디서 잘못됐는지 파악하기 어려워진다.
특히 flatMap() 안의 비동기 작업은 결과가 언제 나올지 예측하기 어렵고 흐름을 추적하기 어렵다.
그리고 스트림이 밀려서 백프레셔가 처리되지 않으면 함수의 상태가 변하거나 데이터 손실이 발생한다.
Kotlin의 Coroutine 비교
우선 코루틴은 JDK21 이하의 버전에서도 경량화된 쓰레드를 사용할 수 있다는 장점이 있다.
가상 쓰레드는 기존 쓰레드 방식을 완전히 대체하여 TaskExecutor 교체로 애플리케이션 전체에 적용하지만, 코루틴은 메서드 단위로 원하는 곳에서만 경량 쓰레드를 적용할 수 있다는 장점이 있다.
하지만 코루틴은 컨텍스트 스위칭이 필요한 순간마다 프로덕션 코드에 변경이 필요하고, 함수의 색 문제가 생길 수 있다.
빨간색의 함수는 빨간색 함수만 호출 가능하기 때문에 계속해서 함수의 색을 고려해야하는 문제
suspend function들은 전염성이 있어서 suspend가 전파될 수 있다.
Kotlin 언어에 대한 러닝커브 역시 무시하지 못한다.
사실 함수의 색상 문제(Function color problem)에 관한 내용도 다뤘어야했는데, 뭔 빨간색 파란색 그딴게 어딨어(...)
공부 순서가 나중으로 가다보니 분량상 끊어서 다음 포스트에서 소개하겠다.
출처 및 인용.
https://techblog.woowahan.com/15398/