일상/레벨업 독서

[이펙티브 자바] Item 79 과도한 동기화는 피하라.

Gamii 2022. 6. 13. 23:50
728x90
핵심 정리

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

 

 

이번 아이템에서는 과도한 동기화는 성능을 떨어뜨리고, 교착상태에 빠뜨리고, 심지어 예측할 수 없는 동작을 낳기도 한다.

 

 

응답 불가와 안전 실패를 피하려면 동기화 메서드나 동기화 블록 안에서는 제어를 절대로 클라이언트에 양도하면 안 된다. 예를 들어 동기화된 영역 안에서는 재정의할 수 있는 메서드는 호출하면 안 되며, 클라이언트가 넘겨준 함수 객체를 호출해서도 안된다.(외계인 메서드 : 통제할 수 없는 메서드)

 

 

 



외계인 메서드(alien method)

외계인 메서드가 하는 일에 따라 동기화된 영역은 예외를 일으키거나, 교착 상태에 빠지거나, 데이터를 훼손할 수도 있다.

다음은 어떤 집합(Set)을 감싼 래퍼 클래스이고, 이 클래스의 클라이언트는 집합에 원소가 추가되면 알림을 받을 수 있다.(관찰자 패턴)

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

    @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)
            result |= add(element); // notifyElementAdded 호출
        return result;
    }
}

관찰자들은 addObserver와 removeObserver 메서드를 호출해 구독을 신청하거나 해지한다. 두 경우 모두 다음 콜백 인터페이스의 인스턴스를 메서드에 건넨다.

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

 

 

 

예제 1) ConcurrentModificationException 예외 발생

 

람다를 사용하지 않고 익명 클래스를 사용했다. s.removeObserver 메서드에 함수 객체 자신을 넘겨야 하기 때문이다. 람다는 자신을 참조할 수단이 없기 때문이다.

 

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)
            	//값이 23일 때, 구독 해지
                s.removeObserver(this);
        }
    });

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

 

이 프로그램은 23까지 출력한 다음 ConcurrentModificationException을 던진다. 이유는 관찰자의 added  메서드 호출이 일어난 시점이 notifyElementAdded가 관찰자들의 리스트를 순회하는 도중이기 때문이다.

 

 

ConcurrentModification은 동기화된 컬렉션에 대해서 반복문을 실행하는 도중에 컬렉션 클래스 내부의 값이 변경되는 상황이 포착되면 그 즉시 ConcurrentModificationException이 발생하고 즉시 멈추는 것을 말한다.

 

 

 

 

added 메서드는 ObservableSet의 removeObserver 메서드를 호출하고, 이 메서드는 다시 observers.remove 메서드를 호출한다. 여기서 문제가 발생한다. 리스트에서 원소를 제거하려 하는데, 마침 지금은 이 리스트를 순회하는 도중이다. notifyElementAdded 메서드에서 수행하는 순회는 동기화 블록 안에 있으므로 동시 수정이 일어나지 않도록 보장하지만, 정작 자신이 콜백을 거쳐 되돌아와 수정하는 것까지는 막지 못한다.

 

 

 

 

 

예제2) 쓸데없이 백그라운드 스레드를 사용하는 관찰자

 

구독해지를 하는 관찰자를 작성하는데, removeObserver를 직접 호출하지 않고 실행자 서비스(ExcutorService)를 사용해 다른 스레드한테 부탁할 것이다.

 

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이 발생한다. (메인 스레드는 작업을 기다리고 있음)
                    // 특정 태스크가 완료되기를 기다린다. (submit의 get 메서드)
                    exec.submit(() -> s.removeObserver(this)).get();
                } catch (ExecutionException | InterruptedException ex) {
                    throw new AssertionError(ex);
                } finally {
                    exec.shutdown();
                }
            }
        }
    });

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

 

이 프로그램을 실행하면 예외는 나지 않지만 교착상태에 빠진다.

백그라운드 스레드가 s.removeObserver를 호출하면 관찰자를 잠그려 시도하지만 락을 얻을 수 없다. 메인 스레드가 이미 락을 쥐고 있기 때문이다. 그와 동시에 메인 스레드는 백그라운드 스레드가 관찰자를 제거하기만을 기다리는 중이다. 따라서 교착상태에 빠진다.

 

 

 

예외, 교착상태 해결 방법

 

1) 외계인 메서드 호출을 동기화 블록 바깥으로 옮기면 된다.

 

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

notifyElementAdded 메서드에서 관찰자 리스트를 복사해 쓰면 락 없이도 안전하게 순회할 수 있다. 동기화 영역 바깥에서 호출되는 외계인 메서드를 열린 호출(open call)이라 한다. 외계인 메서드는 얼마나 오래 실행될지 알 수 없는데, 동기화 영역 안에서 호출된다면 그동안 다른 스레드는 보호된 자원을 사용하지 못하고 대기해야만 한다. 따라서 열린 호출은 실패 방지 효과 외에도 동시성 효율을 크게 개선해준다.

 

 

 

2) CopyOnWriteArrayList를 사용하자.

 

자바의 동시성 컬렉션 라이브러리인 CopyOnWriteArrayList는 ArrayList를 구현한 클래스로, 내부를 변경하는 작업은 항상 깨끗한 복사본을 만들어 수행하도록 구현했다. 내부의 배열은 절대 수정되지 않으니 순회할 때 락이 필요 없어 매우 빠르다. 

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

 

 

 

기본 규칙은 동기화 영역에서는 가능한 한 일을 적게 하는 것이다. 락을 얻고, 공유 데이터를 검사하고, 피료하면 수정하고, 락을 놓는다. 오래 걸리는 작업이라면 아이템78의 지침을 어기지 않으면서 동기화 영역 바깥으로 옮기는 방법을 찾아보자.