관리 메뉴

HAMA 블로그

Rust (async/Future) vs Kotlin Coroutines vs Java Virtual Threads 차이 [기초] 본문

소프트웨어 사색

Rust (async/Future) vs Kotlin Coroutines vs Java Virtual Threads 차이 [기초]

[하마] 이승현 (wowlsh93@gmail.com) 2024. 12. 20. 14:49
Rust vs Kotlin vs Java

Rust의 컴파일 타임 vs Kotlin/Java의 런타임

특징 Rust (async / Future)  Kotlin Coroutines  Java Virtual Threads
비동기 실행 모델 Future 기반 (poll-driven) Suspend 함수 기반 (Continuation) 사용자 모드 스레드 기반 (Thread API)
상태 관리 컴파일 타임에 상태 기계로 변환 런타임에서 상태를 관리 런타임에서 사용자 모드 스레드 관리
최적화 컴파일 타임 최적화 (zero-cost abstraction) 일부 컴파일 타임, 주로 런타임 최적화 주로 런타임 최적화
런타임 필요성 런타임 스케줄러 필요 (Tokio, async-std 등) 코루틴 디스패처 필요 Java 런타임 자체에서 관리
제어권 개발자에 의해 언어&라이브러리 차원의 키워드로 명시적 으로 제어권 반환해야함  개발자에 의해 언어&라이브러리 차원의 키워드로 명시적으로 제어권 반환 해야함  명시적 제어권 반환하는 키워드가 새로 생긴건 없으며, 기존 자바 동기 코드를 사용해도 내부에서 알아서 비동기 처리 
난이도 중간  중간  쉬움 & 세밀한 제어 및 부모-자식관계 관리등 안됨 

 

Rust의 컴파일 타임

Rust의 비동기 프로그래밍 모델에서는 컴파일 타임에 많은 작업을 수행하여 효율적인 비동기 실행을 지원합니다. Rust의 비동기 실행 모델은 async/await 키워드와 Future 트레이트를 기반으로 하며, 다른 언어들에 비해 컴파일 타임 최적화가 더 강력한 특징을 가지고 있습니다.

Rust의 async와 Future 트레이트

  • Rust에서 **async fn**은 **컴파일 타임에 상태 기계(state machine)**로 변환됩니다.
  • async fn이 호출되면 미리 생성된 상태 기계 객체가 반환되며, 이 객체는 Future 트레이트를 구현합니다.
  • Future는 poll 기반의 실행 모델로, 런타임에서 필요한 시점에 실행됩니다.

Rust의 강점: 컴파일 타임에 많은 것을 결정

  • Rust는 async fn을 Future 객체로 변환하여, 비동기 함수의 모든 상태를 컴파일 타임에 정의합니다.
  • 이는 런타임 오버헤드를 줄이고, 높은 성능과 안전성을 보장합니다.
  • 비동기 코드의 런타임 비용을 최소화하기 위해 설계되었으며, 이 점에서 Kotlin이나 Java보다 더 낮은 레벨의 최적화를 제공합니다.

사실 위의 말 가지고는 이해 할 수 가 없습니다.  세상은 좋은 선생님들 덕분에 돌아갑니다. :-) 
아래에 직접 구현된 예시가 있습니다. (컴파일 타임에 코루틴이 구현되는 방식 - 예제 코드 솔직히 킹왕짱.) 
https://github.com/cfsamson/Asynchronous-Programming-in-Rust/tree/main/ch08 

 

Asynchronous-Programming-in-Rust/ch07 at main · cfsamson/Asynchronous-Programming-in-Rust

Asynchronous Programming in Rust, published by Packt - cfsamson/Asynchronous-Programming-in-Rust

github.com

핵심코드만 발췌)

// coroutine fn async_main() {
//     println!("Program starting");
//     let txt = http::Http::get("/600/HelloAsyncAwait").wait;
//     println!("{txt}");
//     let txt = http::Http::get("/400/HelloAsyncAwait").wait;
//     println!("{txt}");

// }


// 위의 코드가 아래 코드로 컴파일 시점에 변경 // 

// 설명) 상태를 가진 코루틴 객체가 첫번째 wait만나기 전까지 상태를 저장하고, 자식 비동기 호출하고 
// 이런식으로 함수가 특정 위치부터 이전 상태를 기반으로 재실행됨 


fn async_main() -> impl Future<Output=String> {
    Coroutine0::new()
}

enum State0 {
    Start,
    Wait1(Box<dyn Future<Output = String>>),
    Wait2(Box<dyn Future<Output = String>>),
    Resolved,
}

struct Coroutine0 {
    state: State0,
}

impl Coroutine0 {
    fn new() -> Self {
        Self { state: State0::Start }
    }
}


impl Future for Coroutine0 {
    type Output = String;

    fn poll(&mut self, waker: &Waker) -> PollState<Self::Output> {
        loop {
        match self.state {
                State0::Start => {
                    // ---- Code you actually wrote ----
                    println!("Program starting");

                    // ---------------------------------
                    let fut1 = Box::new( http::Http::get("/600/HelloAsyncAwait"));
                    self.state = State0::Wait1(fut1);
                }

                State0::Wait1(ref mut f1) => {
                    match f1.poll(waker) {
                        PollState::Ready(txt) => {
                            // ---- Code you actually wrote ----
                            println!("{txt}");

                            // ---------------------------------
                            let fut2 = Box::new( http::Http::get("/400/HelloAsyncAwait"));
                            self.state = State0::Wait2(fut2);
                        }
                        PollState::NotReady => break PollState::NotReady,
                    }
                }

                State0::Wait2(ref mut f2) => {
                    match f2.poll(waker) {
                        PollState::Ready(txt) => {
                            // ---- Code you actually wrote ----
                            println!("{txt}");

                            // ---------------------------------
                            self.state = State0::Resolved;
                            break PollState::Ready(String::new());
                        }
                        PollState::NotReady => break PollState::NotReady,
                    }
                }

                State0::Resolved => panic!("Polled a resolved future")
            }
        }
    }
}



Kotlin/Java의 런타임

코틀린 코루틴의 협력적 스케줄링 (Cooperative Scheduling)

특징:

  • 스레드나 작업이 자발적으로 제어권을 넘겨주는 방식으로 동작합니다.
  • 작업 중간에 명시적으로 제어권을 반환하는 지점 (yield, await, suspend 등)을 코드에 삽입해야 합니다.
  • 런타임 스케줄러는 이 반환 지점에서 다른 작업으로 전환합니다.

장점:

  • 컨텍스트 스위칭 비용이 매우 낮음: 협력적 스케줄링은 커널의 간섭 없이 사용자 공간에서 이루어지므로 매우 빠름.
  • 개발자가 작업 흐름을 더 세밀하게 제어할 수 있음.
  • 대량의 경량 작업을 실행할 때 효율적.

단점:

  • 비협력적인 코드가 있다면 문제가 발생: 작업이 제어권을 반환하지 않으면, 다른 작업이 실행되지 못하고 시스템이 멈출 수 있음.
  • 협력적 스케줄링은 블로킹 작업을 자체적으로 처리하지 못하므로, I/O 작업 등을 비동기로 처리해야 함.

동작 방식

  1. 협력적 스케줄러가 관리하는 작업은 사용자 공간에서 실행됩니다.
  2. 각 작업은 명시적으로 제어권을 반환해야 다른 작업이 실행될 수 있습니다.
  3. 모든 작업은 동일한 스레드에서 실행될 수 있습니다 (멀티스레드 환경도 가능).
import kotlinx.coroutines.*
import kotlin.random.Random

suspend fun fetchData(id: Int): String {
    delay(Random.nextLong(100, 500)) // 비동기 대기 (명시적 제어권 반환)
    return "Data for request $id"
}

fun main() = runBlocking {
    val jobs = List(100) { id -> // 100개의 코루틴 생성
        launch {
            val data = fetchData(id)
            println("Coroutine $id: $data on thread ${Thread.currentThread().name}")
        }
    }

    jobs.forEach { it.join() } // 모든 작업이 끝날 때까지 대기
}

delay() 호출 시 제어권을 반환해 다른 작업이 실행됩니다.

 

Java Virtual Thread - OS 커널 스레드와 분리된 사용자 모드 스레드 (User-mode Threads)

특징:

  • 사용자 모드 스레드는 OS 커널 스레드와 독립적으로 관리됩니다.
  • Java Virtual Threads와 같이 사용자 공간에서 구현된 가벼운 스레드입니다.
  • 작업 실행은 OS의 스케줄러가 아닌 사용자 모드 스케줄러에서 관리됩니다.

장점:

  • OS 커널 스레드의 제한 없이 대량의 사용자 모드 스레드를 실행할 수 있음.
  • 스레드 생성과 스위칭이 커널 스레드보다 훨씬 빠름.
  • 기존의 동기 프로그래밍 모델(블로킹 호출 포함)을 유지하면서 비효율을 제거 가능.

단점:

  • I/O 작업이나 블로킹 호출은 내부적으로 비동기로 처리되어야 함.
  • 커널 스레드에 비해 구현이 복잡할 수 있음.

동작 방식

  1. 사용자 모드 스케줄러는 사용자 모드 스레드를 관리하며, 이 스레드는 필요 시 커널 스레드에서 실행됩니다.
  2. 사용자 모드 스레드가 실행을 중단하면(예: I/O 대기) 커널 스레드가 다른 사용자 모드 스레드를 실행합니다.
  3. 이 과정은 투명하게 이루어지며, 기존 동기 코드를 그대로 사용할 수 있습니다.

Java의 Virtual Threads는 사용자 모드 스레드입니다.

  • **Thread.startVirtualThread**를 통해 가벼운 사용자 모드 스레드를 생성합니다.
  • 내부적으로 스케줄러가 I/O 대기 및 실행을 관리하며, OS 스레드가 비효율적으로 차단되지 않습니다.
import java.util.concurrent.*;
import java.util.Random;

public class Main {
    public static void main(String[] args) throws InterruptedException {
        ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
        Random random = new Random();

        for (int i = 0; i < 100; i++) { // 100개의 Virtual Threads 생성
            int id = i;
            executor.submit(() -> {
                try {
                    Thread.sleep(random.nextInt(400) + 100); // 블로킹 호출
                    System.out.println("Virtual Thread " + id + ": Data fetched on thread " + Thread.currentThread().getName());
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            });
        }

        executor.shutdown();
        executor.awaitTermination(1, TimeUnit.MINUTES); // 모든 작업이 끝날 때까지 대기
    }
}

 

협력적 스케줄링과 사용자 모드 스레드의 차이

특징협력적 스케줄링사용자 모드 스레드

작업 전환 방식 작업이 명시적으로 제어권을 반환해야 함 사용자 모드 스케줄러가 작업 전환을 관리
스케줄링 비선점형 (Non-preemptive) 선점형 (Preemptive)
I/O 처리 비동기 I/O와 결합하여 동작 내부적으로 비동기 I/O 처리, 블로킹 호출과 호환
스레드 관리 단일 OS 스레드에서 실행 가능 여러 사용자 모드 스레드가 OS 스레드에서 매핑되어 실행
사용 예시 Kotlin 코루틴, Python async/await Java Virtual Threads, Go 언어의 goroutine
성능 컨텍스트 스위칭 비용이 낮음 더 많은 스레드를 처리할 수 있지만 스위칭 비용이 더 높음
코드 스타일 비동기 코드 (async/await, suspend) 동기 스타일과 비동기 스타일 모두 지원

Rust Future와 다른 모델 비교

  Rust Future Kotlin Coroutine Java Virtual Threads  Go Goroutine
스케줄링 방식 협력적 스케줄링 협력적 스케줄링 선점형 스케줄링 선점형 스케줄링
제어권 반환 명시적 (Poll::Pending) 명시적 (suspend, yield) 자동 자동
실행 트리거 Lazy (스케줄러 필요) Lazy (런타임 필요) Eager (즉시 실행 가능) Eager (즉시 실행 가능)
I/O 처리 방식 논블로킹 논블로킹 동기 호출도 비동기로 처리 가능 논블로킹
스케줄러 제어 가능성 개발자가 직접 제어 (poll 호출) Dispatchers로 스케줄링 제어 JVM 스케줄러 자동 관리 Go의 GMP 스케줄러 자동 관리
사용 스타일 비동기 (async/await, poll) 비동기 (async/await) 동기 (Thread API와 동일) 동기 (go 키워드)

별책부록1)

Java Virtual ThreadsKotlin Coroutines 모두 결국 JVM 스레드 풀(예: ForkJoinPool) 위에서 실행됩니다.
둘 모두 OS Thread를 직접 사용 할 때 이루어지는 블로킹 및  컨텍스트 스위칭을 줄여서 동시성 처리 효율을 극대화 하는 방향으로 설계됩니다. Java Virtual Threads 를 사용하면 학습곡선이 낮아집니다. 명시적인 동시성 처리를 안하니깐~
대신 디테일한 조정을 못하게 되겠지요. Go언어의 Goroutine생각하면 Kotlin Coroutine보다 Java Virtual Threads가 훨씬 잘 쓰일듯. (한데.. 코프링이 워낙 좋아서 또.. 몰러)

좀 편하게 살자 !!!    (https://tech.kakaopay.com/post/ro-spring-virtual-thread/
)


JVM과 OS 스레드 관계

  • JVM은 기본적으로 OS 스레드를 기반으로 동작합니다.
  • Java에서의 **스레드(Thread)**는 OS 커널 스레드에 직접 매핑됩니다.
  • 따라서 Java의 스레드 API를 통해 생성한 스레드는 JVM에서 OS 스레드로 매핑되어 커널 스케줄러에 의해 관리됩니다.


별책부록2)

Kotlin Coroutines에서는 간단히 처리할 수 있지만 Java Virtual Threads로는 더 많은 코드와 복잡한 제어가 필요한 경우의 예제

시나리오 1: 부모-자식 관계로 작업 그룹 관리

  1. 문제:
    • 100개의 작업을 병렬로 실행하지만, 특정 조건에서 중간에 멈춰야 합니다(예: 하나의 작업이 실패하면 전체 작업을 취소).
    • 모든 작업이 끝난 후, 결과를 종합해서 처리해야 합니다.
  2. 목표:
    • 작업의 그룹 단위 관리 (부모-자식 관계).
    • 작업 중 하나라도 실패하면 나머지 작업을 취소.
    • 모든 작업이 성공하면 결과를 합산.

시나리오 2: 병렬 작업에서 타임아웃과 취소 관리

  1. 문제:
    • 100개의 작업을 병렬로 실행하며, 각각 최대 300ms 안에 완료되어야 합니다.
    • 작업이 실패하거나 타임아웃이 발생하면, 해당 작업을 취소하고 나머지 작업은 계속 진행합니다.
    • 결과를 모두 모아 성공적으로 완료된 작업만 처리합니다.

Kotlin Coroutines 구현

Kotlin 코루틴은 **구조화된 동시성(Structured Concurrency)**를 제공하므로, 부모-자식 관계를 자연스럽게 관리할 수 있습니다. coroutineScope나 supervisorScope를 사용해 간단히 구현 가능합니다.

//// 시나리오 1

import kotlinx.coroutines.*
import kotlin.random.Random

suspend fun fetchData(id: Int): Int {
    delay(Random.nextLong(100, 500)) // 비동기 대기
    if (Random.nextBoolean()) throw RuntimeException("Error in task $id") // 실패 가능성
    return id * 10
}

fun main() = runBlocking {
    try {
        val results = coroutineScope { // 부모 코루틴 스코프
            (1..100).map { id ->
                async { fetchData(id) } // 자식 코루틴 실행
            }.awaitAll() // 모든 결과를 기다림
        }
        println("All tasks completed successfully: ${results.sum()}")
    } catch (e: Exception) {
        println("Failed: ${e.message}")
    }
}


//// 시나리오 2


import kotlinx.coroutines.*
import kotlin.random.Random

suspend fun fetchData(id: Int): Int {
    delay(Random.nextLong(100, 500)) // 비동기 대기
    if (Random.nextBoolean()) throw RuntimeException("Error in task $id") // 실패 가능성
    return id * 10
}

fun main() = runBlocking {
    val results = (1..100).map { id ->
        async {
            try {
                withTimeout(300) { fetchData(id) } // 300ms 안에 완료되지 않으면 취소
            } catch (e: Exception) {
                null // 실패하거나 타임아웃 발생 시 null 반환
            }
        }
    }.awaitAll()

    val successfulResults = results.filterNotNull()
    println("Successful results: ${successfulResults.sum()}")
}

Kotlin 코루틴 동작 방식:

  1. **coroutineScope**는 부모 코루틴으로, 자식 코루틴들의 생명 주기를 관리합니다.
  2. 작업 중 하나라도 실패하면, 나머지 작업을 자동으로 취소합니다.
  3. 결과를 awaitAll()로 간단히 모아서 처리합니다.

Java Virtual Threads 구현

Java Virtual Threads는 구조화된 동시성을 기본적으로 제공하지 않으므로, 비슷한 기능을 구현하려면 더 많은 코드와 수작업이 필요합니다

/// 시나리오 1

import java.util.concurrent.*;
import java.util.*;
import java.util.stream.*;
import java.util.Random;

public class Main {
    public static void main(String[] args) throws InterruptedException {
        ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
        List<Future<Integer>> futures = new ArrayList<>();
        Random random = new Random();

        // 부모 작업: 결과 수집
        try {
            for (int i = 1; i <= 100; i++) {
                final int id = i;
                futures.add(executor.submit(() -> {
                    try {
                        Thread.sleep(random.nextInt(400) + 100); // 블로킹 대기
                        if (random.nextBoolean()) throw new RuntimeException("Error in task " + id); // 실패 가능성
                        return id * 10;
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    }
                }));
            }

            // 결과 처리
            int sum = 0;
            for (Future<Integer> future : futures) {
                sum += future.get(); // 작업 완료 대기 및 결과 수집
            }
            System.out.println("All tasks completed successfully: " + sum);
        } catch (ExecutionException e) {
            System.out.println("Failed: " + e.getCause().getMessage());

            // 자식 작업 취소
            for (Future<Integer> future : futures) {
                future.cancel(true);
            }
        } finally {
            executor.shutdown();
            executor.awaitTermination(1, TimeUnit.MINUTES);
        }
    }
}

/// 시나리오 2

import java.util.concurrent.*;
import java.util.*;
import java.util.stream.*;
import java.util.Random;

public class Main {
    public static void main(String[] args) throws InterruptedException {
        ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
        Random random = new Random();
        List<Future<Integer>> futures = new ArrayList<>();

        for (int i = 1; i <= 100; i++) {
            final int id = i;
            futures.add(executor.submit(() -> {
                try {
                    Thread.sleep(random.nextInt(400) + 100); // 블로킹 대기
                    if (random.nextBoolean()) throw new RuntimeException("Error in task " + id);
                    return id * 10;
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }));
        }

        List<Integer> results = new ArrayList<>();
        for (Future<Integer> future : futures) {
            try {
                results.add(future.get(300, TimeUnit.MILLISECONDS)); // 300ms 타임아웃 설정
            } catch (TimeoutException | ExecutionException e) {
                future.cancel(true); // 타임아웃 시 작업 취소
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }

        System.out.println("Successful results: " + results.stream().mapToInt(Integer::intValue).sum());
        executor.shutdown();
    }
}

Java Virtual Threads 동작 방식:

  1. Virtual Threads는 ExecutorService에서 관리되며, 각 작업은 Future 객체로 반환됩니다.
  2. 작업 실패 시 예외를 처리하려면 추가적인 로직(try-catch와 future.cancel())이 필요합니다.
  3. 구조화된 동시성이 제공되지 않으므로, 부모-자식 관계를 직접 관리해야 합니다.
Comments