Virtual Thread (JDK21)

조제·2024년 9월 4일
0

가상 스레드

[기존 자바 스레드 모델의 문제와 한계]

  • 스프링 프레임워크는 멀티 스레드 모델을 사용하고 있으며, 1개의 요청을 1개의 스레드가 처리하는 thread-per-request 방식으로 동작하고 있다. 따라서 동시 요청이 많다면 스레드의 수 역시 증가해야한 대응할 수 있다.
  • 하지만 기존 JDK의 스레드는 운영 체제(OS) 스레드의 Wrapper이기 때문에, 사용 가능한 스레드의 수가 하드웨어 수준보다 훨씬 적게 제한되어 있었다. OS 스레드는 비용이 높아 요청량에 비례하여 늘릴 수 없기 때문이다.
  • 가질 수 있는 스레드의 양은 제한적인데, 자바 스레드는 OS 스레드의 Wrapper라서 I/O 작업을 만나면 블로킹되기까지 한다.

[여러 가지 대안에 대한 검토]

  • 비동기 API에 계속 의존하기
    • Future, CompletableFuture와 같은 비동기 API를 사용할 수 있다.
  • 코루틴을 자바 언어에 추가하기
    • 코루틴을 도입하게 되면 스레드용 API와 코루틴용 API로 자바 플랫폼이 나뉘게 된다.
  • user-mode 스레드를 나타내는 새로운 public class 추가하기
  • 하드웨어를 최대한 활용하고자 하는 일부 개발자들은 스레드를 공유하는 방식을 사용하기도 한다. 대표적으로 리액티브 스택 기반의 스프링 webflux가 있다.
    • 한 스레드가 I/O 작업이 끝나기를 기다리는(블로킹) 대신, 해당 스레드를 반납하여 다른 요청을 처리할 수 있도록 하는 것이다. I/O 작업은 제외하고 연산을 수행하는 동안에만 스레드를 보유하기 때문에 적은 수의 스레드로도 많은 동시 요청을 처리할 수 있다.
    • 비용이 상당히 크다. 순차적인 것처럼 보이는 요청 처리 단계가 다른 스레드에서 실행될 수 있으며, 이로 인해 Stack Trace를 위한 컨텍스트를 제공할 수 없고, 요청 처리 로직을 순차적으로 살펴볼 수도 없다.

[가상 스레드 핵심 목표]

  • thread-per-request 스타일의 서버 애플리케이션이 하드웨어를 최적으로 활용할 수 있도록 한다.
  • java.lang.Thread API를 사용하는 기존 코드가 최소한의 변경만으로 가상 스레드를 채택할 수 있도록 한다.
  • 기존의 JDK 도구를 사용해 가상 스레드의 문제 해결, 디버깅 및 프로파일링을 쉽게 수행할 수 있도록 한다.

하드웨어를 최적으로 활용하지 못하는 근본적인 원인은 OS 스레드와 자바 스레드가 일대일 대응되기 떄문이다. 즉, 자바 스레드가 실행되는 것은 OS 스레드가 사용중임을 의미한다. 따라서 해결 방안은 자바 런타임에서 OS 스레드와 일대일 대응되지 않는 더 효율적인 스레드를 구현해 사용하는 것이다. 운영체제가 많은 가상 주소 공간을 제한된 양의 물리적 RAM에 매핑하여 메모리가 넉넉한 것처럼 보이게 하는 것처럼, 자바 런타임은 많은 수의 가상 스레드를 적은 수의 OS 스레드에 매핑하여 스레드가 넉넉한 것처럼 보이게 하는 것이다.

[가상 스레드 풀링 금지]

풀링은 고가의 리소스를 공유하기 위한 것이다. 하지만 가상 스레드는 라이프사이클 동안 하나의 작업만 실행하도록 설계되었으므로 절대 풀링해서는 안된다. 따라사 풀링 없이 항상 새롭게 생성해주면 된다. 만약 애플리케이션 코드에서 스레드 풀 기반의 ExecutorService를 사용중이라면 가상 스레드 기반의 ExecutorService로 마이그레이션 해야 한다.

var executor = Executors.newFixedThreadPool(10)
var executor = Executors.newVirtualThreadPerTaskExecutor()

[스레드 로컬의 사용]

스레드 로컬은 현재 스레드의 실행과 연관된 데이터들을 다루는 기법으로 캐싱, 파라미터 숨기기 등 다양한 목적으로 사용되고 있다. 하지만 스레드 로컬은 현실적으로 다음과 같은 문제를 갖고 있고, 이로 인해 메모리 누수나 메모리 에러 등이 발생할 수 있다.

  • 명확한 생명주기가 없음(unbounded lifetime)
  • 변경 가능성에 대해서 제약이 없음(unconstrained mutability)
  • 메모리 사용에 대해서 제약이 없음(unconstrained memory usage)
  • 값비싼 상속 기능을 사용하는 InheritableThreadLocal의 성능 문제

따라서 가상 스레드로 전환하고자 한다면 무거운 객체를 스레드 로컬에 저장하지 않도록 해야 한다.

[자바의 가상 스레드(Virtual Thread) 요약]

  • 기존 자바의 스레드는 OS 스레드와 일대일 대응되도록 구현됨
  • 이로 인해 스레드의 개수가 제한되며, I/O 작업 시에 블로킹되어 하드웨어를 최적으로 활용하지 못함
  • 다른 언어들은 제약으로 인해 비동기 API, 코루틴 등으로 문제를 해결했지만 자바는 제약이 없음
  • JVM 수준에서 이를 처리하고 기존 코드와의 호환성을 유지하는 방식으로 문제를 해결함
  • 가상 스레드는 비용이 저렴한 스레드이므로 풀링하지 말아야 함
  • 또한 수백만 개의 가상 스레드가 리소스를 공유할 수 있으므로 ThreadLocal은 주의해서 사용해야 함

[주요 특징]

  • 경량 스레드 : 가상 스레드는 매우 경량으로 생성되며, 수천에서 수백만 개까지도 쉽게 생성할 수 있습니다. 기존의 플랫폼 스레드와 비교했을 때, 메모리 사용량이 적고, 생성 및 컨텍스트 스위칭의 비용이 매우 낮습니다.
  • 동시성 처리 : 가상 스레드는 많은 양의 동시성을 필요로 하는 애플리케이션에 적합합니다. 기존 스레드의 병목 현상을 줄이고 동시성을 높일 수 있습니다.
  • 블로킹 작업의 비동기 처리 : 전통적인 블로킹 작업(예: I/O 작업)은 가상 스레드에서 비동기적으로 처리되며, 이는 비동기 프로그래밍의 복잡성을 줄이고 코드 가독성을 높입니다.
  • 간단한 코드 : 비동기 처리를 이해 복잡한 콜백이나 CompletableFuture 같은 패턴을 사용할 필요가 없어, 코드가 단순하고 유지보수가 쉬워집니다.
  • 기존 코드와 호환성 : 가상 스레드는 기존의 Java 코드와 호환되며, 별도의 큰 변경 없이 기존 프로젝트에 통합할 수 있습니다.

[사용 예시]

public static void main(String[] args) throws InterruptedException {
    try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
        for (int i = 0; i < 1000; i++) {
            executor.submit(() -> {
                // 가상 스레드에서 실행할 코드
                System.out.println(Thread.currentThread());
            });
        }
    }
}

가상 스레드 성능 비교

[Spring boot 3에서 가상 스레드 활성화]

spring:
	threads:
		virtual:
			enabled: true

[controller]

단순히 blocking I/O를 수행하는 역할을위해 5초간 sleep 후 가상 스레드 여부와 스레드 정보를 응답하는 API

@GetMapping
public ResponseEntity<ThreadInfo> getThreadInfo() throws InterruptedException {
      Thread.sleep(5000);
      return ResponseEntity.ok(
              new ThreadInfo(
                      Thread.currentThread().isVirtual(),
                      Thread.currentThread().toString())
      );
}

[API 호출 테스트]

application.use-virtual=true 인 경우에 /thread 호출은 다음과 같이 응답하여 가상 스레드가 사용됨을 확인할 수 있다.

{
    "isVirtual": true,
    "threadName": "VirtualThread[#57]/runnable@ForkJoinPool-1-worker-2"
}

application.use-virtual=false인 경우에 /thread 호출은 다음과 같이 응답하여 플랫폼 스레드가 사용됨을 확인할 수 있다.

{
    "isVirtual": false,
    "threadName": "Thread[#45,http-nio-8080-exec-2,5,main]"
}

[처리량 테스트]

jmeter를 통해서 가상 스레드와 플랫폼 스레드의 처리량을 비교

1000개의 스레드를 생성하여 각 스레드가 100초간 반복 호출하도록 설정

[가상 스레드 적용시 처리량]


가상 스레드의 경우 초당 181.7건의 처리량을 보였다.

가상 스레드의 경우 모든 응답 시간은 균일하게 5초가 소요되었다.

제약 없이 스레드를 생성하여 처리할 수 있기 때문에 blocking I/O 작업 동안 다른 스레드의 대기가 발생하지 않기 때문이다.

[플랫폼 스레드 적용시 처리량]


플랫폼 스레드의 경우에는 별도의 thread pool 개수 설정을 하지 않았으므로 디폴트 값이 200개로 수행된다.

플랫폼 스레드의 경우에는 초당 39.3건의 처리량을 보였다.

200개의 스레드로 제한되어 있기 때문에 blocking I/O 작업 동안 다른 스레드에서의 대기가 발생하기 때문이다.

[결론]

JDK 21의 가상 스레드는 자바 애플리케이션의 성능과 확장성을 크게 향상시킬 수 있는 중요한 기능이다. 기존 스레드 모델의 한계를 극복하고, 대규모 동시성 작업을 보다 효율적으로 처리할 수 있게 해준다.


참고
https://mangkyu.tistory.com/309
https://devel-repository.tistory.com/71

profile
조제

0개의 댓글