포스트

가상 스레드의 내부 - 스케줄러와 캐리어, 핀닝, 그리고 쓰지 않아야 할 때

가상 스레드를 “스레드를 무한히 만들 수 있게 해주는 것”으로 이해하면, 켜자마자 좋아질 것 같지만 그렇지 않은 경우를 만난다. 무엇이 바뀌고 무엇이 그대로인지는 구현을 봐야 갈린다.


무엇이 달라지는가

플랫폼 스레드는 OS 스레드와 1:1이다. 스택이 보통 1MB 잡히고, 생성과 컨텍스트 스위칭이 커널 일이다. 그래서 수천 개를 만들면 메모리와 스케줄링이 무너진다. 풀을 만들어 재사용하는 이유가 이것이다.

가상 스레드는 JVM이 관리한다. 스택이 GC 대상인 힙에 stack chunk 객체로 저장되고, 실행에 따라 늘었다 줄었다 한다(JEP 444). 그래서 수십만 개를 만들어도 된다. 실행할 때는 캐리어 스레드에 올라탄다. 캐리어는 기본적으로 work-stealing ForkJoinPool의 플랫폼 스레드이고, 병렬도의 기본값은 사용 가능한 프로세서 수다.

이 구조에서 블로킹의 의미가 바뀐다. 가상 스레드가 InputStream.read()나 Thread.sleep()으로 블로킹되면, JVM이 그 스레드의 스택을 힙에 저장하고(unmount) 캐리어를 놓아 준다. 캐리어는 다른 가상 스레드를 실행한다. IO가 끝나면 다시 올라탄다(mount).

stateDiagram-v2
    [*] --> Mounted: 캐리어에 올라탐
    Mounted --> Unmounted: IO·sleep으로 블로킹<br/>스택을 힙에 저장
    Unmounted --> Mounted: IO 완료 후 다시 올라탐
    Mounted --> Pinned: 언마운트할 수 없는 블로킹
    Pinned --> Mounted: 블로킹 해제
    Mounted --> [*]: 종료

그래서 얻는 것은 동기 코드의 모양으로 비동기의 동시성이다. 콜백도 리액티브 연산자도 없이, 스택 트레이스와 디버거가 그대로 동작하는 코드로 수만 개의 동시 요청을 다룬다.


핀닝: 캐리어가 묶이는 경우

가상 스레드가 unmount되지 못하고 캐리어를 쥔 채 블로킹되는 것을 핀닝이라 한다. 캐리어가 코어 수만큼밖에 없으므로, 핀닝된 가상 스레드가 그 수만큼 쌓이면 나머지는 아무것도 못 한다. 가상 스레드를 켰는데 동시성이 오히려 나빠지는 경우가 이것이다. 그 상황을 직접 재현하고 잰 결과는 가상 스레드를 켜서 37배 느려진 엔드포인트에 있다.

JEP 444가 꼽은 원인은 둘이다.

  • synchronized 블록 안에서의 블로킹. JDK 21 기준으로 모니터를 쥔 상태에서는 unmount할 수 없었다. 그래서 synchronized를 ReentrantLock으로 바꾸라는 권고가 나왔다. JDK 24(JEP 491)에서 이 제약이 해소됐으므로, 실행하는 JDK 버전에 따라 이 조언이 유효한지가 달라진다.
  • 네이티브 메서드나 foreign function 안에서의 블로킹. 이것은 여전히 핀닝이다.

JEP 491은 JDK 24 이후에도 남는 경우를 더 적는다. 클래스 로딩 중 블로킹, 클래스 초기화 블록 안의 블로킹, 다른 스레드의 클래스 초기화를 기다리는 경우다.

진단 방법도 버전에 따라 다르다. JDK 21~23에서는 -Djdk.tracePinnedThreads=full로 핀닝 발생 시 스택을 찍어 볼 수 있다. JEP 491은 JDK 24에서 이 속성을 제거해 설정해도 효과가 없게 했고, JFR의 jdk.VirtualThreadPinned 이벤트는 남은 핀닝 상황을 위해 유지했다.


무엇이 그대로인가

가상 스레드가 바꾸지 않는 것도 있다.

  • CPU 작업은 빨라지지 않는다. 캐리어 수가 코어 수이므로 계산 병렬도는 그대로다. JEP 444도 “Virtual threads are not faster threads — they do not run code any faster than platform threads.”라고 적는다. 가상 스레드는 속도가 아니라 규모(처리량)를 위한 것이고, 그 규모는 IO 대기에서 나온다.
  • 메모리 모델은 같다. happens-before 규칙이 그대로 적용된다(자바 메모리 모델).
  • 외부 자원의 상한은 그대로다. DB 커넥션 풀이 10개면 가상 스레드 10,000개가 그 10개를 기다린다. 동시성의 병목이 스레드에서 커넥션으로 옮겨갈 뿐이다.
  • ThreadLocal은 동작하지만 비싸질 수 있다. JEP 444는 가상 스레드도 플랫폼 스레드처럼 ThreadLocal을 지원한다고 적는다. 스레드가 수십만 개면 ThreadLocal도 그만큼 생긴다. ScopedValue가 그 대안으로 나왔다.

세 번째가 실무에서 가장 중요하다. 스레드 풀은 의도치 않은 동시성 상한의 역할도 하고 있었다. 그것을 없애면 상한이 사라지는 것이 아니라 보이지 않는 곳으로 이동한다.


상한이 사라진 자리

이후 ParityPay 9편에서 타임아웃 없는 외부 호출이 플랫폼 스레드 200개를 24초에 고갈시키는 것을 봤다. 같은 조건에서 가상 스레드는 붕괴하지 않는다. 대신 진행 중인 호출을 상한 없이 쌓는다. 서버는 살아 있고 응답은 오지 않으며, 느린 상대에게 요청을 계속 보낸다.

플랫폼 스레드의 고갈은 시끄러운 실패이고, 가상 스레드의 무한 누적은 조용한 실패다. 어느 쪽이든 상한은 명시적으로 둬야 한다. 세마포어 기반 벌크헤드(동시 호출 수를 permit 수로 제한하는 격벽)가 그 자리를 대신한다. spring-ops-lab의 S1에서 벌크헤드 permit 20개가 thread dump에 정확히 20개로 보인 것이 그 장치다.

그래서 가상 스레드로 옮기면 “스레드 풀 크기”라는 암묵적 상한을 잃고, 대신 명시적 상한을 스스로 설계해야 한다.


쓰지 않아야 할 때

  • CPU 바운드 작업. 이득이 없고 캐리어만 오래 쥔다. 기존 풀이 맞다.
  • 풀링이 자원 재사용을 위한 것일 때. DB 커넥션 풀은 스레드를 아끼려고 있는 것이 아니라 커넥션을 아끼려고 있다. 그대로 둔다.
  • JDK 21~23에서 synchronized 안 블로킹이 많은 코드. 바꾸기 전에 핀닝을 먼저 측정한다.
  • ThreadLocal에 크게 의존하는 프레임워크 위. 동작은 하지만 메모리 특성이 달라진다.

무엇을 재면 확인되는가

  1. 같은 부하에서 플랫폼 스레드 풀과 가상 스레드의 처리량·p99·메모리를 비교한다. IO 대기가 길수록 차이가 커진다.
  2. 핀닝 빈도와 위치를 본다. JDK 21~23은 -Djdk.tracePinnedThreads=full, JDK 24 이후는 JFR jdk.VirtualThreadPinned 이벤트로 본다.
  3. 외부 호출을 느리게 만들고 진행 중 호출 수를 센다. 가상 스레드에서 이 값에 상한이 있는지.
  4. 커넥션 풀을 그대로 두고 가상 스레드만 켰을 때 병목이 어디로 옮겨가는지.

3번과 4번이 spring-ops-lab의 S1에 추가할 행으로 남아 있다. 설계 문서에 “가상 스레드는 S1의 추가 행으로만”이라고 적어 뒀고, 아직 TBD다.


실무와의 접점

Spring과 Java 21에서 가상 스레드를 다뤘을 때는 기능 소개에 가까웠다. 지금 다시 보면 실무적 결론은 하나다. 가상 스레드는 “스레드를 아끼는” 문제를 푼다. 상한을 설계하는 문제는 그대로 남고, 오히려 더 명시적으로 해야 한다.


정리

  • 핀닝의 원인과 진단 도구는 JDK 버전에 따라 다르다. JDK 21~23의 synchronized 핀닝과 jdk.tracePinnedThreads는 JDK 24에서 각각 해소되고 제거됐다.
  • 플랫폼 스레드의 고갈은 시끄럽고, 가상 스레드의 누적은 조용하다. 둘 다 상한이 필요하다.
참고한 자료외부 출처 3 · 블로그 글 1

외부 출처

이 블로그의 관련 글

Spring과 JVM 백엔드 장애 대응과 관측
이 글은 저작권자의 CC BY 4.0 라이선스를 따릅니다.

댓글

아직 댓글이 없습니다