Virtual Thread

xellos·2024년 12월 8일
0

Java

목록 보기
2/2

목차

  1. Thread
    a. Java 의 Thread
    b. Virtual Thread 의 구조
    c. 기본적인 메커니즘
    d. 상세한 메커니즘
  2. 스케줄러의 동작 방식 - ForkJoinPool(Work Stealing)
    a. Thread Pool 과 문제점
    b. 해결 방안(?)
    c. Work Stealing 개념의 등장
  3. 중간 정리
    a. 차이점
    b. 주의사항
  4. 실제 운영 서버에 적용
    a. 정보
    b. 지표
    ⅰ.JVM Thread
    ⅱ. JVM Statistic Threads / Buffers & 메모리 할당
    ⅲ. GC 지표
    ⅳ. CPU / Load Average
  5. 코틀린 코루틴과의 차이점
    a. 기존의 동작 방식
    b. 코틀린의 동작 방식
    c. 코루틴 정리
    d. 차이점

1. Thread

1) Java 의 Thread

Java의 쓰레드 모델은 Native 쓰레드로, Java의 유저 쓰레드를 생성하면 JNI(Java Native Inerface) 를 통하여 커널 영역에서 시스템 콜을 호출하며 OS 가 커널 쓰레드를 생성하고 매핑하여 작업을 수행하는 형태입니다.

2) Virtual Thread 의 구조


Platform Thread 의 기본 스케줄러는 ForkJoinPool 방식을 사용합니다. 스케줄러는 Thread Pool 을 관리하고, Virtual Thread 의 작업 분배 역할을 합니다. 여기서 Platform은 실제 운영 체제의 Thread 와 JVM 내부의 Virtual Thread 를 중계하는 플랫폼으로 해석할 수 있습니다.

가장 주요한 사항으로는 Virtual Thread 가 JVM 영역 내부에 존재하며, JVM에 의해 스케줄링 된다는 점입니다. 이로 인해 얻을 수 있는 장점은 다음과 같습니다.

  • 작업에 흐름을 관리함에 있어서 OS에 별도의 시스템 콜이 발생하지 않습니다.
    • IO 발생시, 운영체제에 제어권을 넘겨주지 않음으로써 발생하는 스위칭 비용을 절약할 수 있습니다.
      제어권 자체를 JVM 이 길게 가져갈 수 있다.
  • 상대적으로 OS로 부터 Thread 를 할당받아서 작업을 수행하는것 보다 더 적은 리소스(메모리, 시간)로 작업을 할 수 있습니다.
    • 일반 JAVA Thread 보다 Virtual Thread 생성 비용이 1 / 10 수준으로 저렴하다고함.

기본적인 메커니즘 )

Platform Thread 와 연결될 Virtual Thread 에서, Blocking 이 발생하면 대기중인 Virtual 쓰레드를 연결하여 작업을 처리합니다.

상세한 메커니즘 )

  • Virtual Thread == 요청 처리 메인스트림
  • 메인 스트림의 작업 처리 흐름
    • DB 조회 -> 요청 분석 -> DSP 요청 -> 선별 -> 응답 파싱
    • 흰색 글씨: 현재 진행중인 작업
    • 연한 붉은색 작업: IO 가 포함되지 않은 작업
    • 진한 붉은색 작업: IO 가 포함된 작업
    • 푸른색 작업: IO를 포함한 작업이 완료됨을 의미

시작

1차 IO 발생

1차 작업 완료

2차 IO 발생


2. 스케줄러의 동작 방식 - ForkJoinPool (Work Stealing)

1) Thread Pool 과 문제점

Thread Pool 을 병렬 혹은 동시 프로그래밍에서 빠질 수 없는 도구입니다. 여러 코어 혹은 CPU 를 활용하는 측면에서나 과도하게 Thread 가 생성되는 비효율성을 줄이는 측면에서나 Thread Pool 을 좋은 해답이 됩니다.

그러나 일반적인 Thread Pool 의 구현에서도 병목이 되는 부분이 생기는데, 바로 Thread Pool 에 맡겨질 Work 를 담고 있는 Queue 부분입니다.

2) 해결 방안(?)

위의 문제는 Queue 를 하나만 사용해서 생기는 문제이기 때문에 Queue 를 여러 개 사용해서 문제를 해결할 수 있습니다. 즉, Thread 마다 Queue 를 사용하는 것입니다.

하지만, 이는 부족한 해결 방법입니다. 예를 들어 하나의 Work 를 처리하면 새로운 Work 가 여러개 생기는 형태의 작업인 경우에는 Thread Pool 에 있는 Thread 중 하나의 Thread 에만 집중적으로 Work 가 몰리게 되는 현상이 발생할 수 있습니다.
-> 즉, 한 Thread 에는 작업이 넘치는데 다른 Thread 는 놀고 있을 수 있습니다.

3) Work Stealing 개념의 등장

Work Stealing 이란 위에서 살펴본 Thread 가 전용 Queue 를 사용함으로써 생기는 Thread 간 불균형을 해결하기 위한 방법입니다. 여기서의 개념은 한 Thread 가 자신의 Queue에 더이상 처리할 Work 가 없다면 다른 Thread Queue 에 있는 Work 를 가져가 (Stealing) 자신이 처리하는 것입니다.

여기서 Work Queue 는 Deque(Double Ended Queue) 자료구조로 구현되어 Queue 와는 달리 Front 와 End 모두에서 push 와 pop 이 가능한 자료구조 입니다.

  • 즉, Virtual Thread 는 각 플랫폼 쓰레드 큐에서 서로 작업을 빼올 수 있는 구조

3. 중간 정리

1) 차이점

Thread

  • OS에 의해 스케줄링
  • 커널 쓰레드와 1:1로 매핑
  • 작업 단위는 Runnable

Virtual Thread

  • 가상 쓰레드
  • JVM에 의해 스케줄링
  • 플랫폼 쓰레드와 1:N 매핑
  • 작업 단위는 Continuation (+ 상태 관리)

2) 주의사항

인프라 시스템의 자원 고갈 문제

IO가 발생한 작업을 중단하고 즉시 다른 요청 메인스트림을 처리하므로, 예열이 필요한 경우나 순간적으로 트래픽이 몰릴 경우 DB 커넥션과 가은 인프라 자원을 순간적으로 고갈시킬 수 있습니다.

따라서 내부적으로 적은 인프라 자원을 소비하도록 캐싱 및 커넥션 풀 개수를 미리 어느정도 제한한느 튜닝작업이 필요할 수 있습니다.

No Pooling

Virtual Thread 는 값싼 일회용품이라고 보면 됩니다. 생성비용이 작기 때문에 쓰레드 풀을 만드는 행위 자체가 낭비가 될 수 있습니다. 필요할 때마다 생성하고 GC 에 의해 소멸되도록 방치하는 것이 좋습니다.

CPU Bound 작업에는 비효율적

IO 작업이 없이, CPU 작업만 수행하는 것은 기존의 방식보다 성능이 떨어집니다. 컨텍스트 스위칭이 빈번하지 않는환경이라면 기존의 쓰레드 모델을 사용하는 것이 더 나을 수 있습니다.

Pinned Issue

Virtual Thread 내에서 synchronizedparallelStream 혹은 네이티브 메서드를 쓰면 virtual thread 가 플랫폼 쓰레드에 적용될 수 없는 상태가 되어버립니다. 이를 Pinned(고정된) 상태라고 하는데요. 이는 Virtual Thread 의 성능 저하를 유발할 수 있습니다.

서드파티 앱이나, synchronized 과정 중에 다소 시간이 걸리는 연산이 존재하는 경우엔 ReenterantLock 으로 대체할 수 있을지 고려해야 합니다.

  • ReenterantLock 예제
public static void initialize(List<Company> publisherList) {
    Map<String, CompanyDto> newStore = new ConcurrentHashMap<>();
    for (Company publisher : publisherList) {
      ...
    }
    ReentrantLock lock = new ReentrantLock();
    lock.lock();
    try {
        STORE = newStore;
    } finally {
        lock.unlock();
    }
}

Thread Local

Virtual Thread 는 수시로 생성되고 소멸되며 스위칭됩니다. 백 만개의 쓰레드를 운용할 수 있도록 설계가 되었기 때문에, 항상 크기를 작게 유지하는 것이 좋습니다.


4. 실제 운영서버 적요

1) 정보

버전 정보

  • spring-boot: 3.3.4
  • mysql-connector-j: 9.1.0
  • undertow: 2.3.17
    • JAVA 기반의 웹서버: NIO 기반의 비동기 Non-Blocking API 를 지원
    • Servlet 4.0 규격 지원, Java EE 스펙 지원
    • tomcat 으로 테스트해봤으나, 성능이 너무 나오지 않는것으로 판단.
    • 기존의 undertow 를 그대로 사용하는 방식 채택

일일 트래픽

  • 하나의 인스턴스 기준
  • 낮시간 기준 1500 ~ 2200 QPS

2) 지표

JVM Thread

푸른선: waiting-thread -> 약 2000 가량의 waiting 가 감소.

  • 미적용
  • 적용

JVM Statistics Threads / Buffer & 메모리 할당

Thread 전체로 보면 기존대비 필요한 Threads 양이 약 40% 감소.
메모리 할당 빈도역시 줄어든것 확인 가능
(위에서 언급한 Virtual Thread가 일반 Thread 보다 가볍다고 한 부분 확인)

  • 미적용
  • 적용

GC 지표

GC시 걸리는 Stop The World 평균 시간이 34% 정도 감소

  • 미적용(GC STW)

    • 최소: 0
    • 평균: 5.06ms
    • 최대: 17.3ms
  • 적용(GC STW)

    • 최소: 0
    • 평균: 3.34ms
    • 최대: 15.8ms

CPU / Load Average

프로세스 사용량 기준 평균 약 3.2%의 CPU 사용량 감소, 기존 대비 약 20% 정도의 CPU 사용량 감소.
Load Avergage 는 1.45 감소, 기존 대비 약 12% 감소

  • 미적용
  • 적용

5. 코틀린 코루틴과의 차이점

1) 기존의 동작 방식

Thread 는 일단 생성 비용이 비싸고 작업을 전화하는 비용이 비쌉니다. 또한 한 Thread가 다른 Thread 로부터 작업을 기다려야할 때 Blocking 되게 되면 해당 Thread 는 하는 작업 없이 다른 작업이 끝날때 까지 기다려야하기 때문에 자원이 낭비됩니다. 아래의 그림은 작업의 단위가 Thread 일 경우 생기는 고질적인 문제점 입니다.

  • Thread1 에서 작업 수행 도중, Thread2 의 작업 결과물이 필요합니다. 그때, Thread1 은 아무 하는 일 없이 Blocking 되며 Thread2 로부터 결과를 받아 작업을 재개하기까지 시간이 걸립니다.
  • 짧은 시간동안만 Blocking 이 되면 다행이지만, 실제 상황에서는 Thread 의 성능을 제대로 발휘하지 못하는 Blocking 이 반복될 수 있습니다.

2) 코틀린의 동작 방식

코틀린에서도 Thread 라는 작업의 단위를 사용하지만, Thread 내부에서 작은 Thread 처럼 동작하는 코루틴이 존재합니다. Thread 하나를 일시 중단 가능한 Thread 처럼 사용하는 것이 바로 Coroutine 입니다.

코루틴의 동작 방식은 다음과 같습니다.

  • Thread1 에서 Coroutine 2개를 생성합니다. Thread1 의 Coroutine1 에서는 작업 1을 그대로 수행하게 만들고, Thread2 에서는 작업 2를 수행하게 합니다.
  • 마찬가지로 Thread1의 Coroutine1 에서 작업 2의 결과가 필요합니다. 하지만 작업 2가 끝나지 않아 작업 1을 마칠 수 없습니다.
  • 이때, Coroutine1은 Thread1을 Blocking 하는 대신 자신의 작업을 일시 중단하고 Coroutine2에 Thread1 리소스 사용 권한을 늘려줍니다. 이제 Thread1 의 Coroutine2 가 작업 3을 위해 Thread 1을 사용합니다.

3) 코루틴 정리

  • 하나의 메인 스트림 작업에서 IO가 걸리는 서브 루틴들 중단이 가능하도록 나누어져 있다.
  • 메인 스트림을 이루는 서브 루틴들의 상태(중단, 진행 가능 등)를 관리하는 무언가가 있다. (Continuation)
  • 하나의 메인 스트림에 대하여 Blocking 을 최대한 회피하며 작업을 진행하는 방식
  • 각각의 서브 루틴들이 앞에 있는 서브 루틴에 비종속적이어야 한다.
    • A, B, C 서브 루틴이 각각 별개의 작업일 경우 효율적
  • 역할적으로만 보면 Node 와 유사 (Event Loop)
    • Node 의 경우 IO 를 만나면 C 코드로 이루어진 부분에서 운영체제에 쓰레드를 요청
    • 해당 쓰레드에서 IO 를 수행후, 값을 콜백으로 넘겨줌
    • 기능적인 문제 해결 방식이 매우 다르지만, 메인 스트림이 되는 작업을 가급적 막지 않는 것이 역할적으로 유사하다는 의미

4) 차이점

  • Coroutine: 메인 스트림을 구성하는 내부의 서브 루틴간의 Non-Blocking 을 구현
    • 메인 스트림 하위 서브 루틴이 서로간에 비종속적인 관계에서 사용했을때 효율적
    • 하나의 요청 단위를 얼마나 효율적으로 처리할 것인가의 관점
    • 상대적으로 미시적인 관점
  • Virtual Thread: 메인 스트림 간의 비동기 Non-Blocking 을 구현
    • 메인스트림의 하위 서브 루틴이 종속적인 관계에서 사용했을때 효율적
    • 시스템 전체의 관점에서 얼마나 자원을 효율적으로 회전시킬 것인가의 관점
    • 상대적으로 거시적인 관점

참고 자료

0개의 댓글