자바

java 21 처리량 향상을 위한 대안 - virtual thread 알아보자

알쓸개잡 2023. 11. 24.

2023년 9월 21일에 java 21이 배포가 되었다. java 21에서 새롭게 소개된 항목 중 하나가 기존의 스레드보다 더 경량화되어 설계된 virtual thread (가상 스레드)라고 할 수 있겠다. 이번 포스팅에서 새롭게 소개된 virtual thread(이하 가상 스레드)에 대해서 정리해 보고자 한다.

 

기존 스레드 모델의 문제점

  • 전통적인 스레드는 java.lang.Thread 클래스의 인스턴스이다.
  • 플랫폼 스레드라고 불린다.

플랫폼 스레드

  • 플랫폼 스레드(user thread)는 OS 스레드(kernel thread)와 1:1 맵핑된다.
  • 사용가능한 플랫폼 스레드의 수는 OS 스레드 수로 제한된다.
  • 운영 체제에서 유지 관리하는 비교적 큰 스레드 스택 및 리소스가 소모되어 제한적일 수 있다.
  • 플랫폼 스레드 생성에 따른 오버헤드를 피하기 위해 보통 스레드 풀을 사용한다.

플랫폼 스레드 확장성 문제

  •  플랫폼 스레드는 비용이 비싸기 때문에 무한정 생성하여 사용할 수가 없다. 
  • 보통은 요청당 하나의 플랫폼 스레드에 할당하여 처리하는데 이를 thread-per-request 패턴이라고 한다.
  • 이러한 패턴은 서버가 처리할 수 있는 동시 요청의 수가 서버의 하드웨어 성능에 정비례하므로 서버의 처리량에 제약이 따른다.
  • 플랫폼 스레드 내에서 다른 서비스의 데이터 요청을 위한 블록킹 I/O가 발생시 플랫폼 스레드는 유휴 상태로 유지된다.
  • 이는 컴퓨팅 리소스의 낭비이고 높은 처리량을 지원하기 위한 애플리케이션을 구현하는데 있어서 주요 장애물이 된다.

Reactive Programming 이슈

  • Reactive Programming은 다른 시스템의 응답을 기다리는 플랫폼 스레드의 문제를 해결하기 위한 대안이다.
  • 비동기 API는 응답을 기다리지 않고 콜백을 통해서 작동한다.
  • 스레드가 비동기 API를 호출할 때마다 원격 시스템이나 데이터베이스 응답이 올 때까지 플랫폼 스레드가 풀로 반환된다.
  • 나중에 응답이 오면 JVM은 풀에서 응답을 처리할 다른 스레드를 할당한다.
  • 이러한 이유로 하나의 비동기 요청을 처리하는데 여러 스레드가 관여하게 된다.
  • 비동기 프로그래밍은 지연시간은 제거 되지만 여전히 플랫폼 스레드 수에 제약이 있으므로 확장성에 한계가 있다.

virtual thread (가상 스레드)

  • 플랫폼 스레드와 마찬가지로 java.lang.Thread의 인스턴스다.
  • 플랫폼 스레드와 달리 OS 스레드에 직접 연결되지 않는다.
  • 가상 스레드에서 실행중인 코드가 블록킹 되면 실행 가능한 다른 가상 스레드가 실행된다.
  • 가상 스레드는 가상 메모리와 비슷한 방식으로 구현되었다.
    • 많은 스레드를 시뮬레이션 하기 위해 Java 런타임은 많은 수의 가상 스레드를 적은 수의 OS 스레드에 맵핑한다.
  • 가상 스레드는 대부분의 시간을 블록 된 상태로 I/O 작업이 완료될 때까지 기다리는 작업을 실행하는데 적합하다.
  • 가상 스레드는 플랫폼 스레드에 비해서 더 빠른 스레드가 아니다.
  • 가상 스레드는 속도(지연시간 단축)가 아닌 확장성(높은 처리량)을 제공하기 위한 것이다.

가상 스레드 모델
출처 https://howtodoinjava.com/java/multi-threading/virtual-threads/

 

가상 스레드 스케줄링

  • 자바 런타임은 가상 스레드가 실행될 때 스케줄링을 한다.
  • 가상 스레드를 스케줄링 할 때 플랫폼 스레드에 가상 스레드를 마운트 하는데 이때 OS는 플랫폼 스레드를 스케줄링한다.
  • 가상 스레드가 마운트 되는 플랫폼 스레드를 캐리어라고 한다.
  • 가상 스레드가 블록킹 되는 I/O 작업을 수행할 때 캐리어에서 마운트가 해제된다.
  • 가상 스레드가 캐리어에서 마운트 해제되면 자바 런타임 스케줄러가 다른 가상 스레드를 캐리어에 마운트 한다.
  • I/O 작업이 완료되면 가상 스레드는 다시 캐리어에 마운트 된다.

가상 스레드의 특징

  • 가상 스레드는 stop(), suspend(), resume() 메서드를 지원하지 않는다.
  • 지원하지 않는 메서드를 호출하는 경우에 UnsupportedOperationException 이 발생한다.
  • single thread group에 속한다.
  • NORM_PRIORITY 우선순위로 고정된다. setPriority() 호출 효과는 없다.
  • 항상 데몬스레드로 동작한다. setDaemon(false) 호출 효과는 없다.
  • JVM은 종료 전에 모든 가상 스레드가 종료되기를 기다리지 않는다.
  • Thread.getAllStackTraces() 메서드는 가상 스레드의 스택 추적 및 맵에 대한 정보를 포함하지 않는다.
  • Thread.isVirtual() 메서드를 통해 가상 스레드 여부를 확인할 수 있다.
  • 가상 스레드가 마운트 된 플랫폼 스레드를 찾을 수 있는 방법은 아직 없다.

가상 스레드 생성 및 실행

  • Thread와 Thread.Builder API는 플랫폼 스레드와 가상 스레드를 모두 생성할 수 있는 방법을 제공한다.
  • java.util.concurrent.Executors 클래스는 각 작업에 대해 가상 스레드를 시작하는 ExecutorService를 생성하는 메서드를 제공한다.

다음은 Thread와 Thread.Builder API를 이용한 가상 스레드를 생성하는 코드다.

Thread thread = Thread.ofVirtual()
	.start(() -> System.out.println("Hello"));

//스레드가 종료할 때까지 대기.
thread.join();

 

Thread.Builder builder = Thread.ofVirtual().name("MyThread");
Runnable task = () -> {
    System.out.println("Running thread");
};
Thread t = builder.start(task);
System.out.println("Thread t name: " + t.getName());
t.join();
  • Thread.Builder 인터페이스를 사용하면 스레드 이름과 같은 일반적인 스레드 속성을 사용하여 스레드를 만들 수 있다.
  • Thread.Builder.ofPlatform()은 플랫폼 스레드를 생성한다.
  • Thread.Builder.ofVirtual()은 가상 스레드를 생성한다.

다음은 Executors.newVirtualThreadPerTaskExecutor() 메서드를 사용하여 가상 스레드를 생성하는 코드다.

try (ExecutorService myExecutor = Executors.newVirtualThreadPerTaskExecutor()) {
    Future<?> future = myExecutor.submit(() -> System.out.println("Running thread"));
    future.get();
    System.out.println("Task completed");
    // ...
}
  • ExecutorService.submit(Runnable)이 호출될 때마다 새로운 가상 스레드가 생성되어 작업을 실행한다.

가상 스레드 도입 가이드

  • 가상 스레드와 플랫폼 스레드의 가장 큰 차이점은 동일한 프로세스에서 수백만 개 이상의 가상 스레드를 쉽게 실행할 수 있다는 것이다.
  • 서버가 더 많은 요청을 동시에 처리할 수 있어 처리량을 높이고 하드웨어 낭비를 줄임으로써 request-per-thread 방식으로 작성된 서버 애플리케이션을 보다 효율적으로 실행할 수 있다는 것이 가상 스레드의 강력한 힘이다.

블로킹 I/O API를 사용하는 간단한 동기식 코드 작성

  • 플랫폼 스레드를 차단하는 것은 상대적으로 비싼 리소스를 낭비하는 것이다.
  • 가상 스레드는 경량 스레드 이므로 차단 비용은 저렴하다.
  • 가상 스레드에서는 간단한 동기식 스타일 코드를 작성하고 블록킹 I/O API를 사용하는 것이 효과적이다.

다음과 같은 논블록킹 비동기 스타일로 작성된 코드는 가상 스레드의 이점을 크게 누리지 못한다.

CompletableFuture.supplyAsync(info::getUrl, pool)
   .thenCompose(url -> getBodyAsync(url, HttpResponse.BodyHandlers.ofString()))
   .thenApply(info::findImage)
   .thenCompose(url -> getBodyAsync(url, HttpResponse.BodyHandlers.ofByteArray()))
   .thenApply(info::setImageData)
   .thenAccept(this::process)
   .exceptionally(t -> { t.printStackTrace(); return null; });

반면에 블록킹 동기식 스타일로 작성된 다음의 코드는 가상 스레드에서 큰 효과를 얻을 수 있다.

try {
   String page = getBody(info.getUrl(), HttpResponse.BodyHandlers.ofString());
   String imageUrl = info.findImage(page);
   byte[] data = getBody(imageUrl, HttpResponse.BodyHandlers.ofByteArray());   
   info.setImageData(data);
   process(info);
} catch (Exception ex) {
   t.printStackTrace();
}



가상 스레드를 풀링 하지 않기

가상 스레드는 수백만 개 까지 생성할 수 있을 만큼 경량화된 스레드다. 상대적으로 비싼 플랫폼 스레드의 수에는 제약이 있기 때문에 풀링을 사용하여 과도한 리소스 사용을 보호하였지만 가상 스레드는 그럴 필요가 없다.

플랫폼 스레드를 n개의 가상 스레드로 변환하는 것은 거의 이점이 없으며, 변환이 필요한 것은 가상 스레드 내에서 실행될 Task(작업)다.

모든 애플리케이션 작업을 스레드로 표현하려면 다음 코드처럼 shared thread pool executor를 사용하지 않는 것이 좋다.

Future<ResultA> f1 = sharedThreadPoolExecutor.submit(task1);
Future<ResultB> f2 = sharedThreadPoolExecutor.submit(task2);
// ... use futures

대신에 다음 코드와 같이 virtual thread executor를 사용하는 것이 좋다.

try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
   Future<ResultA> f1 = executor.submit(task1);
   Future<ResultB> f2 = executor.submit(task2);
   // ... use futures
}

Executors.newVirtualThreadPerTaskExecutor()에서 반환되는 것은 스레드 풀을 사용하지 않는다. 대신 submit()에 의해 실행되는 각 작업에 대해서 새로운 가상 스레드를 생성한다.

ExecutorService 자체는 가볍기 때문에 다른 간단한 객체와 마찬가지로 새 객체를 만들 수 있다.

try 블록이 끝날 때 암시적으로 호출되는 close() 메서드는 ExecutorService에 제출된 모든 작업, 즉 ExecutorService에 의해 생성된 모든 가상 스레드가 종료될 때까지 자동으로 대기한다.

위와 같은 패턴은 다음 샘플 코드와 같이 서로 다른 서비스에 대한 여러 발신 호출을 동시에 수행하려는 시나리오에 유용하다.

void handle(Request request, Response response) {
    var url1 = ...
    var url2 = ...
 
    try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
        var future1 = executor.submit(() -> fetchURL(url1));
        var future2 = executor.submit(() -> fetchURL(url2));
        response.send(future1.get() + future2.get());
    } catch (ExecutionException | InterruptedException e) {
        response.fail(e);
    }
}
 
String fetchURL(URL url) throws IOException {
    try (var in = url.openStream()) {
        return new String(in.readAllBytes(), StandardCharsets.UTF_8);
    }
}

작고 수명이 짧은 동시 작업의 경우에도 위와 같이 새 가상 스레드를 만드는 것이 좋다.

세마포어를 사용하여 동시성 제한

  • 외부 서비스에서 동시 접속 처리의 한계로 인하여 가상 스레드를 통한 동시성 작업에 제한을 걸어야 하는 경우가 있다.
  • 기존 스레드의 스레드 풀은 다음 예시처럼 동시성을 제한하는 역할을 하기도 하였다.
ExecutorService es = Executors.newFixedThreadPool(10);
...
Result foo() {
    try {
        var fut = es.submit(() -> callLimitedService());
        return f.get();
    } catch (...) { ... }
}

위 샘플 코드는 제한된 서비스에 대한 동시 요청을 10개까지만 허용한다.

하지만 가상 스레드는 값싼 만큼 희소하지도 않기 때문에 풀링을 해서는 안된다.

가상 스레드를 사용할 때 일부 서비스에 대한 액세스의 동시성을 제한하려면 Semaphore 클래스를 사용해야 한다.

Semaphore sem = new Semaphore(10);
...
Result foo() {
    sem.acquire();
    try {
        return callLimitedService();
    } finally {
        sem.release();
    }
}

위 코드는 가상 스레드에서 실행될 작업 코드다. Semaphore를 통해서 한 번에 10개까지 제한된 서비스를 호출하도록 제한다.

외부 서비스가 데이터베이스인 경우 connection pool 자체가 세마포어 역할을 한다. connection pool이 10개의 연결로 제한되면 연결을 획득하려는 11번째 스레드가 차단된다. connection pool 위에 추가적인 semaphore를 추가할 필요는 없다.

 

Thread-Local 변수에 값비싼 재사용 객체 캐시하지 않기

  • 가상 스레드 역시 Thread-Local 변수를 지원하지만 보다 안전하고 효율적인 Scoped Value를 사용하는 것이 좋다.
  • JDK 21에서는 현재 preview 기능으로 Scoped Value가 제공되고 있다. Scoped Value에 대해서는 아래 포스팅에서 정리하였다.
  • Thread-Local 변수의 용도로 재사용 가능한 객체를 캐싱하는 것인데 이러한 객체는 일반적으로 메모리를 많이 사용하고 변경 가능하며 스레드에 안전하지 않기 때문에 사용을 피하는 것이 좋다.

예를 들어 SimpleDateFormat의 인스턴스는 생성 비용이 많이 들고 thread safe 하지 않다.

다음 코드는 SimpleDateFormat 인스턴스를 ThreadLocal에 캐시 하는 코드다.

static final ThreadLocal<SimpleDateFormat> cachedFormatter = 
       ThreadLocal.withInitial(SimpleDateFormat::new);

void foo() {
  ...
	cachedFormatter.get().format(...);
	...
}

 

위와 같은 캐싱은 플랫폼 스레드가 풀링 된 경우처럼 스레드 로컬에 캐시 된 고가의 객체가 여러 작업에서 공유되고 재사용되는 경우에만 유용하다. 스레드 풀에서 실행할 때 많은 작업이 foo()를 호출할 수 있지만, 풀 스레드당 한 번씩만 인스턴스화되고 재사용되는 것은 풀의 개수가 제한적이기 때문에 문제 되지 않는다.

 

반면에 가상 스레드는 풀링 되지 않고 관련 없는 작업에서 재사용되지 않는다. 가상 스레드에서 실행되는 모든 작업에서 foo()를 호출할 때마다 새로운 SimpleDateFormat의 인스턴스화가 트리거 된다. 또한 동시에 실행되는 가상 스레드가 매우 많을 수 있기 때문에 값비싼 객체의 경우에는 상당히 많은 메모리를 소비할 수 있다는 것에 주의해야 한다.

Synchronized 블록 대신 ReentrantLock 사용

현재 가상 스레드 구현의 한계는 동기화된 블록 내부에서 블록킹 되는 작업을 수행하면 가상 스레드를 마운트 하고 있는 플랫폼 스레도(캐리어)도 함께 블록킹되는 것이다. (OS 스레드 pinning이라고 부른다.)

블록 되는 시간이 길고 빈번한 경우 pinning은 서버의 처리량에 부정적인 영향을 미칠 수 있다.

다만, 인메모리 작업과 같이 수명이 짧은 작업이나 빈번하지 않게 호출되는 synchronized 블록은 부정적인 영향을 미치지 않는다.

 

OS 스레드가 pinning 되는 경우는 다음 두 가지이다.

  • synchronized 블록 혹은 메서드 안의 코드가 실행되는 경우
  • 네이티브 메서드 혹은 외부 함수를 실행하는 경우

synchronized 블록은 플랫폼 스레드와 유사하게 애플리케이션의 처리량을 제한할 수 있다.

이를 개선하기 위해서 synchronized 블록 대신에 ReentrantLock을 사용해야 한다.

다음 코드의 frequentIO() 메서드는 수명이 긴 IO 블록킹 작업을 처리하는 메서드라고 가정했을 때

synchronized(lockObj) {
    frequentIO();
}

다음과 같이 수정해야 한다.

final ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
    frequentIO();
} finally {
    lock.unlock();
}

frequentIO는 수명이 긴 블록킹 IO를 수행하는 메서드인 경우에 ReentrantLock으로 대체하는 것이 좋다.

위에서 언급했듯이 수명이 짧은 작업이나 빈번하지 않게 호출되는(예를 들어 시작 시에만 수행되는) synchronized 블록은 ReentrantLock으로 대체하지 않아도 된다.

가상 스레드 디버깅

  • 디버거는 플랫폼 스레드와 같이 가상 스레드를 살펴볼 수 있다.
  • JDK Flight Recorder(JFR)를 통해서 가상 스레드를 살펴 볼 수 있다.
    • jdk.VirtualThreadStart 및 jdk.VirtualThreadEnd는 가상 스레드가 시작 및 종료되는 시점을 나타낸다. 기본적으로 비활성화되어 있다. JDK Mission Control(JMC)을 통해서 사용자 지정 JFR 구성을 사용하여 jdk.VirtualThreadStart 및 jdk.VirtualThreadEnd 이벤트를 활성화할 수 있다.
    • jdk.VirtualThreadPinned는 가상 스레드가 임계 기간보다 오래 pinned 되었음을 나타낸다. 기본적으로 20ms 임계값으로 활성화된다.
    • jdk.VirtualThreadSubmitFailed는 리소스 문제로 인해 가상 스레드 파킹 혹은 시작하지 못했음을 나타낸다. 가상 스레드를 파킹하면 기본 캐리어 스레드가 다른 작업을 수행할 수 있도록 해제되고, 가상 스레드를 파킹 해제하면 계속 진행되도록 예약된다. 이 이벤트는 기본적으로 활성화되어 있다.
    • 녹화된 파일의 이름이 recording.jfr이라고 했을 때 다음 명령을 통해서 가상 스레드 이벤트를 확인할 수 있다.
jfr print --events \
jdk.VirtualThreadStart,jdk.VirtualThreadEnd,jdk.VirtualThreadPinned, \
jdk.VirtualThreadSubmitFailed recording.jfr
  • jcmd tool을 통해서 가상 스레드를 살펴볼 수 있다.
    • plain text 형식뿐 아니라 JSON 형식으로도 스레드 덤프를 생성할 수 있다.
    • jcmd 스레드 덤프에는 I/O 작업에서 블록 된 가상 스레드 및 ExecutorService에 의해서 생성된 가상 스레드 정보를 확인할 수 있다.
jcmd <PID> Thread.dump_to_file -format=text <file>
jcmd <PID> Thread.dump_to_file -format=json <file>

 

 

 

지금까지 정리한 내용은 오라클 공식 문서의 내용을 대부분 정리한 것이다.

가상 스레드에 대한 내용을 검색하다가 코드 레벨에서 가상 스레드의 동작 방식에 대해서 잘 정리한 블로그도 소개한다.

https://perfectacle.github.io/2022/12/29/look-over-java-virtual-threads/#more

 

Java Virtual Threads 훑어보기

Java의 Virtual Threads는 JDK 19에 Preview Features로 추가되었다.프로젝트 룸(Loom)에서 개발한 기능으로 알고있는데 사실 큰 관심도 없던(뭐하는 지도 모르던) 프로젝트였고, JDK 19가 LTS도 아니기 때문에

perfectacle.github.io

 


참고링크

오라클 공식 문서

JEPS 444

https://howtodoinjava.com/java/multi-threading/virtual-threads/

https://dev.java/learn/new-features/virtual-threads/

댓글

💲 추천 글