들어가며
지난 1편에서는 가상 스레드(virtual thread)의 장점을 살펴보고 가상 스레드를 어떻게 생성하고 시작하는지 알아봤습니다. 이어서 이번 글에서는 컨텍스트 스위칭(context switching)의 작동 방식을 살펴보려고 합니다. 1편에서 살펴본 VirtualThread 클래스의 멤버 변수와 가상 스레드 시작 시 수행하는 사전 작업을 어떻게 활용하는지 참고하면서 2편을 읽어보시면 조금 더 쉽게 이해하실 수 있을 것 같습니다.
- 생성과 시작
- 컨텍스트 스위칭
- 고정(pinned) 이슈와 한계
2편은 다음과 같은 순서로 진행합니다.
컨텍스트 스위칭의 작동 방식
가상 스레드가 시작된 후 블로킹 I/O 작업을 만나면 컨텍스트 스위칭이 발생합니다. 이때 해당 가상 스레드는 실행 중이던 캐리어 스레드(carrier thread)와의 매핑이 끊어지며, 이후 블로킹 I/O 작업이 완료됐을 때 다시 캐리어 스레드에서 작업이 재개됩니다. 이 작업은 가상 스레드의 park
와 unpark
메서드가 수행하는데 요. 각 메서드가 어떤 방식으로 작동하는지 소스 코드와 함께 자세히 알아보겠습니다.
1편과 마찬가지로 소스 코드는 OpenJDK 21+35가 기준이며, 글 중간에 등장하는 괄호 속 숫자는 함께 첨부한 소스 코드에서 각 설명에 해당하는 위치에 주석으로 남겨 놓은 숫자를 의미합니다. 가상 스레드의 작동을 보다 깊이 이해할 수 있도록 소스 코드와 함께 읽기를 권장합니다.
park 메서드
VirtualThread클래스의 park
메서드는 작업 중 블로킹 I/O 작업을 만나 해당 가상 스레드를 잠시 멈추고 캐리어 스레드에 다른 가상 스레드를 매핑하기 위해서 호출합니다. park
메서드에서는 가상 스레드의 상태를 'PARK' 상태로 변환하는 중이라는 의미인 PARKING
으로 변환(1)한 후 yieldContinuation
메서드를 호출합니다.
yieldContinuation
메서드는 unmount
(3)와 Continuation.yield
메서드(4)를 차례로 실행해 현재 캐리어 스레드에서 가상 스레드와의 연관 관계를 제거하고 캐리어 스레드를 다른 가상 스레드에게 양보합니다.
// java.lang.VirtualThread.java
final class VirtualThread extends BaseVirtualThread {
void park() {
...
setState(PARKING); // (1) 상태를 PARKING으로 설정
try {
yielded = yieldContinuation(); // (2) 내부 메서드 yieldContinuation 호출
} ...
}
private boolean yieldContinuation() {
...
unmount(); // (3) 현재의 캐리어 스레드에서 가상 스레드와의 연관관계 제거
try {
return Continuation.yield(VTHREAD_SCOPE); // (4) 현재 캐리어 스레드를 다른 가상 스레드에게 양보
} ...
}
}
Continuation
클래스의 yield
메서드(1)는 현재 실행 중인 작업을 중단하는 역할을 하며, 내부적으로 doYield
네이티브 메서드(2)를 호출합니다. doYield
네이티브 메서드는 현재 실행 중인 작업을 중단한 뒤 이후 다시 재개하기 위해 메모리의 스택 영역에 쌓인 프레임을 힙 영역에 객체로 저장한 후 스택에서 해당 프레임을 제거해 실행 중이던 작업을 빠져나오게 합니다.
// jdk.internal.vm.Continuation.java
public class Continuation {
public static boolean yield(ContinuationScope scope) { // (1)
...
return cont.yield0(scope, null);
}
private boolean yield0(ContinuationScope scope, Continuation child) {
...
int res = doYield(); // (2) 네이티브 메서드 실행
...
}
private native static int doYield(); // 네이티브 메서드
}
아래 그림은 park
메서드 실행 시 메모리 변화를 나타낸 그림입니다. 캐리어 스레드에서 main()
메서드를 실행한 후 first()
메서드에서 가상 스레드를 생성합니다. 가상 스레드에 포함된 작업은 Continuation
클래스의 run
메소드를 통해 실행됩니다. 이후 가상 스레드 내에서 second()
메서드를 실행한 뒤 메서드 내부의 블로킹 I/O 작업을 만나 VirtualThread
클래스의 park
메서드가 호출됩니다. 이때 Continuation.run()
메서드 이후에 실행돼 쌓인 스택 프레임은 힙 영역에 저장됩니다.
스택에서 힙 영역으로 옮겨지는 메서드 프레임의 범위는 Continuation.run()
이후에 실행된 작업부터 doYield
네이티브 메서드를 호출한 메서드 프레임까지입니다. 아래 OpenJDK C++ 소스 코드(continuationFreezeThaw.cpp)는 doYield
네이티브 메서드를 통해 실행되는 코드 중 힙에 저장되는 스택 범위에 대한 내용입니다. 여기서 스택 프레임의 주소는 높은 메모리 주소(+ 방향)에서 낮은 메모리 주소(- 방향)의 방향으로 쌓인다는 것에 주의하세요. 즉, 스택 프레임이 추가될 때마다 스택 포인터가 낮은 주소(- 방향) 방향으로 이동합니다.
소스 코드를 보면 힙에 저장할 스택의 범위를 _cont_stack_top
와_cont_stack_bottom
을 통해 정의합니다. _cont_stack_top
(1)은 doYield
네이티브 메서드를 호출한 마지막 스택 프레임(frame_sp
)(2)에서 doYield
네이티브 메서드 stub
프레임(doYield_stub_frame_size
)(3)은 제외합니다. _cont_stack_bottom
(4)은 enterSpeical
메서드를 통해서 진입한 지점(_cont.entrySP
)(5)에서 스택 프레임의 메타 데이터를 추가(frame::metadata_words_at_top
)하고, 정렬에 필요한 프레임(ContinuationHelper::frame_align_words(_cont.argsize())
)(6)은 제외합니다. 이렇게 도출된 범위 안의 스택 프레임은 힙에 StackChunk
객체로 저장됩니다.
// continuationFreezeThaw.cpp
FreezeBase::FreezeBase(JavaThread* thread, ContinuationWrapper& cont, intptr_t* frame_sp) : ... {
...
_cont_stack_top = frame_sp + doYield_stub_frame_size; // (1) = (2) + (3)
_cont_stack_bottom = _cont.entrySP() + (_cont.argsize() == 0 ? frame::metadata_words_at_top : 0) // (4) = (5) - (6)
- ContinuationHelper::frame_align_words(_cont.argsize());
...
}
아래 그림은 continuationFreezeThaw.cpp 소스 코드 상단에 주석으로 들어가 있는 스택을 표현한 그림을 간략화한 것입니다. + 방향의 최상단에 캐리어 프레임(carrier frame)이 위치하고, 이후에 호출된 프레임이 호출된 순서대로 -방향으로 쌓이는 것을 확인할 수 있습니다.
// continuationFreezeThaw.cpp
+----------------------------+
| carrier frames |
|----------------------------|
| Continuation.run |
|============================|
| enterSpecial frame |
^ |============================| <-- (5) JavaThread::_cont_entry
(+) | | ? alignment word ? | <-- (6)
| |----------------------------| <--\ (4) _cont_stack_bottom
| | | |
| | ? caller stack args ? | |
Address | | | |
| |----------------------------| |
| | frame | |
| +----------------------------| \__ Continuation frames
(-) | | frame | /
| |----------------------------| |
| frame | |
|----------------------------| <--/ (1) _cont_stack_top
| doYield/safepoint stub | <- (3) doYield_stub_frame_size
|============================| <- (2) the sp passed to freeze
| Native freeze/thaw frames |
+----------------------------+
doYield
네이티브 메서드를 통해 현재의 스택 프레임을 힙 영역에 StackChunk
객체로 저장한 후 VirtualThread
클래스의 Continuation.run
메서드를 호출했던 부분(1)으로 돌아가 이후 작업인 afterYield
(2) 메서드를 실행합니다. 이때 가상 스레드의 상태는 앞서 VirtualThread.park()
메서드에서 변경한 PARKING
이므로 afterYield
메서드에서는 상태를 PARKED
로 변경한 후(3), 리턴합니다. 이렇게 가상 스레드는 실행 중이던 작업을 멈추고 대기 상태에 빠집니다.
// java.lang.VirtualThread.java
final class VirtualThread extends BaseVirtualThread {
private void runContinuation() {
...
try {
cont.run(); // (1)
} finally {
if (cont.isDone()) {
afterTerminate();
} else {
afterYield(); // (2) cont.run()에서 yield된 경우, afterYield 메서드 실행
}
}
}
private void afterYield() {
...
if (s == PARKING) {
setState(PARKED); // (3)
...
} ...
}
}
unpark 메서드
park
메서드를 통해 중지된 작업을 다시 실행하려면, 힙 영역에 저장된 StackChunk
객체(아래 그림에서 cont
)를 스택 영역으로 다시 불러와 중지된 부분부터 이어서 진행해야 합니다.
이와 같은 복구 과정은 VirtualThread
클래스의 unpark
메서드를 통해 수행됩니다. unpark
메서드가 호출될 때 가상 스레드의 상태가 PARKED
면, submitRunContinuation
메서드(1)를 통해 스케줄러에게 runContinuation
메서드를 실행하도록 합니다. 그렇게 Continuation.run
으로 진입해서 enterSpecial
네이티브 메서드(2)를 호출합니다. 이때 이전에 시작했던 적이 있다면 이전에 park
메서드 실행 시 힙에 저장한 StackChunk
객체를 사용해 기존 작업을 재개하기 위해 isContinue
인자를 true
로 설정합니다.
// java.lang.VirtualThread.java
final class VirtualThread extends BaseVirtualThread {
void unpark() {
Thread currentThread = Thread.currentThread();
if (!getAndSetParkPermit(true) && currentThread != this) {
int s = state();
if (s == PARKED && compareAndSetState(PARKED, RUNNABLE)) {
...
try {
submitRunContinuation(); // (1) 해당 메서드에서 scheduler.execute(runContinuation)가 실행됨
}
....
}
}
// jdk.internal.vm.Continuation
public class Continuation {
public final void run() {
while (true) {
...
try {
boolean isVirtualThread = (scope == JLA.virtualThreadContinuationScope());
if (!isStarted()) { // 첫번째 실행하는 케이스
enterSpecial(this, false, isVirtualThread);
} else {
assert !isEmpty();
enterSpecial(this, true, isVirtualThread); // (2) 일시중지 후 다시 실행하는 현재와 같은 경우에는 isContinue 인자를 true로 넣어서 호출
}
} finally {
...
}
}
...
}
private native static void enterSpecial(Continuation c, boolean isContinue, boolean isVirtualThread);
}
enterSpecial
네이티브 메서드는 이전에 중지된 작업이 있을 경우 doYield
네이티브 메서드와는 반대로 힙 영역에 저장된 Continuation
의 스택 프레임을 복원한 뒤 이어서 실행합니다. 이때 현재 스택에 쌓여있는 Continuation.run()
프레임 위에 복원해 작업을 이어서 진행합니다. 네이티브 메서드의 구현을 보면, isContinue
플래그를 확인한 후(1) 이전에 StackChunk
로 저장했던 객체를 스택으로 복원해 중지된 시점부터 다시 처리합니다(2).
// sharedRuntime_x86_64.cpp
static void gen_continuation_enter(MacroAssembler* masm, ... ) {
...
__ testptr(reg_is_cont, reg_is_cont); // (1) isContinue 플래그를 확인해 중간에 일시 정지된 작업인지 확인
__ jcc(Assembler::notZero, L_thaw);
...
__ bind(L_thaw);
__ call(RuntimeAddress(StubRoutines::cont_thaw())); // (2) cont_thaw를 호출해 StackChunk에서 스택 프레임을 현재 스택으로 복원
...
}
컨텍스트 스위칭 예시 살펴보기 - NioSocketImpl 클래스
가상 스레드가 블로킹 I/O 작업을 만날 때 컨텍스트 스위칭이 어떻게 발생하는지 예시를 통해 확인해 보겠습니다. NioSocketImpl
클래스는 소켓을 이용한 TCP 통신에 주로 사용하는 소켓 구현체입니다. TCP 통신에서 요청을 보내고 받는 I/O 과정에서 가상 스레드가 어떻게 park
되고 unpark
되는지 확인해 보겠습니다.
park 메서드 실행 과정
TCP를 통해 서버와 통신하기 위해 클라이언트는 서버에 TCP 요청을 보냅니다. 이후 NioSocketImpl
클래스의 implRead
메서드를 통해 읽기 요청을 하는데요. 이 때 읽기 작업이 바로 완료되지 않으면 park
메서드로 넘어갑니다(1). NioSocketImpl
클래스의 park
메서드는 읽기 요청을 한 FileDescriptor
에서 읽기 준비가 완료될 때까지 기다리는 역할을 합니다. 이때 가상 스레드인 경우 Poller.poll
메서드를 통해서 폴링을 요청합니다(2).
// sun.nio.ch.NioSocketImpl.java
public final class NioSocketImpl extends SocketImpl implements PlatformSocketImpl {
private int implRead(byte[] b, int off, int len) throws IOException {
...
n = tryRead(fd, b, off, len);
while (IOStatus.okayToRetry(n) && isOpen()) {
park(fd, Net.POLLIN); // (1) tryRead를 호출 후 읽기 작업이 바로 완료되지 않으면 park() 호출
n = tryRead(fd, b, off, len);
}
...
}
private void park(FileDescriptor fd, int event, long nanos) throws IOException {
Thread t = Thread.currentThread();
if (t.isVirtual()) {
// (2) 가상 스레드의 경우 Poller.poll 메서드를 호출해 폴링 요청
Poller.poll(fdVal(fd), event, nanos, this::isOpen);
if (t.isInterrupted()) {
throw new InterruptedIOException();
}
} ...
}
}
Poller
클래스는 요청한 I/O 작업이 완료됐는지 확인하는 폴링 역할을 수행합니다. Poller
클래스가 로드될 때 정적 초기화 블록에서 폴링을 수행하는 스레드를 생성하고 정적 변수로 관리합니다(1). Poller
클래스의 poll
정적 메서드를 호출하면, 읽기/쓰기 작업에 따라 적절한 폴러(poller)에 폴링 작업을 할당합니다(2, 3). Poller.poll()
메서드는 pollDirect
메서드를 호출하며, pollDirect
메서드는 폴러의 폴링 리스트에 등록하고(4) LockSupport.park()
메서드를 호출합니다(5).
// sun.nio.ch.Poller.java
public abstract class Poller {
// (1) 정적 초기화 블록으로 Poller 클래스에서 관리해 주는 폴러들을 생성해줌
static {
...
try {
Poller[] readPollers = createReadPollers(provider); // (2) 읽기 전용 폴러 생성
READ_POLLERS = readPollers;
READ_MASK = readPollers.length - 1;
Poller[] writePollers = createWritePollers(provider); // (3) 쓰기 전용 폴러 생성
WRITE_POLLERS = writePollers;
WRITE_MASK = writePollers.length - 1;
} ...
}
// (4) Poller.poll() 메서드는 내부에서 pollDirect 메서드를 호출
private void pollDirect(int fdVal, long nanos, BooleanSupplier supplier) throws IOException {
register(fdVal); // 폴러가 폴링하는 리스트에 등록
try {
boolean isOpen = supplier.getAsBoolean();
if (isOpen) {
if (nanos > 0) {
LockSupport.parkNanos(nanos);
} else {
LockSupport.park(); // (5) 파킹 진행
}
}
} ...
}
}
LockSupport
클래스는 스레드의 동기화를 지원하기 위한 유틸리티 클래스로 java.util.concurrent
패키지에 위치합니다. LockSupport
클래스의 park
메서드(1)는 해당 스레드의 타입에 맞는 park
메서드를 호출하며, 궁극적으로 VirtualThread
클래스의 park
메서드가 호출됩니다(2).
이와 같은 과정을 통해 가상 스레드는 TCP 요청을 보낸 뒤 PARKED
상태로 전환되고, 이후 작업은 실행되지 않고 멈추며, 캐리어 스레드는 다른 가상 스레드로 컨텍스트 스위칭해 새로운 작업을 시작합니다.
unpark 메서드 실행 과정
TCP의 응답이 클라이언트 쪽에 도착했을 때 가상 스레드가 어떻게 다시 작업을 재개하는지 확인해 보겠습니다. 작업 재개는 폴러에서 폴링 중 소켓 데이터 수신이 확인된 순간부터 진행됩니다(1). Poller
클래스 구현체인 KQueuePoller
(macOS 기준)에서 KQueue.poll()
메서드를 통해 이벤트 수신이 확인되면, 이벤트 받은 곳을 순회하며 후처리를 진행합니다(2). 폴러는 사전에 보관하던 스레드 목록에서 소켓 데이터를 요청했던 스레드를 찾아(3) LockSupport.unpark
메서드에 인자로 넣고 호출합니다(4).
// sun.nio.ch.KQueuePoller.java
class KQueuePoller extends Poller {
@Override
int poll(int timeout) throws IOException {
int n = KQueue.poll(kqfd, address, MAX_EVENTS_TO_POLL, timeout); // (1) 소켓 데이터 수신이 확인됨
int i = 0;
while (i < n) { // (2) 순회하면서 이벤트 받은 곳을 순회
long keventAddress = KQueue.getEvent(address, i);
int fdVal = KQueue.getDescriptor(keventAddress);
polled(fdVal); // 해당 파일 디스크립터를 후처리
i++;
}
return n;
}
}
// sun.nio.ch.Poller.java
public abstract class Poller {
final void polled(int fdVal) {
wakeup(fdVal); // wakeup 메서드를 호출
}
private void wakeup(int fdVal) {
Thread t = map.remove(fdVal); // (3) 소켓 데이터를 요청했던 스레드를 찾음
if (t != null) {
LockSupport.unpark(t); // (4) 해당 스레드의 unpark 진행
}
}
}
LockSupport.unpark
메서드는 결과적으로 VirtualThread.unpark
메서드를 호출해 힙 영역에 저장된 StackChunk
객체를 다시 스택 영역에 넣어줍니다. 힙 영역에 저장했던 메서드 프레임을 다시 스택 안으로 불러올 경우, 소켓 읽기를 요청한 후 park
메서드를 호출했던 곳인 NioSocketImpl.implRead
메서드 안으로 돌아와 이후 작업을 이어서 진행합니다. 즉 이전에 작업을 멈췄던 park
메서드를 호출한 지점(1)으로 돌아와 그다음 라인인 tryRead
메서드를 호출해 소켓에 들어온, 서버로부터 받은 데이터를 읽는 작업부터 시작합니다.
// sun.nio.ch.NioSocketImpl.java
public final class NioSocketImpl extends SocketImpl implements PlatformSocketImpl {
private int implRead(byte[] b, int off, int len) throws IOException {
...
n = tryRead(fd, b, off, len);
while (IOStatus.okayToRetry(n) && isOpen()) {
park(fd, Net.POLLIN); // (1)
n = tryRead(fd, b, off, len); // (2) 소켓에 들어온 데이터 확인
}
...
}
}
마치며
2편에서는 가상 스레드의 컨텍스트 스위칭이 구체적으로 어떤 과정으로 진행되는지 알아봤습니다. park
와 unpark
메서드를 통해 가상 스레드가 캐리어 스레드와의 매핑이 끊어진 후 다시 연결되는 과정을 확인했고, NioSocketImpl
클래스를 통해, 소켓 통신의 예시에서 컨텍스트 스위칭이 구체적으로 어떻게 진행되는지 살펴봤습니다. 마지막 3편에서는 가상 스레드의 중요한 이슈인 고정(pinned) 이슈와 함께 가상 스레드의 한계를 알아보겠습니다.