Notice
Recent Posts
Recent Comments
Link
«   2025/04   »
1 2 3 4 5
6 7 8 9 10 11 12
13 14 15 16 17 18 19
20 21 22 23 24 25 26
27 28 29 30
Archives
Today
Total
관리 메뉴

전공공부

[아이템 79.] 과도한 동기화는 피하라 본문

Study/Java

[아이템 79.] 과도한 동기화는 피하라

monitor 2023. 7. 10. 23:26
과도한 동기화의 문제

충분하지 못한 동기화도 문제이지만 과도한 동기화도 문제다. 성능을 떨어뜨리고 교착상태(Deadlock)에 빠질 수 있으며 심지어 응답 불가나 잘못된 결과를 계산해내는 안전 실패(safety failure)를 일으킬 수 있다.

 

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

 

1. 동기화된 블록 안에서는 재정의 가능한 메서드

2. 클라이언트가 넘겨준 함수 객체를 동기화 메서드 내부에서 호출 

 

무슨 일을 할지 모르기 때문에 예외를 발생시키거나, 교착상태를 만들거나, 데이터를 훼손할 수 있다. 위와 같은 메서드를 외계인 메서드(alien method)라고 한다.

 

외계인 메서드 예시 코드

동기화 블럭 내부에서 외계인 코드를 호출한다.

 

더보기
public class ForwardingSet<E> implements Set<E> {

 private final Set<E> s;

 public ForwardingSet(Set<E> s) { this.s = s; }

 public void clear() { s.clear(); }
 public boolean contains(Object o) { return s.contains(o); }
 public boolean isEmpty() { return s.isEmpty(); }
 public int size() { return s.size(); }
 public Iterator<E> iterator() { return s.iterator(); }
 public boolean add(E e) { return s.add(e); }
 public boolean remove(Object o) { return s.remove(o); }
 public boolean containsAll(Collection<?> c)
	 { return s.containsAll(c); }
 public boolean addAll(Collection<? extends E> c)
	 { return s.addAll(c); }
 public boolean removeAll(Collection<?> c)
	 { return s.removeAll(c); }
 public boolean retainAll(Collection<?> c)
	 { return s.retainAll(c); }
 public Object[] toArray() { return s.toArray(); }
 public <T> T[] toArray(T[] a) { return s.toArray(a); }

 @Override public boolean equals(Object o)
 { return s.equals(o); }
 @Override public int hashCode() { return s.hashCode(); }
 @Override public String toString() { return s.toString(); }
}
public class ObservableSet<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(observer);
        }
    }

    private void notifyElementAdded(E element) {
        synchronized(observers) {
            for (SetObserver<E> observer : observers)
                observer.added(this, element); //3. 해당 메서드는 아래 펑셔널 인터페이스로 정의한 것
        }
    }

    @Override
    public boolean add(E element) { // 1
        boolean added = super.add(element); //Set의 add 메서드 
        if (added) //added 이후 notifiyElementAdded 호출
            notifyElementAdded(element); //2
        return added;
    }

    @Override
    public boolean addAll(Collection<? extends E> c) {
        boolean result = false;
        for (E element : c)
            result |= add(element); // notifyElementAdded 호출
        return result;
    }
}

 

@FunctionalInterface
public interface SetObserver<E> {
    // ObservableSet에 원소가 추가되면 호출된다.
    void added(ObservableSet<E> set, E element);
}

addObserver와 removeObserver 메서드를 호출해 관찰자를 추가하거나 제거한다. 이제 이 외계인 메서드를 통해 잘못된 코드 예제들을 살펴본다.

 

 

아래는 외계인 메서드의 예시이다. 단순히 생각하면 for문이 set.add를 통해 23까지 진행이 되면 s.removeObserver가 지금 바라보는 SetObserver 객체를 삭제 해줄 수 있을 것만 같다.

 

그런데 순서대로 따라가다 보면 notifyElementAdded가 사용하고 있는 도중에 사용하는 객체를 삭제하기 때문이다. 

 

정작 자기 자신의 스레드가 콜백을 거쳐 돌아와 현재 객체를 수정하는 것을 막지 못한다.

public class Item79 {

    @FunctionalInterface
    public interface SetObserver<E> {
        // ObservableSet에 원소가 추가되면 호출된다.
        void added(ObservableSet<E> set, E element);
    }

    public static void main(String[] args) {
    ObservableSet<Integer> set = new ObservableSet<>(new HashSet<>());
    set.addObserver(new SetObserver<>() {
        public void added(ObservableSet<Integer> s, Integer e) { //4. 호출
            System.out.println(e);
            if (e == 23)
                s.removeObserver(this);
        }
    });

    for (int i = 0; i < 100; i++)
        set.add(i);
    }
}

실제 예시

 

 

쓸데없는 백그라운드 스레드

public class Item79 {

    @FunctionalInterface
    public interface SetObserver<E> {
        // ObservableSet에 원소가 추가되면 호출된다.
        void added(ObservableSet<E> set, E element);
    }

    public static void main(String[] args) {
    ObservableSet<Integer> set = new ObservableSet<>(new HashSet<>());
    set.addObserver(new SetObserver<>() {
        public void added(ObservableSet<Integer> s, Integer e) { //메인이 락 걸리는 포인트
            System.out.println(e);
            if (e == 23) {
                ExecutorService exec = Executors.newSingleThreadExecutor();
                try {
					//lock이 main과 exec에서 각자 발생한다.
					exec.submit(() -> s.removeObserver(this)).get(); //exec 락 걸리는 포인트
                } catch (ExecutionException | InterruptedException ex) {
                    throw new AssertionError(ex);
                } finally {
                    exec.shutdown();
                }
            }
        }
    });
    
    for (int i = 0; i < 100; i++)
        set.add(i);
    }
}

아래 락이 걸리는 메서드들을 가져와서 따로 보게되면 아래와 같다.

 

    public boolean removeObserver(SetObserver<E> observer) { //ExecutorService에 의해 락 걸리는중
        synchronized (observers) {
            return observers.remove(observer);
        }
    }
    
    private void notifyElementAdded(E element) { 
        synchronized (observers) { //main thread 사용처 인 곳에서 이 부분 때문에 lock 걸리는 중
            for(SetObserver<E> observer : observers) {
                observer.added(this, element);
            }
        }
    }

아래와 같이 각각의 스레드에서 락이 걸려 교착상태가 발생한다.

 

그럼 해결 방법은...?

 

당연히 해결 방법이 존재한다.

 

1. 외계인 메서드를 동기화 블럭 바깥에 두는 것이다.

    private void notifyElementAdded(E element) { //main thread가 lock 걸리는 중
    	List<SetObserver<E>> snapshot = null;
        synchronized (observers) {
            snapshot = new ArrayList<>(observers);
        }
        for (SetObserver<E> observer : snapshot) {
            observer.added(this, element);
        }
    }

 

이렇게 하면 main thread는 동기화를 해제하고 exec thread가 removeObserver 처리를 하여도 해당 스레드만 해당 observer 객체를 가지기 때문에 교착 상태를 해결 할 수 있다.

 

2. 자바 라이브러리 활용

private final List<SetObserver<E>> 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<E> observer : observers)
        observer.added(this, element);
}

CopyOnWriteArrayList 객체는 내부를 변경하는 작업이 생길때 마다 깨끗한 복사본을 만들어서 수행하도록 구현되어있다.

 

계속해서 내부 배열을 수정하면 끔직히 느리겠지만 이런식의 조회 위주의 리스트 용도로 쓰이기 좋다.

 

성능 측면에서 과도한 동기화

가변 클래스를 작성하려면 두가지 중 하나를 따르자.

 

1. 동기화를 전혀 하지 않고 그 클래스를 사용해야하는 클래스가 외부에서 알아서 동기화 할 수 있게 하자.

2. 동기화를 내부에서 수행해 스레드 안전한 클래스로 만들자

 

전자는 StringBuilder, java.util,등의 방식이고 후자의 예시는 StringBuilder,등등 이다.

 

선택하기 어렵다면 동기화 하지말고 스레드 안전하지 않다고 문서에 명기하자.

 

결론 
1. 교착상태와 데이터 훼손을 피하려면 동기화 영역 내부에서 외계인 메서드를 호출하지 말자.
2. 가변 클래스를 설계 할 때는 동기화해야 할 지 스스로 고민하자.