virtual thread 동기화: synchronized의 한계

allocProc
13 min readNov 16, 2023
Photo by Amanda Vick on Unsplash

TL;DR

  • virtual thread(이하 ‘VT’) 를 synchronized 블록과 함께 사용할 때는 조심하자.
  • synchronized 블록에서 blocking I/O 를 실행할 경우 VT 를 사용했다 하더라도 java thread(carrier thread) 가 블락된다.
  • VT 를 사용하는 프로젝트에서 임계영역을 ‘효율적으로’ 보호하고자 한다면 ReentrantLock 을 사용하자.

최근에 Spring Webflux 와 VT 를 사용한 기존 Spring Boot 의 성능을 비교하는 글을 읽었다. 결론을 보니 부하가 심해질수록 Webflux 가 성능이 좋다는듯. 아직은 Webflux 가 성능이 더 좋은가보다~ 하고 넘어가려는 찰나에 해당 글에 달린 흥미로운 댓글을 하나 발견한다.

위 댓글의 내용을 요약하면 아래와 같다.

  • Spring Boot 쪽이 성능이 안좋게 나온건 MySQL JDBC 드라이버 때문일 수 있다.
  • VT 의 성능을 최대한 이끌어 내고 싶으면 synchronized 블록을 사용해선 안된다.
  • synchronized 블록은 VT 를 java 스레드(캐리어 스레드) 에 '고정' 시킨다.
  • 이는 VT 의 장점을 상쇄시키는 것
  • MySQL JDBC driver 가 내부적으로 synchronized 블록을 많이 사용하므로 이를 사용해서 성능 테스트를 한 것은 적절하지 않다.

여기서 주목해야할 점은 ‘synchronized 블록이 VT 를 java 스레드(캐리어 스레드)에 고정(pinned)시킨다’ 고 언급한 부분이다. 만약 이 말이 사실이라면, blocking I/O 를 만났을 때 다른 VT 가 캐리어 스레드를 사용할 수 있도록 해당 캐리어 스레드를 반납하는 VT 의 핵심 동작이 없어진거나 마찬가지이므로 Spring Boot 가 제 성능을 내지 못했다는 주장도 이해가 된다.

전혀 몰랐던 사실이라 흥미롭지만 우선 이 말이 사실인지 부터 검증해보자. 먼저 OpenJDK 공식 문서를 찾아보니 VT 를 단독으로 다루는 페이지가 있다. 그리고 해당 문서의 절반즈음에 위 댓글 작성자가 언급했던 부분이 정확히 명시되어있었다.

  • 대부분의 blocking 작업은 VT 를 캐리어 스레드에서 분리(unmount)하여, 캐리어 스레드(OS 스레드)가 그 동안 다른 작업을 할 수 있도록 해준다.
  • 하지만 몇몇 blocking 작업은 VT 를 캐리어 스레드에서 분리하지 못한다. → pinning
  • 이는 OS 레벨의 한계 혹은 JDK 레벨의 한계 때문
  • pinning 을 유발하는 경우는 크게 두 가지가 있다.
    - synchronized 블록 혹은 메소드를 실행
    - 네이티브 메소드 실행
  • pinning 이 애플리케이션의 오동작을 유발하는건 아니지만 확장성을 해칠 수 있다.
  • 필요한 경우 ReentrantLock 을 대신 사용하라.

문서의 내용을 보니 synchronized 블록에서는 VT 가 캐리어 스레드로부터 분리되지 못한다는 작성자의 주장은 사실이다. 이와 더불어 ReentrantLock 을 대신 사용하라는 내용도 문서에 똑같이 나와있는걸 볼 수 있다. 그리고 이와 비슷한 내용은 스프링 공식 블로그에서도 찾아볼 수 있었다.

위 OpenJDK 공식 문서와 마찬가지로 VTsynchronized 블록과 함께 사용하면 발생하는 문제에 대해 언급하고 있고, 스프링 프레임워크 내부에서도 synchronized 블록을 사용하는 곳이 많은데 점차 없애나갈 예정이라고 한다.

실험

공식 문서까지 이렇게 이야기하는데 그런가보다 하고 넘어갈 수도 있지만 아직 찝찝함을 지울 수 없다. 진짜 문서에서 언급한 것 처럼 동작하는지 눈으로 확인해야 직성이 풀릴 것 같다. 한번 간단한 코드를 짜서 확인해보자.

class Cat {
fun sleep() {
log("고양이 잠 쿨쿨")
sleep(Duration.ofSeconds(1L))
log("고양이 잠 끝")
}
}

class Dog {
fun sleep() {
log("강아지 잠 쿨쿨")
sleep(Duration.ofSeconds(1L))
log("강아지 잠 끝")
}
}

위 고양이, 강아지 클래스는 1초씩 blocking 연산을 수행하는 sleep() 메소드를 하나씩 가지고 있다.

fun main() {
val catVT = ofVirtual().name("고양이")
.start {
Cat().sleep()
}

val dogVt = ofVirtual().name("강아지")
.start {
Dog().sleep()
}

catVT.join()
dogVt.join()
}
fun log(message: String) {
logger.info("{} | $message", Thread.currentThread())
}

그리고 VT 두개를 생성한 후 각각 고양이, 강아지 인스턴스 하나씩 생성하여 sleep() 메소드를 호출하도록 구현한다. 위 코드를 실행한 결과는 아래와 같다.

sleep() 메소드는 병렬로 잘 수행된걸 볼 수 있고 각각의 VT 는 두 개의 캐리어 스레드에서 실행되었다. 위 로그에서 볼 수 있듯이 VT 는 기본적으로 ForkJoinPool 를 통해 적합한 캐리어 스레드를 찾는다. 그리고 이 ForkJoinPool 의 캐리어 스레드 수는 기본적으로 해당 머신의 코어 수와 같다. 이와 관련된 코드는 VirtualThread.java 클래스의 createDefaultScheduler() 메소드의 내부를 보면 확인할 수 있다.

하지만 캐리어 스레드 두 개를 써버리면 VT 의 효용이 크지 않으니 캐리어 스레드를 하나로 제한해보자. 이를 위해 VM 옵션을 아래처럼 설정하고 다시 코드를 실행했다.

-Djdk.virtualThreadScheduler.parallelism=1 
-Djdk.virtualThreadScheduler.maxPoolSize=1
-Djdk.virtualThreadScheduler.minRunnable=1

캐리어 스레드를 하나로 제한했고, 두 VT 가 제한된 하나의 캐리어 스레드(worker-1)로 두 sleep() 메소드를 동시에 실행한 것을 볼 수 있다. 하지만 처음 결과와 다르게 ‘고양이 잠 쿨쿨’ 과 ‘강아지 잠 쿨쿨’ 출력 사이에 꽤 눈에 띄는 시간차(5ms)가 있다. 한 VT 가 캐리어 스레드에서 unmount 하고 다른 VT 가 다시 mount 하는 동작을 수행하는데 든 시간으로 보인다.

이제 얼추 준비는 끝났고 우리가 보고싶었던 VT pinning 을 재현해보자. 이를 위해 Cat 클래스의 sleep() 메소드에 synchronized 키워드를 붙여서 실행했다. 만약 VT 가 캐리어 스레드에 고정된다면 두 sleep() 메소드는 동시에 실행되지 못하고 1초 간격으로 따로 실행되어야한다.

class Cat {
@Synchronized
fun sleep() {
log("고양이 잠 쿨쿨")
sleep(Duration.ofSeconds(1L))
log("고양이 잠 끝")
}
}

class Dog {
fun sleep() {
log("강아지 잠 쿨쿨")
sleep(Duration.ofSeconds(1L))
log("강아지 잠 끝")
}
}

고양이 sleep() 메소드가 온전히 끝나고 나서야 강아지 sleep() 메소드가 실행되었다. 두 메소드는 동시에 실행되지 못했고 이는 ‘고양이’ VT 가 캐리어 스레드에서 unmount 하지 못했기 때문으로 보인다. 이게 pinning 때문이 맞는지 확인하는 확실한 방법이 하나 더 있다. VT pinning 이 발생하면 로그로 남겨주는 옵션(-Djdk.tracePinnedThreads=short)이 있는데, 이를 추가하고 다시 코드를 실행해보자.

로그도 Cat.sleep() 메소드에서 monitor lock 때문에 pinning 이 발생했다고 알려주고 있다.

이제 synchronized 블록을 ReentrantLock 으로 바꿀 경우 pinning 이 발생하지 않는지 확인해보자. 기존 Cat 클래스 코드를 아래 처럼 변경했다.

class Cat {
private val lock: Lock = ReentrantLock()

fun sleep() {
if (lock.tryLock(10, TimeUnit.SECONDS)) {
try {
log("고양이 잠 쿨쿨")
sleep(Duration.ofSeconds(1L))
log("고양이 잠 끝")
} finally {
lock.unlock()
}
}
}
}
class Dog {
fun sleep() {
log("강아지 잠 쿨쿨")
sleep(Duration.ofSeconds(1L))
log("강아지 잠 끝")
}
}

VT 가 하나의 캐리어 스레드로 동시에 잘 실행되었다. 조금 더 확실히 하기 위해 방금 전 synchronized 코드와 동일하게 tracePinnedThreads 옵션을 켠채로 실행했지만 pinning 과 관련한 아무런 로그도 출력되지 않았다.

이로써 synchronizedVT 의 pinning 을 유발하고 ReentrantLock 은 그렇지 않다는 걸 확인했다.

의문

그렇다면 여기서 또 하나 궁금한 점이 생긴다.

synchronized 블록이든 ReentrantLock 이든 둘 다 스레드 동기화를 위한 도구이고, 락을 획득하지 못하면 해당 스레드는 대기하도록 만드는 동작은 똑같은데 synchronizedVT 를 캐리어 스레드에 고정시키는 현상을 유발하는걸까? 아무래도 임계영역의 보호라는 기능에 있어서 이 둘의 내부적인 구현 방식이 크게 다른 모양이다. 이와 관련하여 조금 더 찾아보자.

synchronized 구현 상세

synchronized 키워드는 JVM 의 스펙일 뿐이고 그 내부적인 구현은 JVM 구현체마다 다르다. 구현을 보기 전에 스펙에 어떻게 명시되어있는지 짚고 넘어가보자. Oracle 공식 문서 중에 Threads and Locks 이라는 문서를 보면 Synchronization 이라는 제목의 단락이 있다.

여기서 monitor 라는 개념이 등장하는데, 우리가 흔히 알고있는 lock 그 자체라고 보면 될 것 같다. 스펙을 요약하면 아래와 같다.

  • 자바의 모든 객체는 monitor 를 가지고 있고 스레드는 이 monitor 를 통해 객체에 대한 lock 과 unlock 동작을 수행할 수 있다.
  • 동시에 하나의 스레드만 monitor 의 lock 를 가질 수 있다.
  • lock 을 획득하지 못한 다른 스레드는 lock 을 획득할 때 까지 block 된다.
  • synchronized 메소드는 자동으로 lock 동작을 수행하며 이 동작이 완전히 이루어지기 전까지 메소드 바디는 실행되지 않는다.

역시 스펙 문서 답게 ~ 해야한다 정도만 적혀있다. 모든 객체는 monitor 라는걸 가지고 있고 스레드는 이걸로 락을 수행하는구나 정도만 알고 넘어가면 될 것같다. 이제 HotSpot JVM 이 이 스펙을 어떻게 구현했는지 살펴보자.

OpenJDK 공식 문서 중 Synchronization and Object Locking 이라는 문서를 살펴보면 HotSpot JVM 에서 lock 이 어떻게 구현되었는지 대략적으로 설명되어있다.

  • HotSpot JVM 에서 객체는 메모리에 header 라는 필드를 가지고 있다. header 필드에는 해당 객체에 관한 여러가지 메타데이터가 저장되어 있고, 여기에 lock 과 관련된 정보도 저장된다.
  • 한 스레드가 lock 을 시도할 경우 해당 스레드의 스택 프레임에 lock record 를 생성하고 객체의 header 필드에 lock record 를 가리키는 포인터를 생성한다. → thin lock
  • 만약 여러 스레드가 lock 을 시도할 경우 ObjectMonitor 라는 별도의 객체를 생성하여 lock 을 관리한다.

문서를 아주 개략적으로 요약하면 위와 같고 이 밖에도 biased locking 를 비롯해 lock 과 관련한 여러가지 설명이 많지만 이번 글의 주제와 맞지 않아 생략했다.

보아하니 synchronized 는 java 수준이 아니라 더 밑의 JVM 수준에서 c++ 로 모두 구현되어있는 모양이다.

ReentrantLock 구현 상세

ReentrantLocksynchronized 와 다르게 java 로 쓰여있는데, java.util.concurrent.locks 패키지에 보면 RentrantLock.java 파일이 있다. 락을 획득하는 tryLock() 메소드를 한번 살펴보자.

생각보다 간단하다. 현재 락이 획득 가능한 상태면 현재 락의 소유를 현재 스레드로 변경하는 CAS 연산을 수행하고 락을 획득할 수 없는 상태면 false 를 반환한다. ReentrantLock 은 tryLock() 말고도 락을 유연하게 사용하기 위한 여러가지 메소드를 제공한다.

💡

이제 왜 synchronized 안에서는 VT 를 사용했음에도 캐리어 스레드가 block 됐는지 알 것 같다. ReentrantLock 은 java 수준에서 구현되어있고 lock 을 유연하게 사용할 수 있도록 만들어진 도구이다. 그래서 lock 과 관련된 동작들을 VT 수준에서 모두 수행할 수 있다. 그래서 VT 로 lock 을 획득하더라도 캐리어 스레드는 다른 일을 할 수 있는 것이다. 하지만 synchronized 는 애초에 JVM 수준에서 구현되어 있고 캐리어 스레드가 lock 과 관련된 동작을 하도록 구현되어있다. 그래서 synchronized 의 구현에 VT 와 관련된 대응 로직을 넣지 않는 이상 캐리어 스레드는 block 될 수 밖에 없다.

작은 궁금증 하나 해결하겠다고 생각보다 멀리 와버린 기분이다. “synchronized 안에서는 blocking 메소드 쓰면 안된다!” “혹시 임계 영역에서 blocking 메소드를 쓰려거든 ReentrantLock 을 사용하자!” 정도만 머릿속에 집어넣고 넘어가도 충분할 듯 하다.

MySQL Connector/J 레포지토리를 살펴보니 synchronized 관련 코드를 ReentrantLock 으로 대체하는 PR 올라오기는 했는데, 릴리즈될 수 있을까?

--

--