Java Virtual Thread
1. 가상 스레드란?
- 가상 스레드: 처리량이 많은 동시 애플리케이션 코드를 작성, 유지보수 및 관차하는 데 드는 수고를 획기적으로 줄여주는 경량 스레드
- 목표
- 단순히 thread-per-request 스타일로 작성한 서버 애플리케이션이 최적으로 하드웨어를 활용할 수 있도록 확장
java.lang.Thread
API를 사용하는 기존 코드가 최소한의 변경만으로 가상 스레드를 채택할 수 있도록 지원
- 기존 JDK 도구를 사용하여 가상 스레드 문제 해결, 디버깅 및 프로파일링을 쉽게 수행
- 아래 세 가지는 목표가 아니다.
- 기존 스레드 구현을 제거하거나 기존 애플리케이션을 가상 스레드를 사용하도록 조용히 마이그레이션 하는 것
- Java의 기본 동시성 모델을 변경하는 것
- Java 언어나 라이브러리에서 새로운 데이터 병렬 처리 구조를 제공하는 것 (
Stream
API는 여전히 대규모 데이터 세트를 병렬로 처리하는 좋은 방법이다.)
2. 가상 스레드 등장 배경
- Java 개발자들은 거의 30년 동안 동시 서버 애플리케이션 기본 구성 요소로 스레드를 사용해 왔다.
- 메서드의 모든 문은 스레드 내에서 실행되며 멀티 스레드에선 한 번에 여러 스레드가 실행된다.
- 각 스레드는 로컬 변수를 저장하고 메서드 호출을 조정하는 스택을 제공할 뿐만 아니라 문제 발생 시 컨텍스트도 제공한다.
2.1 thread-per-request style
- 서버 애플리케이션을 일반적으로 독립적인 사용자 요청 당 하나의 스레드를 할당하여 요청을 처리한다.
- 플랫폼의 동시성 단위(스레드)를 사용하기에 이해도 쉽고 프로그래밍도 쉽고 디버깅 및 프로파일링도 쉽다.
- 서버 애플리케리션의 확장성은 지연 시간(latency), 동시성(concurrency), 처리량(throughput)과 리틀의 법칙에 의해 결정된다.
- 주어진 요청 처리 기간(지연 시간)에 서버가 동시에 처리하는 요청 수(동시성)는 도착 속도(처리량)에 비례하여 증가해야 한다.
- ex) 평균 지연 시간이 50ms인 애플리케이션이 동시에 10개 요청을 처리하여 초당 200개 처리량을 달성한다고 가정했을 때 2000건을 처리하려면 100건의 요청을 동시 처리해야 한다.
- 애플리케이션이 증가하는 요청량을 따라잡으려면 처리량이 즈가함에 따라 스레드 수가 증가해야 한다.
- 하지만 자바의 스레드는 OS 스레드와 1대 1 대응하기 때문에 사용 가능한 스레드 수가 제한되어 있다.
- 요청량으로 인해 지속 시간 동안 OS 스레드를 계속 소비하면 CPU 또는 네트워크 연결 같은 다른 리소스가 소진되기 전에 스레드 수가 제한되는 경우가 많다.
- 이는 스레드 풀을 사용해도 마찬가지인데 스레드 생성 비용을 낮추어도 총 스레드 수를 증가시키지 않기 때문
2.2 asynchronous style
- 하드웨어를 최대한 활용하기 위해 thread-sharing style을 사용할 수도 있다.
- thread-per-request는 한 요청을 처음부터 끝까지 처리하며 I/O 작업이 완료될 때까지 기다린다.
- thread-sharing은 I/O 작업이 완료될 때까지 해당 스레드를 풀로 반환하여 다른 요청을 처리할 수 있도록 한다.
- 결과적으로 적은 스레드 수로 많은 수의 동시 작업을 수행할 수 있다.
- OS 스레드 부족으로 인한 처리량 제한을 없앨 수 있는 것
- 하지만 비동기 프로그래밍을 사용해야하는 대가가 있다.
- I/O 작업이 완료될 때까지 기다리지 않고 나중에 콜백 완료 신호를 보내는 별도의 I/O 메서드 집합을 사용해야 한다.
- 요청의 각 단계가 다른 스레드에서 수행될 수 있으며 모든 스레드는 서로 다른 요청에 속하는 단계를 interleaved 방식으로 실행한다.
- 프로그램 동작을 이해하기 어렵게 만들며 디보깅 및 프로파일링도 어렵게 한다.
2.3 가상 스레드를 사용한 thread-per-request
- thread-per-request 스타일을 유지하기 위해선 스레드를 더 효율적으로 구현하여 더 많이 사용할 수 있도록 해야 한다.
- OS 스레드르 효율적으로 구현할 수는 없지만 Java 런타임과 OS 스레드의 1대1 대응을 분리하는 방식은 가능하다.
- Java 런타임은 많은 수의 가상 스레드를 적은 수의 OS 스레드에 매핑하여 스레드가 넉넉한 것처럼 동작하게 할 수 있다.
- 운영체제가 많은 가상 주소 공간을 제한된 물리적 RAM에 매핑하여 메모리가 넉넉한 것처럼 보이게 하는 것과 비슷하다.
- 가상 스레드는 OS 스레드와 연결되지 않은
java.lang.Thread
의 인스턴스다.
- 플랫폼 스레드는 OS 스레드를 감싼 래퍼로 전통적인
java.lang.Thread
- 가상 스레드는 thread-per-request 방식으로 동작하기에 요청 전체를 한 가상 스레드에서 실행한다.
- 단 가상 스레드는 CPU에서 계산을 수행하는 동안에만 OS 스레드와 매핑되어 OS 스레드를 소비한다.
- 가상 스레드는 I/O 같은 blocking 로직을 만나면 작동을 멈추고 OS 스레드를 반납하는데 OS 스레드는 다른 가상 스레드와 연결되어 다른 일을 할 수 있게 된다.
- 결과적으로 비동기 스타일과 동일한 확장성을 제공하지만 투명하게 달성된다는 점이 다르다.
- 가상 스레드는 생성 비용이 저렴하여 매우 풍부한 스레드라 높은 수준의 동시성과 처리량을 제공한다.
- Java 플랫폼의 멀티 스레드 설계 및 해당 툴링과 조화를 이룬다.
2.4 가상 스레드의 의미
- 가상 스레드는 플랫폼 스레드와 달리 저렴하고 풍부하므로 풀링해서는 안 된다.
- 대부분의 가상 스레드는 수명이 짧고 호출 스택이 얕으며 단일 HTTP 클라이언트 호출 또는 단일 JDBC 쿼리만 수행한다.
- 요약하면 가상 스레드는 하드웨어를 최적으로 활용하면서 Java 플랫폼의 설계와 조화를 이루는 안정적인 thread-per-request 스타일을 유지한다.
3. 가상 스레드 사용
3.1 가상 스레드는 풀링하지 마라
- 플랫폼 스레드와 달리 가상 스레드는 생성 비용이 적으므로 풀링할 필요가 없다.
- 플랫폼 스레드의 경우에는 생성 비용이 비싸기 위해 개수를 제한하고 풀 안의 스레드를 재사용하며 애플리케이션을 동작시킨다.
- 비싼 리소스를 매우 많은 가상 스레드에서 효율적으로 공유할 수 있도록 캐싱 전략을 사용하도록 해야 한다.
- DB 커넥션과 같은 비싼 리소스를 스레드 로컬 변수에 저장한 후 나중에 같은 스레드의 다른 작업에서 사용하는 경우가 있다.
- 가상 스레드를 사용으로 코드를 마이그레이션 하는 경우 모든 가상 스레드가 비싼 리소스를 생성하면 성능이 크게 저하될 것이다.
3.2 가상 스레드 스케줄링
- 플랫폼 스레드의 경우 OS 스레드를 코어에 할당하는 OS 스케줄러에 의존한다.
- 반면 가상 스레드의 경우 JDK 자체 스케줄러가 존재하는데 가상 스레드를 코어에 직접 할당하는 대신 가상 스레드를 플랫폼 스레드에 할당한다.
- 플랫폼 스레드는 여전히 OS에 의해 스케줄링된다.
- JDK의 가상 스레드 스케줄러는 work-stealing
ForJoinPool
로 FIFO 모드에서 작동한다.
- 스케줄러의 병렬성은 플랫폼 스레드의 수
- 이 포크조인 풀은 병렬 스트림 구현에 사용되는 공통 풀과는 구분된다.
- 가상 스레드가 할당되는 플랫폼 스레드를 가상 스레드의 ‘캐리어’라고 한다.
- 스케줄러는 가상 스레드와 특정 플랫폼 스레드 사이의 선호도를 유지하지 않는다.
- Java 코드 관점에서 가상 스레드는 현재 캐리어와 논리적으로 독립적
- 가상 스레드에서 캐리어를 특정할 수 없는데
Thread.currentThread()
를 사용해도 가상 스레드 자체가 반환된다.
- 캐리어와 가상 스레드의 스택 추적은 분리되어 있다. 가상 스레드의 예외는 캐리어 스택 프레임에 포함되지 않는다.
- 캐리어의 스레드 로컬 변수는 가상 스레드에서 사용할 수 없고 반대도 마찬가지다.
https://findstar.pe.kr/2023/04/17/java-virtual-threads-1/