Virtual Thread

이경환·2024년 7월 15일

JAVA

목록 보기
7/7

들어가기


Java 21는 (Java 8이후 3번째 LTS 버전) 23년 9월 19일에 릴리즈 된 버전입니다.
그중 핵심은 가상 스레드 Virtual Thread입니다. 이번 시간을 통해 Virtual Thread에 대해 알아보며 도입하게 된 이유, 장점, 주의할 점에 대해 간단히 알아보는 시간을 가져보겠습니다.

도입 배경


Java 진영에서는 애플리케이션에서 동시성 처리를 위해 스레드를 사용해왔습니다. 하지만 기존 Thread Per Request 방식의 애플리케이션에는 한계가 있었고 기존의 Java의 스레드 모델에서의 플랫폼 스레드를 가져다 Wrappig 해서 쓰는 구조는 I/O 작업시 Blocking되는 문제 까지있습니다.
예를들어 스레드 풀에 존재하는 스레드는 CPU를 가지고 요청을 처리할 때 파일 쓰기 같은 I/O 작업을 만나면 CPU를 OS에 반환하고 실행할 수 없는 상태(Non-Runnable)가 됩니다.

진행중이던 스레드가 작업을 중단하고 I/O 작업이 끝날 때까지 대기합니다. 그러다 I/O작업이 긑나면 남은 작업을 이어가고 스레드는 스레드 풀에 반환합니다. 만약 전체 모든 스레드가 Blocking 돼있느 상황이라면 새로운 요청이 와도 톰캣의 내부 큐에 들어오게 되고, 사용 가능한 스레드가 스레드풀에 반환되어야 요청이 실행됩니다. 수 많은 I/O 작업을 할때 Java의 스레드 동작 방식에서 비효율이 발생하게 됩니다. 조금 더 Java의 전통적인 스레드 모델을 자세히 알아보겠습니다.

기존의 Java 스레드 모델

  • Java의 전통적인 스레드는 OS 스레드를 Wrapping 하는 방식을 사용합니다 이것을 플랫폼 스레드 라고 부릅니다. 따라서 실질적으로 OS의 스레드를 이용하는 방식으로 동작합니다.
  • OS 커널을 통해 사용하는 스레드는 제한적이고 유지비용이 많이듭니다 기존 Java 애플리케이션들은 이를 해결하고자 스레드 풀(Thread Pool) 을 만들어 사용했습니다.
  • Spring Boot 애플리케이션의 기본적인 클라이언트 요청 처리 방식은 Thread Per Request 입니다. 애플리케이션은 스레드 풀에서 스레드를 꺼내 쓰기때문에 스레드 풀을 늘릴수록 좋겠지만 이는 현실적으로 불가능합니다. 결국 애플리케이션의 처리량은 스레드 풀에서 감당하는 범위를 넘어설 수 없습니다.
  • 보통 tomcat 서버는 디폴트로 200개의 thread를 가진 thread pool을 지원합니다. Spring은 하나의 request(요청)을 처리하기 위해서 하나의 스레드를 사용합니다.일반적으로 Blocking 방식으로 처리가 되며 시스템 부하가 높다면 context switching과 read data loading으로 인해 overhead가 발생합니다.

여러 해결책

위와 같이 Java의 스레드 모델에서 오는 Blocking 문제나 동기 문제등 스레드 문제를 해결하고자 하는 여러 방법이나 대안을 간단하게 요약해보겠습니다.

비동기 API에 의존하기 -> Futrue, CompletableFutured와 같은 비동기 API 사용하기

  • 비동기 API는 동기식 코드와 결합되기 어려움
  • 여러 콤비네이터(thenAccept, thenCompose)등 콤비네이터를 학습해야하는 부담

코루틴을 Java에 추가하기

  • 코루틴을 도입하게 되면 스레드용 API와 코루틴용 API로 자바 플랫폼이 나뉘게 되고, 자바 플 랫폼의 모든 계층과 도구에 이러한 구조를 도입해야 한다.

새로운 user-mode 스레드 클래스의 도입

  • 기존의 java.lang.Thread와 독립적인 새로운 user-mode 스레드의 도입을 고려 Lock의 획 득 여부 확인이나 ThreadLocal등 광범위하게 사용되는 Thread.currentThread() 메서드 때문에 혼란을 가중할 수 있다는 판단에 Thread API를 유지하고 ExecutorService와 같은 더 고수준의 API를 사용해 해결한다 (이 부분은 나중에도 나오기 때문에 기억합니다.)

Reactive Programming

  • 기존의 자바 패러다임과는 맞지 않는 부분이있다. 코드 작성 러닝커브가 있습다.ex) Mono, Flux) Stack Trace를 위한 컨텍스트를 제공할 수 없고, 요청 처리 로직을 순차적으로 살펴볼 수도 없어 디버깅이 힘들다.

Virtual Thread


가상 스레드 란 기존의 전통적인 Java 스레드에 더하여 새롭게 추가되는 경량 스레드입니다. OS 스레드를 그대로 사용하지 않고 JVM 자체적으로 내부 스케줄링을 통해서 사용할 수 있는 경량의 스레드를 제공합니다. 하나의 Java 프로세스가 수십만~ 수백만개의 스레드를 동시에 실행할 수 있게끔 설계되었습니다.

해결하고자 하는 문제

위의 내용을 요약해보자면 기존 Java의 스레드 모델에는 다음과 같은 문제가 있습니다.

  • Java의 스레드는 OS를 기반으로합니다.
  • 애플리케이션 처리량(throughput)은 스레드 풀의 감당량을 넘어설 수 없습니다.
  • Spring boot의 Thread Per Request 구조에서 I/O 잡억을 할때 Blocking작업을 하며
    overhead가 발생합니다.
  • 코루틴, Reactive Programming과 webflux 등 여러 대안을 제시했지만 문제가 있습니다.
  • 자바 플랫폼 디자인의 호환성, 전통적인 Java 스레드 기반을 그대로 사용하며 디버깅, 프로파일링등 기존의 도구도 그대로 사용할 수 있도록 합니다.

그래서 Jdk21 에서는 위의 문제를 해결하고자 다음을 목표로 삼았습니다.

  • 기존 thread-per-request 애플리케이션에서 높은 처리량(throughput) 확보
  • java.lang.Thread API를 사용하는 기존 코드가 최소한의 변경을 유지한 채 자바 플랫폼의 디자인과 조화를 이루는 코드 생성

특징

Virtual Thread의 특징에 대해 알아보겠습니다.

Reactive Programming 과의 비교

  • Reactive programming 이 달성하고자 하는, 리소스를 효율적으로 throughput을 감당하려는 목적은 동일합니다.
  • 가상 스레드의 논 블로킹 처리는 JVM레벨에서 담당해주기 때문에 Web MVC 스타일로 코드를 작성하더라도 내부에서 가상 스레드가 기존의 플랫폼 스레드를 직접 사용하는 방식보다 효율적으로 스케줄링하여 처리량을 높일 수 있습니다.

즉 가상 스레드 는 기존 스레드 방식의 이점을 누리면서도 Reactive programming의 장점을 취할 수 있다.

참고자료: https://techblog.woowahan.com/15398/
여러 다른 기술 vs Virtual Thread

구조와 동작 원리


가상 스레드의 구조에 대해 알아보겠습니다.

플랫폼 스레드와 가상 스레드의 구조 차이


기존 스레드는 스레드 풀을 사용하여 접근하는 방식을 사용했습니다.

전통적인 방식의 스레드에 비해 가상 스레드는 OS스레드를 감싼 구조가 아니고 JVM에서 자체적으로 가상 스레드를 OS 스레드와 연결하는 스케줄링을 합니다. 이 작업을 mount/unmount라고 하며 기존 플렛폼 스레드를 Carrier 스레드 라고 합니다. 여기서 중요한것은 가상 스레드 풀이란 것 없이 사용합니다.

여기서 스케줄링을 통해 큰차이가 발생하는데 기존의 스레드는 Blocking이 발생하면 대기 상태에 놓였지만 가상 스레드는 Blocking이 발생하면 내부 스케줄링을 통해 실제 작업을 처리하는 Carrier 스레드는 다른 가상 스레드의 작업을 처리하면됩니다.

Virtual Thread의 구조

  • 스케줄러는 platform thread pool을 관리하고, Virtual Thread의 작업 분배 역할을 합니다.
  • 가상 스레드는 carrier 스레드를 가지고 있습니다. 실제로 작업을 수행시키는 platform thread를 의미합니다. carrier 스레드는 workQueue를 가지고 있습니다.
  • 가상 스레드는 스케줄러라는 ForkJoinPool을 가지고 있습니다. Carrier 스레드의 Pool 역할을 하고, 가상 스레드의 작업 스케줄링을 담당합니다.
  • 가상 스레드는 runContinuation 이라는 가상 스레드의 실제 작업 내용(Runnable)을 가지고 있습니다.

가상 스레드 동작원리

  • 실행될 가상 스레드의 작업인 runContinuation을 carrier thread의 work queue에 Push합니다.
  • Work queue에 있는 runContinuation들이 forkJoinPool에 의해 work stealing 방식으로 carrier thread에서 처리됩니다.
  • 처리되던 runContinuation들은 I/O, Sleep으로 인한 interrupt나 작업 완료 시 work queue에서 pop되어 힙 메모리로 돌아갑니다.

사용하는 자원의 차이

  • 플랫폼 스레드는 메모리를 미리 할당된 Stack을 사용하는 반면 가상 스레드는 필요시 마다 Heap을 사용합니다.

사용법


import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class Main {
    public static void main(String[] args) throws Exception {
        run();
    }

    public static void run() throws Exception {
         
         // Virtual Thread 방법 1
         Thread.startVirtualThread(() -> {
	        System.out.println("Hello Virtual Thread");
         });
         
         // Virtual Thread 방법 2
         Runnable runnable = () -> System.out.println("Hi Virtual Thread");
         Thread virtualThread1 = Thread.ofVirtual().start(runnable);
         
         // Virtual Thread 이름 지정
         Thread.Builder builder = Thread.ofVirtual().name("JVM-Thread");
         Thread virtualThread2 = builder.start(runnable);
         
         // 스레드가 Virtual Thread인지 확인하여 출력
         System.out.println("Thread is Virtual? " + virtualThread2.isVirtual()); 
         
         // ExecutorService 사용
         try (ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i <3; i++) {
                executorService.submit(runnable);
            }
         }
    }
}

위의 코드를 보면 알 수 있듯 기존 플랫폼 스레드의 문법과 크게 차이가 없이 가상 스레드를 만들 수 있는것을 알 수 있습니다. Executors를 사용해 가상스레드를 만들고 측정 또한 가능합니다.

Java 가상 스레드를 만든 feature loom 팀이 하위호환성, 추상화에 얼마나 진심인지 알 수 있습니다.

참고자료: https://findstar.pe.kr/2023/04/17/java-virtual-threads-1/

  • 스레드 Blocking이 발생하지 않는 경우에는 기존의 플랫폼 스레드가 처리량이 높습니다 가상스레드는 스케줄링의 영향을 받기 때문으로 보입니다. 하지만 Blocking이 발생하면 가상 스레드의 처리량이 더 높습니다.
  • DB 쿼리에서 가상 스레드가 사용될 경우 SQLTransientConnectionException이 발생 할 수있습니다. 한정된 DB 자원에 접근할 때는 semaphores도 고려해야 하는 것 같습니다.

성능 비교

참고자료: https://techblog.woowahan.com/15398/
여러 다른 기술 vs Virtual Thread
참고자료: https://www.youtube.com/watch?v=_lp3ohne-i8
단순 스레드를 여러번 실행시켰을 때 비교

주의할 점


  • 기존 스레드 풀을 사용하지 말고, 개별 작업에 가상 스레드 를 할당하는 형태로 변경하자.
    • 가상 스레드는 값싼 일회용품이라고 보시면 됩니다. 생성비용이 작기 때문에 스레드 풀을 만드는 행위 자체가 낭비가 될 수 있습니다. 필요할 때마다 생성하고 GC에 의해 소멸되도록 방치해버리게 좋습니다
  • ThreadLocals 에 값비싼 객체를 캐싱하지 말자
    • 가상 스레드도 스레드이기 때문에 ThreadLocal을 지원합니다 기존 플랫폼 스레드는 스레드 로컬 내부에 값비싼 객체를 캐싱하는 패턴을 사용해왔습니다. 이는 모든 작업이 해당 스레드의 객체를 유도하는 것입니다.
    • 하지만 가상 스레드는 작업당 하나의 스레드를 활용하는 것을 권장하며 내부의 객체를 공유하지 않습니다.
    • 따라서 내부에 값비싼 객체를 캐싱하는 것은 주의 해야합니다.
  • synchronized 키워드 사용시 주의가 필요하다. (Pinning 이슈)
    • pinning이슈가 발생하면 가상 스레드의 이점을 누릴 수 없습니다. synchronized가 필요한 경우 자바의 동시성 유틸리티에 있는 lock 을 사용합니다. 이렇게 되면 pinning의 영향에서 벗어날 수 있다.
    • (개발팀에서는 synchronized 키워드를 사용해도 쓰레드가 pinning 되지 않도록 개선하고 있다고합니다.)
    • pinning 이 발생하는지 탐지하려면 JFR을 사용하거나 -Djdk.tracePinnedThread 옵션을 사용하면 pinning을 탐지할 수 있다.

Pinning이란?
synchronized 키워드를 사용한 코드 블럭 안에서 blocking IO작업을 수행하는 경우에는 가상 스레드 를 unmount 할 수 없어서 Carrier Thread(Platform Thread)까지 Blocking 되는 현상이 발생한다. (이를 pinning 이라고 지칭함)

  • CPU bound 작업엔 비효율
    • IO 작업 없이 CPU 작업만 수행하는것은, 플랫폼 스레드만 사용하는것보다 성능이 떨어집니다. 컨텍스트 스위칭이 빈번하지 않은 환경이라면, 기존 스레드모델을 사용하시는것이 이득입니다.

마치며


profile
개선하는 개발자, 이경환입니다

0개의 댓글