-
Virtual Thread & Structured Concurrency & CoroutineDEV 2023. 12. 3. 12:34
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 내부 스케줄링을 통해서 수십만~수백만 개의 스레드를 동시에 사용
배경
- 자바의 스레드는 OS의 스레드를 기반
- 자바의 전통적인 스레드는 OS 스레드를 랩핑(wrapping) → 플랫폼 스레드
- Java 애플리케이션에서 스레드를 사용하는 코드는 실제적으로는 OS 스레드를 이용
- OS 커널에서 사용할 수 있는 스레드는 개수가 제한적이고 생성과 유지 비용이 비싸다
- 애플리케이션은 비싼 자원인 플랫폼 스레드를 효율적으로 사용하기 위해서 스레드 풀(Thread Pool) 만들어서 사용
- 처리량(throughput)의 한계
- 기본적인 Web Request 처리 방식은 Thread Per Request (하나의 요청/하나의 스레드)
- 1 request = 1 transaction = 1 thread
- OS 커널에서 사용할 수 있는 스레드는 개수가 제한적
→ 애플리케이션의 처리량(throughput)은 스레드 풀에서 감당할 수 있는 범위를 넘어서 늘어날 수 없다 - Blocking으로 인한 리소스 낭비
- Thread per Request 모델에서는 요청을 처리하는 스레드에서 IO 작업 처리할 때 Blocking 발생
- Blocking 비용을 해결하기 위해 Non-blocknig 방식의 Reactive Programming으로 발전
- Reactive Programming의 단점
- 제어 흐름을 잃는다
- 작성, 이해하는 비용 크다
- 컨텍스트를 잃는다
- Reactive programming은 스레드를 넘나들면서 처리
- Exception Stack trace, Debugger, Profiling 모두 스레드 기반
- 처리하기 위해 거쳐 온 콘텍스트가 스택 트레이스에 유지되지 않는다는 것
- 컨텍스트 확인이 어려워져 결국 디버깅이 힘들어진다.
- 전염성이 있다
- 한 메소드가 Mono를 반환하면 이를 사용하는 다른 메서드도 Mono를 반환해야 하며 이러한 방식은 특정 패러다임을 강제
- 기존의 자바 프로그래밍의 패러다임은 스레드를 기반으로 하기 때문에 라이브러리들 모두 Reactive 방식에 맞게 새롭게 작성
Virture thread의 목표
- 애플리케이션의 높은 처리량(throughput)
- Blocking 발생시 내부 스케줄링을 통해 다른 작업을 처리 - 자바 플랫폼의 디자인과 조화를 이루는 코드
- 기존 스레드 구조 그대로 사용
💡 플랫폼 스레드는 블로킹 비용이 크고, 리엑티브 프로그래밍은 코드의 비용이 크니블로킹 되더라도 비용이 저렴한 경량의 스레드를 사용하면서, 기존 자바의 코딩방식으로 개발하자.
Carrier thread와 Virtual thread
- 위 그림처럼 virtual thread2가 블로킹되는 순간 스케줄러는 virtual thread를 unmount 하고 virtural thread2를 마운트 하여 carrier thread에서 실행한다.
- virtual thread의 구조를 조금 더 자세히 설명한다면 아래와 같다.
- 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() → 계속 실행
- Continuation.yield(일시중단) → stack 메모리가 → heap으로 이동
→ platform thread는 다른 virtual thread를 실행
→ 이전 작업이 실행가능하다는 신호를 받으면 heap
→ platform thread stack으로 이동하여 가능한 carrier thread(worker thread)에서 실행
💡 결국 virtual thread를 blocking 하는 것이지만, platform thread를 blocking 하는 것보다 훨씬 저렴하다.
그럼 멀티쓰레드를 사용하고 싶다면? 동시성, 동기화 이슈는 어떻게 해결되는 거지?
- 수백만 개의 가상스레드를 어떻게 찾고 디버깅할 수 있는가?
- multi-threading 하면 항상 나오는 이야기 ⇒ 동시성, 동기화
동시성
병렬성(Parallelism)
- 많은 작업을 물리적으로 동시에 수행
- 실제로 동시에 실행되는 것 → 물리적인 개념
동시성(Concurrency)
- 프로그램 조각들이 실행 순서와 무관하게 동작할 수 있도록 만들어 한 번에 여러 개의 작업을 처리할 수 있도록 만든 구조
- 동시에 실행되는 것처럼 보이는 것 → 논리적인 개념
- 동시성 프로그래밍은 “수행해야 할 작업(Task)들을 한 개의 스레드에서만이 아니라 다른 스레드에서도 어떻게 동시에 일을 시킬 수 있을까?”라는 아이디어에서 시작
Structured Concurrency(구조화된 동시성)
- https://openjdk.org/jeps/428
- https://openjdk.org/jeps/437
- JDK 21에는 preview
- 서로 다른 스레드에서 실행되는 여러 작업을 단일 작업 단위로 취급
→ 오류 처리 및 취소가 간소화되고 안정성이 향상되며 관찰 가능성이 향상 - 복잡한 멀티스레드들의 활동을 Scope 안에서 다 통제할 수 있도록 하겠다는 목적
- 아래 링크에서StructuredTaskScope를 이용한 간단한 사용방법을 볼 수 있다.
- https://www.baeldung.com/java-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 방식
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'DEV' 카테고리의 다른 글
chatGPT로 면담 예약 시스템 만들기 (0) 2023.12.14 HuggingFace에서 Transformer 모델을 fine-turning 해보자 (0) 2023.12.08 LangChain에 대하여 (1) 2023.12.03 JIT compiler & GraalVM in java (2) 2023.12.03 Spring batch jobScope, stepScope (0) 2023.12.02