🎯 ReentrantLock이 동작하는 원리
요약 📌 ReentrantLock의 전체적인 동작 과정
- lock()을 호출하면 CAS를 통해 state 값을 0 → 1로 변경하여 락을 획득 시도.
- 락을 얻지 못하면 CLH 대기 큐에 노드를 추가하고 park() 상태로 대기.
- 선행 스레드가 unlock()을 호출하면 다음 스레드를 unpark()하여 실행.
- unpark()된 스레드는 다시 tryAcquire()를 실행하여 락을 획득.
- 락을 얻은 후에는 CLH 큐의 head를 자신으로 변경.
- unlock()을 호출하면 다음 대기 스레드에게 신호를 보내서 락을 넘김.
1. ReentrantLock의 핵심 개념
- Java에서 기본적으로 synchronized 블록을 사용하면 모니터 락을 활용할 수 있음.
- 하지만 synchronized는 타임아웃이나 인터럽트 처리 등이 불가능하며, 대기 공간이 하나로 제한되어 있음.
- 이를 보완하기 위해 ReentrantLock이 등장했고, 보다 유연한 락 설정이 가능함.
- 락을 걸거나 해제하는 시점을 제어할 수 있으며, 공정(Fair) / 비공정(NonFair) 모드를 지원.
2. ReentrantLock의 주요 동작 방식
- 락을 획득하는 과정에서 두 번의 시도가 있음:
- 첫 번째 시도: initialTryLock()
- CompareAndSet(CAS) 연산을 통해 state 값을 0 → 1로 변경하여 락을 획득하려고 시도.
- 성공하면 락을 얻고 종료.
- 실패하면 다음 단계로 진행.
- 두 번째 시도: tryAcquire()
- initialTryLock()에서 실패한 경우 한 번 더 CAS 연산을 시도.
- 비공정 모드(NonFairSync)에서는 재빠르게 락을 잡을 수 있도록 시도.
- 공정 모드(FairSync)에서는 대기 큐에 있는 스레드가 있다면 락을 획득하지 않고 대기.
- 두 번의 시도에도 실패하면 acquire()를 호출하여 CLH 큐에서 대기하게 됨.
3. CLH 대기 큐 (CLH Queue)
- ReentrantLock 내부에서는 CLH 대기 큐를 사용하여 스레드가 대기함.
- CLH 대기 큐는 FIFO(선입선출) 방식의 대기열을 유지하는 이중 연결 리스트 구조.
- 락을 얻지 못한 스레드는 CLH 큐에 노드를 추가하고 park()를 통해 대기 상태로 들어감.
🔹 CLH Queue에서 대기하는 방식
- 대기할 노드를 큐에 삽입.
- 이전 노드(head)가 락을 해제할 때까지 기다림.
- 이전 노드가 unlock()을 호출하면, 현재 노드가 unpark() 되어 락을 획득함.
- 락을 획득하면 head를 자신으로 변경.
🔹 FairSync vs NonFairSync 차이점
- FairSync: CLH 큐에 FIFO 순서대로 락을 부여.
- NonFairSync: CLH 큐의 순서를 무시하고 현재 락이 비어있다면 즉시 획득 가능. (선점 가능)
4. 락을 얻지 못하면 acquire()를 통해 대기
- acquire() 메서드는 AQS(AbstractQueuedSynchronizer)의 핵심 메서드로, 락을 얻지 못한 스레드는 CLH Queue에 노드로 추가된 후 대기.
- 내부적으로 park()을 호출하여 OS 레벨에서 스레드를 대기 상태로 전환.
- 락을 얻을 차례가 되면 unpark() 호출을 통해 다음 대기 중인 스레드를 깨움.
5. 락을 해제하는 unlock() 과정
- unlock()을 호출하면 다음과 같은 과정이 수행됨:
- state 값을 감소시켜 락 해제
- CLH 큐의 head가 변경됨
- 다음 대기 중인 스레드에게 unpark() 호출
- 다음 스레드는 acquire()를 통해 다시 락을 획득
6. 추가적인 최적화 (Spin Lock)
- NonFairSync 모드에서는 락을 빨리 획득하려고 Spin(짧은 루프에서 재시도) 를 수행함.
- CLH 큐에서 head에 있는 스레드는 일정 횟수 tryAcquire()를 재시도하며, 바로 락을 얻을 수 있으면 park 없이 획득.
- Spin을 사용하는 이유
- unpark() 후 즉시 락을 얻지 못하면 다시 park() 해야 함 → 불필요한 시스템 콜이 발생.
- 이를 줄이기 위해 일정 횟수 동안 tryAcquire()를 재시도함.
🔒 Condition의 존재 이유와 동작 원리
요약 📌 Condition의 역할과 필요성
- Mutex는 단순히 상호 배제만을 제공하며, 스레드 간 순서를 조정하는 기능이 부족하다.
- Condition은 특정 상태가 만족될 때까지 스레드를 대기시키고, 필요한 스레드만 깨울 수 있다.
- synchronized는 단일 대기열만을 제공하므로, 여러 대기 공간이 필요한 경우 ReentrantLock과 Condition을 사용해야 한다.
🧐 Condition이 필요한 이유
Mutex
는 상호 배제(Mutual Exclusion)를 위한 도구이며, **동기화(Synchronization)**를 제공하지 않는다.- 특정 조건이 충족될 때까지 대기하거나, 작업 간의 순서를 조정하는 기능이 필요할 때
Condition
변수가 사용된다. - 예를 들어, 버퍼에 데이터가 추가될 때까지 기다리는 경우
Condition
이 필요하다.
🤝 Condition과 Mutex는 왜 함께 동작할까?
- Condition 변수를 변경하고 signal을 수행하는 과정은 원자적으로 유지되어야 한다.
- Condition이 바뀌고 signal이 발생하는 동안 다른 스레드가 이를 변경할 수 있기 때문.
- 따라서
Condition
은 Mutex를 통해 보호되는 동작이 필요하다. - Java에서는
Lock
과Condition
변수를 함께 사용하는 구조를 제공하며, 이를 **모니터(Monitor)**라고 부른다.
🚀 Java에서 Condition 사용 방법
✅ synchronized
를 이용한 동기화
class SharedResource {
private boolean available = false;
public synchronized void produce() throws InterruptedException {
while (available) {
wait(); // 🔄 다른 스레드가 notify()할 때까지 대기
}
available = true;
System.out.println("Produced!");
notify(); // 🚀 대기 중인 스레드 중 하나를 랜덤하게 깨움
}
public synchronized void consume() throws InterruptedException {
while (!available) {
wait(); // 🔄 다른 스레드가 notify()할 때까지 대기
}
available = false;
System.out.println("Consumed!");
notify(); // 🚀 대기 중인 스레드 중 하나를 랜덤하게 깨움
}
}
public class SynchronizedExample {
public static void main(String[] args) {
SharedResource resource = new SharedResource();
Thread producer = new Thread(() -> {
try {
for (int i = 0; i < 5; i++) {
resource.produce();
Thread.sleep(100);
}
} catch (InterruptedException e) {
e.printStackTrace();
}
});
Thread consumer = new Thread(() -> {
try {
for (int i = 0; i < 5; i++) {
resource.consume();
Thread.sleep(100);
}
} catch (InterruptedException e) {
e.printStackTrace();
}
});
producer.start();
consumer.start();
}
}
⚠️ 문제점: synchronized는 단일 대기열(entry queue)만 제공하므로, 여러 대기열이 필요한 경우 Condition을 사용해야 한다.
✅ ReentrantLock과 Condition을 활용한 동기화
아래 코드는 Producer와 Consumer의 동작 순서를 보장하지 못한다.
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
class SharedResource {
private final ReentrantLock lock = new ReentrantLock();
private final Condition condition = lock.newCondition();
private boolean available = false;
public void produce() throws InterruptedException {
lock.lock();
try {
while (available) {
condition.await(); // 🔄 다른 스레드가 signal()할 때까지 대기
}
available = true;
System.out.println("Produced!");
condition.signal(); // 🚀 소비자 스레드 깨우기
} finally {
lock.unlock();
}
}
public void consume() throws InterruptedException {
lock.lock();
try {
while (!available) {
condition.await(); // 🔄 다른 스레드가 signal()할 때까지 대기
}
available = false;
System.out.println("Consumed!");
condition.signal(); // 🚀 생산자 스레드 깨우기
} finally {
lock.unlock();
}
}
}
public class ConditionExample {
public static void main(String[] args) {
SharedResource resource = new SharedResource();
Thread producer = new Thread(() -> {
try {
for (int i = 0; i < 5; i++) {
resource.produce();
Thread.sleep(100);
}
} catch (InterruptedException e) {
e.printStackTrace();
}
});
Thread consumer = new Thread(() -> {
try {
for (int i = 0; i < 5; i++) {
resource.consume();
Thread.sleep(100);
}
} catch (InterruptedException e) {
e.printStackTrace();
}
});
producer.start();
consumer.start();
}
}
Condition을 사용하면 await()으로 대기 후 signal()로 특정 스레드만 깨울 수 있음.