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

oyeon·2022년 3월 27일
0

Effective Java

목록 보기
7/8

과도한 동기화는 1. 성능을 떨어뜨리고 2. 교착상태에 빠뜨리고 3. 예측할 수 없는 동작 을 일으킨다.

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

동기화된 영역 안에서는
1. 재정의할 수 있는 메서드는 호출 하면 안 된다.
2. 클라이언트가 넘겨준 함수 객체를 호출해선 안 된다.(아이템 24)

위에 해당하는 메서드는 '무슨 일을 할지 알지 못하며 통제할 수 없는' 외계인 메서드이다.

외계인 메서드를 사용하면 동기화된 영역은 1. 예외를 일으키거나 2. 교착상태에 빠지거나 3. 데이터를 훼손 할 수 있다.

예시

어떤 집합(Set)을 감싼 래퍼 클래스이고 집합에 원소가 추가되면 알림을 받는 관찰자 패턴을 사용한 예제 코드이다. ForwardingSet은 아이템 18 참고.

[코드 79-1] 잘못된 코드. 동기화 블록 안에서 외계인 메서드를 호출한다.

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 콜백 인터페이스의 인스턴스를 매개변수로 갖는다.

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

문제를 일으킬 수 있는 익명함수 콜백 - 예외 발생

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

        set.addObserver(new SetObserver<Integer>() {
            @Override
            public void added(ObservableSet<Integer> s, Integer e) {
                System.out.println(e);
                if (e == 23) // 값이 23이면 자신을 구독해지
                    s.removeObserver(this); // this를 넘겨주어야하기 때문에 람다가 아닌 익명 클래스 사용
            }
        });

        for (int i = 0; i < 100; i++)
            set.add(i);
    }
}
  • 이 프로그램은 23까지 출력한 다음 ConcurrentModificationException 예외를 던진다.
    image

  • 관찰자의 added 메서드를 호출한 시점이 notifyElementAdded가 관찰자들의 리스트를 순회하는 도중이기 때문이다.(add 메서드에서 notifyElementAdded 수행)

  • added 메서드는 ObservableSet의 removeObserver 메서드를 호출하고, 이 메서드는 다시 observers.remove 메서드를 호출한다. 여기서 문제가 발생!

  • 리스트에서 원소를 제거하려 하는데, 마침 지금 이 리스트를 순회하는 도중이다. 즉, 허용되지 않은 동작이다.

  • notifyElementAdded 메서드에서 수행하는 순회는 동기화 블록 안에 있으므로 동시 수정이 일어나지 않도록 보장하지만, 자신이 콜백을 거쳐 되돌아와 수정하는 것까지는 막지 못한다.

문제를 일으키는 스레드 - 교착상태

[코드 79-2] 쓸데없이 백그라운드 스레드를 사용하는 관찰자

package Item79;

import java.util.HashSet;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

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

        set.addObserver(new SetObserver<Integer>() {
            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);
    }
}

위의 exec는 새로운 쓰레드에 removeObserver를 수행하는 람다함수를 넘긴다.

이 메서드는 실행하면 23까지 출력하고 계속 멈춰있다.(교착상태)

removeObserver를 수행하기 위해서는 observers(락)를 획득해야하는데 이 observers는 메인스레드에서 실행중인 notifyElementAdded 메서드에 의해 획득될 수 없다.

불변식이 임시로 깨진 경우

위의 두 예(예외 발생과 교착상태)의 경우 observers(동기화 영역이 보호하는 자원)의 일관성이 깨지지 않았다.

불변식이 임시로 깨진 경우 -> 자바 언어의 락은 재진입을 허용하므로 교착상태에 빠지지는 않는다.

락의 재진입 가능성: 이미 락을 획득한 스레드는 다른 synchronized 블록을 만났을 때 락을 다시 검사하지 않고 진입 가능하다.

재진입 가능한 락은 다음과 같이 교착상태를 회피할 수는 있게하지만, 안전실패(데이터 훼손)로 변모시킬 수 있다.

public class Test {
  public synchronized void a() {
    b(); // 이론적으로라면 여기서 교착상태여야하지만 같은 스레드에 한해 재진입을 허용하기 때문에 
  }
  public synchronized void b() { // 진입 가능하다 (락이 보호하는 데이터에 대해 개념적으로 관련이 없는 다른 작업이 진행중임에도)
  }
  public static void main(String[] args) {
    new Test().a();
  }
}

위의 문제(예외, 교착상태, 재진입 가능한 락의 문제)들을 해결할 수 있는 방법

[코드 79-3] 외계인 메서드를 동기화 블록 바깥으로 옮겼다.

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

더 나은 방법 - 동시성 컬렉션 라이브러리의 CopyWriteArrayList 사용

[코드 79-4] CopyWriteArrayList를 사용해 구현한 스레드 안전하고 관찰 가능한 집합

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는 ArrayList를 구현한 클래스로 내부를 변경하는 작업은 항상 깨끗한 복사본을 만들어 수행하도록 구현돼있다.

내부의 배열은 수정되지 않아 순회할 때 락이 필요 없어 매우 빠르다.

다른 용도로 사용된다면 매번 복사해서 느리지만, 수정할 일이 적고 순회만 빈번하게 일어난다면 Observer 리스트 용도로는 최적이다.

성능을 고려한다면

가변 클래스를 작성할 때는 두 가지 선택을 할 수 있다.

  1. 동기화를 하지 않고, 그 클래스를 동시에 사용해야 하는 클래스가 외부에서 알아서 동기화하게 한다.(ex. Vector와 Hashtable을 제외한 java.util 패키지)

  2. 동기화를 내부에서 수행해 thread-safe 한 클래스로 만드는 것이다.(아이템 82) 다만, 클라이언트가 외부에서 객체 전체에 락을 거는 것보다 동시성을 개선할 수 있을 때 선택해야 한다. (ex. java.util.concurrent 패키지) (아이템 81)

클래스를 내부에서 동기화 하기로 한 경우 사용할 만한 기법

  • 락 분할 (lock splitting)
  • 락 스트라이핑(lock striping)
  • 비차단 동시성 제어(nonblocking concurrency control)
    ...

여러 스레드가 호출할 가능성이 있는 메서드가 정적 필드를 수정한다면, 그 필드를 사용하기 전에 반드시 동기화해야 한다.

정리

  • 기본 규칙은 동기화 영역에서는 가능한 한 일을 적게 하는 것이다. 오래 걸리는 작업이라면 아이템 78의 지침을 어기지 않으면서 동기화 영역 밖으로 옮기는 방법을 찾아보는 것이 좋다.
  • 교착상태와 데이터 훼손을 피하려면 동기화 영역 안에서 외계인 메서드를 절대 호출하지 말자.(동기화 영역에서의 작업은 최소한으로 줄인다.)
  • 가변 클래스를 설계할 때는 스스로 동기화해야 할지 고민해야 한다.
  • 과도한 동기화를 피하는 것이 중요하다.
  • 합당한 이유가 있는 경우에만 내부에서 동기화하고 동기화 여부를 문서에 남기자.(아이템 82)
profile
Enjoy to study

0개의 댓글