JAVA/Effective Java

item 79. 과도한 동기화는 피하라

Garonguri 2022. 8. 31. 17:32
728x90

* 동기화란?

여러개의 thread가 하나의 리소스를 사용하려고 할 때 ,사용하려는 thread를 제외한 나머지 thread들이 리소스에 접근하지 못하게 막는것

 

* 동기화 방법

  • 메소드 자체를 synchronized로 선언
  • 블록으로 객체를 받아 lock을 거는 방법 - syncrhonized(this)

 

* 과도한 동기화의 문제점

: 성능을 떨어트리고, 교착상태에 빠뜨리며, 데이터를 훼손하고, 예측할 수 없는 동작을 낳는다.

 

응답 불가와 안전 실패를 피하려면 동기화 메서드나 동기화 블록 안에서는 제어를 절대로 클라이언트에 양도하지 말아라!

제어 ex) 동기화된 영역 안에서 재정의할 수 있는 메서드 호출, 클라이언트가 넘겨준 객체 호출

----> 동기화된 영역을 포함한 클래스 관점에서, 클라이언트가 넘겨준 메서드는 외계인 메서드와도 같다.

 


// 잘못된 예시. 동기화된 블록 안에서 외계인 메서드가 호출되고 있다.

public class Observable<E> extends ForwardingSet<E> {
    public ObservableSet(Set<E> set) { super(set); }
    
    private final List<SetObserver<E>> 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(obeserver);
        }
    }
    
    private void notifyElementAdded(E element) {
        synchronized(observers) {
            for (SetObserver<E> 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> c) {
        boolean result = false;
        for (E element : c)
            resutl |= add(element); //notifyElementAdded를 호출한다.
        return added;
    }
}
  1. 관찰자들은 addOverserver와 removeObserver 메서드를 호출해 구독을 신청하거나 해지한다.
  2. 두 경우 다음 콜백 인터페이스의 인스턴스를 메서드에 건넨다.
// 눈으로 보기에, ObserverSet은 잘 동작할 것 같다.

@FuntionalInterface 
public interface SetObserver<E> {
    void added(ObservableSet<E> set, E element);
}
// 0~99를 출력하는 코드

public static void main(String[] args) {
    ObservableSet<Integer> set = new ObservableSet<>(new HashSet<>());
    set.addObserver((s,e) -> System.out.println(e));
    for (int i = 0; i< 100; i++)
	    set.add(i);
}

여기에, 하나를 추가해서 실험해보자. 25가 되면 자기 자신을 지워보겠다.

// 25가 되면 자기 자신을 제거하는 관찰자 코드 추가하기

set.addObserver(new SetObserver<>() {
    public void added(ObservableSet<Ineger> s, Integer e) {
	    System.out.println(e);
	    if (e == 25)
		    s.removeObserver(this);
    }
}

언뜻 보면 잘 실행되어야 할 것 같지만, 사실 그렇지 않다.

심지어 25까지 출력한 다음 ConcurrentModificationException을 던진다.

 

왜?

: 리스트에서 원소를 제거하려 하는데, 이때 이 리스트를 순회하는 도중이기 때문에, 허용되지 않은 동작 취급을 당하는 것이다.

notifyElementAdded 메서드에서 수행하는 순회는 동기화 블록 안에 있다. 따라서 동시 수정이 일어나지 않도록 보장하지만,

정작 자신이 콜백을 거쳐 되돌아와 수정하는 것까지 막지는 못한다.

 


이상한 실험을 하나 더 해보겠다.

 

자기 자신을 제거하는 관찰자를 작성할건데, removeObserver을 직접 호출하지 않고, ExecutorService를 이용해 다른 스레드에게 부탁을 해볼것이다.

// 쓰잘데기 없이 백그라운드 스레드를 사용하는 관찰자 코드. 이상하다.

set.addObserver(new SetObserver<> {
    public void added(ObservableSet<Integer> s, Integer e) {
	    System.out.println(e);
	    if (e == 25) {
		    ExecutorService exec = Excutors.newSingleThreadExcutor();
		    try (
			    exec.submit(() -> s.removeObserver(this)).get();
			} catch (ExcutionException | InterruptedException ex) {
				throw new AssertionError(ex);
			} finally {
				exec.shutdown();
			}
	    }
    }
}

얘를 실행한 결과로는, 교착 상태에 빠진다.

 

왜?

: 백그라운드 스레드가 s.removeObserver를 호출하면 관찰자를 잠그려 시도하지만, 락을 얻을 수는 없다. 메인 스레드가 이미 락을 쥐고 있기 때문이다. 

그와 동시에, 메인 스레드는 백그라운드 스레드가 관찰자를 제거하기만 기다리는 중이다.

바로 이 상태가 교착 상태이다. 


그럼 도대체 어떻게 해야할까?

: 외계인 메서드의 호출 부분을 동기화 블록 바깥으로 옮기면 된다.

private void notifyElementAdded(E element) {
	    List<SetObserver<E>> snapshot = null;
	    
	    synchronized(observers) {
		    snapshot = new ArrayList<>(observers);
		}
		
		for (SetObserver<E> observer : snapshot) {
			    observer.added(this, element);
		}
}

기본적으로, 동기화 영역에서는 가능한 일을 적게 해라.

만약 많은 일을 해야 하거나 오래 걸리는 작업이라면, 동기화 영역 바깥으로 옮기는 방법을 찾아봐라.

 

과도한 동기화가 초래하는 비용은 락을 얻는데 드는 CPU시간이 아니다.

바로, 모든 코어가 메모리를 일관되게 보기 위한 지연 시간에 드는 비용과, 코드 최적화를 제한하는데서 나오는 비용이다.

 


 

지켜야 할 규칙

  1. 동기화를 전혀 하지 말고, 그 가변 클래스를 동시에 사용해야 하는 클래스가 외부에서 알아서 동기화하게 하자.
    • java.util이 취한 방식
  2. 동기화를 내부에서 수행해 스레드 안전한 가변 클래스로 만들자.: 단, 이 경우는 클라이언트가 외부에서 객체 전체에 락을 거는 것보다 동시성을 월등히 개선할 수 있을 때만 선택해라.
    • java.util.concurrent가 취한 방식
  3. 가변 클래스를 내부에서 동기화하기로 했다면, 다음과 같은 기법을 사용해 동시성을 높여라.
    1. 락 분할
    2. 락 스트라이핑
    3. 비차단 동시성 제어
  4. 여러 스레드가 호출할 가능성이 있는 메서드가 정적 필드를 수정한다면 그 필드를 사용하기 전에 반드시 동기화를 해야 한다.

 

핵심 정리

교착상태와 데이터 훼손을 피하려면 동기화 영역 안에서 외계인 메서드를 절대 호출하지 말자.
동기화 영역 안에서의 작업은 최소한으로 줄이자 .
가변 클래스를 설계할 때는 스스로 동기화해야 할지 고민하자.
멀티 코어 세상인 지금도 과도한 동기화를 피하는 게 과거 어느 때보다 중요하다. 
합당한 이유가 있을 때만 내부에서 동기화하고, 동기화했는지 여부를 문서에 명확히 밝히자
728x90