From b037cb0fc3a6042bab942f2ef0e660903d5c76c7 Mon Sep 17 00:00:00 2001 From: Yiseul Park Date: Wed, 1 Oct 2025 19:34:57 +0900 Subject: [PATCH] =?UTF-8?q?feat:=2014=EC=9E=A5=20=EC=A0=95=EB=A6=AC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- study/ch14.md | 323 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 323 insertions(+) create mode 100644 study/ch14.md diff --git a/study/ch14.md b/study/ch14.md new file mode 100644 index 0000000..4d386b8 --- /dev/null +++ b/study/ch14.md @@ -0,0 +1,323 @@ +# 동기화 클래스 구현 +> 💡상태 의존적인 클래스란? +> - 상태 기반 선행 조건을 가진 클래스 +> - 상태 의존적인 클래스를 새로 구현하는 가장 간단한 방법: 기존의 동기화 클래스를 활용해 필요 기능 구현 +> - ex) Semaphore, BlockingQueue + +## 1. 상태 종속성 관리 +### 상태 기반 조건이 만족되지 않을 떄 +- 단일 스레드 프로그래밍은 앞으로도 상태 기반 조건이 만족될 가능성이 없으므로 반드시 오류 발생 +- 병렬 프로그래밍은 다른 스레드가 상태 기반 조건을 변경할 수 있으므로 오류가 발생이 적고, 상태 기반 조건이 만족될 때까지 기다리는 경우가 많음 + +### 상태 종속적인 작업의 동기화 구조 +- 상태 변수를 확인할 때는 락을 확보 +- 선행 조건을 만족하지 않았다면 다른 스레드에서 상태 변수를 변경할 수 있도록 락을 풀고 기다림 + ``` + void blockingAction() thows InterruptedException { + 상태 변수에 대한 락 확보 + while (선행 조건이 만족하지 않음) { + 확보했던 락을 풀어줌 + 선행 조건이 만족할만한 시간만큼 대기 + 인터럽트에 걸리거나 타임아웃이 걸리면 멈춤 + 락을 다시 확보 + } + 작업 실행 + 락 해제 + } + ``` + ```java + public class ArrayBlockingQueue extends AbstractQueue implements BlockingQueue, Serializable { + public void put(E e) throws InterruptedException { + Objects.requireNonNull(e); + ReentrantLock lock = this.lock; + lock.lockInterruptibly(); + + try { + while(this.count == this.items.length) { + this.notFull.await(); + } + + this.enqueue(e); + } finally { + lock.unlock(); + } + } + } + +### 선행 조건을 만족하지 않은 경우 +#### 1) 예외를 발생시키거나 또는 오류 값 반환 +- 호출자는 호출할 때마다 예외 처리를 해줘야함 +#### 2) 스핀 대기 방법 + - 호출자가 선행 조건이 만족될 때까지 반복해서 확인 + - 대기 시간 없이 다시 호출 -> CPU 자원 소모 + - 짧은 시간 동안 대기 후 다시 호출 -> CPU 자원 소모 줄이지만 응답 시간 길어짐 (과다 대기 가능성) +#### 3) 상태 의존 메서드 내부에서 재시도 반복(폴링/대기) + - 호출자는 예외 처리나 재시도 코드 작성 필요 없음 + - 대기 시간에 따라 응답 시간이 달라질 수 있음 + - 인터럽트에 걸리거나 타임아웃이 걸리면 멈출 수 있도록 구현하는 것이 좋음 +#### 4) 조건 큐 + - 조건이 맞지 않으면 스레드를 멈추고, 원하는 조건에 도달하면 스레드를 깨움 + - 조건 큐에는 스레드가 값으로 들어감 + - 여러 스레드를 한 덩어리(wait set)로 관리. 특정 조건이 만족할 때까지 한꺼번에 대기 가능 + - 모든 객체는 스스로를 조건 큐로 사용할 수 있음 + - wait(), notify(), notifyAll() 메서드 제공 + - wait(): 현재 스레드가 락을 반납하고 해당 객체의 조건 큐에서 대기 상태로 들어감 + - notify(): 조건 큐에 있는 스레드 중 하나를 깨움 (랜덤) + - notifyAll(): 조건 큐에 있는 모든 스레드를 깨움 + - 자바 객체의 암묵적인 락과 조건 큐는 굉장히 밀접히 관련되어 있음 + - 객체의 암묵적인 락을 확보한 상태에서만 wait(), notify(), notifyAll() 메서드 호출 가능 + - 락 객체와 조건 큐 객체는 반드시 같아야 함 + - 객체 내부의 상태를 확인하기 전에는 조건이 만족할 때까지 대기할 수 없고 + - 객체 내부의 상태를 변경하지 못하는 한 해당 객체의 조건 큐에 있는 스레드를 깨울 수 없음 + - 폴링/대기 방법과 작동하는 모습은 똑같지만 CPU 자원, 컨텍스트 스위치 비용, 응답 속도 측면에서 훨씬 효율적 + - 타임아웃이 걸리면 멈출 수 있도록 구현하는 것이 좋음 +```java +@ThreadSafe +public class BoundedBuffer extends BaseBoundedBuffer { + public synchronized void put(V v) throws InterruptedException { + while (isFull()) { + wait(); + } + doPut(v); + notifyAll(); + } +} +``` + +## 2. 조건 큐 활용 +### 조건 서술어 +- 특정 기능이 상태 종속적이 되도록 만드는 선행 조건을 의미함 +- 조건 서술어는 상태 변수를 기반으로 하고 있고, 상태 변수는 락으로 동기화되어 있으니 조건 서술어를 확인하거나 변경할 때는 반드시 락을 확보해야 함 +- wait 메서드는 먼저 락을 해제하고 현재 스레드를 대기 상태로 둠 +- 타임아웃 발생/인터럽트 발생/notify()/notifyAll() 메서드 호출을 통해 알림을 받을 때까지 대기 +- 스레드가 대기 상태에서 깨어나면 다시 락을 확보하고 wait 메서드 호출 직후의 코드부터 실행 재개 + - 락 확보함에 있어 우선 순위는 갖지 않음 + +### wait 메서드 +- 대기 상태 스레드가 깨어났다고 해서 조건 서술어가 만족된다는 보장은 없음 + - wait 메서드가 알아서 리턴되는 경우가 있음 + - notify/notifyAll 메서드가 호출되었지만 다른 스레드가 먼저 락을 확보해 조건 서술어를 변경했을 수도 있음 + - 동일한 조건 큐를 대상으로 하는 다른 조건 서술어가 만족돼 호출한 것일 수 있음 +- 하나의 조건 큐에 여러 조건 서술어가 걸려 있을 수 있음 + +#### 조건부 wait 메서드 사용 시 주의 사항 +- 항상 조건 서술어를 명시해야 한다. +- wait 메서드 호출 전과 리턴된 이후 모두 조건 서술어를 확인해야 한다. +- 조건 서술어를 확인하는 데 관련된 모든 상태 변수는 해당 조건 큐 락에 의해 동기화되어야 한다. +- wait, notify, notifyAll 메서드는 반드시 해당 조건 큐 락을 확보한 상태에서 호출해야 한다. +- 조건 서술어를 확인한 후 실제 작업을 실행해 작업이 끝날 때까지 락을 확보해야 한다. + +> 주의 사항을 지키지 않은 경우, 놓친 신호(특정 스레드가 이미 만족한 조건 서술어를 확인하지 못해 대기 상태에 들어가는 상황) 발생 + +### notify/notifyAll 메서드 +- 여러 개의 스레드가 하나의 조건 큐를 놓고 대기 상태에 들어가 있을 수 있으므로 notify 메서드보단 notifyAll 메서드를 사용하는 것이 더 안전 +- notify 메서드는 조건 서술어가 여러 개 걸려 있을 때 조건이 만족되지 않는 스레드를 깨울 수 있음 +- notify 메서드를 사용해도 되는 경우 + - 단일 조건에 따른 대기 상태에서 깨우는 경우 + - 한 번에 하나씩 처리하는 경우: 하나의 스레드만 실행시킬 수 있는 경우 +- 대부분의 경우 위 두 가지 조건을 만족하지 못해 notifyAll 메서드를 사용하는 것이 좋음 +- 조건부 알림 방법 + - notifyAll 메서드를 호출하여 모든 스레드를 깨우는 건 비효율적일 수 있음 + - 대기 상태에서 빠져나올 수 있는 상태를 만들어주는 경우에만 알림 메서드를 호출 + +### 하위 클래스 안정성 문제 +- 상태 기반으로 동작하는 클래스는 하위 클래스에게 대기와 알림 구조를 완전하게 공개하고 그 구조를 문서로 남기거나 +- 하위 클래스에서 대기와 알림 구조에 전혀 접근할 수 없도록 제한해야 함 +- 일반적으로 조건 큐를 클래스 내부 캡슐화해서 외부에서는 조건 큐를 사용할 수 없도록 막는 게 좋음 + +## 3. 명시적인 조건 객체 +- 암묵적인 락을 일반화한 형태가 Lock 클래스인 것처럼 암묵적인 조건 큐를 일반화한 형태는 Condition 클래스 + - Condition 클래스에선 wait, notify, notifyAll 메서드 대신 await, signal, signalAll 메서드 사용 + - 주의할 점: Condition 객체도 Object 클래스를 상속받아 wait, notify, notifyAll 메서드를 가지고 있으므로 사용하지 않도록 주의 +- 암묵적인 락 하나는 조건 큐를 하나만 가질 수 있지만, Condition 클래스는 Lock 하나에 여러 개의 조건으로 대기할 수 있음 + - Condition 객체를 활용하면 단일 알림 조건을 만족시킬 수 있음 (signalAll 메서드 대신 signal 메서드 사용 가능) +- Condition 객체는 Lock 객체의 공정성을 그대로 물려받음 +- Condition 객체 & ReentrantLock 클래스 조합 vs 암묵적인 조건 큐 & synchronized 키워드 조합 + - 공정한 큐 관리 방법이나 하나의 락에서 여러 개의 조건 큐가 필요한 경우 Condition 객체 & ReentrantLock 클래스 조합이 더 나음 + - 그 외에는 암묵적인 조건 큐 & synchronized 키워드 조합이 더 나음 +```java +@ThreadSafe +public class ConditionBoundedBuffer { + protected final Lock lock = new ReentrantLock(); + private final Condition notFull = lock.newCondition(); + private final Condition notEmpty = lock.newCondition(); + private final V[] items = (V[]) new Object[100]; + private int tail, head, count; + + public void put(V v) throws InterruptedException { + lock.lock(); + try { + while (count == items.length) { + notFull.await(); + } + items[tail] = v; + if (++tail == items.length) { + tail = 0; + } + ++count; + notEmpty.signal(); + } finally { + lock.unlock(); + } + } +} +``` + +## 4. 동기화 클래스 내부 구조 +- AQS(AbstractQueuedSynchronizer) 클래스는 락이나 동기화 클래스를 구현할 때 유용한 기능을 제공 +- AQS 기반 동기화 클래스는 대기 상태에 들어갈 수 있는 지점이 단 한군데여서 컨텍스트 스위치 비용이 적음 +- java.util.concurrent 패키지의 동기화 클래스 모두 AQS 클래스를 활용해 구현됨 + +## 5. AbstractQueuedSynchronizer +- AQS 기반 동기화 클래스가 담당하는 가장 기본 연산은 확보와 해체 + - 확보 연산: 상태 기반으로 동작하며 리소스에 대한 접근을 얻는 과정 (대기 상태에 들어갈 수 있음) + - 해제 연산: 접근이 끝나 리소스를 반납하는 과정 (확보 연산에서 대기 중인 스레드를 깨움) +- AQS 클래스는 상태 변수를 int 타입으로 관리 + - ReentrantLock 클래스는 소속된 스레드에서 락을 몇 번 확보했는지 세기 위해 상태 변수를 사용 + - Semaphore 클래스는 사용 가능한 퍼밋 수를 세기 위해 상태 변수를 사용 +- 배타적 확보 연산 vs 배타적이지 않은 확보 연산 + - 배타적(Exclusive) 확보 연산 동기화 클래스 + - 한 번에 단 하나의 스레드만 리소스를 확보할 수 있음 + - 예시: ReentrantLock + - 비배타적(Shared) 확보 연산 동기화 클래스 + - 여러 스레드가 동시에 리소스를 확보할 수 있음 + - 예시: Semaphore(퍼밋 수 > 1), CountDownLatch, ReentrantReadWriteLock의 읽기 락 + +## 6. java.util.concurrent 패키지의 동기화 클래스에서 AQS 활용 모습 +### ReentrantLock +- 배타적인 확보 연산만 제공하는 동기화 클래스 +- 동기화 상태 값을 확보된 락의 개수를 세는 데 사용 +- owner 변수를 통해 현재 락을 확보한 스레드를 추적 + - tryRelease 메서드에서 unlock 메서드 호출 전 owner 변수로 현재 스레드인지 확인 + - tryAcquire 메서드에서 재진입 시도인지 최초 진입 시도인지 확인하기 위해 owner 변수 업데이트 +```java +public class ReentrantLock implements Lock, Serializable { + abstract static class Sync extends AbstractQueuedSynchronizer { + @ReservedStackAccess + protected final boolean tryRelease(int releases) { + int c = this.getState() - releases; + if (this.getExclusiveOwnerThread() != Thread.currentThread()) { + throw new IllegalMonitorStateException(); + } else { + boolean free = c == 0; + if (free) { + this.setExclusiveOwnerThread((Thread)null); + } + + this.setState(c); + return free; + } + } + } + static final class NonfairSync extends Sync { + protected final boolean tryAcquire(int acquires) { + if (this.getState() == 0 && this.compareAndSetState(0, acquires)) { + this.setExclusiveOwnerThread(Thread.currentThread()); + return true; + } else { + return false; + } + } + } +} +``` + +### Semaphore +- 동기화 상태 값을 현재 남아 있는 퍼밋 수로 사용 +- compareAndSetState 메서드를 활용해 퍼밋 수를 원자적으로 증가/감소 +```java +public class Semaphore implements Serializable { + static final class FairSync extends Sync { + protected int tryAcquireShared(int acquires) { + int available; + int remaining; + do { + if (this.hasQueuedPredecessors()) { // 공정성 보장을 위해 대기열에 먼저 온 스레드가 있는지 확인 + return -1; + } + + available = this.getState(); + remaining = available - acquires; + } while(remaining >= 0 && !this.compareAndSetState(available, remaining)); + + return remaining; + } + } +} +``` + +### ReentrantReadWriteLock +- 상태 변수를 읽기 락과 쓰기 락의 확보 상태를 모두 나타내는 데 사용 + - 상태 변수의 상위 16비트: 읽기 락의 확보 횟수 + - 상태 변수의 하위 16비트: 쓰기 락의 확보 횟수 +- 읽기 락은 비배타적 확보/해제 연산, 쓰기 락은 배타적 확보/해제 연산 +```java +public class ReentrantReadWriteLock implements ReadWriteLock, Serializable { + abstract static class Sync extends AbstractQueuedSynchronizer { + static int sharedCount(int c) { return c >>> 16; } // 상위 16bit + static int exclusiveCount(int c) { return c & 0xFFFF; } // 하위 16bit + + @ReservedStackAccess + final boolean tryWriteLock() { + Thread current = Thread.currentThread(); + int c = this.getState(); + if (c != 0) { + int w = exclusiveCount(c); + if (w == 0 || current != this.getExclusiveOwnerThread()) { + return false; + } + + if (w == 65535) { + throw new Error("Maximum lock count exceeded"); + } + } + + if (!this.compareAndSetState(c, c + 1)) { + return false; + } else { + this.setExclusiveOwnerThread(current); + return true; + } + } + + @ReservedStackAccess + final boolean tryReadLock() { + Thread current = Thread.currentThread(); + + int c; + int r; + do { + c = this.getState(); + if (exclusiveCount(c) != 0 && this.getExclusiveOwnerThread() != current) { + return false; + } + + r = sharedCount(c); + if (r == 65535) { + throw new Error("Maximum lock count exceeded"); + } + } while(!this.compareAndSetState(c, c + 65536)); + + if (r == 0) { + this.firstReader = current; + this.firstReaderHoldCount = 1; + } else if (this.firstReader == current) { + ++this.firstReaderHoldCount; + } else { + HoldCounter rh = this.cachedHoldCounter; + if (rh != null && rh.tid == LockSupport.getThreadId(current)) { + if (rh.count == 0) { + this.readHolds.set(rh); + } + } else { + this.cachedHoldCounter = rh = (HoldCounter)this.readHolds.get(); + } + + ++rh.count; + } + + return true; + } + } +} +``` \ No newline at end of file