포스트

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

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

무엇이 달라지는가

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

가상 스레드는 JVM이 관리한다. 스택이 힙에 저장되고 필요한 만큼만 자란다. 수십만 개를 만들어도 된다. 실행할 때는 캐리어 스레드(기본적으로 ForkJoinPool의 플랫폼 스레드, 병렬도는 보통 CPU 코어 수)에 올라탄다.

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

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

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

가상 스레드가 unmount되지 못하고 캐리어를 쥔 채 블로킹되는 것을 핀닝이라 한다. 캐리어가 코어 수만큼밖에 없으므로, 핀닝된 가상 스레드가 그 수만큼 쌓이면 나머지는 아무것도 못 한다. 가상 스레드를 켰는데 동시성이 오히려 나빠지는 경우가 이것이다.

원인은 둘이다.

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

진단은 -Djdk.tracePinnedThreads=full로 핀닝 발생 시 스택을 찍어 보는 것이다.

무엇이 그대로인가

가상 스레드가 바꾸지 않는 것이 중요하다.

  • CPU 작업은 빨라지지 않는다. 캐리어 수가 코어 수이므로 계산 병렬도는 그대로다. 가상 스레드는 IO 대기를 위한 것이다.
  • 메모리 모델은 같다. happens-before 규칙이 그대로 적용된다(자바 메모리 모델).
  • 외부 자원의 상한은 그대로다. DB 커넥션 풀이 10개면 가상 스레드 10,000개가 그 10개를 기다린다. 동시성의 병목이 스레드에서 커넥션으로 옮겨갈 뿐이다.
  • ThreadLocal은 동작하지만 비싸질 수 있다. 스레드가 수십만 개면 ThreadLocal도 그만큼 생긴다. ScopedValue가 그 대안으로 나왔다.

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

상한이 사라진 자리

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

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

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

쓰지 않아야 할 때

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

무엇을 재면 확인되는가

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

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

실무와의 접점

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

정리

  • 가상 스레드는 JVM이 관리하고 블로킹 시 캐리어를 놓아 준다. 동기 코드의 모양으로 높은 동시성을 얻는다.
  • 핀닝은 캐리어를 쥔 채 블로킹되는 것이고, synchronized(JDK 23 이하)와 네이티브 프레임에서 생긴다.
  • CPU 작업, 메모리 모델, 외부 자원 상한은 그대로다. 병목이 스레드에서 커넥션으로 옮겨갈 뿐이다.
  • 스레드 풀은 암묵적 동시성 상한이기도 했다. 그것을 없애면 상한을 명시적으로 설계해야 한다.
  • 플랫폼 스레드의 고갈은 시끄럽고, 가상 스레드의 누적은 조용하다. 둘 다 상한이 필요하다.

참고

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

댓글

아직 댓글이 없습니다