Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
323 changes: 323 additions & 0 deletions study/ch14.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,323 @@
# 동기화 클래스 구현
> 💡상태 의존적인 클래스란?
> - 상태 기반 선행 조건을 가진 클래스
> - 상태 의존적인 클래스를 새로 구현하는 가장 간단한 방법: 기존의 동기화 클래스를 활용해 필요 기능 구현
> - ex) Semaphore, BlockingQueue

## 1. 상태 종속성 관리
### 상태 기반 조건이 만족되지 않을 떄
- 단일 스레드 프로그래밍은 앞으로도 상태 기반 조건이 만족될 가능성이 없으므로 반드시 오류 발생
- 병렬 프로그래밍은 다른 스레드가 상태 기반 조건을 변경할 수 있으므로 오류가 발생이 적고, 상태 기반 조건이 만족될 때까지 기다리는 경우가 많음

### 상태 종속적인 작업의 동기화 구조
- 상태 변수를 확인할 때는 락을 확보
- 선행 조건을 만족하지 않았다면 다른 스레드에서 상태 변수를 변경할 수 있도록 락을 풀고 기다림
```
void blockingAction() thows InterruptedException {
상태 변수에 대한 락 확보
while (선행 조건이 만족하지 않음) {
확보했던 락을 풀어줌
선행 조건이 만족할만한 시간만큼 대기
인터럽트에 걸리거나 타임아웃이 걸리면 멈춤
락을 다시 확보
}
작업 실행
락 해제
}
```
```java
public class ArrayBlockingQueue<E> extends AbstractQueue<E> implements BlockingQueue<E>, 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<V> extends BaseBoundedBuffer<V> {
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<V> {
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;
}
}
}
```
Loading