Home [Effective Java] Item79. 과도한 동기화는 피하라!
Post
Cancel

[Effective Java] Item79. 과도한 동기화는 피하라!

Item79. 과도한 동기화는 피하라!

Intro

  • 충분하지 못한 동기화로 인해 겪을 수 있는 피해를 다룬 지난 아이템과 달리, 이번 아이템에서는 과도한 동기화로 인한 피해를 다룬다.
  • 과도한 동기화는 성능을 떨어뜨리고, 교착 상태에 빠뜨리고, 심지어 예측 불가한 동작을 낳기도 한다.

과도한 동기화(정확성 측면)

  • 응답 불가와 안전 실패를 피하려면 동기화 메서드나 동기화 블록 안에서는 제어를 절대로 클라이언트에 양도하면 안된다.
    • 예를 들어, 동기화된 영역 안에서는 재정의 가능한 메서드 호출은 안 되며, 클라이언트가 넘겨준 함수 객체 호출도 안 된다.
  • 동기화된 영역을 포함한 클래스 관점에서는 이런 메서드는 모두 바깥 세상에서 온 외계인 같은 존재이기에, 그 메서드(alien method)가 무슨 일을 할지 예측하거나 통제하는 것이 불가능하다.
    • 외계인 메서드가 하는 일에 따라 동기화된 영역을 예외를 일으키거나, 교착상태에 빠지거나, 데이터를 훼손할 수 있다.

예시

  • 어떤 집합을 감싸는 래퍼 클래스이고, 이 클래스의 클라이언트는 집합에 원소가 추가되면 알림을 받을 수 있다.
    • 바로 관찰자 패턴이다.
  • 아이템 18에서 사용한 ForwardingSet을 재사용해 구현했다.
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
31
32
33
34
35
36
37
38
39
40
41
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 메서드를 호출해 구독을 신청하거나 해지한다.

    • 이 메서드들은 SetObserver 함수형 인터페이스를 메서드에 건넨다.

      1
      2
      3
      4
      
      @FuntionalInterface public interface SetObserver<E> {
      		// ObservableSet에 원소 추가 시 호출됨.
      		void added(ObservableSet<E> set, E element);
      }
      
  • 눈으로 보기에 ObservableSet은 잘 동작할 것처럼 보인다. 한 번 살펴보자!

    • 0부터 99까지 출력하는 코드다.
    1
    2
    3
    4
    5
    6
    7
    8
    9
    
    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);
    }
    
    • 위 코드는 0부터 99까지 정상적으로 출력한다.
  • 더 시도해보자.

    • 평상시에는 앞서와 같이 동작하다가 값이 23이면 자기 자신을 제거(구독 해지)하는 관찰자를 추가한 경우다.
    1
    2
    3
    4
    5
    6
    7
    8
    
    set.addObserver(new SetObserver<Integer>() {
      public void added(ObservableSet<Integer> set, Integer element) {
        System.out.println(element);
        if  (element == 23) {
          set.removeObserver(this);
        }
      }
    });
    
    • 이 프로그램은 0부터 23까지 출력한 후 관찰자 자신을 구독해지한 다음 조용히 종료할 것이다.
      • 하지만 그렇게 동작하지 않는다!!!!
    • 이 프로그램은 23까지 출력한 다음 ConcurrentModificationException을 던진다.
      • 관찰자의 added 메서드 호출이 일어난 시점이 notifyElementAdded가 관찰하의 리스트를 순회하는 도중이기 때문.
      • added 메서드는 ObservableSet의 removeObserver 메서드 호출 -> 이 메서드는 다시 observers.remove 메서드 호출 -> 마침 이 리스트를 순회 중인 상태 -> 허용되지 않은 동작
    • notifyElementAdded 메서드에서 수행사는 순회는 동기화 블록 안에 있어 동시 수정이 일어나지는 않는다. 하지만 콜백을 거쳐 되돌아온 후 수정하는 것까지 막을 수는 없다.
  • 더더 시도해보자.

    • 구독 해지를 하는 관찰자를 작성하는데, removeObserver를 직접 호출하지 않고 실행자 서비스(ExecutorService)를 사용해 다른 스레드에게 부탁한다.
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    
    set.addObserver(new SetObserver<Integer>() {
      @Override
      public void added(ObservableSet<Integer> set, Integer element) {
        System.out.println(element);
        if  (element == 23) {
          ExecutorService executorService = Executors.newSingleThreadExecutor();
          try{
            executorService.submit(() -> set.removeObserver(this)).get();
          }catch (ExecutionException | InterruptedException e){
            throw new AssertionError(e);
          }finally {
            executorService.shutdown();
          }
        }
      }
    });
    
    • 이 프로그램을 실행하면 예외는 나지 않지만 교착상태에 빠진다.
    • 백그라운드 스레드가 set.removeObserver를 호출하면 관찰자를 잠그려 시도한다. 하지만 메인 스레드가 락을 쥐고 있기 때문에 락을 얻을 수 없다. 그와 동시에 메인 스레드는 백그라운드 스레드가 관찰자 제거를 수행하기만을 기다리고 있다. -> 이러한 상황을 교착상태라 한다.
    • 위와 같은 사례는 굳이 백그라운드 스레드에 구독 해지를 넘길 필요가 없어 좀 억지스러운 사례이나, 이러한 교착 상태 문제만큼은 중요하다.
      • 실제 시스템에서도 동기화된 영역 안에서 외계인 메서드를 호출하여 교착 상태에 빠지는 사례가 자주 있다.

불변식이 임시로 깨진 경우

  • 앞서의 두 예(예외와 교착상태)에서는 운이 좋았다.
    • 이유? 동기화 영역이 보호하는 자원(관찰자)은 외계인 메서드(added)가 호출될 때 일관된 상태였기 때문이다.
  • 자바 언어의 락은 재진입(reentrant)을 허용한다. 그래서 교착 상태에 빠지지 않는다.
    • 예외를 발생시킨 첫 번째 예의 경우, 외계인 메서드를 호출하는 스레드는 이미 락을 쥐고 있으니 다음 번 락 획득도 성공하게 된다.
      • 그 락이 보호하는 데이터에 대해 개념적으로 관련 없는 작업이 진행 중인 상황인데도 그렇다.
    • 이렇듯 락이 제 구실을 하지 못하는 문제로 인해 참혹한 결과가 빚어질 수 있다.
      • 재진입 가능 락은 객체지향 멀티스레드 프로그램을 쉽게 구현할 수 있게 해준다. 하지만 응답불가(교착상태)가 될 상황을 안전실패(데이터훼손)로 변모시킬 수 있다.

해결 방법

  • 외계인 메서드 호출을 동기화 블록 바깥으로 옮긴다.

    1
    2
    3
    4
    5
    6
    7
    8
    
    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);
    }
    
    • 이처럼 동기화 영역 바깥에서 호출되는 외계인 메스드를 열린 호출(open call)이라 한다.
      • 외계인 메서드는 얼마나 오래 실행될지 알 수 없다. 동기화 영역 안에서 호출된다면 그동안 다른 스레드는 보호된 자원을 사용하지 못하고 대기만 할 수밖에 없다. 따라서 열린 호출은 실패 방지 효과 외에도 동시성 효율을 크게 개선해준다.
  • 자바의 동시성 컬렉션 라이브러리의 CopyOnWriteArrayList를 사용한다.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    
    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는 정확히 이러한 문제 해결을 위해 설계된 것이므로 이를 사용하면 된다.
      • 내부를 변경하는 작업은 항상 깨끗한 복사본을 만들어 수행하도록 구현되었고, 내부 배열은 절대 수정되지 않으니 순회할 때 락이 필요 없어 매우 빠르다.
      • 수정할 일은 드물고 순회만 빈번히 일어나는 관찰자 리스트 용도로는 최적이다. 다른 용도로는 끔직이 느릴 수 있다.

동기화 규칙

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

과도한 동기화(성능 측면)

  • 자바의 동기화 비용은 빠르게 낮아져 왔지만, 과도한 동기화를 피하는 일은 오히려 과거보다 더 중요해졌다.
    • 멀티코어가 일반화된 오늘날, 과도한 동기화가 초래하는 비용은 락을 얻는 데 드는 CPU 시간이 아니라, 경쟁하는 데 낭비하는 시간이다.
    • 즉, 병렬로 실행할 기회를 잃고, 모든 코어가 메모리를 일관되게 보기 위한 지연시간이 진짜 비용이다.
    • 가상 머신의 코드 최적화를 제한한다는 점도 과도한 동기화의 또 다른 숨은 비용이라 할 수 있다.

해결 방법

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

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

  • 클라이언트가 외부에서 객체 전체에 락을 거는 것보다 동시성을 월등히 개선할 수 있을 때만 두 번째 방법을 선택한다.
  • 두 번째 방법을 사용하는 경우, 락 분할, 락 스트라이핑, 비차단 동시성 제어 등의 다양한 기법을 동원해 동시성을 높여줄 수 있다.

자바 API

  • java.util은 첫 번째 방식, java.util.concurrent는 두 번째 방식을 취함.
  • 자바 초창기에는 이 지침을 따르지 않은 클래스가 있었는데, StringBuffer다. 거의 항상 단일 스레드에서 쓰였음에도 내부적으로 동기화를 수행했다. 이로 인해 뒤늦게 StringBuilder가 등장했다.
    • StringBuilder는 동기화하지 않은 StringBuffer다.
  • 위와 비슷한 이유로 java.util.Random은 동기화하지 않은 버전인 java.util.concurrent.ThreadLocalRandom으로 대체되었다.

핵심 정리

  • 교착상태와 데이터 훼손을 피하려면 동기화 영역 안에서 외계인 메서드를 절대 호출하지 말자.
  • 동기화 영역 안에서의 작업은 최소한으로 줄이자.
  • 가변 클래스 설계 시, 스스로 동기화해야 할지 고민하자.
  • 멀티코어 발전과 함께 과도한 동기화 사용의 문제 해결은 그 어느 때보다 중요해졌다.
  • 합당한 이유가 있을 때만 내부에서 동기화하고, 동기화 여부를 문서에 정확히 밝히자.
This post is licensed under younghwani by the author.

[Effective Java] Item78. 공유 중인 가변 데이터는 동기화해 사용하라!

[Effective Java] Item80. 스레드보다는 실행자, 태스크, 스트림을 애용하라!

Comments powered by Disqus.