Search

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

생성일
2023/08/17 02:09
챕터
11장 -동시성

요약

동기화 영역 내에서 외계인 메서드를 호출하면 에러나 교착 상태가 발생할 수 있고 데이터가 훼손될 수 있으니, 가급적 동기화 영역 밖에서 외계인 메서드를 호출(열린 호출) 하자.
동기화를 과하게 하면, 많은 비용이 발생하고 성능이 떨어지니 주의하자.

동기화 영역에서 외계인 메서드 호출 금지

응답 불가와 안전 실패를 피하려면 동기화 메서드나동기화 블록 안에서는 제어를 클라이언트에 양도하면 안된다.
동기화된 영역 안에서 재정의할 수 있는 메서드를 호출해서는 안되고, 클라이언트가 넘겨준 함수 객체(외계인 메서드)도 호출하면 안된다.
그렇지 않는다면 동기화된 영역에서 에러를 일으키거나, 교착상태에 빠지거나, 데이터 훼손할 수 있다.
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; } } @FunctionalInterface public interface SetObserver<E> { // ObservableSet에 원소가 더해지면 호출된다. void added(ObservaleSet<E> set, E element); }
Java
복사
위 코드에서 added라는 추상 메서드를 외부에서 정의하여 동작시켜, 정상 동작을 하고 에러를 일으키고 교착상태에 빠지는 예시를 살펴볼 것이다.
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); }
Java
복사
위 코드는 동작의 예시를 보여주기 위한 아주 간단한 코드로, add()를 호출할 때마다 외계인 메서드로 제공한 람다식을 동작하여 0부터 99까지 출력할 것이다.
set.addObserver(new SetObserver<>() { public void added(ObservableSet<Integer> s, Integer e) { System.out.println(e); if (e == 23) s.removeObserver(this); } })
Java
복사
만약 외계인 메서드를 위의 익명 클래스에서 정의한 메서드로 대체해 실행시킨다고 하면, 0부터 99까지를 요소로 add()를 호출하고 요소가 23일 때 자기 자신(익명 클래스)을 지우려할 것이다.
add(23)을 호출 받아 notify를 위해 observers를 순회하며 observer의 메서드를 호출한 상황에 위 코드가 실행되는 것이라서, 해당 메서드가 포함된 자기자신 observer를 삭제하려하면 concurrentModificationException이 발생한다.
set.addObserver(new SetObserver<>() { public void added(ObservableSet<Integer> s, Integer e) { System.out.println(e); if (e == 23) { ExecutorService exec = Executors.newSingleThreadExcutor(); try { exec.submit(() -> s.removeObserver(this)).get(); } catch (ExecutionException | InterruptedException ex) { throw new AssertionError(ex); } finally { exec.shutdown(); } } } }
Java
복사
위 코드는 이전의 에러가 발생하는 코드와 기능은 동일하지만, 외계인 메서드를 단일 스레드에서 실행시키는 것이 아닌 새로운 스레드를 만들어 실행시킨다.
이 코드에서의 문제는 notifyElementAdded 메서드와 removeObserver 메서드 모두 observers 객체로 synchronized 되어 있기 때문에, 서로 lock을 필요로 하는 상황에 한 쪽이 잡고 있으므로 교착상태에 빠지게 된다.
자바 기능…?
위의 문제가 되는 2가지 예제처럼 클라이언트가 제공하는 외계인 메서드는 얼마나 오래 실행되고 어떤 동작을 수행할 지 모르니, 동기화 영역 안에서는 외계인 메서드를 호출하지 말자.
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); }
Java
복사
위의 에러가 발생하거나 교착 상태가 발생하는 코드는 이와 같이 내부 배열을 복사하여 외계인 메서드 호출을 동기화 영역 밖으로 옮기면 해결된다.
private final List<SetObserver<E>> observers = new CopyOnWriteArrayList<>();
Java
복사
자바에 이런 기능을 미리 구현해둔 라이브러리가 있다. 위 코드에서의 CopyOnWriteArrayList는 이름 그대로 내부를 변경할 때 리스트를 복사하여 작업을 수행한다.
이와 같이 동기화 영역 밖에서 외계인 메서드를 호출하는 것을 열린 호출(open call)이라 한다. 열린 호출을 사용하면 실패 방지 효과와 동시성 효율을 크게 개선해준다.

과도한 동기화 금지

과도한 동기화를 하게되면 경쟁하느라 시간 낭비를 하게되고, 병렬로 실행할 기회를 잃고, 모든 코어가 메모리를 일관되게 보기 위한 시간 지연이 발생하게 된다.
만약 가변 클래스를 작성한다면, 동기화를 전혀하지말고 해당 클래스를 동시에 사용해야 하는 외부에서 알아서 동기화하게 만들자.
만약 클라이언트가 외부에서 객체 전체에 락을 거는 것보다 동시성을 월등히 개선 할 수 있다면, 동기화를 내부에서 수행해 스레드 안전한 클래스로 만들자.
클래스 내부에서 동기화한다면, 락 분할(lock splitting), 락 스트라이핑(lock striping), 비차단 동시성 제어(nonblocking concurrency control) 등 다양한 기법을 통해 동시성을 높일 수 있다.
락 분할
락을 독립적인 상태 변수마다 따로 분리하여, 락의 정밀도를 높이는 방식
락 스트라이핑
자료 구조나 구간 블록을 여러 개의 락으로 분할하여, 특정 구간마다 담당하는 락을 지정해 락의 정밀도를 높이는 방식
비차단 동시성 제어
non-blocking으로 여러 접근을 제어하는 I/O Multiplexing이나 낙관적 락, CAS처럼 lock 없이 경쟁 상태를 최소화하면서 동시성을 제어하는 방식