본문 바로가기

이거저거

Java - Virtual Thread - 고정 (Pinning)

Java 21에서 추가된 Virtual Thread(이하 가상 스레드)의 고정(Pinning)에 대해서 고정되는 이유를 중심으로 몇 가지 내용들을 얘기해 보겠습니다. 가상 스레드에 대해서는 어느 정도 알고 있다는 것을 전제로 작성됐습니다.

0. 기본 용어 및 개념

가상 스레드는 가상 스레드 스케줄러에 의해서 플랫폼 스레드에 할당되어 실행됩니다. 이 때 가상 스레드가 플랫폼 스레드에 할당되는 것을 mount(이하 마운트)라는 단어를 사용하고, 할당이 해제되는 것을 unmount(이하 언마운트)라는 용어를 사용합니다. 그리고, 특정 가상 스레드가 할당되어 실행되게 해 주는 플랫폼 스레드를 캐리어(carrier)라고 부릅니다. 보통 가상 스레드는 캐리어를 통해 실행되다가 블록킹 IO 작업을 하게 되는 경우나 Object.wait가 호출될 때 캐리어에서 언마운트됩니다. 그리고, 다시 해당 작업이 끝나면 스케줄러에 의해 그 시점에 사용 가능한 캐리어에 마운트 되어 이어지는 작업들을 수행하게 됩니다. 그런데, 가상 스레드가 언마운트가 돼야 하는데 그렇지 못하게 되는 경우가 있습니다. 이런 경우를 고정(Pinning)이라고 합니다. 그리고, 해당 가상 스레드를 고정된 가상 스레드(pinned virtual thread)라고 부릅니다. 언마운트가 되지 못하는, 즉 고정되는 경우는 다음과 같은 경우입니다.

  1. 가상 스레드가 synchronized 메소드나 블록을 실행할 때
  2. 네이티브 메소드를 실행하거나 외래 함수(foreign function)를 호출할 때

여기에서 네이티브 메소드는 java가 아닌 C나 C++ 같은 다른 언어로 구현되어 JNI를 통해 호출되는 메소를 얘기하며, 외래 함수는 JEP 424로 정의된 것으로서, JNI 없이 직접 네이티브 코드를 호출할 수 있는 Java API입니다.

마운트, 언마운트
mount, unmount를 연결, 연결 해제로 이야기하지 않고 그대로 마운트, 언마운트라고 표현했습니다. 실제로 디스크를 마운트 하는 것을 연결한다고도 많이 표현하기 때문에 그렇게 용어를 사용할 수도 있었지만, 정확히는 얹혀간다는 의미이기 때문에 영어 그대로 마운트, 언마운트라는 단어를 사용했습니다.

1. 고정되는 이유

두 번째 이유부터 간단히 얘기하면, 네이티브 메소드는 JVM 외부기 때문에 가상 스레드 스케줄링 체계와 호환이 어려워 그렇다고 보면 됩니다. 그렇다면, 첫 번째 이유는 JVM 내부 세상인데 왜 그럴까요? 이 부분은 좀 자세히 살펴보겠습니다.

Java에서 synchronized 키워드에 의한 묵시적 락(Lock)을 내재적 락(intrinsic lock) 또는 모니터 락(monitor lock)이라고 부릅니다. 여기서 모니터 락이라고 부르는 이유는 동시성과 관련된 이론 및 용어 중에 모니터에 기반하기 때문입니다. Java에서 모든 객체는 획득, 보유, 해제할 수 있는 모니터라고 부르는 것을 가지고 있습니다. 하나의 객체의 모니터는 한 번에 하나의 스레드만 소유할 수 있습니다. 어떤 스레드가 특정 인스턴스의 synchronized 메소드를 실행하려면 해당 객체의 모니터를 획득해야 하고, 실행이 끝나면 모니터를 해제(release)합니다.

JVM은 synchroized 키워드를 구현하기 위해 현재 어떤 스레드가 객체의 모니터를 보유하고 있는지 추적합니다. 여기서, 추적하는 스레드는 가상 스레드가 아니라 플랫폼 스레드(캐리어 스레드)입니다. 그래서, 가상 스레드가 객체의 모니터를 획득하면 JVM은 그 가상 스레드가 아닌 가상 스레드의 캐리어 스레드가 모니터를 획득한 것으로 기록합니다. 이 상황에서 synchronized 메소드(또는 블록)내에서 블록킹 IO 등의 언마운트 조건이 있어서 가상 스레드를 캐리어에서 언마운트 하면 어떻게 될까요?

다시 한번 상황을 정리해서 얘기해 보겠습니다. 객체(인스턴스) Foo에 bar라는 synchronized 메소드가 있다고 하겠습니다. 가상 스레드 A는 캐리어 스레드 FooBar에 마운드 되어 실행될 때, bar()를 호출합니다. 이때 FooBar는 Foo의 모니터를 획득하게 되고 JVM의 이를 추적합니다. bar를 실행하다 중간 부분에서 블록킹 IO 작업을 만났습니다. 가상 스레드 스케줄러는 FooBar에서 A를 언마운트하고, 다른 가상 스레드 B를 마운트 시킵니다. 이 상황에서 Foo 모니터는 계속 A한테 속해야 함에도 모니터는 캐리어 스레드에 속하게 되어, B에게 속하는 상황이 됩니다. 그러면, 결국 B가 락을 갖게 되는 상황이 되며, 원래 A가 가지고 있어야 하는 락 상태가 무너지게 됩니다. 그래서, 가상 스레드 내에서 synchronaized 메소드나 블록이 실행될 때는 해당 블록이 다 종료될 때까지 언마운트 가능 상황이어도 언마운트 되지 않고 고정됩니다.

synchronized에 의한 고정은 조만간 해결될 예정으로 보입니다. JEP 491: Synchronize Virtual Threads without Pinning 에서 이를 해결하기 위한 방법을 제정하였고, Java 24에 적용될 예정입니다. 

2. 가상 스레드 고정이 문제가 되는 경우

일반적으로 가상 스레드의 고정은 고정되는 시간이 길어지지 않는 이상 크게 문제 되지 않고, 자연스럽다고도 볼 수 있습니다. 하지만, 고정되는 시간이 길어지면 문제가 됩니다. 가상 스레드가 고정되어 캐리어 스레드가 반환이 못 되는 상황이 지속되는 경우 이를 보완하기 위해 캐리어 스레드가 새로 추가되지 않습니다. 그러므로, 고정되는 시간이 긴 가상 스레드가 많아지면, 모든 캐리어 스레드들을 점유하게 되는 현상이 발생할 수 있습니다. Spring Boot의 경우에도 공식 문서에서 가상 스레드 사용 옵션을 활성활 할 때 이 부분을 잘 검토하라고 얘기하고 있습니다.

3. 가상 스레드 고정을 피하는 방법

가상 스레드 고정을 피하는 방법은 다음의 두 가지 중에 하나입니다.

  • ReentrantLock 사용
  • 가상 스레드를 사용하는 부분을 플랫폼 스레드로 변경

ReentrantLock은 Lock 인터페이스를 구현한 상위 수준의 락 구현체로서 JVM 내부적인 모니터를 이용하는 것이 아니라 클래스 내부의 로직을 통해 락을 구현하고 있습니다. 그래서, 가상 스레드든 플랫폼 스레드든 상관없이 동일하게 동작합니다. 그래서, synchronized를 ReentrantLock 사용으로 변경하면 고정 현상을 발생하지 않습니다. 관심 있는 분들은 ReentrantLockAbstractQueuedSynchronizer 클래스의 소스 코드를 살펴보시는 것도 좋을 거 같습니다.

 

4. synchronized를 ReentrantLock으로 꼭 변경해야 하는가

  • 성능에 영향을 미친다면 반드시 변경
  • 그렇지 않다면 코드 문맥적 상황에 따라 변경 여부 결정

가상 스레드의 고정 현상이 성능이나 확장성(scalability)에 영향을 미친다면 반드시 변경해야겠지만 그렇지 않다면 반드시 변경하기보다는 코드 문맥적 상황에 따라 선택하는 것이 자연스럽습니다. 자바 병렬 프로그래밍 (Java Concurrency in Practice) 13.4에서 얘기하고 있는 것처럼 묵시적 락인 synchronized를 위주로 쓰면서, 명확한 지점에서 락을 걸거나, 락을 확보할 때 타임아웃을 지정해야 하거나, 폴링 형태로 락을 확보해야 하는 경우 등에서는 명시적인 락인 ReentrantLock을 사용하면 될 것입니다.

Java 24가 별문제 없이 나오면 고민 안 해도 될 문제이기는 한데, Java 24로 전환 전까지는 살짝 고민해야 할 사항이기는 합니다.

5. 참고