티스토리 뷰

728x90
반응형

과도한 동기화는 피하라


  • 과도한 동기화는 성능을 떨어뜨리고, 교착상태에 빠질 수 있으며 예측할 수 없는 동작을 유발할 수 있습니다.

 

💡 응답불가와 안전실패를 피하려면 동기화 메서드나 블럭안에서는 제어권을 클라이언트에게 양도하면 안됩니다.

  • 동기화된 영역안에서는 재정의할 수 있는 메서드를 호출해서는 안됩니다.
  • 클라이언트가 넘겨준 함수(람다)를 호출해서는 안됩니다.

 

💡 예제 코드

@FunctionalInterface
public interface SetObserver<E> {
    void added(ObservableSet<E> set, E element);
}

public class ForwardingSet<E> implements Set<E> {

    private final Set<E> s;

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

    @Override
    public int size() { return s.size(); }

    @Override
    public boolean isEmpty() { return s.isEmpty(); }

    @Override
    public boolean contains(Object o) { return s.contains(o); }

    @Override
    public Iterator<E> iterator() { return s.iterator(); }

    @Override
    public Object[] toArray() { return s.toArray(); }

    @Override
    public <T> T[] toArray(T[] a) { return s.toArray(a); }

    @Override
    public boolean add(E e) { return s.add(e); }

    @Override
    public boolean remove(Object o) { return s.remove(o); }

    @Override
    public boolean containsAll(Collection<?> c) { return s.containsAll(c); }

    @Override
    public boolean addAll(Collection<? extends E> c) { return s.addAll(c); }

    @Override
    public boolean retainAll(Collection<?> c) { return s.retainAll(c); }

    @Override
    public boolean removeAll(Collection<?> c) { return s.removeAll(c); }

    @Override
    public void clear() { s.clear(); }
}

public class ObservableSet<E> extends ForwardingSet<E> {

    public ObservableSet(Set<E> s) {
        super(s);
    }

    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);
        }
    }

    public void notifyElementAdded(E element) {
        synchronized (observers) {
            for (SetObserver<E> observer : observers) {
                observer.added(this, element);
            }
        }
    }

    @Override
    public boolean add(E e) {
        boolean added = super.add(e);
        if (added) {
            notifyElementAdded(e);
        }
        return added;
    }

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

 

🧨 문제가 발생할 수 있는 상황 - 예외 발생

  • 아래 코드는 element가 23일 때 구독을 해지하고 종료할거 같지만 실제로는 ConcurrentModificationException이 발생하게 됩니다. 이는 리스트에서 원소를 제거할려는데 동시에 리스트를 순회하려 하기 때문입니다.
  • add 메서드 내부에서는 notifyelementAdded메서드가 수행되고 있고 main 메서드의 added 메서드에서는 removeObserver 메서드를 호출하고 있는데 여기서 문제가 발생하게 됩니다. 리스트에서는 원소 23인 경우 제거할려고하고 있는데 하지만 이는 리스트를순회하는 도중입니다. 즉 허용되지 않는 동작입니다. notifyElementAdded 메서드에서 수행하는 순회는 동기화 블럭 안에 있으므로 동시 수정이 발생하지 않도록 보장하지만, 자신이 콜백을 거쳐 되돌아가는 수정까지는 막지 못합니다.
public class Example {

    public static void main(String[] args) {
        ObservableSet<Integer> set = new ObservableSet<>(new HashSet<>());

        set.addObserver(new SetObserver<Integer>() {
            @Override
            public void added(ObservableSet<Integer> set, Integer element) {
                System.out.println(element);

                // 값이 23이라면 자신을 구독해지
                // this를 넘겨주어야하기 때문에 람다가 아닌 익명객체 사용
                if (element == 23) set.removeObserver(this);
            }
        });

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

 

🧨 문제가 발생할 수 있는 상황 - 교착 상태

  • 아래 예제코드를 수행하면 23까지 출력은 되지만 그 뒤에 교착상태에 빠지게 됩니다.
  • 1 - try 문에 잇는 set.removeObserver 메서드를 호출하면 관찰자를 잠글려고 합니다.
  • 2 - 하지만 락은 메인 메서드가 쥐고 있으므로 락을 얻을 수 없게됩니다.
  • 3 - 이때 동시에 메인 스레드에서는 백그라운드 스레드가 관찰자를 제거하기를 기다리고 있습니다. 
  • 4 - 교착상태에 빠지게 됩니다.
  • 물론 자바 언어에서는 락의 재진입을 허용하고 있기 때문에 교착상태에 빠지지 않을 수 있습니다. 이런 재진입 가능 락은 객체 지향 멀티스레드 프로그램을 쉽게 구현할 수 있게 해주지만, 응답 불가(교착 상태)가 될 상황을 안전 실패(데이터 훼손)로만 바꿔서 문제를 만들 수 있습니다.
public class Example {

    public static void main(String[] args) {
        ObservableSet<Integer> set = new ObservableSet<>(new HashSet<>());

        set.addObserver(new SetObserver<Integer>() {
            @Override
            public void added(ObservableSet<Integer> set, Integer element) {
                System.out.println(element);

                if (element == 23) {
                    ExecutorService exec = Executors.newSingleThreadExecutor();
                    try {
                        // 여기서 lock이 발생하게 됩니다.(메인 스레드는 작업을 가리키고 있음)
                        exec.submit(() -> set.removeObserver(this)).get();
                    } catch (ExecutionException | InterruptedException e) {
                        throw new AssertionError(e);
                    } finally {
                        exec.shutdown();
                    }
                }
            }
        });

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

 

💡 해결방법

📜 콜백 메서드를 동기화 바깥으로 옮겨 해결

  • 문제가 될 여지가 있는 코드를 동기화 블럭 바깥으로 옮겨 해결합니다.
public void notifyElementAdded(E element) {
    List<SetObserver<E>> snapshot = null;
    synchronized (observers) {
        snapshot = new ArrayList<>(observers);
    }

    for (SetObserver<E> observer : snapshot) {
        observer.added(this, element);
    }
}

 

📜 CopyOnWriteArrayList

  • java,util.concurrent에서 제공하는 CopyOnWriteArrayList 클래스는 이런 문제를 해결하기 위해 설계된 객체로, ArrayList를 구현하되 내부를 변경하는 작업은 늘 복사본을 만들어 수행하도록 구현되었다. 다른 용도로는 느리지만, 순회가 대부분이고 간혹 수정할 일이 있는 관찰자 리스트 용도로는 적절합니다.

 

✔️ 정리

  • 클라이언트가 넘겨준 함수는 동기화 영역 안에서 호출하면 안됩니다.
  • 동기화 영역 안에서의 작업은 최소한으로 줄이는게 좋습니다.
  • 가변 클래스를 설계할 때는 스스로 동기화가 필요한지 고민해야합니다.

 

 

 

 

 

728x90
반응형