가상 스레드가 다음 코드를 실행할 때, 겉으로는 일반 스레드와 차이가 없어 보입니다:
int count = socket.getInputStream().read(buffer);
process(buffer, count);
소켓에 일시적으로 데이터가 없으면 현재 가상 스레드는 일시 중단됩니다. 이를 실행하던 Carrier 스레드는 다른 가상 스레드를 실행할 수 있습니다. 네트워크 데이터가 도착하면 원래 가상 스레드가 다시 스케줄링되고, read()가 반환된 뒤 process() 실행이 계속됩니다.
여기서 몇 가지 의문이 생깁니다:
- Carrier 스레드는 코드를 순차적으로 실행할 뿐인데, 현재 메서드가 아직 끝나지 않았다면 다른 작업을 어떻게 실행할 수 있을까요?
- 가상 스레드가 일시 중단된 상태는 어디에 저장될까요?
- 네트워크 이벤트가 도착했을 때 Poller는 대응하는 가상 스레드를 어떻게 찾을까요?
- 스케줄링 큐에는 가상 스레드, 콜백 함수, 또는 전체 호출 스택 중 무엇이 저장될까요?
- 네트워크 데이터는 어떻게 원래
buffer로 돌아오나요? Continuation.yield(),Thread.yield()및LockSupport.park()는 각각 어떤 역할을 담당할까요?
이 글은 JDK 21 구현을 기준으로 Socket 읽기 한 번의 호출 과정을 따라가며 이 문제들을 분석합니다. JDK 21의 가상 스레드는 M:N 스케줄링을 사용하며, 많은 가상 스레드가 JDK에 의해 소수의 플랫폼 스레드 위에 스케줄링되어 실행됩니다. 기본 스케줄러는 독립적인 ForkJoinPool입니다. (OpenJDK)
Carrier 스레드에는 작업 전환 명령이 없습니다
먼저 가상 스레드를 없애고 일반 워커 스레드 하나만 살펴보겠습니다:
이 스레드는 항상 순차적으로 실행됩니다. 큐에서 Runnable 하나를 꺼내 run()을 호출하고, 해당 메서드가 반환할 때까지 기다린 다음 다음 반복으로 넘어갑니다.
Runnable taskA = queue.take();
taskA.run();
Runnable taskB = queue.take();
taskB.run();
Carrier 스레드도 같은 방식으로 동작합니다. taskA.run()이 아직 반환되지 않았는데 갑자기 taskB.run()으로 넘어가지는 않습니다.
가상 스레드가 전환될 수 있는 이유는 taskA.run()이 애플리케이션 호출이 아직 끝나지 않은 상태에서 일시적으로 반환될 수 있기 때문입니다. 반환하기 전에 JVM은 가상 스레드의 호출 스택을 저장합니다. 이후 이 작업을 다시 실행할 때 JVM은 원래 호출 스택을 복원하고 코드 실행을 이어갑니다.
따라서 Carrier 스레드 하나에서 실제로 일어나는 일은 다음과 같습니다:
virtualThreadAResumeTask.run(); // 保存调用栈后暂时返回
virtualThreadBResumeTask.run(); // Carrier 回到调度循环后执行
Carrier 스레드는 여전히 일반적인 메서드 호출 규칙을 따릅니다. 특수한 기능은 Continuation에 있으며, 아직 완료되지 않은 호출 스택을 일시 중단하고 복원할 수 있게 합니다.
스케줄링 큐에는 무엇이 저장될까
JDK 21의 VirtualThread 내부에는 몇 가지 핵심 필드가 있습니다. 진단 정보, 스레드 컨테이너, 인터럽트 처리를 생략하면 다음과 같이 정리할 수 있습니다:
각 필드의 의미는 다음과 같습니다.
scheduler는 가상 스레드 스케줄러를 가리킵니다.cont는 일시 중단하고 복원할 수 있는 실행 상태를 저장합니다.runContinuation은 스케줄러에 제출되는 작업입니다.state는 가상 스레드의 현재 상태(실행 중, 일시 중단, 스케줄링 가능)를 기록합니다.parkPermit은 한 번의 unpark 허가권을 저장합니다.carrierThread는 현재 가상 스레드를 실행 중인 플랫폼 스레드를 가리킵니다.
가상 스레드를 생성할 때 Continuation이 만들어지고, 현재 가상 스레드에 바인딩된 메서드 참조가 생성됩니다:
this.cont = new VThreadContinuation(this, task);
this.runContinuation = this::runContinuation;
메서드 참조는 일반 Runnable로 펼쳐 쓸 수 있습니다:
스케줄링 큐에 저장되는 핵심은 이러한 복원 작업입니다. 이 작업은 VirtualThread 참조를 보유하며, VirtualThread는 다시 Continuation을 보유합니다.
VirtualThread는 ForkJoinPool의 특정 작업 큐를 직접 조작하지 않습니다. 다음 호출만 합니다:
scheduler.execute(runContinuation);
기본 스케줄러는 일반 Runnable을 받으면 이를 ForkJoinTask로 래핑한 뒤 내부 큐에 넣습니다. OpenJDK 설명에 따르면 작업을 스케줄러 Worker에 제출하면 로컬 큐에 들어가고, 다른 스레드가 제출하면 외부 제출 큐에 들어갑니다. (GitHub)
가상 스레드의 첫 실행
가상 스레드를 시작하면 내부적으로 상태가 NEW에서 STARTED로 변경된 후 runContinuation을 제출합니다:
어떤 Carrier 스레드가 ForkJoinPool 큐에서 작업을 꺼내 최종적으로 다음을 호출합니다:
virtualThread.runContinuation();
runContinuation()의 주요 구조는 다음과 같습니다:
mount()는 현재 플랫폼 스레드를 Carrier로 기록하고 JVM 안의 현재 스레드 ID를 변경합니다:
private void mount() {
Thread carrier = Thread.currentCarrierThread();
setCarrierThread(carrier);
carrier.setCurrentThread(this);
}
따라서 가상 스레드의 애플리케이션 코드에서 다음을 호출하면:
Thread.currentThread()
VirtualThread 객체를 얻게 되며, 하위 Carrier 스레드가 아닙니다.
그런 다음 다음을 실행합니다:
cont.run();
첫 호출에서 Continuation은 사용자 작업 진입점에서 실행을 시작합니다. OpenJDK의 runContinuation()은 실제로 mount() 후에 cont.run()을 호출하고, finally에서 unmount()와 후속 상태 처리를 수행합니다. (GitHub)
Continuation.yield()가 Carrier를 해제하는 방식
애플리케이션 코드의 호출 관계가 다음과 같다고 가정해 보겠습니다:
가상 스레드가 네트워크 대기 지점에 도달했을 때, Carrier 스레드의 논리 호출 스택은 다음과 같을 수 있습니다:
Continuation.yield()를 실행하면 JVM은 현재 Continuation에 속한 스택 프레임을 동결합니다. 지역 변수, 객체 참조, 호출 관계, 프로그램 실행 위치가 모두 보존됩니다.
예를 들어 handleRequest()에 대응하는 스택 프레임에는 다음과 같은 값이 있을 수 있습니다:
socket -> Socket@100
user -> User@200
buffer -> byte[]@300
count -> 尚未赋值
이 데이터는 가상 스레드의 실행 상태에 속하며, 이제 현재 Carrier 스레드의 플랫폼 스택에 의존하지 않습니다.
동결에 성공하면 제어 흐름은 Continuation에서 벗어나 cont.run()을 호출한 지점으로 돌아갑니다. 이때 가상 스레드의 깊은 호출 스택은 Carrier 스레드에서 제거되므로, Carrier 스레드에는 스케줄러 관련 스택 프레임만 남습니다:
ForkJoinPool.runWorker()
VirtualThread.runContinuation()
cont.run()이 반환된 후 runContinuation()은 finally로 들어가 unmount()를 실행합니다:
private void unmount() {
Thread carrier = this.carrierThread;
carrier.setCurrentThread(carrier);
setCarrierThread(null);
}
runContinuation()이 반환하면 Carrier 스레드는 ForkJoinPool의 작업 루프로 돌아가 큐에서 다른 작업을 가져옵니다.
이후 어떤 Carrier 스레드가 다시 cont.run()을 호출하면 JVM은 이전에 동결된 스택 프레임을 복원합니다. 원래 Continuation.yield()에서 반환이 시작되고, 호출 체인이 순서대로 이어집니다:
Continuation.yield() 返回
VirtualThread.park() 返回
LockSupport.park() 返回
Poller.poll() 返回
NioSocketImpl.implRead() 继续
여기서 handleRequest()를 다시 호출하지도, 메서드 처음부터 다시 실행하지도 않습니다. 복원 지점은 앞서 멈춘 위치입니다.
park()가 즉시 다시 큐에 들어가지 않는 이유
Continuation.yield()는 실행을 일시 중단할 책임만 맡을 뿐, 가상 스레드를 언제 계속 실행할지는 결정하지 않습니다. 스케줄링 전략은 누가 호출했는지에 따라 달라집니다.
가상 스레드가 Thread.yield()를 호출하면, 현재 스레드는 여전히 실행 가능한 상태이지만 Carrier를 잠시 양보합니다. 실행 스택이 동결된 후 afterYield()가 runContinuation을 즉시 다시 제출합니다:
RUNNING
-> YIELDING
-> YIELDED
-> 重新提交
-> RUNNING
LockSupport.park()는 대기 조건이 아직 충족되지 않았음을 나타냅니다. 예를 들어 네트워크 데이터, 잠금 해제, 큐 요소를 기다리는 경우입니다. 실행 스택이 동결된 후 가상 스레기를 즉시 실행 큐에 다시 넣으면, 복구하고 조건을 확인한 뒤 다시 중단되는 과정을 반복해 CPU를 낭비하게 됩니다.
상태 변화는 다음과 같습니다.
RUNNING
-> PARKING
-> PARKED
다른 스레드가 unpark()를 호출해야 다시 실행할 수 있는 상태가 됩니다:
PARKED
-> UNPARKED
-> 重新提交
-> RUNNING
OpenJDK의 afterYield()는 현재 상태를 확인합니다. PARKING은 PARKED로 바뀌고 기본적으로 다시 제출하지 않습니다. YIELDING은 YIELDED로 바뀌고 즉시 제출합니다. (GitHub)
해당 코드는 다음과 같이 단순화할 수 있습니다:
Socket 읽기가 Poller로 이어지는 과정
이제 네트워크 I/O 경로로 들어갑니다.
int count = socket.getInputStream().read(buffer);
JDK 21의 Linux 구현을 예로 들면, JDK는 먼저 Socket 읽기를 시도합니다. 커널 수신 버퍼에 이미 데이터가 있으면 읽기가 바로 완료되므로 가상 스레드를 일시 중단할 필요가 없습니다.
현재 데이터가 없다면 비차단 읽기에서 EAGAIN 또는 그에 해당하는 상태를 얻습니다. JDK는 이후 파일 디스크립터를 Poller에 등록하고 현재 가상 스레드를 일시 중단합니다. JEP 444는 이 과정을 설명합니다. JDK의 차단 네트워크 작업이 즉시 완료될 수 없으면 가상 스레드를 언마운트하고, I/O를 완료할 수 있게 되면 다시 스케줄러에 제출합니다. (OpenJDK)
단순화한 읽기 로직은 다음과 같습니다:
Poller.poll(fd) 내부는 다음과 같이 추상화할 수 있습니다:
void poll(int fd) {
Thread thread = Thread.currentThread();
waiters.put(fd, thread);
registerWithEpoll(fd);
LockSupport.park();
}
현재 실행 중인 것은 가상 스레드이므로 Thread.currentThread()는 해당 VirtualThread를 반환합니다.
현재 두 가지 연결 관계가 존재합니다.
Linux 커널의 epoll 기록:
监听 fd 37 的可读事件
JDK의 Poller 기록:
fd 37 -> VirtualThread@500
운영체제는 파일 디스크립터만 알며 Java 가상 스레드는 알지 못합니다. Poller의 매핑은 커널 이벤트를 다시 Java 스레드 객체에 연결하는 역할을 합니다. JDK 21의 Poller는 네트워크 이벤트 통지와 가상 스레드 깨우기 사이의 중간 계층입니다. (GitHub)
등록을 마치면 현재 가상 스레드는 다음을 실행합니다:
LockSupport.park();
park()는 VirtualThread.park()로 들어가 PARKING 상태를 설정하고 Continuation.yield()를 호출합니다. 실행 스택이 동결된 후 가상 스레드는 PARKED가 되고 Carrier 스레드는 해제됩니다.
대기 중 객체 관계는 다음과 같습니다:
이 시점의 가상 스레드는 Carrier 스레드를 점유하지 않으며 실행 큐에도 없습니다. Poller는 대기 관계를 보관하고, Continuation은 실행 상태를 저장합니다.
네트워크 준비 완료 후 다시 스케줄링하는 과정
네트워크 데이터가 도착하면 Linux 프로토콜 스택은 바이트를 Socket의 커널 수신 버퍼에 넣고 fd를 읽을 수 있는 상태로 표시합니다.
Linux의 Poller 스레드는 보통 epoll_wait()에서 차단되어 있습니다. 이벤트가 반환되면 준비된 파일 디스크립터를 얻습니다:
int[] readyFds = epollWait();
for (int fd : readyFds) {
Thread thread = waiters.remove(fd);
if (thread != null) {
LockSupport.unpark(thread);
}
}
Poller는 사용자의 handleRequest()를 실행하지 않으며, Poller 스레드에서 원래의 InputStream.read()를 호출하지도 않습니다. 해당 fd를 기다리는 가상 스레드를 찾아 unpark()만 호출합니다.
VirtualThread.unpark()의 핵심 로직은 다음과 같습니다:
submitRunContinuation()은 결국 다음을 실행합니다:
scheduler.execute(runContinuation);
이 단계에서 가상 스레드를 다시 스케줄러에 넘깁니다. OpenJDK의 unpark()는 먼저 parkPermit을 설정하고, 상태 CAS로 이미 일시 중단된 가상 스레드를 UNPARKED로 바꾼 다음 복원 작업을 제출합니다. (GitHub)
unpark()를 호출하는 주체는 보통 Poller 스레드이며, 이는 기본 스케줄러의 Worker가 아니므로 이 작업은 일반적으로 ForkJoinPool의 외부 제출 경로를 따릅니다.
이후 Carrier 스레드 하나가 작업을 꺼내 다시 다음을 호출합니다:
virtualThread.runContinuation();
상태가 UNPARKED에서 RUNNING으로 바뀌고, 가상 스레드가 새로운 Carrier 스레드에 마운트된 후 다음을 실행합니다:
cont.run();
앞서 동결된 호출 스택이 복원되고, LockSupport.park()가 반환된 뒤 Socket 읽기 로직이 계속됩니다.
네트워크 데이터는 스케줄링 큐에 들어가지 않는다
스케줄링 큐에는 네트워크 데이터가 없으며, 다음과 같은 객체도 없습니다:
new ResumeTask(virtualThread, networkData);
Poller의 역할은 ‘fd가 이제 읽을 수 있을 수 있다’는 알림을 전달하는 것이고, 스케줄러는 가상 스레드의 실행을 예약합니다. 네트워크 바이트는 그대로 Socket의 커널 수신 버퍼에 남아 있습니다.
가상 스레드가 복구되면 읽기 루프는 다시 운영체제를 호출합니다:
처음 nonBlockingRead()가 EAGAIN을 반환하면 가상 스레드는 대기를 시작합니다.
네트워크가 준비되면 Poller가 unpark()를 호출하고, 가상 스레드가 복구된 뒤 다시 다음을 실행합니다:
nonBlockingRead(fd, buffer);
이번에는 커널 수신 버퍼에 이미 데이터가 있으므로 시스템 호출이 바이트를 원래 buffer에 복사하고 바이트 수를 반환합니다.
여전히 원래 buffer에 접근할 수 있는 이유는 Continuation이 저장한 호출 스택에 다음 참조가 그대로 남아 있기 때문입니다:
buffer -> byte[]@300
데이터 전달 경로는 다음과 같습니다:
스케줄링 큐는 실행 가능 여부만 전달합니다. Poller는 준비 알림만 전달합니다. 네트워크 데이터는 커널 Socket 버퍼에 저장됩니다.
parkPermit이 누락된 깨우기를 방지하는 방식
가상 스레드가 일시 중단될 준비를 하는 동안 네트워크 이벤트가 먼저 도착할 수 있습니다.
다음 순서를 생각해 보겠습니다:
虚拟线程发现当前没有数据
Poller 注册 fd
网络数据立即到达
Poller 调用 unpark()
虚拟线程随后才执行 park()
unpark()가 이미 PARKED 상태인 스레드만 깨울 수 있다면 이 알림은 사라집니다. 이후 가상 스레드가 park()에 들어가 계속 복구되지 않을 수 있습니다.
parkPermit은 용량이 1인 허가권과 같습니다:
private volatile boolean parkPermit;
unpark()는 먼저 다음을 설정합니다:
parkPermit = true;
park()가 시작할 때 먼저 허가권을 소비하려 합니다:
if (getAndSetParkPermit(false)) {
return;
}
네트워크 이벤트가 이미 도착했다면 park()는 즉시 반환하며 호출 스택을 동결하지 않습니다.
unpark()를 여러 번 연속 호출해도 허가권은 하나만 저장됩니다:
unpark(thread);
unpark(thread);
unpark(thread);
결과적으로 다음과 같이 하나만 남습니다:
parkPermit = true
다음 park()가 이 허가권을 소비한 뒤 false가 됩니다.
JUC 동기화 도구가 일반적으로 조건 확인에 루프를 사용하는 이유도 이것입니다:
while (!conditionSatisfied()) {
LockSupport.park();
}
park()가 반환됐다는 것은 스레드가 다시 실행 기회를 얻었다는 뜻일 뿐이며, 비즈니스 조건은 다시 확인해야 합니다.
PARKING이 중복 스케줄링을 방지하는 방식
RUNNING과 PARKED 두 상태만으로는 부족합니다. 호출 스택을 동결하는 데에는 일련의 실행 과정이 필요하기 때문입니다.
가상 스레드는 먼저 다음을 실행합니다:
state = PARKING;
Continuation.yield();
PARKING을 설정한 뒤 Continuation이 동결을 완료하기 전에 Poller가 이미 unpark()를 호출할 수 있습니다.
이때 원래 Carrier 스레드는 아직도 현재 가상 스레드를 실행 중일 수 있으므로 즉시 복원 작업을 스케줄러에 제출할 수 없습니다. 그러면 다른 Carrier 스레드가 동시에 같은 가상 스레드를 가져갈 수 있습니다.
따라서 PARKING 상태에서 unpark()는 parkPermit만 설정하고 작업은 제출하지 않습니다.
Continuation의 동결이 완료되면 원래 Carrier가 afterYield()를 실행합니다:
state = PARKED;
if (parkPermit
&& compareAndSetState(PARKED, UNPARKED)) {
submitRunContinuation();
}
이렇게 하면 미리 도착한 깨우기 알림을 보존하면서도 두 Carrier 스레드가 동시에 같은 가상 스레드를 실행하는 것을 막습니다.
또한 runContinuation()은 복구 전에 상태에 CAS를 적용합니다. 스케줄링 큐에 동일한 가상 스레드를 가리키는 복원 작업이 둘 생겨도, 상태를 RUNNING으로 바꿀 수 있는 Carrier 스레드는 하나뿐입니다.
세 가지 스레드의 협업 방식
전체 구현에는 ForkJoinPool, Continuation, Poller, 운영체제가 관여하지만, 실행 과정은 여전히 세 가지 일반 스레드 루프로 나눠 생각할 수 있습니다.
Carrier 스레드의 루프:
while (true) {
Runnable task = scheduler.takeTask();
task.run();
}
Poller 스레드의 루프:
가상 스레드의 Socket 읽기 루프:
세 실행 흐름은 모두 순차적으로 진행됩니다. 다음 객체를 통해 서로 연결됩니다:
Carrier 스레드는 복원 작업을 실행하고, Poller는 fd를 통해 대기 중인 스레드를 찾으며, 가상 스레드는 스케줄러를 통해 자신을 다시 제출하고, Continuation은 일시 중단 위치와 지역 객체 참조를 보존합니다.
전체 호출 경로
전체 과정을 연결하면 다음과 같은 핵심 경로를 얻을 수 있습니다:
이 경로를 이해하면 가상 스레드의 스케줄링이 더 이상 신비롭지 않습니다. Carrier 스레드는 항상 일반 작업 루프를 실행합니다. Continuation은 호출 스택을 보존한 채 현재 작업이 일시적으로 반환될 수 있게 합니다. Poller는 네트워크 fd를 대기 중인 가상 스레드와 연결하고, unpark()는 이벤트가 도착한 뒤 복원 작업을 다시 제출합니다.
JDK 21과 JDK 24의 Pinning 차이
JDK 21에서 가상 스레드가 synchronized Monitor를 보유한 채 일부 차단 작업에 들어가면 Continuation이 언마운트되지 못해 가상 스레드와 Carrier 스레드가 함께 차단될 수 있습니다. 이러한 현상을 Pinning이라고 부릅니다.
따라서 JDK 21 단계에서는 긴 synchronized 임계 영역에서 네트워크 I/O를 수행하지 않도록 자주 권하며, 필요하면 ReentrantLock을 사용합니다.
JDK 24는 JEP 491을 통해 JVM Monitor와 가상 스레드의 협업 방식을 변경했습니다. 가상 스레드가 synchronized 메서드나 코드 블록 안에서 차단되어도 대부분의 경우 언마운트하고 Carrier 스레드를 해제할 수 있습니다. JDK 24 이후에는 synchronized와 java.util.concurrent.locks 중 무엇을 선택할지 Monitor Pinning만 피하려는 이유보다, 중단 가능한 획득, 공평성, 타임아웃, 조건 변수 등의 의미에 따라 판단하는 편이 좋습니다. (OpenJDK)
잠금을 보유한 상태에서 장시간 네트워크 I/O를 수행할 때도 여전히 주의가 필요합니다. Carrier를 해제할 수 있더라도 잠금 자체는 계속 보유되어 있으므로, 같은 임계 영역에 들어가야 하는 다른 스레드는 계속 기다립니다.
참조 소스
- JEP 444: Virtual Threads. (OpenJDK)
- JDK 21 java.lang.VirtualThread. (GitHub)
- JDK 21 jdk.internal.vm.Continuation. (GitHub)
- JDK 21 sun.nio.ch.Poller. (GitHub)
- JEP 491: Synchronize Virtual Threads without Pinning. (OpenJDK)