Effective Java : 아이템 79. 과도한 동기화는 피하라.
- 프로그래밍 언어/JAVA
- 2023. 9. 3.
아이템 79. 과도한 동기화는 피하라.
- 외계인 메서드는 클래스 외부에서 제공된 함수 객체 같은 것을 의미한다.
- 동기화 블록에서는 외계인 메서드, 재정의 가능한 메서드를 호출하면 안된다. 만약 호출하면 Deadlock, 안전 실패가 발생할 수 있음.
- 반드시 외계인 메서드, 재정의 가능한 메서드는 동기화 블록 밖에서 호출해야함.
- 동기화 블록을 사용할 때의 규칙
- 동기화 영역에서 가능한 일을 적게 한다.
- 다른 스레드는 동기화 영역의 락을 얻기 위해 대기할 것이다. (낭비)
- 동기화 영역에서 외계인 메서드를 호출하지 않는다.
- 외계인 메서드는 어떤 상태를 변경할지 모른다. (안전 실패, 데드락)
- 외계인 메서드는 얼마나 걸릴지 모른다. (낭비)
- 동기화 영역에서 가능한 일을 적게 한다.
- 동기화에 필요한 비용은?
- 동기화가 초래하는 진짜 비용은 락을 얻는데 드는 CPU 시간이 아님.
- 병렬로 실행할 기회를 잃고, 모든 코어가 메모리를 일관되게 보기 위한 지연시간.
- 가상머신의 코드 최적화를 제한함.
- 동기화는 가능한 외부 클라이언트에서 락을 획득해서 처리하도록 한다. 동시성이 많이 개선될 경우에만, 내부 인스턴스에서 구현해서 사용한다. (Java.util.Concurrent 같은 것들)
- 피할 수 없는 동시성 문제도 있음.
- synchronized 블록을 호출해서 static 필드를 업데이트 할 때, 여러 쓰레드가 여러 인스턴스를 복제해서 업데이트 하면 동시성 문제가 발생한다.
- 자바는 재진입(reentrant) 락을 허용함.
- 재진입 락을 사용하면 멀티 스레드 프로그램을 쉽게 구현할 수 있도록 함.
- 하지만 데드락 문제를 안전 실패(데이터 훼손)으로 변모시킬 수 있음.
동기화 블록에서는 Deadlock, 안전 실패를 고려해야 한다.
먼저 용어를 살펴보면 다음과 같다.
- Deadlock: 서로 다른 쓰레드가 락을 차지하려고 경합하는 상태를 의미한다.
- 안전 실패: 원래라면 에러가 발생해야하지만 데이터가 훼손되고 프로그램은 계속 동작하는 상태를 의미한다.
동기화 블록 내에서는 반드시 위의 두 가지 상태가 발생할 수 있다는 것을 인지해야한다. 그리고 위 두 가지 상태를 피하려면 동기화 블록 내에서 다음을 반드시 지켜야 한다.
- 재정의 가능한 메서드를 호출하면 안됨.
- 외계인(외부 클라이언트가 전달해준 함수 객체)를 호출하면 안됨.
외계인이나 재정의 가능한 메서드가 왜 문제가 되는 것일까? Synchronized 블록으로 보호되는 부분은 해당 인스턴스의 모니터락을 얻을 수 있는 쓰레드만 접근이 가능하다. 아래 코드를 살펴보자.
public class WrongCase {
public synchronized void hello() {
hello2();
}
public void hello2() {
System.out.println("fire");
}
}
hello() 메서드는 단 하나의 쓰레드만 접근 가능하다. syncrhonized 키워드로 보호된 블록은 이 인스턴스의 모니터락을 얻어야만 가능하기 때문이다. 그렇지만 hello2()라는 메서드는 synchronized로 동기화 되어 있지 않기 때문에 많은 쓰레드들이 자유롭게 접근할 수 있다. 여기서 동시성 문제 / 동기화 문제(안전 실패, DeadLock)가 발생할 수 있다.
이것을 클라이언트가 전달해 준 함수 객체로 확장하면 답은 더 명확해진다. 클라이언트가 전달해 준 함수 객체를 BiConsumer라고 보자. 동기화 된 hello3() 메서드는 한번에 하나의 쓰레드만 접근 가능하다. 하지만 외부 클라이언트가 전달해준 consumers라는 객체는 언제든지 바뀔 수 있는 외계인 객체다. 따라서 동시성 문제가 발생할 수 밖에 없다.
public List<BiConsumer> consumers = new ArrayList<>();
public synchronized void hello3() {
consumers.stream().forEach(biConsumer -> biConsumer.accept(new Object(), new Object()));
}
동기화 블록에서의 Deadlock 예시.
아래 코드에서 동기화 블록에서 외계인 메서드를 호출했을 때, 발생할 수 있는 데드락 문제를 살펴보고자 한다.
public class ObservablesSet<E> extends ForwardingSet<E> {
private final List<SetObserver> observers = new ArrayList<>();
public void addObserver(SetObserver<E> observer) {
synchronized (observers) {
observers.add(observer);
}
}
public boolean removeObserver(SetObserver<E> observer) {
synchronized (observers) {
return observers.remove(observer);
}
}
private void notifyElementAdded(E element) {
synchronized (observers) {
for (SetObserver observer : observers) {
// 외계인 메서드 호출되는 부분
observer.added(this, element);
}
}
}
@Override
public boolean add(E element) {
boolean added = super.add(element);
if (added) {
notifyElementAdded(element);
}
return added;
}
@Override
public boolean addAll(Collection<? extends E> collection) {
boolean result = false;
for (E element : collection) {
result |= add(element);
}
return result;
}
@Override
protected Set<E> delegate() {
return new HashSet<>();
}
}
이런 클래스가 있다고 가정해보자. 이 클래스를 간략히 설명해보면 다음과 같다.
- Set 인스턴스에 값을 보관한다. HashSet을 사용한다.
- add() 메서드가 호출되면 새로운 원소를 Set에 추가한다. 추가한 후 notifyElementAdded()를 호출해서 구독하고 있는 Observer에게 이 사실을 알린다.
- Observer는 SetObserver라는 함수형 인터페이스다. 이 함수형 객체는 BiConsumer와 동일하다.
- Observers에 접근할 때는 synchronized를 이용해 동기화 한 다음에 작업한다.
이 때 한 가지 문제점은 syncrhonized 블록 내에서 외계인 메서드를 호출하는 구문이 있다는 것이다. 문제가 되는 메서드는 added()를 호출하는 부분이다. 이 구문이 호출되는 것 자체는 동기화 되어있지만, observer.added()가 호출되는 부분은 동기화 되어 있지 않다. 즉, observers라는 녀석부터 observer.added()까지는 호출되었을 때 동기화 되어 있지 않기 때문에 어떤 사이드 이펙트가 발생할 지 알 수 없다.
private void notifyElementAdded(E element) {
synchronized (observers) {
for (SetObserver observer : observers) {
// 외계인 메서드 호출되는 부분
observer.added(this, element);
}
}
}
아래 같이 메서드를 작성한 다음에, 호출하면 실제로 외계인 메서드 호출에 따른 데드락 문제가 발생한다.
// DeadLock
public static void method3() {
ObservablesSet<Integer> set = new ObservablesSet<>();
set.addObserver(new SetObserver<>() {
@Override
public void added(ObservablesSet<Integer> set, Integer element) {
if (element == 21) {
ExecutorService executor = Executors.newSingleThreadExecutor();
try{
// 블록킹
executor.submit(() -> set.removeObserver(this)).get();
} catch (ExecutionException | InterruptedException e) {
e.printStackTrace();
} finally {
executor.shutdown();
}
}
System.out.println(element);
}
});
for (int i = 0; i < 100; i++) {
set.add(i);
}
}
데드락은 다음과 같이 동작하기 때문에 발생한다.
- 21이라는 값을 add()를 통해서 추가한다.
- add()가 호출되면 noitfyElementAdded()가 호출된다.
- notifyElemtnAdded()는 시작할 때 observers의 락을 획득한 다음에 동작한다. 그리고 각 observer.added()를 호출한다.
- i = 21인 경우, 새로운 쓰레드를 만들어서 작업을 호출한다. 이 때, 자기 자신(observer)를 observers로부터 삭제한다. 그리고 get() 메서드를 호출하는데, 쓰레드의 작업이 끝날 때까지 블록킹 된다. 즉, 메인 쓰레드는 exeuctor.submit()에 블록킹 된다.
- 다른 스레드는 removeObserver()를 호출한다. removeObserver()는 Observers의 락을 획득하려고 한다. 그렇지만 메인 쓰레드가 락을 가지고 있어서 락을 얻기 위해 대기한다.
메인 쓰레드는 락을 가진 채, 자식 쓰레드의 작업이 끝날 때까지 블로킹 된다. 자식 쓰레드는 부모 쓰레드가 가지고 있는 락을 얻어야 작업을 할 수 있다. 이런 이유 때문에 데드락이 발생하게 된다. 이런 것은 외계인 메서드를 동기화 블록 내에서 호출했기 때문에 발생한다. 외계인 메서드는 이 클래스 내에서 동기화가 관리되지 않기 때문에 여러 문제를 불러올 수 있다.
동기화 블록에서의 Deadlock 해결방법 - 1 (동기화 밖에서 호출)
첫번째 해결방법은 동기화 블록 밖에서 외계인 메서드를 호출하도록 바꾸는 것이다. 그리고 이렇게 할 때는, 외계인 메서드가 적절히 잘 동기화 되는지도 잘 살펴봐야 한다.
private void notifyElementAdded(E element) {
final List<SetObserver> snapshot;
// 외계인 메서드 호출되는 부분 --> 동기화 영역 바깥으로 분리.
synchronized (observers) {
snapshot = new ArrayList<>(observers);
}
for (SetObserver observer : snapshot) {
observer.added(this, element);
}
}
외계인 메서드 호출되는 부분은 observer.added()이기 때문에 이 부분을 동기화 블록에서 분리하면 된다. 가장 간단한 방법으로는 호출해야 할 Observer를 안전하게 복사하는 구문을 동기화 블록내에서 실행한다. 그리고 복사된 Observer를 동기화 구문 밖에서 호출하도록 한다. 이 경우, 데드락 문제가 해결된다.
동기화 블록에서의 Deadlock 해결방법 - 2 (Concurrent 라이브러리 사용)
앞서서 외계인 메서드를 동기화 구문 밖에서 호출하도록 분리하는 작업을 했고, 이를 위해서 동기화 구문 내에서 객체를 복사해서 호출했다. 이런 용도로 만들어진 라이브러리가 있는데 CopyOnWriteArrayList라는 녀석이다.
CopyOnWriteArrayList는 다음과 같이 동작한다.
- 내부를 변경하는 작업은 항상 깨끗한 복사본을 만들어 수행함.
- 내부의 배열은 절대 수정되지 않으니 순회할 때 락이 필요없음.
private final List<SetObserver> observers = new CopyOnWriteArrayList<>();
public void addObserver(SetObserver<E> observer) {
observers.add(observer);
}
public boolean removeObserver(SetObserver<E> observer) {
return observers.remove(observer);
}
private void notifyElementAdded(E element) {
for (SetObserver observer : observers) {
observer.added(this, element);
}
}
위처럼 수정해서 사용하면 되는데, Synchronized 구문이 모두 제거된 것을 알 수 있다.
멀티 쓰레드 동기화의 기본 규칙
멀티 쓰레드 환경에서 가변 데이터를 공유해야한다면 동기화는 필수적이다. 여기서는 동기화의 기본적인 규칙을 살펴보고자 한다.
- 동기화 영역에서 가능한 일을 적게 한다.
- 다른 스레드는 동기화 영역의 락을 얻기 위해 대기할 것이다. (낭비)
- 동기화 영역에서 외계인 메서드를 호출하지 않는다.
- 외계인 메서드는 어떤 상태를 변경할지 모른다. (안전 실패, 데드락)
- 외계인 메서드는 얼마나 걸릴지 모른다. (낭비)
멀티 쓰레드를 동기화 할 때는 이런 것들을 감안해서 코드를 작성해야한다. 그렇다면 멀티 쓰레드 동기화를 과도하게 했을 때 발생할 수 있는 비용은 어떤 것일까?
- 동기화가 초래하는 진짜 비용은 락을 얻는데 드는 CPU 시간이 아님.
- 병렬로 실행할 기회를 잃고, 모든 코어가 메모리를 일관되게 보기 위한 지연시간.
- 가상머신의 코드 최적화를 제한함.
아이템 78에서 공부했을 때, 각 코어마다 캐시 메모리를 가지고 있고 그곳에서 값을 읽어온다고 했다. 그리고 동기화 구문을 이용하면 캐시의 값이 메인 메모리에 저장되는 것을 보장한다고 했다. 그리고 이것은 상대적으로 느린 작업이다. 동기화를 과도하게 사용했을 때는, 이처럼 캐시에서 빠르게 처리할 수 있는 부분이 메모리에 반영될 때까지 기다려야 한다는 문제가 발생한다.
또한 volatile, syncrhozined 키워드를 썼을 때 컴파일러가 최적화 하는 JVM 코드를 제한하는 것도 확인했다. 이런 부분 역시 과도한 동기화에서 발생할 수 있는 성능 희생 문제가 될 것이다.
동기화를 하는 지침
동기화를 하는 지침은 두 가지가 있다.
- 외부 클라이언트에서 모두 동기화를 담당하게 한다. → java.util이 이런 방식.
- 내부 클라이언트에서 모두 동기화를 담당하게 한다. → java.util.concurrent가 이런 방식.
일반적으로는 두번째 방법을 선택했을 때, 동시성이 월등이 개선될 때만 두번째 방법을 선택해야한다. 즉, 대부분은 인스턴스를 사용하는 외부 클라이언트가 전체 락을 잡고 동시성 문제를 해결해야한다.
그외 → 피할 수 없는 동시성 문제.
책에는 이런 구절이 있다. 이것은 동기화 블록을 사용한다고 하더라도 피할 수 없는 동시성 문제가 존재한다는 것을 의미한다.
여러 스레드가 호출할 가능성이 있는 메서드가 정적 필드를 수정한다면 그 필드를 사용하기 전에 반드시 동기해야한다. 클라이언트가 여러 스레드로 복제돼 구동되는 상황이라면 다른 클라이언트에서 이 메서드를 호출하는 걸 막을 수 없으니 외부에서 동기화 할 방법이 없다. 결과적으로, 이 정적 필드가 심지어 private라도 서로 관련 없는 스레드들이 동시에 읽고 수정할 수 있게 된다.
이것에 대한 적절한 예시는 아래에서 볼 수 있다.
public class UnescapeableCase {
private static int value;
public synchronized void increment(int newValue) {
String threadName = Thread.currentThread().getName();
System.out.printf("threadName : %s , value : %d \n", threadName, value);
value = newValue;
System.out.printf("threadName : %s , value : %d \n", threadName, value);
}
public static void main(String[] args) throws InterruptedException {
int threadSize = 3;
ExecutorService executorService = Executors.newFixedThreadPool(threadSize);
CountDownLatch latch = new CountDownLatch(threadSize);
for (int i = 0; i < 3; i++) {
int index = i;
executorService.submit(() -> {
new UnescapeableCase().increment(index);
latch.countDown();
});
}
latch.await();
System.out.println(UnescapeableCase.value);
}
}
이 코드를 살펴보면 다음과 같다.
- increment() 메서드는 한번에 하나의 쓰레드만 접근할 수 있다. 왜냐하면 인스턴스의 모니터락은 1개 뿐이기 때문이다.
- 그런데 만약 인스턴스를 3개 만들면, 3개의 모니터락이 생긴다. 각 인스턴스의 increment()는 하나의 쓰레드에 의해서 호출되지만, 총 3개가 동시에 호출될 수 있다.
- increment() 3개가 동시에 호출되면, value라는 값은 3개에 의해서 동시에 업데이트 될 수 있다.
결론적으로 syncrhonized 블록을 이용해서 동기화를 하려고 했지만, 모니터락에 의해서 통제되는 동시성 문제는 여러 인스턴스를 복제해서 사용하게 되면 회피할 수 있게 된다. 즉, 궁극적으로는 해결할 수 없는 동시성 문제로 바꿔진다.
자바는 재진입락(reentrant)을 허용함 → 데드락이 안전실패로 변질될 수 있음.
자바는 재진입락을 허용한다. 재진입락은 다음을 허용하는 락을 의미한다.
- 쓰레드 A가 B라는 객체의 락을 가지고 있다. 이 상태에서 쓰레드 A가 메서드를 계속 호출해서 다시 한번 B라는 객체의 락이 필요한 시점이 올 수 있다.
이 때, 재진입락이 아니면 쓰레드 A의 메서드는 자기 자신이 가지고 있는 락을 얻을 때까지 데드락 상태에 빠지게 된다. 반면 재진입락이라면, 쓰레드 A의 메서드는 자기 자신이 가지고 있는 락을 얻어서 후속 작업을 진행할 수 있다.
재진입락은 자가 호출을 통해서 발생할 수 있는 데드락 상태를 개선해 줄 수 있다. 그러나 이 호출 과정에서 만약 특정 객체들의 상태를 바꾸게 된다면, 안전 실패(데이터 훼손)이 발생할 수 있게 되는 것이다.
'프로그래밍 언어 > JAVA' 카테고리의 다른 글
Effective Java : 아이템 81. wait, notify보다는 동시성 유틸리티를 애용하라. (0) | 2023.09.04 |
---|---|
Effective Java : 아이템 80. 스레드보다는 실행자, Task, Stream을 애용하라. (0) | 2023.09.03 |
Effective Java : 아이템 78. 공유 중인 가변 데이터는 동기화해 사용하라. (0) | 2023.08.31 |
Effective Java : 아이템 77. 예외를 무시하지 말라 (0) | 2023.08.31 |
Effective Java : 아이템 69. 예외는 진짜 예외 상황에만 사용하라 (0) | 2023.08.30 |