지난 2편에서는 가상 스레드(virtual thread)의 컨텍스트 스위칭(context switching)이 구체적으로 어떤 과정으로 진행되는지 알아봤습니다. 마지막 3편에서는 가상 스레드의 중요한 이슈인 고정(pinned) 이슈와 함께 가상 스레드의 한계에 대해서 알아보겠습니다.
3편은 다음과 같은 순서로 진행합니다.
고정 이슈란
가상 스레드가 블로킹 I/O 작업을 만날 때 모종의 이유로 캐리어 스레드(carrier thread)와 연결된 가상 스레드가 교체되지 않고 고정되는 현상이 발생할 때가 있습니다. 이를 고정(pinned) 이슈라고 합니다. 이 현상이 발생하면 해당 캐리어 스레드는 더 이상 작업을 진행하지 않고 CPU와 격리되는데 이때 전체 시스템의 성능이 저하되거나 데드락(deadlock) 같은 치명적인 이슈가 발생할 수 있습니다. 따라서 가상 스레드를 사용하기 전에 고정 이슈가 발생할 수 있는 곳을 미리 확인해야 합니다.
아래 그림은 고정 이슈가 발생하는 상황을 나타낸 것입니다. Task 1, 2가 가상 스레드를 통해서 실행되다가(그림 1), Task 1에서 블로킹 I/O 작업을 만나 컨텍스트 스위칭될 때(그림 2) 고정 이슈가 발생합니다. 고정 이슈가 발생하면 가상 스레드는 PINNED
상태로 변경되고, 마운트된 플랫폼 스레드는 WAITING
상태로 변경돼 OS 레벨에서 CPU와 격리됩니다(그림 3). 원래대로라면 Task 1에서 컨텍스트 스위칭될 때 캐리어 스레드 A가 스케줄러(scheduler)에 반환돼 가상 스레드 C와 연결돼야 하 지만, 캐리어 스레드 A가 가상 스레드 A와 고정돼 버리면서 성능 저하가 발생합니다.
소스 코드와 함께 살펴보는 고정 이슈 발생 과정
고정 이슈가 발생하는 과정을 JDK 코드를 기반으로 자세히 알아보겠습니다. 1편 및 2편과 마찬가지로 소스 코드는 OpenJDK 21+35가 기준이며, 글 중간에 등장하는 괄호 속 숫자는 함께 첨부한 소스 코드에서 각 설명에 해당하는 위치에 주석으로 남겨 놓은 숫자를 의미합니다.
가상 스레드에서 park
메서드를 통해 컨텍스트 스위칭을 진행할 때, 앞서 2편에서 확인한 것과 같이 Continuation
클래스의 doYield
메서드를 호출합니다(1). 이때 doYield
네이티브 메서드를 통해 스택의 메서드 프레임이 힙의 StackChunk
객체로 저장됩니다. doYield
의 결괏값은 int
타입의 숫자인데요. Continuation
클래스의 yield0
메서드는 doYield
네이티브 메서드를 호출해 그 결과가 0인 경우 true
, 그렇지 않으면 false
를 리턴합니다(2).
// jdk.internal.vm.Continuation.java
public class Continuation {
private boolean yield0(ContinuationScope scope, Continuation child) {
...
int res = doYield(); // (1) 네이티브 메서드 호출해 스택의 메서드 프레임을 힙에 저장
...
return res == 0; // (2) doYield의 결괏값 확인
}
}
doYield
를 통해 호출하는 네이티브 메서드의 일부를 조금 더 자세히 살펴보겠습니다.
freeze_internal
메서드는 실질적인 yield
작업을 수행하는 메서드입니다. 이 freeze_internal
메서드(1) 내부에서 is_pinned()
메서드를 호출해 현재 작업 수행 위치가 JVM의 크리티컬 섹션 내부에 있는지 확인하고, held_monitor_count()
메서드를 호출해 현재 보유 중인 모니터의 개수를 확인합니다(2)(크리티컬 섹션이란 JVM의 핵심 데이터 구조나 자원을 보호하기 위해 내부 락(lock)이나 뮤텍스(mutex)를 사용하는 코드 영역을 말합니다). 확인 결과 고정 이슈가 발생한 원인이 둘 중 하나라면 if
문 내부로 들어가 고정 이슈가 발생한 원인이 구체적으로 무엇인지 확인해 반환합니다(3). 만약 크리티컬 섹션의 내부에 있지 않고 모니터도 보유하지 않은 경우라면 마지막으로 yield
메서드를 호출한 상위 메서드 프레임 중 네이티브 메서드 호출이 있었는지 확인합니다(4). 이와 같은 과정을 거쳐 고정 이슈 발생 원인을 파악해 반환하는데요. 결괏값은 freeze_result
열거형(5)으로 전달되며 원인은 총 세 가지(freeze_pinned_cs
, freeze_pinned_native
, freeze_pinned_monitor
)로 분류됩니다.
// continuationFreezeThaw.cpp
static inline int freeze_internal(JavaThread* current, intptr_t* const sp) { // (1)
...
if (entry->is_pinned() || current->held_monitor_count() > 0) { // (2) 크리티컬 섹션 및 모니터 확인
...
freeze_result res = entry->is_pinned() ? freeze_pinned_cs : freeze_pinned_monitor; // (3) 구체적인 고정 이슈의 원인(크리티컬 섹션, 모니터) 확인
...
return res;
}
...
JRT_BLOCK
...
freeze_result res = fast ? freeze.try_freeze_fast() : freeze.freeze_slow(); // (4) 상위 메서드 프레임 중 네이티브 메서드 호출이 있었는지 확인
...
return res;
JRT_BLOCK_END
}
enum freeze_result { // (5) yield의 결과
freeze_ok = 0,
freeze_ok_bottom = 1,
freeze_pinned_cs = 2,
freeze_pinned_native = 3,
freeze_pinned_monitor = 4,
freeze_exception = 5
};
Continuation
클래스의 yield0
메서드의 결괏값은 상위로 반환되면서 결국 VirtualThread
클래스의 park
메서드까지 올라와 yielded
라는 변수에 저장됩니다(1). yielded
변수의 값을 확인해 고정 이슈가 발생했는지 확인하고(2), 만약 고정 이슈가 발생했으면 현재의 가상 스레드에 고정한 채로 캐리어 스레드를 정지(park
)하기 위해 parkOnCarrierThread
메서드를 실행합니다(3). parkOnCarrierThread
메서드는 Unsafe
클래스의 park
메서드를 실행해(4), 해당 캐리어 스레드를 일시 중지시켜 더 이상 CPU를 할당받지 못하게 합니다(참고로 Unsafe
클래스는 주로 Java 애플리케이션 개발자가 일반적으로 접근할 수 없는 저수준의 시스템 기능에 접근하고자 할 때 사용합니다). 이 과정에서 가상 스레드와 그에 마운트된 캐리어 스레드가 함께 일시 정지됩니다.
// java.lang.VirtualThread.java
final class VirtualThread extends BaseVirtualThread {
void park() {
...
try {
yielded = yieldContinuation(); // (1) yield 결괏값을 yielded 변수에 저장
} finally {
...
}
...
if (!yielded) { // (2) 고정 이슈 발생
parkOnCarrierThread(false, 0); // (3) 고정 이슈 발생 시 캐리어 스레드를 정지(park)
}
}
private void parkOnCarrierThread(boolean timed, long nanos) {
...
try {
if (!parkPermit) {
if (!timed) {
U.park(false, 0); // (4) Unsafe의 park 메서드를 호출해 캐리어 스레드를 일시 정지
} else if (nanos > 0) {
U.park(false, nanos);
}
}
} ...
}
}
이후 정지를 유발한 블로킹 I/O 작업이 완료되면 PINNED
상태였던 가상 스레드는 해당 캐리어 스레드를 재개해 다시 CPU를 할당받을 수 있게 해야 합니다. 이 과정은 VirtualThread
클래스의 unpark
메서드에서 수행합니다. 가상 스레드의 상태가 PINNED
라면(1) Unsafe
클래스의 unpark
메서드를 실행해(2) 해당 캐리어 스레드를 재개합니다.
// java.lang.VirtualThread.java
final class VirtualThread extends BaseVirtualThread {
void unpark() {
...
if (s == PARKED && compareAndSetState(PARKED, RUNNABLE)) {
...
} else if (s == PINNED) { // (1) 상태가 PINNED일 때
synchronized (carrierThreadAccessLock()) {
Thread carrier = carrierThread;
if (carrier != null && state() == PINNED) {
U.unpark(carrier); // (2) 캐리어 스레드를 재개
}
}
}
...
}
}
고정 이슈 발생 원인
JVM 소스 코드 중 Continuation
클래스의 내부 열거형(enum
) 클래스 Pinned
에서 고정 이슈의 원인 세 가지를 확인할 수 있습니다(1). 각 고정 이슈의 원인은 pinnedReason
메서드를 통해(2) 매핑되며, pinnedReason
메서드의 인자 reason
은 Continuation
클래스의 doYield
네이티브 메서드의 결괏값에 해당하고, 이 결괏값은 다시 네이티브 코드의 freeze_result
열거형 해당합니다(3). 즉, doYield
네이티브 메서드로 받은 int
타입의 결괏값 중 2, 3, 4가 각각 Pinned.CRITICAL_SECTION
, Pinned.NATIVE
, Pinned.MONITOR
로 매핑됩니다(4).
// jdk.internal.vm.Continuation.java
public class Continuation {
public enum Pinned { // (1) 고정 발생 원인
/** Native frame on stack */ NATIVE,
/** Monitor held */ MONITOR,
/** In critical section */ CRITICAL_SECTION }
}
private static Pinned pinnedReason(int reason) { // (2) 고정 발생 원인 매핑 메서드
return switch (reason) { // (4) doYield 네이티브 메서드 결괏값이 매핑
case 2 -> Pinned.CRITICAL_SECTION;
case 3 -> Pinned.NATIVE;
case 4 -> Pinned.MONITOR;
default -> throw new AssertionError("Unknown pinned reason: " + reason);
};
}
}
// continuationFreezeThaw.cpp
enum freeze_result { // (3) doYield 네이티브 메서드의 결괏값
freeze_ok = 0,
freeze_ok_bottom = 1,
freeze_pinned_cs = 2, // Pinned.CRITICAL_SECTION으로 매핑
freeze_pinned_native = 3, // Pinned.NATIVE로 매핑
freeze_pinned_monitor = 4, // Pinned.MONITOR로 매핑
freeze_exception = 5
};
Continuation
클래스의 내부 열거형 클래스인 Pinned
의 각 열거 상수들을 기준으로 고정 이슈의 원인을 조금 더 구체적으로 살펴보겠습니다.
Pinned.NATIVE
스택에 네이티브 메서드 프레임이 속한 상황에 해당합니다. 가상 스레드가 JNI(Java Native Interface) 혹은 외부 함수 및 메모리 API(Foreign Function & Memory API)를 호출할 때 스택에 네이티브 메서드 프레임이 쌓입니다. 이렇게 네이티브 메서드 프레임이 쌓인 후 내부 로직에서 yield
작업을 수행하는 경우, JVM은 해당 네이티브 코드의 실행을 제어하거나 중단할 수 없어 스택에 쌓인 해당 프레임을 힙으로 옮길 수 없기 때문에 고정 이슈가 발생합니다.
Pinned.MONITOR
가상 스레드가 멀티 스레드 환경에서 객체를 동기화하기 위해 객체의 모니터를 가지고 있는 상황에 해당하는데요. 특히 synchronized
블록이나 메서드를 통해 해당 객체의 모니터를 획득한 상태를 의미합니다. 현재 JVM의 구현에 따라 객체의 모니터를 가상 스레드가 아닌 플랫폼 스레드가 소유하기 때문에 고정 이슈가 발생합니다.
Pinned.CRITICAL_SECTION
JVM의 내부 크리티컬 섹션 안에서 실행 중인 경우에 해당합니다. 아래와 같은 케이스에 해당합니다.
- 클래스 로딩: JVM은 새로운 클래스를 로딩할 때 클래스 로더의 데이터 구조를 보호하기 위해 크리티컬 섹션을 사용합니다.
- 메모리 할당 및 가비지 컬렉션: JVM은 새로운 객체를 생성할 때 힙 메모리에서 메모리를 할당해야 합니다. 이때 메모리를 할당하기 위해 내부 데이터 구조에 대한 동기화가 필요하며, 이를 위해 크리티컬 섹션을 사용합니다.
- 스레드 생성 및 관리: JVM은 새로운 스레드를 생성하거나 시작할 때 내부 스레드 테이블이나 관리 구조를 업데이트해야 하며, 이를 위해 크리티컬 섹션을 사용합니다.
위와 같은 JVM의 내부 크리티컬 섹션 안에서 실행 중인 경우 JVM 내부 크리티컬 섹션 동기화 메커니즘을 보호하기 위해 고정 이슈가 발생합니다.
고정 이슈로 인한 가상 스레드의 한계와 미래
가상 스레드가 PINNED
상태가 되면 가상 스레드와 캐리어 스레드가 고정된 후 캐리어 스레드는 대기 상태에 빠지며 CPU에서 격리됩니다. 이로 인해 스케줄러에서 작업 중인 워커 스레드(worker thread)의 수가 줄어 전체 시스템의 성능이 저하되거나 데드락 같은 치명적인 이슈가 발생할 수 있습니다.
이와 관련해 최근 Netflix Technology Blog를 통해 소개된 가상 스레드 도입 중 발생한 데드락 이슈 사례를 살펴보겠습니다. 이 사례에서는 먼저 스케줄러에 할당된 모든 캐리어 스레드에 고정 이슈가 발생해 대기 상태에 빠지면서 스케줄러에 남아 있는 워커 스레드가 없는 상황이 발생합니다. 이때 상태 복구 작업을 다른 가상 스레드에서 수행해야 하지만 이미 스케줄러에 할당된 모든 캐리어 스레드가 대기 상태에 있기 때문에 가상 스레드 작업을 진행하지 못해 데드락이 발생합니다.
아래 그림과 함께 조금 더 구체적으로 상황을 살펴보겠습니다. 먼저 락의 소유권을 플랫폼 스레드 A가 가지고 있고 다른 가상 스레드들(B, C, D, E, F)에서 해당 락을 얻으려 합니다(그림 4). 이때 가상 스레드 C, D, E, F는 마침 synchronized
블록 내에서 해당 락을 얻으려고 했기 때문에 캐리어 스레드와 고정됐고, 결과적으로 스케줄러에 할당된 모든 캐리어 스레드가 정지됐습니다. 이 상황에서 플랫폼 스레드 A에서 락의 소유권을 해제하면 다음 순서인 가상 스레드 B가 해당 락을 가져갈 차례가 되는데요. 스케줄러에는 가상 스레드 B를 실행해 줄 캐리어 스레드가 남아있지 않기 때문에 가상 스레드 B는 다음 락을 가져오지 못하고, 결국 데드락 상태에 빠집니다(그림 5).
이런 상황은 애플리케이션에 심각한 영향을 끼칠 수 있습니다. 특히 각 HTTP 요청이 하나의 가상 스레드에 할당되는 서버 애플리케이션에서는, 각 요청을 처리해 줄 캐리어 스레드가 남아 있지 않게 되는 경우 서버가 더 이상 요청을 처리하지 못해서 더 이상 서버가 응답하지 않는 치명적인 상황이 발생할 수 있습니다.
JDK 진영에서도 이와 같은 고정 이슈의 심각성을 인지하고 이를 개선하기 위해 노력하고 있습니다. 예를 들어 synchornized
사용 시 고정 이슈가 발생하는 구체적인 원인은 객체의 모니터를 가상 스레드가 아닌 플랫폼 스레드가 소유하기 때문인데요. 이를 해결하기 위해 JVM의 synchronized
구현을 변경해 플랫폼 스레드가 아닌 가상 스레드 레벨에서 모니터를 관리하는 방향으로 개선하는 작업이 진행 중입니다. 이 사항은 JEP 491 문서에서 확인할 수 있습니다. 이 JEP 개발이 언제 완료될지 아직 자세히 알 수는 없지만, 완료된다면 더욱 많은 프로젝트에서 보다 적극적으로 가상 스레드를 도입할 것으로 기대하고 있습니다.
마치며
가상 스레드는 복잡한 비동기 코드를 사용하지 않아도 높은 동시성 성능을 발휘할 수 있기 때문에 점차 많은 곳에서 도입할 것으로 예상합니다. 이때 JDK 소스 코드를 보고 가상 스레드의 작동 원리를 조금 더 자세히 파악한다면 가상 스레드를 원래의 도입 목적에 맞게 사용할 수 있고, 문제 발생 시 빠르게 원인을 파악할 수 있을 것입니다.
지금까지 총 세 편에 걸쳐 가상 스레드의 생성과 시작, 컨텍스트 스위칭, 종료, 고정 이슈를 소스 코드 수준에서 어떤 원리로 작동하는지 혹은 어떤 원인 때문에 발생하고 어떻게 해결할 수 있는지 살펴봤는데요. 이 세 편의 글이 저희가 경험한 것처럼 독자 여러분께 추상적이었던 가상 스레드의 개념이 조금 더 구체적으로 다가오는 계기가 되기를 바라며 이만 마치겠습니다.