블로킹 호출은 왜 가상 스레드의 캐리어를 놓아주는가 — 마운트·언마운트와 pinning

seonwoo_jung·3일 전

1. 왜 이걸 파고들었나

"가상 스레드는 그냥 가벼운 스레드"라는 문장은 외우기 쉽지만, 막상 가벼운지 — 소켓 read() 하나가 어떻게 OS 스레드를 붙잡지 않고 놓아주는지 — 를 인과 사슬로 설명하려니 막혔다. 게다가 "synchronized 안에서 블로킹하면 성능이 죽는다"는 경고는 자주 들었는데, 그 "pinning"이 정확히 스택의 어느 지점에서 왜 일어나는지는 몰랐다.

그래서 JEP 444와 OpenJDK의 VirtualThread·Continuation 소스(JDK 21 기준)를 따라가며 정리했다. 이 글에서 얻어갈 것은 세 가지다: 가상 스레드가 실제로 무엇의 합성인지, 블로킹 지점에서 스택이 어디로 이동하는지, 그리고 그 이동이 막히는(pinning) 조건이 무엇인지.

2. 가상 스레드 = Continuation + Scheduler

가상 스레드는 마법이 아니라 두 조각의 합성이다.

VirtualThread
 ├─ Continuation  ← 재개 가능한 실행 상태 (스택 프레임들)
 └─ Scheduler     ← 기본값: ForkJoinPool (FIFO async 모드, parallelism = CPU 코어 수)
  • Continuation은 실행 중인 스택 프레임 전체를 캡처했다가 나중에 그 지점부터 다시 이어 실행할 수 있는 원시 구조다. yield를 호출하면 현재 스택을 접어(freeze) 힙의 stack chunk 객체로 옮기고 제어를 호출자에게 돌려준다. 재개할 때는 그 chunk를 다시 캐리어 스택 위로 펼친다(thaw).
  • Carrier thread는 가상 스레드를 실제로 실행하는 플랫폼(OS) 스레드다. 기본 스케줄러인 ForkJoinPool의 워커이며, 가상 스레드는 스스로 CPU에서 도는 게 아니라 이 캐리어 위에 올라타서(mount) 돈다.

즉 가상 스레드가 "실행 중"이라는 건 곧 어떤 캐리어에 마운트되어 있다는 뜻이고, "대기 중"이라는 건 스택이 힙에 접혀 있고 어떤 캐리어에도 올라타 있지 않다는 뜻이다.

3. mount / unmount 사이클

가상 스레드 T가 소켓 read()에서 블로킹하려는 순간을 따라가 보자.

  [carrier P1]  스택:  ...→ read()→ park()
        │
        │ 1. JDK I/O 코드가 "지금 데이터 없음" 감지 → Continuation.yield()
        ▼
  freeze: T의 스택 프레임을 힙 stack chunk로 복사   ← UNMOUNT
        │
        ▼
  [carrier P1]  이제 자유 → FJPool 큐에서 다른 가상 스레드 U를 mount
  ...
  (데이터 도착, Selector가 T를 깨움)
        │
        ▼
  thaw: T의 stack chunk를 아무 캐리어 P2 스택으로 복사 → 이어서 실행   ← MOUNT

핵심은 JDK의 블로킹 API가 재작성되어 있다는 점이다. SocketChannel.read(), Thread.sleep(), LockSupport.park(), BlockingQueue.take() 같은 블로킹 지점들은 내부적으로 "지금 가상 스레드에서 실행 중이면 OS를 블로킹하지 말고 continuation을 yield하라"로 바뀌었다. 그래서 애플리케이션 코드는 여전히 익숙한 동기 블로킹 스타일인데도, 실제로는 캐리어를 놓아준다. 언마운트되면 캐리어 스택은 비고 힙에는 T의 접힌 스택만 남으므로, 수만 개의 대기 중 가상 스레드가 소수의 캐리어를 공유할 수 있다.

다시 깨어날 때 T가 원래 캐리어로 돌아갈 필요는 없다는 점도 중요하다. thaw는 힙의 stack chunk를 그 시점에 여유 있는 아무 캐리어 위로 복사하면 되기 때문이다.

4. 왜 스택을 "힙"에 두는가

경량성의 실체는 스택의 위치에 있다. 플랫폼 스레드는 고정 크기 스택(리눅스 기본 약 1MB)을 OS가 예약한다 — 수만 개면 산술적으로 수십 GB다. 반면 가상 스레드의 스택은 힙에 놓인 stack chunk 객체이고, 실제로 사용한 깊이만큼만 차지하며 GC 대상이 된다. 호출 깊이가 깊어지면 chunk를 늘리고, 얕아지면 회수한다. 이 "쓴 만큼만"이 수만 개를 동시에 띄울 수 있는 근거다.

5. Pinning: 언마운트가 막히는 경우

Continuation.yield()는 현재 캐리어 스택을 통째로 힙으로 옮기는 연산이다. 그런데 스택 프레임 중에 네이티브 상태와 얽힌 것이 있으면 그대로 옮길 수 없다. 대표적으로 두 경우다.

  1. synchronized 모니터를 잡은 채 블로킹 — 모니터는 특정 OS 스레드에 귀속된 상태라, 가상 스레드가 캐리어를 바꾸면 모니터 소유권이 깨진다. 그래서 JDK 21에서는 언마운트를 포기하고 T를 캐리어에 pin한다.
  2. 네이티브 메서드(JNI)나 foreign function 호출 스택 안에서 블로킹 — C 스택 프레임은 힙으로 접을 수 없다.

pinning이 일어나면 그 캐리어는 T가 블로킹을 끝낼 때까지 다른 가상 스레드를 돌리지 못한다. 대기 가상 스레드가 많은데 캐리어가 전부 pin되면 처리량이 급락한다(캐리어 기아). 상태로 보면 정상 경로와 예외 경로의 차이가 곧 확장성의 차이다.

       블로킹 진입     │  yield()
                       ▼
                  PARKING ──(freeze 성공)──▶ PARKED   ← 언마운트됨, 캐리어 자유
                       │
                (freeze 불가: monitor/native)
                       ▼
                    PINNED   ← 캐리어 붙잡은 채 OS 블로킹

PARKED는 스택이 힙에 안전하게 접히고 캐리어는 반납된 이상적 경로, PINNED는 접지 못해 캐리어를 붙든 채 OS 수준에서 블로킹된 예외 경로다.

6. 예시: pinning을 눈으로 확인하기

-Djdk.tracePinnedThreads=short 옵션으로 실행하면 pin이 일어난 스택을 stderr로 덤프해 준다.

// 실행: java -Djdk.tracePinnedThreads=short Main
var lock = new Object();
Thread.ofVirtual().start(() -> {
    synchronized (lock) {          // 모니터 획득
        try { Thread.sleep(100); } // 이 블로킹에서 언마운트 시도 → pin 발생
        catch (InterruptedException e) {}
    }
}).join();
// stderr 에 "VirtualThread[...] ... <== monitors:1" 형태의 pin 스택이 찍힌다.

Thread.sleep()은 가상 스레드에서 continuation yield로 구현되므로 평소엔 캐리어를 놓아준다. 하지만 synchronized 안이라 언마운트가 거부되고 캐리어에 고정된다. 같은 코드를 ReentrantLock으로 바꾸면 — 락은 캐리어가 아니라 가상 스레드에 귀속되고 continuation과 함께 이동하므로 — pin 로그가 사라진다. 이것이 "Loom 시대엔 synchronized보다 ReentrantLock" 권고의 근거다.

한 가지 덧붙이면, JEP 491(JDK 24)에서 synchronized 블로킹 시에도 언마운트가 가능하도록 모니터 구현이 개선되어 synchronized에 의한 pinning은 사실상 사라진다고 알려져 있다. 위 설명은 그 이전, JDK 21 기준이다.

7. 자주 오해하는 것

  • "가상 스레드는 OS 스레드보다 그냥 빠르다" — 아니다. 단일 작업의 실행 속도는 같다(결국 같은 캐리어 위에서 돈다). 빨라지는 건 개수다. 블로킹 대기 중인 스레드가 OS 스레드를 붙잡지 않으므로, 같은 하드웨어로 훨씬 많은 동시 블로킹 작업을 수용한다.
  • 스케줄러는 시분할(preemption)을 하지 않는다. 기본 FJPool은 오직 블로킹(yield) 지점에서만 캐리어를 양보한다. yield 지점이 없는 CPU 바운드 무한 루프는 캐리어를 계속 점유한다. 즉 가상 스레드는 "많은 블로킹 I/O"에 최적화된 도구이지 CPU 병렬성을 늘리는 도구가 아니다.
  • "가상 스레드는 풀에 담아 재사용해야 한다" — 반대다. 생성 비용이 거의 없으므로 작업당 하나씩 만들고 버리는 게 정석이며, 풀링은 오히려 ThreadLocal 오염 같은 문제를 만든다.

8. 정리

가상 스레드는 힙에 저장된 재개 가능한 스택(continuation)이고, 블로킹 지점에서 이 스택을 캐리어에서 떼어냈다가(unmount) 나중에 아무 캐리어에나 다시 붙여(mount) 실행을 이어가는 것이 전부다 — 단, 떼어낼 수 없는 프레임이 있으면 캐리어에 고정(pinned)된다.

더 파고들 만한 주제 두 가지: Continuation/StackChunk의 freeze·thaw가 GC(특히 stack chunk의 write barrier)와 어떻게 상호작용하는가, 그리고 JEP 491이 synchronized pinning을 없앤 모니터 재구현의 실제 메커니즘.

참고 자료

  • JEP 444: Virtual Threads (Final) — https://openjdk.org/jeps/444
  • JEP 491: Synchronize Virtual Threads without Pinning
  • OpenJDK 소스: java.lang.VirtualThread, jdk.internal.vm.Continuation (JDK 21)
  • Ron Pressler, "State of Loom"

0개의 댓글