ReentrantLock과 Condition
synchronized와 wait·notifyAll로 만든 버퍼를 ReentrantLock과 Condition으로 옮기면서 await와 signal을 쓰는 규칙, 락을 놓는 위치, 인터럽트와 시간 제한 대기를 배웁니다.
앞 글에서 본 정지의 뿌리는 객체 하나에 대기 집합이 하나뿐이라는 데 있었습니다. 생산자와 소비자가 서로 다른 조건을 기다리면서도 한곳에 섞여 있으니, notify()가 엉뚱한 스레드를 깨울 수 있었습니다. java.util.concurrent.locks 패키지의 Condition은 이 제약을 풉니다. Condition의 API 문서는 Condition이 wait, notify, notifyAll을 별도의 객체로 떼어 내어, 객체 하나에 대기 집합을 여러 개 둔 것과 같은 효과를 낸다고 설명합니다.
대기실을 실제로 나누는 일은 다음 글에서 하고, 이 글에서는 Condition을 하나만 써서 앞 글들의 버퍼를 그대로 옮깁니다. 도구가 바뀌어도 락을 쥔 채 조건을 확인하고, while 안에서 기다리고, 상태를 바꾼 뒤 알린다는 원리는 같습니다. 달라지는 점은 락을 얻고 놓는 일을 코드에 직접 써야 한다는 것과, 그 대가로 얻는 몇 가지 기능입니다. ReentrantLock 자체의 기본 사용법은 19장에서 다뤘습니다.
모니터 메서드와 Condition 메서드의 대응
Condition은 ReentrantLock의 newCondition()으로 만들며, 만들어진 Condition은 그 락에 묶입니다. 내장 모니터로 하던 일은 다음과 같이 옮겨집니다.
| 내장 모니터 | ReentrantLock과 Condition |
|---|---|
synchronized로 모니터 얻기 | lock.lock() 또는 lock.lockInterruptibly() |
| 블록을 벗어나면 모니터가 자동으로 풀림 | finally에서 lock.unlock() 호출 |
wait() | condition.await() |
wait(timeout) | condition.awaitNanos(nanos) 또는 condition.await(time, unit) |
notify() | condition.signal() |
notifyAll() | condition.signalAll() |
| 객체마다 대기 집합 하나 | newCondition()을 부를 때마다 대기실 하나 |
wait()를 부르려면 그 객체의 모니터를 쥐고 있어야 했던 것처럼, await()와 signal(), signalAll()도 그 Condition을 만든 락을 쥔 스레드만 부를 수 있습니다. 이 규칙을 어기면 어떻게 되는지부터 보겠습니다.
락 없이 await를 부르면
아래 예제는 newCondition()으로 Condition을 만든 뒤, 락을 얻지 않고 바로 await()를 부릅니다. 컴파일은 문제없이 됩니다.
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
public final class AwaitWithoutLock {
public static void main(String[] args) {
ReentrantLock lock = new ReentrantLock();
Condition changed = lock.newCondition();
try {
changed.await();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}실행하면 기다리지 않고 바로 예외로 끝납니다. 스택 트레이스의 맨 아래 줄이 await()를 부른 9번째 줄이고, 그 위의 두 줄은 await() 안에서 예외를 던진 JDK 내부 코드입니다.
Exception in thread "main" java.lang.IllegalMonitorStateException
at java.base/java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.enableWait(AbstractQueuedSynchronizer.java:1613)
at java.base/java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.await(AbstractQueuedSynchronizer.java:1734)
at AwaitWithoutLock.main(AwaitWithoutLock.java:9)IllegalMonitorStateException은 비검사 예외라서 catch (InterruptedException e)로는 잡히지 않고, main 밖으로 나가 프로그램을 끝냅니다. 위 출력은 JDK 21에서 실행한 것이며, JDK 내부 클래스의 줄 번호는 버전에 따라 다를 수 있습니다. ReentrantLock.newCondition의 API 문서는 락을 쥐지 않은 채 Condition의 대기 메서드나 신호 메서드를 부르면 이 예외가 난다고 밝힙니다. 실제로 signal()도 락 없이 부르면 같은 예외가 납니다.
Condition이 별도의 객체이니 락과 상관없이 혼자 기다릴 수 있을 것 같지만, 그렇게 하면 알림을 잃어버릴 수 있습니다. 락 없이 조건을 확인하고 기다리면, 확인과 기다리기 사이에 다른 스레드가 상태를 바꾸고 신호를 보낼 수 있습니다. 그 신호는 아직 기다리지 않는 스레드에게 가지 않으므로 사라지고, 조건이 이미 참인데도 기다리기 시작한 스레드는 다음 신호가 올 때까지 깨어나지 못합니다. wait와 notify를 다룬 글에서 모니터를 쥔 채로만 wait()를 부를 수 있게 한 것과 같은 이유이고, 자바는 이 규칙을 실행 중에 검사합니다.
await는 락을 놓고 기다렸다가 다시 얻습니다
await()를 호출하면 세 가지 일이 차례로 일어납니다. 먼저 쥐고 있던 락을 놓고 그 Condition의 대기실에 들어갑니다. 락을 놓아야 다른 스레드가 들어와 상태를 바꿀 수 있기 때문입니다. 다음으로 signal()에 선택되거나, signalAll()이 불리거나, 인터럽트되거나, 가짜 깨움(spurious wakeup)이 일어날 때까지 기다립니다. 마지막으로 돌아오기 전에 락을 다시 얻습니다. 그래서 await() 다음 줄은 언제나 락을 쥔 상태에서 실행되고, 바로 조건을 다시 검사할 수 있습니다.
가짜 깨움은 Condition에서도 허용됩니다. Condition의 API 문서는 가짜 깨움이 일어날 수 있다고 가정하고 언제나 반복문 안에서 기다리라고 권합니다. wait를 while 안에서 부르는 이유에서 wait()를 while로 감쌌던 것과 같은 이유입니다.
기다리는 중에 인터럽트되면 await()는 InterruptedException을 던지는데, 이때도 락을 다시 얻은 뒤에 던집니다. 대기 중인 스레드를 인터럽트하면서 main이 락을 계속 쥐고 있게 해 보면, 대기하던 스레드는 main이 락을 놓을 때까지 WAITING 상태로 머물다가 그 뒤에야 예외를 받습니다. catch 블록 안에서 lock.isHeldByCurrentThread()를 출력해 보면 true이고, 인터럽트 플래그는 지워져 있습니다. 그러니 await()를 감싼 try의 finally에서 unlock()을 부르는 것은 예외가 난 경로에서도 맞습니다.
더 깊이: 여러 번 얻은 락도 한꺼번에 놓았다가 그대로 돌려놓습니다
ReentrantLock은 같은 스레드가 여러 번 얻을 수 있고, 얻은 횟수(hold count)를 셉니다. 횟수가 2인 상태에서 await()를 부르면 어떻게 될까요? 한 번만 놓는다면 락은 여전히 이 스레드의 것이어서 아무도 상태를 바꾸지 못하고, 대기는 끝나지 않을 것입니다. 그래서 await()는 횟수와 상관없이 락을 완전히 놓고, 돌아오기 전에 원래 횟수를 그대로 되돌려 놓습니다. 직접 확인해 보면 getHoldCount()는 await() 전과 후에 모두 2였고, 그 사이 다른 스레드의 tryLock()은 성공했습니다. 내장 모니터의 wait()도 중첩된 synchronized로 여러 번 얻은 모니터를 같은 방식으로 모두 놓았다가 되돌려 놓습니다.
락은 try 바로 앞에서 얻고, finally 맨 앞에서 놓습니다
synchronized는 블록을 어떻게 벗어나든 모니터를 풀어 주지만, ReentrantLock은 unlock()을 직접 불러야 풀립니다. 예외 때문에 unlock()을 건너뛰면 락은 계속 잡힌 채로 남고, 그 락을 기다리는 스레드는 모두 멈춥니다. 그래서 ReentrantLock의 API 문서는 락을 얻는 호출 바로 뒤에 try 블록을 두고, finally의 첫 문장에서 unlock()을 부르라고 권합니다.
lock.lockInterruptibly(); // 락은 try 바깥에서 얻는다
try {
// 조건 검사, 상태 변경, 신호
} finally {
lock.unlock(); // 락을 얻은 경우에만 여기에 온다
}락을 얻는 줄이 try 바깥에 있어야 하는 이유는 얻기에 실패하는 경우 때문입니다. lockInterruptibly()는 인터럽트되면 락을 얻지 못한 채 InterruptedException을 던집니다. 이 호출을 try 안으로 옮기면, 예외가 났을 때 finally가 쥐지도 않은 락에 unlock()을 부르고, unlock()은 IllegalMonitorStateException을 던집니다.
try {
lock.lockInterruptibly(); // 인터럽트되면 락을 얻지 못한 채 예외를 던진다
// ...
} finally {
lock.unlock(); // 쥐지 않은 락을 풀려다 IllegalMonitorStateException
}finally에서 난 예외는 원래의 InterruptedException을 덮어씁니다. 인터럽트 플래그를 켜 둔 채 이 코드를 실행해 보면 바깥에서 잡히는 예외는 IllegalMonitorStateException뿐이고, getSuppressed()로도 원래 예외를 찾을 수 없습니다. 호출한 쪽은 취소 요청이 있었다는 사실조차 알 수 없게 되는 것입니다. 같은 이유로 tryLock()이 false를 돌려줬다면 락을 얻지 못한 것이므로 unlock()을 부르면 안 됩니다.
Condition 하나로 만든 버퍼
이제 앞 글들의 버퍼를 옮깁니다. 생산자와 소비자가 같은 changed 조건에서 기다리므로 대기실은 여전히 하나이고, 그래서 앞 글과 같은 이유로 상태를 바꾼 뒤에는 signalAll()을 불러야 합니다. 큐와 용량은 lock 하나로만 보호하고, 크기를 읽는 size()도 같은 락을 얻습니다. 생성자의 fair 인자는 뒤에서 설명합니다.
import java.util.ArrayDeque;
import java.util.Queue;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
public final class SingleConditionBuffer<E> {
private final Queue<E> queue = new ArrayDeque<>();
private final int capacity;
private final ReentrantLock lock;
private final Condition changed;
public SingleConditionBuffer(int capacity, boolean fair) {
if (capacity < 1) throw new IllegalArgumentException("capacity");
this.capacity = capacity;
this.lock = new ReentrantLock(fair);
this.changed = lock.newCondition();
}
public void put(E value) throws InterruptedException {
if (value == null) throw new NullPointerException("value");
lock.lockInterruptibly();
try {
while (queue.size() == capacity) changed.await();
queue.add(value);
changed.signalAll();
} finally {
lock.unlock();
}
}
public E take() throws InterruptedException {
lock.lockInterruptibly();
try {
while (queue.isEmpty()) changed.await();
E value = queue.remove();
changed.signalAll();
return value;
} finally {
lock.unlock();
}
}
public int size() {
lock.lock();
try {
return queue.size();
} finally {
lock.unlock();
}
}
public static void main(String[] args) throws InterruptedException {
SingleConditionBuffer<String> buffer = new SingleConditionBuffer<>(1, false);
Thread consumer = Thread.ofPlatform().start(() -> {
try { System.out.println("got=" + buffer.take()); }
catch (InterruptedException e) { Thread.currentThread().interrupt(); }
});
Thread producer = Thread.ofPlatform().start(() -> {
try { buffer.put("condition"); }
catch (InterruptedException e) { Thread.currentThread().interrupt(); }
});
producer.join();
consumer.join();
System.out.println("size=" + buffer.size());
}
}main은 소비자와 생산자를 하나씩 시작하고, 둘 다 끝나기를 기다린 뒤 버퍼의 크기를 출력합니다. 생산자가 넣은 값 하나를 소비자가 꺼냈다면, 마지막에 버퍼는 비어 있어야 합니다.
got=condition
size=015번 실행해 15번 모두 이 두 줄이 이 순서로 나왔습니다. got=condition은 소비자 스레드가 끝나기 전에 출력하고, size=0은 main이 두 스레드를 모두 join()한 뒤에 출력하기 때문입니다. 다만 이 출력만으로는 소비자가 실제로 await()했는지 알 수 없습니다. 생산자가 먼저 실행됐다면 소비자는 큐에 이미 값이 있어 기다리지 않고 바로 가져갑니다. 이 main은 fair로 false를 넘기며, 공정성의 효과를 보여 주지도 않습니다.
락 획득에 성공한 뒤 같은 changed 조건을 사용한다
lockInterruptibly 성공→조건 wait 시 반납→재획득·재검사→변경·signalAll→finally 해제입니다. 같은 락의 소유 여부를 단계마다 따라가며 락의 소유 상태를 확인할 수 있습니다.
SingleConditionBuffer의 put과 take가 같은 락과 changed 조건을 사용하며 대기와 해제 책임을 나누는 지점을 비교합니다.
명시적 락 · 획득과 해제
락 획득에 성공한 뒤 같은 changed 조건을 사용한다
획득에 성공한 뒤 try로 들어가고, 조건 대기에서 복귀한 뒤에는 다시 락을 가진 상태로 검사합니다.
설명과 원래 예시 값
lockInterruptibly()- 락 소유
- 획득에 성공하면 try로 진입
- 조건·해제 동작
- 입구에서 중단되면 try에 들어가지 않으므로 unlock하지 않습니다.
각 메서드의 while
- 락 소유
changed.await()동안 반납- 조건·해제 동작
- put은 가득 참, take는 빈 상태를 재검사합니다. 복귀하려면 락을 다시 얻어야 합니다.
큐 변경과 finally
- 락 소유
- 변경·알림까지 같은 락 보유
- 조건·해제 동작
- 양쪽 모두 changed.signalAll을 호출하고 finally에서 unlock합니다.
정상 완료하면 got=condition 다음 size=0입니다. 이 main의 fair 값은 false이며 실제 await 호출이나 공정성 효과를 출력하지 않습니다.
이 예의 조건
- fair=false이며 main은 실제 await나 공정성 효과를 출력하지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 설명 | |
| 전체 읽기 정보 | 락 획득에 성공한 뒤 같은 changed 조건을 사용한다 락 획득에 성공한 뒤 같은 changed 조건을 사용한다 SingleConditionBuffer의 put과 take가 같은 락과 changed 조건을 사용하며 대기와 해제 책임을 나누는 지점을 비교합니다. 명시적 락 · 획득과 해제 락 획득에 성공한 뒤 같은 changed 조건을 사용한다 획득에 성공한 뒤 try로 들어가고, 조건 대기에서 복귀한 뒤에는 다시 락을 가진 상태로 검사합니다. lockInterruptibly() 락 소유 획득에 성공하면 try로 진입 조건·해제 동작 입구에서 중단되면 try에 들어가지 않으므로 unlock하지 않습니다. 각 메서드의 while 락 소유 changed.await() 동안 반납 조건·해제 동작 put은 가득 참, take는 빈 상태를 재검사합니다. 복귀하려면 락을 다시 얻어야 합니다. 큐 변경과 finally 락 소유 변경·알림까지 같은 락 보유 조건·해제 동작 양쪽 모두 changed.signalAll을 호출하고 finally에서 unlock합니다. 정상 완료하면 got=condition 다음 size=0입니다. 이 main의 fair 값은 false이며 실제 await 호출이나 공정성 효과를 출력하지 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 획득 경계 lockInterruptibly 성공 뒤 try에 진입합니다. |
|
|
| 조건 대기 put은 가득 참, take는 비어 있음에서 changed.await합니다. |
|
|
| 변경과 반납 큐 변경 뒤 signalAll하고 finally에서 unlock합니다. |
|
|
락 획득에 성공한 뒤 같은 changed 조건을 사용한다
lockInterruptibly 성공→조건 wait 시 반납→재획득·재검사→변경·signalAll→finally 해제입니다. 같은 락의 소유 여부를 단계마다 따라가며 락의 소유 상태를 확인할 수 있습니다.
SingleConditionBuffer의 put과 take가 같은 락과 changed 조건을 사용하며 대기와 해제 책임을 나누는 지점을 비교합니다.
명시적 락 · 획득과 해제
락 획득에 성공한 뒤 같은 changed 조건을 사용한다
획득에 성공한 뒤 try로 들어가고, 조건 대기에서 복귀한 뒤에는 다시 락을 가진 상태로 검사합니다.
설명과 원래 예시 값
lockInterruptibly()- 락 소유
- 획득에 성공하면 try로 진입
- 조건·해제 동작
- 입구에서 중단되면 try에 들어가지 않으므로 unlock하지 않습니다.
각 메서드의 while
- 락 소유
changed.await()동안 반납- 조건·해제 동작
- put은 가득 참, take는 빈 상태를 재검사합니다. 복귀하려면 락을 다시 얻어야 합니다.
큐 변경과 finally
- 락 소유
- 변경·알림까지 같은 락 보유
- 조건·해제 동작
- 양쪽 모두 changed.signalAll을 호출하고 finally에서 unlock합니다.
정상 완료하면 got=condition 다음 size=0입니다. 이 main의 fair 값은 false이며 실제 await 호출이나 공정성 효과를 출력하지 않습니다.
이 예의 조건
- fair=false이며 main은 실제 await나 공정성 효과를 출력하지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 설명 | |
| 전체 읽기 정보 | 락 획득에 성공한 뒤 같은 changed 조건을 사용한다 락 획득에 성공한 뒤 같은 changed 조건을 사용한다 SingleConditionBuffer의 put과 take가 같은 락과 changed 조건을 사용하며 대기와 해제 책임을 나누는 지점을 비교합니다. 명시적 락 · 획득과 해제 락 획득에 성공한 뒤 같은 changed 조건을 사용한다 획득에 성공한 뒤 try로 들어가고, 조건 대기에서 복귀한 뒤에는 다시 락을 가진 상태로 검사합니다. lockInterruptibly() 락 소유 획득에 성공하면 try로 진입 조건·해제 동작 입구에서 중단되면 try에 들어가지 않으므로 unlock하지 않습니다. 각 메서드의 while 락 소유 changed.await() 동안 반납 조건·해제 동작 put은 가득 참, take는 빈 상태를 재검사합니다. 복귀하려면 락을 다시 얻어야 합니다. 큐 변경과 finally 락 소유 변경·알림까지 같은 락 보유 조건·해제 동작 양쪽 모두 changed.signalAll을 호출하고 finally에서 unlock합니다. 정상 완료하면 got=condition 다음 size=0입니다. 이 main의 fair 값은 false이며 실제 await 호출이나 공정성 효과를 출력하지 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 획득 경계 lockInterruptibly 성공 뒤 try에 진입합니다. |
|
|
| 조건 대기 put은 가득 참, take는 비어 있음에서 changed.await합니다. |
|
|
| 변경과 반납 큐 변경 뒤 signalAll하고 finally에서 unlock합니다. |
|
|
put과 take는 락을 lock()이 아니라 lockInterruptibly()로 얻습니다. lock()은 락이 풀릴 때까지 인터럽트를 무시하고 기다립니다. 인터럽트 플래그가 켜진 채로 lock()을 불러도 예외 없이 락을 얻고, 플래그는 켜진 채로 남습니다. 반면 lockInterruptibly()는 호출할 때 이미 플래그가 켜져 있거나 기다리는 도중에 인터럽트되면 InterruptedException을 던지고 플래그를 지웁니다. await()는 원래 인터럽트에 반응하므로, 입구까지 lockInterruptibly()로 맞춰 두면 이 버퍼에서 기다리는 어느 단계에서든 취소 요청에 반응하게 됩니다.
입구에서 인터럽트되면 락을 얻지 못했으므로 try에 들어가지 않고, 따라서 finally의 unlock()도 실행되지 않습니다. 도표의 '입구 중단' 경로가 이것입니다. 락을 얻은 경로만 changed.await()에서 락을 놓았다가 다시 얻고, 조건을 다시 검사한 뒤 큐를 바꾸고 signalAll()을 부르며, 마지막에 finally에서 락을 놓습니다.
signalAll()을 signal()로 바꾸면 앞 글의 정지가 그대로 돌아옵니다. Condition을 써도 대기실이 하나뿐이라는 점은 달라지지 않았기 때문입니다. 앞 글의 정지 실험처럼 생산자 둘과 소비자 둘이 용량 1인 버퍼로 값을 주고받게 하고 이 구조에서 signal()만 쓰게 했더니, 20번 중 19번 멈췄습니다. signal()은 다음 글에서 대기실을 둘로 나눈 뒤에야 안전하게 쓸 수 있습니다.
락 얻기와 조건 대기에 시간 제한 두기
ReentrantLock을 쓰는 흔한 이유 하나는 기다리는 시간에 상한을 둘 수 있다는 점입니다. 기다림은 두 군데에서 생깁니다. 하나는 다른 스레드가 쥔 락이 풀리기를 기다리는 시간이고, 다른 하나는 락을 얻은 뒤 원하는 조건이 될 때까지 await로 기다리는 시간입니다. 다음 카운터의 awaitAtLeast는 둘에 따로 제한을 둡니다. 락은 tryLock(100, TimeUnit.MILLISECONDS)로 최대 100ms만 기다리고, 락을 얻은 뒤에는 인자로 받은 timeout만큼 조건을 기다립니다.
import java.time.Duration;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
public final class TimedConditionCounter {
private final ReentrantLock lock = new ReentrantLock();
private final Condition advanced = lock.newCondition();
private long value;
void increment() {
lock.lock();
try {
value++;
advanced.signalAll();
} finally {
lock.unlock();
}
}
boolean awaitAtLeast(long target, Duration timeout) throws InterruptedException {
if (!lock.tryLock(100, TimeUnit.MILLISECONDS)) return false;
try {
long remaining = timeout.toNanos();
while (value < target) {
if (remaining <= 0) return false;
remaining = advanced.awaitNanos(remaining);
}
return true;
} finally {
lock.unlock();
}
}
public static void main(String[] args) throws InterruptedException {
TimedConditionCounter counter = new TimedConditionCounter();
Thread updater = Thread.ofPlatform().start(() -> {
counter.increment();
counter.increment();
});
System.out.println("reached=" + counter.awaitAtLeast(2, Duration.ofSeconds(1)));
updater.join();
}
}main은 값을 두 번 올리는 업데이트 스레드를 시작하고, 값이 2 이상이 되기를 최대 1초 동안 기다립니다. awaitAtLeast는 시간 안에 목표에 닿으면 true를, 락을 얻지 못했거나 시간이 다 되면 false를 돌려주므로, 출력의 reached= 뒤를 보면 됩니다.
reached=trueawaitNanos(remaining)은 최대 remaining 나노초 동안 기다리고, 돌아올 때 남은 시간의 추정치를 돌려줍니다. 시간이 다 되어 돌아왔다면 0 이하의 값을 돌려줍니다. 이 반환값을 다음 반복에 넘기는 것이 핵심입니다. 이 예에서 increment()는 두 번 불리고 매번 signalAll()을 부르므로, 첫 번째 증가 뒤에 깨어난 대기자는 값이 1이라 다시 기다려야 할 수 있습니다. 이때 처음의 1초를 다시 주지 않고 남은 시간만 줍니다. 매번 원래 timeout을 넘긴다면, 목표에는 닿지 않는 신호가 계속 오는 동안 전체 대기 시간이 얼마든지 늘어날 수 있습니다. Condition.awaitNanos의 API 문서도 바로 이 형태의 반복문을 권합니다.
while 조건을 먼저 보기 때문에, 값이 목표에 이르면 남은 시간이 얼마든 true를 돌려줍니다. 시간이 다 되어 깨어났더라도 락을 다시 얻은 순간 조건이 맞았다면 성공으로 보는 것입니다. false는 락을 100ms 안에 얻지 못했거나, 락을 얻은 뒤 1초 안에 값이 2가 되지 않았을 때 나옵니다. 15번 실행해 15번 모두 reached=true가 나왔는데, 업데이트 스레드가 락을 아주 잠깐씩만 쥐기 때문입니다. 시스템이 아주 바쁘면 false도 나올 수 있으며, 그때 반환값만으로는 두 경우 가운데 어느 쪽인지 구분할 수 없습니다. 구분이 필요하다면 boolean 대신 결과의 종류를 나타내는 열거형을 돌려줘야 합니다.
락 입구의 100ms와 조건 대기 예산은 따로 시작한다
입구 100ms tryLock 뒤 새 조건 예산을 시작하고 awaitNanos 잔여값을 재검사합니다. 두 예산의 실제 시작·소진 경계를 단계로 구별합니다.
TimedConditionCounter.awaitAtLeast는 시간 제한 락 획득 뒤 조건 예산을 초기화하고 조건과 잔여값을 차례로 검사합니다.
두 예산 · 서로 다른 시작
락 입구의 100ms와 조건 대기 예산은 따로 시작한다
100ms는 락 입구의 요청값입니다. 락을 얻은 뒤에야 새로운 조건 대기 예산을 시작합니다.
설명과 원래 예시 값
락 획득
- 시간·조건 검사
tryLock(100, MILLISECONDS)- 반환 또는 다음 동작
- 획득하지 못하면 false입니다. 조건 예산은 아직 시작하지 않았습니다.
획득에 성공한 뒤
- 시간·조건 검사
- remaining을 timeout으로 초기화
- 반환 또는 다음 동작
- main은 여기서 새 1초 조건 예산을 사용합니다.
값이 목표보다 작음
- 시간·조건 검사
- remaining ≤ 0인지 검사
- 반환 또는 다음 동작
- 0 이하면 false, 아니면 awaitNanos의 반환 잔여값으로 다시 반복합니다.
값이 목표 이상임
- 시간·조건 검사
- while을 건너뛰거나 빠져나옴
- 반환 또는 다음 동작
- 잔여값이 양수인지 추가로 검사하지 않고 true입니다.
false 한 값으로 락 획득 실패와 조건 예산 종료를 구분할 수 없습니다. 락 재획득·스케줄 지연과 출력 뒤 updater.join까지 전체 실행 시간을 제한하는 코드는 아닙니다.
이 예의 조건
- 락 입구 100ms와 획득 뒤 조건 1초는 별도 예산입니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 설명 | |
| 전체 읽기 정보 | 락 입구의 100ms와 조건 대기 예산은 따로 시작한다 락 입구의 100ms와 조건 대기 예산은 따로 시작한다 TimedConditionCounter.awaitAtLeast는 시간 제한 락 획득 뒤 조건 예산을 초기화하고 조건과 잔여값을 차례로 검사합니다. 두 예산 · 서로 다른 시작 락 입구의 100ms와 조건 대기 예산은 따로 시작한다 100ms는 락 입구의 요청값입니다. 락을 얻은 뒤에야 새로운 조건 대기 예산을 시작합니다. 락 획득 시간·조건 검사 tryLock(100, MILLISECONDS) 반환 또는 다음 동작 획득하지 못하면 false입니다. 조건 예산은 아직 시작하지 않았습니다. 획득에 성공한 뒤 시간·조건 검사 remaining을 timeout으로 초기화 반환 또는 다음 동작 main은 여기서 새 1초 조건 예산을 사용합니다. 값이 목표보다 작음 시간·조건 검사 remaining ≤ 0인지 검사 반환 또는 다음 동작 0 이하면 false, 아니면 awaitNanos의 반환 잔여값으로 다시 반복합니다. 값이 목표 이상임 시간·조건 검사 while을 건너뛰거나 빠져나옴 반환 또는 다음 동작 잔여값이 양수인지 추가로 검사하지 않고 true입니다. false 한 값으로 락 획득 실패와 조건 예산 종료를 구분할 수 없습니다. 락 재획득·스케줄 지연과 출력 뒤 updater.join까지 전체 실행 시간을 제한하는 코드는 아닙니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 락 입구 tryLock(100ms)을 시도합니다. |
|
|
| 조건 예산 시작 획득 성공 뒤 remaining을 timeout으로 초기화합니다. |
|
|
| 조건 반복과 반환 목표 미만이면 잔여값 0 이하에서 false, 아니면 awaitNanos합니다. |
|
|
락 입구의 100ms와 조건 대기 예산은 따로 시작한다
입구 100ms tryLock 뒤 새 조건 예산을 시작하고 awaitNanos 잔여값을 재검사합니다. 두 예산의 실제 시작·소진 경계를 단계로 구별합니다.
TimedConditionCounter.awaitAtLeast는 시간 제한 락 획득 뒤 조건 예산을 초기화하고 조건과 잔여값을 차례로 검사합니다.
두 예산 · 서로 다른 시작
락 입구의 100ms와 조건 대기 예산은 따로 시작한다
100ms는 락 입구의 요청값입니다. 락을 얻은 뒤에야 새로운 조건 대기 예산을 시작합니다.
설명과 원래 예시 값
락 획득
- 시간·조건 검사
tryLock(100, MILLISECONDS)- 반환 또는 다음 동작
- 획득하지 못하면 false입니다. 조건 예산은 아직 시작하지 않았습니다.
획득에 성공한 뒤
- 시간·조건 검사
- remaining을 timeout으로 초기화
- 반환 또는 다음 동작
- main은 여기서 새 1초 조건 예산을 사용합니다.
값이 목표보다 작음
- 시간·조건 검사
- remaining ≤ 0인지 검사
- 반환 또는 다음 동작
- 0 이하면 false, 아니면 awaitNanos의 반환 잔여값으로 다시 반복합니다.
값이 목표 이상임
- 시간·조건 검사
- while을 건너뛰거나 빠져나옴
- 반환 또는 다음 동작
- 잔여값이 양수인지 추가로 검사하지 않고 true입니다.
false 한 값으로 락 획득 실패와 조건 예산 종료를 구분할 수 없습니다. 락 재획득·스케줄 지연과 출력 뒤 updater.join까지 전체 실행 시간을 제한하는 코드는 아닙니다.
이 예의 조건
- 락 입구 100ms와 획득 뒤 조건 1초는 별도 예산입니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 설명 | |
| 전체 읽기 정보 | 락 입구의 100ms와 조건 대기 예산은 따로 시작한다 락 입구의 100ms와 조건 대기 예산은 따로 시작한다 TimedConditionCounter.awaitAtLeast는 시간 제한 락 획득 뒤 조건 예산을 초기화하고 조건과 잔여값을 차례로 검사합니다. 두 예산 · 서로 다른 시작 락 입구의 100ms와 조건 대기 예산은 따로 시작한다 100ms는 락 입구의 요청값입니다. 락을 얻은 뒤에야 새로운 조건 대기 예산을 시작합니다. 락 획득 시간·조건 검사 tryLock(100, MILLISECONDS) 반환 또는 다음 동작 획득하지 못하면 false입니다. 조건 예산은 아직 시작하지 않았습니다. 획득에 성공한 뒤 시간·조건 검사 remaining을 timeout으로 초기화 반환 또는 다음 동작 main은 여기서 새 1초 조건 예산을 사용합니다. 값이 목표보다 작음 시간·조건 검사 remaining ≤ 0인지 검사 반환 또는 다음 동작 0 이하면 false, 아니면 awaitNanos의 반환 잔여값으로 다시 반복합니다. 값이 목표 이상임 시간·조건 검사 while을 건너뛰거나 빠져나옴 반환 또는 다음 동작 잔여값이 양수인지 추가로 검사하지 않고 true입니다. false 한 값으로 락 획득 실패와 조건 예산 종료를 구분할 수 없습니다. 락 재획득·스케줄 지연과 출력 뒤 updater.join까지 전체 실행 시간을 제한하는 코드는 아닙니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 락 입구 tryLock(100ms)을 시도합니다. |
|
|
| 조건 예산 시작 획득 성공 뒤 remaining을 timeout으로 초기화합니다. |
|
|
| 조건 반복과 반환 목표 미만이면 잔여값 0 이하에서 false, 아니면 awaitNanos합니다. |
|
|
두 제한을 더한 1.1초가 메서드 전체의 실행 시간 상한이 되는 것은 아닙니다. awaitNanos가 시간이 다 되어 깨어나도 락을 다시 얻어야 돌아올 수 있고, 그동안 다른 스레드가 락을 쥐고 있다면 더 기다립니다. 스레드가 CPU를 받기를 기다리는 시간도 더해집니다. 남은 시간을 직접 계산할 때는 System.currentTimeMillis()가 아니라 System.nanoTime()을 씁니다. currentTimeMillis()는 벽시계 시각이라 시스템 시각을 맞추면 앞뒤로 움직이지만, nanoTime()은 경과 시간을 재는 용도로만 쓰는 시계라서 그런 영향을 받지 않습니다.
ReentrantLock이 필요한 때
synchronized는 블록을 벗어나면 모니터를 자동으로 풀어 주므로 해제를 빠뜨릴 수 없고 코드도 짧습니다. ReentrantLock은 그 편리함을 내주는 대신 다음과 같은 기능을 줍니다.
| 필요한 것 | synchronized로는 | ReentrantLock으로는 |
|---|---|---|
| 짧은 상호 배제 | 충분함 | 쓸 이유가 없음 |
| 락을 기다리는 동안에도 취소에 반응 | 모니터 입구에서 기다리는 스레드는 인터럽트에 반응하지 않음 | lockInterruptibly() |
| 락을 기다리는 시간에 상한 두기 | 방법이 없음 | tryLock(time, unit) |
| 조건마다 대기실을 따로 두기 | 객체마다 대기 집합 하나 | newCondition()을 여러 번 호출 |
표의 아래 세 줄 가운데 하나가 실제로 필요할 때만 ReentrantLock을 쓰는 것이 좋습니다. 그런 기능이 필요 없는데 쓰면 try와 finally를 매번 직접 맞춰야 하는 부담만 늘어납니다.
생성자에 true를 넘기면 공정 락이 됩니다. API 문서에 따르면 공정 락은 경쟁이 있을 때 가장 오래 기다린 스레드에게 락을 주는 쪽으로 기울고, 그 대가로 전체 처리량이 크게 떨어질 수 있습니다. 또한 락이 공정하다고 해서 스레드 스케줄링까지 공정한 것은 아니며, 인자 없는 tryLock()은 공정 설정을 따르지 않고 락이 비어 있으면 기다리는 스레드가 있어도 바로 얻습니다. Condition에서 깨어난 스레드가 락을 다시 얻는 순서도 이 공정성 설정을 따르는데, 이 이야기는 다음 글에서 이어집니다. 공정 락은 기아가 실제로 확인되고 그 원인이 락을 얻는 순서일 때, 처리량과 함께 비교해 보고 고르는 설정입니다.
명시적 락에서 자주 하는 실수
첫째는 await() 대신 wait()를 부르는 실수입니다. Condition도 평범한 객체라서 Object에서 물려받은 wait()와 notify()가 있고, 그래서 아래 코드는 컴파일됩니다.
lock.lock();
try {
while (count == 0) changed.wait(); // await()여야 한다
} finally {
lock.unlock();
}하지만 changed.wait()는 changed 객체의 모니터를 요구하는데, 이 스레드가 쥔 것은 lock이지 changed의 모니터가 아닙니다. 그래서 락을 쥐고 있는데도 IllegalMonitorStateException: current thread is not owner가 납니다. Condition의 API 문서는 혼란을 피하려면 Condition 객체를 synchronized의 대상으로 쓰거나 그 모니터 메서드를 부르지 말라고 권합니다. 편집기의 자동 완성 목록에서 wait와 await가 나란히 보이므로 특히 조심해야 합니다.
둘째는 한 상태를 synchronized와 ReentrantLock이 나눠 지키는 실수입니다. 같은 큐를 어떤 메서드는 synchronized로, 어떤 메서드는 lock으로 보호하면 두 메서드는 서로를 막지 못합니다. synchronized는 객체의 모니터를 잠그고 lock()은 ReentrantLock 객체를 잠그는데, 둘은 서로 다른 락이기 때문입니다. 한 상태에 접근하는 코드는 모두 같은 락을 써야 합니다.
셋째는 isLocked(), hasWaiters(condition), getWaitQueueLength(condition) 같은 조회 메서드의 결과로 동작을 정하는 실수입니다. 이 메서드들은 호출한 순간의 상태를 알려 줄 뿐이고, 결과를 받아 다음 줄을 실행하는 사이에 상태가 바뀔 수 있습니다. API 문서도 이 메서드들이 시스템 상태를 모니터링하기 위한 것이지 동기화를 제어하기 위한 것이 아니라고 적고 있습니다. 예를 들어 hasWaiters()가 true였어도, 기다리던 스레드가 그사이 시간 만료나 인터럽트로 빠져나갈 수 있으므로 이어서 부른 signal()이 누군가를 깨운다는 보장은 없습니다.
정리
Condition은ReentrantLock의newCondition()으로 만들고,await(),signal(),signalAll()은 그 락을 쥔 스레드만 부를 수 있습니다. 락 없이 부르면IllegalMonitorStateException이 납니다.await()는 락을 완전히 놓고 기다렸다가, 돌아오기 전에 락을 원래 횟수대로 다시 얻습니다. 인터럽트로 끝날 때도 락을 다시 얻은 뒤에 예외를 던집니다. 가짜 깨움이 있으므로 언제나while안에서 기다립니다.- 락은
try바로 앞에서 얻고finally의 첫 문장에서 놓습니다. 락 얻기를try안에 두면 얻기에 실패했을 때 원래 예외가IllegalMonitorStateException에 덮입니다. Condition이 하나뿐이면 대기실도 하나이므로signalAll()을 씁니다.lockInterruptibly()는 락을 기다리는 동안에도 인터럽트에 반응하고,tryLock(time, unit)과awaitNanos는 락 얻기와 조건 대기에 각각 시간 제한을 둡니다.awaitNanos의 반환값을 다음 반복에 넘겨야 전체 대기 시간이 늘어나지 않습니다.
연습 문제
- 창고 재고 카운터에
receive(int quantity)와awaitStock(int minimum, Duration timeout)을 구현하세요. 입고는 양수만 받고, 재고를 늘린 뒤 기다리는 스레드에게 알립니다.awaitStock은 락을 기다리는 동안에도 인터럽트에 반응해야 하지만, 락을 기다리는 시간에는 제한을 두지 않습니다. 락을 얻은 뒤에 조건 대기 시간을 재기 시작하고,awaitNanos가 돌려준 남은 시간을 다음 반복에 쓰세요. 시간이 다 될 때까지 재고가minimum에 이르지 않으면 재고를 바꾸지 않고false를 돌려줍니다. - 동료가 아래처럼 코드를 썼습니다. 인터럽트된 스레드가 이 메서드를 부르면 무슨 일이 일어나는지 설명하고 고치세요.
void put(E value) throws InterruptedException {
try {
lock.lockInterruptibly();
while (queue.size() == capacity) notFull.await();
queue.add(value);
notEmpty.signal();
} finally {
lock.unlock();
}
}TimedConditionCounter의awaitAtLeast가awaitNanos의 반환값을 버리고 매번advanced.awaitNanos(timeout.toNanos())를 부르도록 바뀌었다고 합시다. 목표가 10이고timeout이 1초인데, 다른 스레드가 0.9초마다increment()를 한 번씩 부른다면 원래 코드와 바뀐 코드는 각각 언제, 무엇을 돌려주나요?
해설 보기
1번. 재고 필드와 조건은 같은 락이 보호합니다. 입고는 재고를 늘린 뒤 signalAll()을 부르고, 기다리는 쪽은 stock < minimum인 동안 awaitNanos를 부릅니다. 시간이 다 됐는지 판단하는 것과 재고를 확인하는 것이 모두 락 안에서 일어나므로, 입고와 시간 만료가 거의 같은 순간에 일어나도 기다리는 쪽은 일관된 하나의 상태를 보고 결과를 정합니다.
import java.time.Duration;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
public final class InventoryThresholdSolution {
private final ReentrantLock lock = new ReentrantLock();
private final Condition stockChanged = lock.newCondition();
private int stock;
void receive(int quantity) {
if (quantity <= 0) throw new IllegalArgumentException("quantity");
lock.lock();
try {
stock = Math.addExact(stock, quantity);
stockChanged.signalAll();
} finally {
lock.unlock();
}
}
boolean awaitStock(int minimum, Duration timeout) throws InterruptedException {
if (minimum < 0) throw new IllegalArgumentException("minimum");
lock.lockInterruptibly();
try {
long nanos = timeout.toNanos();
while (stock < minimum) {
if (nanos <= 0) return false;
nanos = stockChanged.awaitNanos(nanos);
}
return true;
} finally {
lock.unlock();
}
}
public static void main(String[] args) throws InterruptedException {
InventoryThresholdSolution inventory = new InventoryThresholdSolution();
Thread delivery = Thread.ofPlatform().start(() -> inventory.receive(12));
boolean ready = inventory.awaitStock(10, Duration.ofMillis(500));
delivery.join();
System.out.println("ready=" + ready);
}
}ready=true15번 실행해 15번 모두 ready=true가 나왔습니다. 입고 스레드가 500ms 안에 한 번도 실행되지 못하면 false가 나올 수도 있지만, 이 예제에서는 거의 일어나지 않습니다. delivery.join()은 입고 스레드가 끝나기를 기다릴 뿐, 이미 정해진 ready 값을 바꾸지 않습니다.
signal()이 아니라 signalAll()을 쓴 데에도 이유가 있습니다. 필요한 수량이 서로 다른 대기자가 여럿이면, 입고 한 번으로 어떤 대기자는 진행할 수 있고 어떤 대기자는 아직 기다려야 합니다. signal()이 하필 아직 기다려야 하는 대기자를 깨우면, 진행할 수 있는 대기자는 신호를 받지 못합니다. 앞 글에서 본 것과 같은 문제입니다.
2번. 인터럽트된 스레드가 부르면 lockInterruptibly()가 락을 얻지 못한 채 InterruptedException을 던집니다. 그러면 finally가 쥐지 않은 락에 unlock()을 부르고, IllegalMonitorStateException이 원래 예외를 덮어씁니다. 호출한 쪽은 취소 요청 대신 락 사용 오류처럼 보이는 예외를 받습니다. 락을 얻는 호출을 try 앞으로 옮기면 됩니다.
void put(E value) throws InterruptedException {
lock.lockInterruptibly();
try {
while (queue.size() == capacity) notFull.await();
queue.add(value);
notEmpty.signal();
} finally {
lock.unlock();
}
}이제 락을 얻지 못하면 try에 들어가지 않으므로 InterruptedException이 그대로 호출한 쪽에 전달됩니다. 락을 얻은 뒤 await()에서 인터럽트되면 await()가 락을 다시 얻은 다음 예외를 던지므로, finally의 unlock()은 이 경로에서도 맞습니다.
3번. 원래 코드는 1초 예산에서 시작합니다. 0.9초에 첫 증가 신호로 깨어나면 값이 1이라 다시 기다리는데, 이때 남은 약 0.1초만 기다립니다. 그 시간이 지나면 남은 시간이 0 이하가 되어 약 1초 만에 false를 돌려줍니다. 바뀐 코드는 깨어날 때마다 1초를 새로 받습니다. 신호가 0.9초마다 오므로 시간이 다 되는 일이 없고, 값이 10이 되는 약 9초 뒤에야 true를 돌려줍니다. 1초 안에 포기해야 할 호출이 9배 가까이 오래 기다린 것입니다. 실제로 시간을 10분의 1로 줄여(제한 100ms, 90ms마다 증가) 실행해 보면 원래 코드는 약 100ms 뒤 false, 바뀐 코드는 약 900ms 뒤 true를 돌려줬습니다.
다음 글
notFull·notEmpty 두 Condition으로 대기실 나누기에서는 newCondition()을 두 번 불러 생산자는 notFull에서, 소비자는 notEmpty에서 기다리게 합니다. 대기실이 역할별로 나뉘면 값을 넣은 쪽은 소비자만, 꺼낸 쪽은 생산자만 깨울 수 있으므로 signal()을 안전하게 쓸 수 있습니다.