• Virtual Thread & Structured Concurrency & Coroutine
    DEV 2023. 12. 3. 12:34

    threads

    2023년 9월 JDK21에 적용되는 virtural thread에 대한 글입니다. virtural thread가 추가된 배경을 살펴보고, 멀티스레드 사용 시 생기는 동시성, 동기화 이슈는 어떻게 해결하려 하는지(Structured Concurrency, 구조화된 동시성), 그리고 코루틴과는 무엇이 다른지 알아보았습니다.

    Project Loom

    • https://wiki.openjdk.org/display/loom/Main
    • purpose of supporting easy-to-use, high-throughput lightweight concurrency and new programming models on the Java platform.
    • Project Loom의 결과물 중 하나 → Virtual Thread

    Virtural Thread

    • JDK 21 : 2023년 9월 19일 LTS 버전으로 릴리즈 예정
    • OS 스레드를 그대로 사용하지 않고 JVM 내부 스케줄링을 통해서 수십만~수백만 개의 스레드를 동시에 사용

    배경

      1. 자바의 스레드는 OS의 스레드를 기반
        - 자바의 전통적인 스레드는 OS 스레드를 랩핑(wrapping) → 플랫폼 스레드
        - Java 애플리케이션에서 스레드를 사용하는 코드는 실제적으로는 OS 스레드를 이용
        - OS 커널에서 사용할 수 있는 스레드는 개수가 제한적이고 생성과 유지 비용이 비싸다
        - 애플리케이션은 비싼 자원인 플랫폼 스레드를 효율적으로 사용하기 위해서 스레드 풀(Thread Pool) 만들어서 사용
        플랫폼 스레드
      2.  처리량(throughput)의 한계
        - 기본적인 Web Request 처리 방식은 Thread Per Request (하나의 요청/하나의 스레드)
            - 1 request = 1 transaction = 1 thread
        - OS 커널에서 사용할 수 있는 스레드는 개수가 제한적
        → 애플리케이션의 처리량(throughput)은 스레드 풀에서 감당할 수 있는 범위를 넘어서 늘어날 수 없다
      3. Blocking으로 인한 리소스 낭비
        - Thread per Request 모델에서는 요청을 처리하는 스레드에서 IO 작업 처리할 때 Blocking 발생
        - Blocking 비용을 해결하기 위해 Non-blocknig 방식의 Reactive Programming으로 발전
        IO blocking
      4. Reactive Programming의 단점
         - 제어 흐름을 잃는다
            - 작성, 이해하는 비용 크다
        - 컨텍스트를 잃는다
            - Reactive programming은 스레드를 넘나들면서 처리
            - Exception Stack trace, Debugger, Profiling 모두 스레드 기반
            - 처리하기 위해 거쳐 온 콘텍스트가 스택 트레이스에 유지되지 않는다는 것
            - 컨텍스트 확인이 어려워져 결국 디버깅이 힘들어진다.
        - 전염성이 있다
            - 한 메소드가 Mono를 반환하면 이를 사용하는 다른 메서드도 Mono를 반환해야 하며 이러한 방식은 특정 패러다임을 강제
            - 기존의 자바 프로그래밍의 패러다임은 스레드를 기반으로 하기 때문에 라이브러리들 모두 Reactive 방식에 맞게 새롭게 작성

    Virture thread의 목표

    1. 애플리케이션의 높은 처리량(throughput)
      - Blocking 발생시 내부 스케줄링을 통해 다른 작업을 처리
    2. 자바 플랫폼의 디자인과 조화를 이루는 코드
      - 기존 스레드 구조 그대로 사용

    virture thread

    💡 플랫폼 스레드는 블로킹 비용이 크고, 리엑티브 프로그래밍은 코드의 비용이 크니블로킹 되더라도 비용이 저렴한 경량의 스레드를 사용하면서, 기존 자바의 코딩방식으로 개발하자.

    Carrier thread와 Virtual thread

    carrier thread

    • 위 그림처럼 virtual thread2가 블로킹되는 순간 스케줄러는 virtual thread를 unmount 하고 virtural thread2를 마운트 하여 carrier thread에서 실행한다.
    • virtual thread의 구조를 조금 더 자세히 설명한다면 아래와 같다.

    virtural thrad 구조

    • blocking 발생시 내부 스케줄링 → ForkJoinPool ⇒ JDK7에 추가된 Fork/Join Framework의 일부
      ⇒ JDK8 parallel stream이 Fork/Join Framework위에서 동작

    virtual thread code

    • Thread.ofVirtual()로 virtual thread 실행 output virtualTread 22, ForkJoinPool-1, worker-1에서 실행됨
    JAVA_HOME/openjdk-20.0.2/Contents/Home/bin/java --enable-preview
    Thread[#21,Thread-0,5,main]
    VirtualThread[#22]/runnable@ForkJoinPool-1-worker-1
    class= class java.lang.VirtualThread
    • java.lang.VirtualThread를 보면 final이고, BaseVirtualThread를 extend 하고 있다.
    final class VirtualThread extends BaseVirtualThread {
    
    • virtual thread가 실행되다가 blocking(Thread.sleep)되고 다시 실행될 때의 thread를 살펴보면
    public class SleepingThread {
        public static void main(String[] args) throws InterruptedException {
            var threads = IntStream.range(0, 10)
                    .mapToObj(
                            index -> Thread.ofVirtual().unstarted(() -> {
                                if(index == 0) {
                                    System.out.println(Thread.currentThread());
                                }
    
                                try {
                                    Thread.sleep(10);
                                } catch (InterruptedException e) {
                                    throw new RuntimeException(e);
                                }
    
                                if(index == 0){
                                    System.out.println(Thread.currentThread());
                                }
                            })
                    ).collect(Collectors.toList());
    
            threads.forEach(Thread::start);
            for(Thread thread : threads){
                thread.join();
            }
    
        }
    }
    • 아래와 비슷한 결과를 얻게 된다. virtualthrad 21이 worker thread1에서 실행되다가 중간에 blocking 되면서 worker thread6에서 다시 실행되는 것을 확인할 수 있다.
    VirtualThread[#21]/runnable@ForkJoinPool-1-worker-1
    VirtualThread[#21]/runnable@ForkJoinPool-1-worker-6
    
    • Thread.sleep함수를 타고 들어가면 Continuation.yield를 만나게 된다. 어디선가 많이 본듯하다.
    • Thread.sleep → VirtualThread.sleepNanos → VirtualThread.doSleepNanos 
      → VirtualThread.tryYield → VirtualThread.yieldContinuation → Continuation.yield(VTHREAD_SCOPE);
    • Continuation.yield(VTHREAD_SCOPE) → 실행이 일시 중단
    • Continuation.run() → 계속 실행

    stack, heap

    • Continuation.yield(일시중단) → stack 메모리가 → heap으로 이동
      → platform thread는 다른 virtual thread를 실행
      → 이전 작업이 실행가능하다는 신호를 받으면 heap
      → platform thread stack으로 이동하여 가능한 carrier thread(worker thread)에서 실행
    💡 결국 virtual thread를 blocking 하는 것이지만, platform thread를 blocking 하는 것보다 훨씬 저렴하다.

    그럼 멀티쓰레드를 사용하고 싶다면? 동시성, 동기화 이슈는 어떻게 해결되는 거지?

    • 수백만 개의 가상스레드를 어떻게 찾고 디버깅할 수 있는가?
    • multi-threading 하면 항상 나오는 이야기 ⇒ 동시성, 동기화

    동시성

    sequential, concurrent, parallel
    concurrent, parallel

    병렬성(Parallelism)

    • 많은 작업을 물리적으로 동시에 수행
      - 실제로 동시에 실행되는 것 → 물리적인 개념

    동시성(Concurrency)

    • 프로그램 조각들이 실행 순서와 무관하게 동작할 수 있도록 만들어 한 번에 여러 개의 작업을 처리할 수 있도록 만든 구조
    • 동시에 실행되는 것처럼 보이는 것 → 논리적인 개념
    • 동시성 프로그래밍은 “수행해야 할 작업(Task)들을 한 개의 스레드에서만이 아니라 다른 스레드에서도 어떻게 동시에 일을 시킬 수 있을까?”라는 아이디어에서 시작

    dead lock

    Structured Concurrency(구조화된 동시성)

    💡 가상스레드는 풍부한 스레드를 제공, 구조화된 동시성은 스레드가 정확하고 견고하게 조정되도록 보장

    그럼 코루틴이랑은 뭐가 다르지?

    Coroutine

    • 루틴의 로컬 상태를 유지하면서 제어를 반환했다가(suspend, yield), 제어를 다시 획득(resume)하는 흐름을 이어 갈 수 있는 제어 장치

    Coroutine을 만드는 몇가지 방법

    1. Generator 방식

    • Loop의 반복 동작을 제어하는데 사용되는 문법
    • 기본적으로 Generator는 반복자
    • 흐름을 제어하는 측면에서 구현
    function* generator(i) {
      yield i;
      yield i + 10;
    }
    
    const gen = generator(10);
    
    console.log(gen.next().value);
    // Expected output: 10
    
    console.log(gen.next().value);
    // Expected output: 20
    
    • C#, Javascript ⇒ Async Await Coroutine ⇒ Generator 방식

    2. Continuation

    • 명령의 실행 순서를 완전히 제어할 수 있는 구문
    • Java, Kotlin ⇒ Continuation 방식

    continuation

    Coroutine과 Virtual Thread

    Coroutine

    • 제어 흐름에 관한 언어적 요소 → 컴파일러 단에서 해결 
    • 아래 코드를 컴파일러가 컴파일시 변경한다.
    suspend fun testCoroutine(ServerHttpRequest) : String
    # complier -->
    Object testCoroutine(ServerHttpRequest, Continuation);

     

    Virtual Thread + Fork/Join pool

    • 동시성 프로그래밍을 위한 시스템적 요소 → JVM에서 해결
    💡 Coroutine + Scheduler = Virtual Thread + Fork/Join pool
    coroutine은 언어적 요소, virtural Thread 시스템적 요소(JVM)
    Virtual Thread를 가능하게 하는 언어적 요소 = Coroutine의 제어 흐름

    Ref.

    728x90
go.