본문 바로가기

탐구일지/Java

Java의 Lock을 기다리는 Thread는 어떻게 잠들까?

1. 배경

 

Java에서 여러 Thread가 하나의 공유 자원에 동시에 접근한다면

데이터의 일관성 보장을 위해  synchronized 나  ReentrantLock 을 사용할 수 있다

 

하나의 Thread가 이미 Lock을 획득하고 있다면,

다른 Thread는 Lock이 해제될 때까지 Critical Section에 진입하지 못한다

 

일반적으로 이런 상황을 두고 Lock을 획득하지 못한 Thread가 Blocking되어 대기한다고 표현한다

그렇다면 Blocking된 Thread는 실제로 무엇을 하고 있을까?

 

Lock을 획득할 때까지 CPU를 계속 사용하며 대기하는 Spin Wait 형태일까?

아니면 실행을 중단하고 CPU 자원을 다른 Thread에 넘기는 것일까?

 

또  synchronized 와  ReentrantLock 은 서로 다른 방식으로 Lock을 관리하는데,

Lock을 획득하지 못한 Thread를 실제로 멈추고 다시 깨우는 과정 역시 같을까?

 

이번 글에서는 두 Lock의 내부 동작에서 시작해

Thread가 어떻게 대기 상태로 전환되고 다시 실행 가능한 상태가 되는지 살펴보고자 한다

 

2. Synchronized와 ReentrantLock의 관리 계층

 

Java에서는 위의 두 가지 Lock 매커니즘을 활용해 접근을 제어한다

 

두 방식 모두 하나의 Thread가 Lock을 획득하면 다른 Thread가 해당 Lock을 획득할 때까지 대기하는 점에서는 비슷하다

 

하지만 Lock과 대기 Thread를 관리하는 위치는 서로 다르다

 

 synchronized 는 Java 언어와 JVM이 직접 지원하는 Monitor 기반의 동기화 방식이다

synchronized (lock) {
    count++;
}

 

하나의 객체에는 Monitor가 논리적으로 연결되며,

동일한 Monitor를 대상으로 여러 Thread가 경쟁하면 JVM이 Lock의 소유권과 대기 Thread를 관리한다

 

따라서  synchronized 를 사용하는 코드에서는 애플리케이션이 별도의 관리를 하지 않는다

 

반면  ReentrantLock 은 Java의 패키지에 구현된, 애플리케이션 레벨의 Lock이다

Lock lock = new ReentrantLock();

lock.lock();

try {
    count++;
} finally {
    lock.unlock();
}

 

 ReentrantLock 의 내부 구현은 AQS( AbstractQueuedSynchronizer )를 기반으로 한다

 

AQS는 하나의 동기화 상태값과 FIFO 형태의 Sync Queue를 이용해

Lock을 획득하지 못한 Thread를 관리하기 위한 기반 구조를 제공한다

 

3. Synchronized의 Thread 대기

 

앞서

 synchronized 는 JVM의 Monitor를 기반으로 Lock과 대기 Thread를 관리한다고 살펴보았다

 

여기서 Monitor는 Java 객체와 논리적으로 연결되는 동기화 개념이고,

HotSpot에서는 실제 대기 Thread를 관리하기 위해  ObjectMonitor 라는 내부 구조를 사용한다

(이 글에서는 Java에서 널리 사용되는 HotSpot JVM을 예시로 구현 형태를 알아보도록 하겠다)

 

객체가 생성될 때마다 항상  ObjectMonitor 가 함께 생성되는 것은 아니다

OpenJDK 공식 문서에는 아래와 같이 설명한다

 


If retrieval of the ObjectMonitor fails, because there is no ObjectMonitor, either because this is the first time inflating or the ObjectMonitor has been deflated a new ObjectMonitor must be created and associated with the object. (출처)


 

즉,  ObjectMonitor 는 단순한 Lock 상태만으로 처리하기 어려워져 Monitor Inflation이 발생할 때 필요해지는 구조다

 

실제로 HotSpot JVM에서는 경합이 크지 않은 경우 바로 Kernel에 진입하지 않고,

CAS 등의 원자적 연산과 짧은 Spin을 통해 Lock 획득을 다시 시도할 수 있다

 

하지만 계속해서 Lock을 획득하지 못한다면 Thread를 CPU에서 계속 실행시키는 것보다 대기 상태로 전환하는 것이 효율적이다

 

이 경우에  ObjectMonitor 가 Lock을 기다리는 Thread를 내부 대기 구조에 등록하고,

JVM 내부의 Thread Parking 메커니즘을 통해 실행을 중단시킨다

 

4. ReentrantLock의 Thread 대기

 

 ReentrantLock 은 생성할 때 Lock 획득 순서에 대한 정책을 선택할 수 있다

new ReentrantLock();        // Unfair
new ReentrantLock(true);    // Fair

 

기본값은 Unfair이다

 

Unfair Lock에서는 새로운 Thread가  lock() 을 호출했을 때,

이미 대기 중인 Thread가 존재하더라도 현재 Lock이 비어 있다면 바로 획득을 시도할 수 있다

 

따라서 먼저 기다리던 Thread보다 나중에 진입한 Thread가 Lock을 먼저 획득하는 상황이 발생할 수 있다

 

반면 Fair Lock은 Lock이 비어 있더라도 자신보다 먼저 기다리고 있는 Thread가 존재하는지 확인한다

이에 따라 새 Thread가 Lock을 바로 가져가지 않고 대기 순서를 따른다

 

즉 두 방식의 차이는 Lock을 획득하기 전에 기존 대기 순서를 고려하는지 여부에 있다

하지만 Lock을 획득하지 못한 이후의 대기 방식은 공통적으로 AQS를 기반으로 한다

 

Thread는 AQS의 Sync Queue에 등록되고,

더 이상 Lock을 획득할 수 없는 상태라면  LockSupport.park() 를 통해 실행을 중단한다

 

이후 Lock을 소유하고 있던 Thread가  unlock() 을 호출하면

대기 중인 Thread가 다시 Lock 획득을 시도할 수 있도록 깨워진다

 

정리하면  ReentrantLock 은 AQS를 통해 대기 순서와 상태를 관리하고,실제로 Thread의 실행을 멈추는 과정에서는  LockSupport.park() 를 사용한다

 

5. LockSupport와 park()

 

 park() 가 호출되면 현재 Thread는 더 이상 실행되지 않고,다시 실행될 수 있는 조건이 충족될 때까지 대기한다

 

이때  LockSupport 는 Thread마다 하나의 Permit을 갖는 것처럼 동작한다

Permit은 최대 하나까지만 유지되어, 개념적으로 0/1 Permit 형태를 지닌다

 

Permit이 1인 경우 한 번의   park() 를 Blocking하지 않고 지나갈 수 있으며,Permit이 0인 경우에는 Thread Blocking이 발생한다

 

이는  park() 와  unpark() 의 실행 순서가 뒤집혀도 신호를 잃지 않도록 하기 위함이다

 

단순한 sleep-wakeup 구조라면 wakeup이 먼저 발생하고 sleep에 진입하는 경우, wakeup 신호를 놓쳐 영원히 대기할 수 있다

이를 깨우는 신호가 먼저 왔는데 당시 Thread가 자고 있지 않아 신호가 사라지는 Lost Wakeup 문제라 한다

 

 unpark() 는 대상 Thread가 사용할 수 있는 Permit을 제공하고,  park()  상태라면 다시 실행 가능한 상태가 되도록 한다

 

Thread가 다시 실행되더라도 Lock을 실제로 획득할 수 있는지는 다시 확인해야 한다

때문에 AQS는 Thread가 깨어날 때마다 현재 자신이 Lock을 획득할 수 있는 상태인지 다시 확인한다

 

다만 Thread를 Parking한 뒤 다시 깨우는 과정에는 Scheduling과 Context Switching 비용이 발생할 수 있다

 

특히 여러 Thread가 하나의 Lock을 순차적으로 기다리는 상황에서

앞선 Thread의 실행이 지연되면 뒤의 Thread도 같이 지연될 수 있다

 

이러한 현상을 Lock Convoy라 한다

 

6. Futex

 

앞서  LockSupport.park() 를 통해 Thread의 실행을 중단할 수 있다는 점을 살펴보았다

하지만 실제로 Thread를 CPU Scheduling 대상에서 제외하고 다시 깨우는 작업은 애플리케이션만으로 처리할 수 없다

Thread가 실제로 대기 상태에 들어가려면 결국 운영 체제의 도움이 필요하다

 

Linux에서는 이러한 대기를 구현하기 위한 대표적인 매커니즘으로 Futex(Fast Userspace Mutex)를 제공한다

Futex의 핵심은 모든 Lock 연산을 Kernel에서 처리하지 않는다는 점이다

 

Lock에 경합이 없다면 User Space에 존재하는 상태값을 CAS와 같은 원자적 연산으로 처리할 수 있다

 

문제는 다른 Thread가 이미 Lock을 가지고 있어 현재 Thread가 더 이상 진행할 수 없는 경우다

이때 Thread를 계속 실행시키면 Lock이 해제될 때까지 CPU를 소모하게 된다

때문에 실제 Blocking이 필요한 시점에는  futex()  System Call을 통해 Kernel의 도움을 받을 수 있다

 

이 구조 때문에 Futex는 일반적인 Kernel Mutex와는 조금 다른 특징을 가진다

 

일반적인 System Call에서는 User Space에서 Kernel로 요청을 전달한 뒤,

Kernel 내부의 자원이나 상태를 대상으로 작업하는 경우가 많다

 

반면 Futex는 User Space에 존재하는 메모리 주소를 Kernel에 전달한다

futex(uaddr, FUTEX_WAIT, expected, ...);

 

여기서  uaddr 는 User Space에 존재하는 Futex Word의 주소이며,

Kernel은 해당 주소의 값을 확인해 Thread를 실제로 Blocking할지 결정한다

 

즉 Futex는 User Space에서 관리되는 동기화 상태와

Kernel이 담당하는 Thread Blocking을 하나의 메모리 주소를 통해 연결한다

 

User Space에서 관리되는  uaddr 의 값이  LOCKED 라면 Thread를 Blocking 상태로 재우고,

그렇지 않으면 즉시 반환하여 기존 Thread의 작업을 이어간다

 

이때 단순히 현재 값을 확인한 뒤 Thread를 재우는 것만으로 처리하면

앞서 살펴본 Lost Wakeup 문제가 Kernel 단에서 다시 발생한다

 

이를 해결하기 위해  FUTEX_WAIT 은 Thread를 단순히 재우는 것이 아니라,

Futex Word의 값을 읽고 Expected Value와 비교하여 Blocking까지 들어가는 과정을 원자적으로 처리한다

 

값이 이미 변경되어 Expected Value와 다르다면 Thread는 잠들지 않고  EAGAIN 을 반환받아 User Space로 돌아간다

 

이처럼 Futex는 Kernel이 단순히 Lock을 대신 관리하는 것이 아니다

Lock의 상태 자체는 여전히 User Space에서 관리할 수 있고,

Kernel은 실제 Thread Blocking이 필요한 순간에만 개입한다

 

이러한 구조 덕분에 경합이 없는 일반적인 경우에는 System Call 없이 User Space에서 빠르게 처리하고,

실제 대기가 필요한 경우에만 Kernel까지 내려가는 것이 가능하다