Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
JoisFe opened this issue Apr 2, 2023 Discussed in #191 · 0 comments
Open

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

JoisFe opened this issue Apr 2, 2023 Discussed in #191 · 0 comments
Labels
11장 동시성 이펙티브 자바 11장 (동시성)

Comments

@JoisFe
Copy link
Member

JoisFe commented Apr 2, 2023

Discussed in https://github.com/orgs/Study-2-Effective-Java/discussions/191

Originally posted by JoisFe April 2, 2023

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

과도한 동기화의 문제점

  • 성능을 떨어뜨림
  • 교착 상태에 빠뜨림
  • 예측할 수 없는 동작을 낳기도 함

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

  • EX) 동기화된 영역 안에서는 재정의할 수 있는 메서드를 호출하면 안되고 클라이언트가 넘겨준 함수 객체를 호출해서도 안됨 (아이템 24. 멤버 클래스는 되도록 static으로 만들라 #48 참고)
  • 동기화된 영역을 포함한 클래스 관점에서는 이런 메서드는 모두 바깥 세상에서 온 외계인일 뿐
  • 해당 메서드가 무슨일을 할지 알지 못하고 통제할 수도 없음
  • 외계인 메서드 (alien method)가 하는 일에 따라 동기화된 영역은 예외를 일으키거나 교착상태에 빠지거나 데이터를 훼손할 수 있음
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();
    }

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

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

    @Override
    public String toString() {
        return s.toString();
    }
}
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 (this.observers) {
            this.observers.add(observer);
        }
    }

    public boolean removeObserver(SetObserver<E> observer) {
        synchronized (this.observers) {
            return this.observers.remove(observer);
        }
    }

    public void notifyElementAdded(E element) {
        synchronized (this.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 = false;

        for (E e : c) {
            result |= add(e);
        }

        return result;
    }
}
  • 위 코드는 어떤 Set을 감싼 래퍼 클래스이고 이 클래스의 클라이언트는 집합에 원소가 추가되면서 알림을 받을 수 있는 코드임
  • 관찰자 패턴 (Observer Pattern) 임을 알 수 있다.
  • (원소가 제거되면 알려주는 기능은 생략한 코드)
  • Observer들은 addObserver와 removeObserver 메서드를 호출하여 subscription (구독) 를 신청하거나 해지 함
@FunctionalInterface
public interface SetObserver<E> {
   
    // ObservableSet에 원소가 더해지면 호출됨 
    void added(ObservableSet<E> set, E element);
}
  • 해당 인터페이스는 구조적으로 BiConsumer<ObservableSet, E>와 같음
  • 그럼에도 커스텀 함수형 인터페이스를 정의한 이유는 이름이 더 직관적이고 다중 콜백을 지원하도록 확장할 수 있음
  • BiConsumer를 그대로 사용했더라고 해당 코드에서는 큰 문제 없음
  • 아이템 44. 표준 함수형 인터페이스를 사용하라 #101 에 의하면 표준 함수형 인터페이스를 사용하라에 맞는대로라면 BiConsumer 를 사용하는게 맞았지 않나 생각함....

ObservableSet은 눈에 보기에 잘 동작할 것으로 보이나 문제가 있음

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);
        }
    }
  • 해당 코드는 부터 99까지를 출력함
set.addObserver(new SetObserver<>() {
            @Override
            public void added(ObservableSet<Integer> set, Integer element) {
                System.out.println(element);
                
                if (element == 23) {
                    set.removeObserver(this);
                }
            }
        });
  • set에 추가된 정숫값을 출력하다가 그 값이 23이면 자기 자신을 제거(구독해지) 하는 observer를 추가한 코드이다
  • 이 코드는 0부터 23까지 출력한 후 observer 자신을 구독해지한 다음 조용히 종료할 것으로 예측됨
  • 하지만 실제로 실행해보면 23까지 출력한 다음 ConcurrentModificationException을 던짐

image

참고

다시 돌아와

ConcurrentModificationException을 던진 이유

  • observer의 added 메서드 호출이 일어난 시점이 notifyElementAdded가 observer의 리스트들을 순회하는 도중이었기 때문
  • added 메서드는 ObservableSet의 removeObserver 메서드를 호출했고 이 메서드는 다시 observers.remove 메서드를 호출
  • 여기서 문제가 발생
  • 리스트에서 원소를 제거하려 하는데 마침지금 이 리스트를 순회하는 도중인 것임
  • 즉 허용되지 않은 동작을 시도한 것임
  • notifyElementAdded 메서드에서 수행하는 순회는 동기화 블록 안에 있으므로 동시수정이 일어나지 않도록 보장하지만 정작 자신이 콜백을 거쳐 되돌아와 수정하는 것까지 막지는 못함
 set.addObserver(new SetObserver<>() {
            @Override
            public void added(ObservableSet<Integer> set, Integer element) {
                System.out.println(element);

                if (element == 23) {
                    ExecutorService exec = Executors.newSingleThreadExecutor();
                    
                    try {
                        exec.submit(() -> set.removeObserver(this)).get();
                    } catch (ExecutionException | InterruptedException ex) {
                        throw new AssertionError(ex);
                    } finally {
                        exec.shutdown();
                    }
                }
            }
        });
  • 구독해지를 하는 observer를 작성하는데 removeObserver를 직접 호출하지 않고 실행자 서비스 (ExecutorService)를 사용해서 다른 스레드한테 부탁할 것임 (아이템 80 참고)
  • 그러나 이 코드로 실행하면 예외는 나지 않지만 교착 상태에 빠짐

교착 상태에 빠지는 이유

  • 백그라운드 스레드가 s.removeObserver를 호출하면 observer를 잠그려 시도하지만 락을 얻을 수 없음
  • 메인 스레드가 이미 락을 쥐고 있기 때문
  • 그와 동시에 메인 스레드는 백그라운드 스레드가 observer를 제거하기만을 기다리는 중
  • --> 교착 상태
  • (사실 observer가 자신을 구독해지하는 데 굳이 백그라운드 스레드를 이용할 이유가 없는 억지스러운 예지만 이러한 문제가 일어날 수 있음을 보여주기 위한 예)
  • 실제 시스템 (특히 GUI 툴킷)에서도 동기화된 영역 안에서 외계인 메서드를 호출하여 교착상태에 빠지는 사례가 자주 있음

위와 똑같은 상황이지만 불변식이 임시로 깨진 경우

  • 자바 언어의 락은 재진입(reentrant) 을 허용하므로 교착상태에 빠지지는 않음
  • 예외를 발생시킨 첫 번째 예에서라면 외계인 메서드를 호출하는 스레드는 이미 락을 쥐고 있으므로 다음번 락 획득도 성공함
  • (그 락이 보호하는 데이터에 대해 개념적으로 관련이 없는 다른 작업이 진행 중인데도 성공함)
  • 이것 때문에 매우 참혹한 결과가 빚어질 수 있음
  • 왜냐하면 락이 제 구실을 하지 못했기 때문
  • 재진입 가능 락은 객체 지향 멀티스레드 프로그램을 쉽게 구현할 수 있도록 해주지만 응답 불가 (교착 상태)가 될 상황을 안전 실패 (데이터 훼손)로 변모시킬 수 있기 때문

해결 방법

  • 외계인 메서드 호출을 동기화 블록 바깥으로 옮기면 됨
  • notifyElementAdded 메서드에서는 observer 리스트를 복사해 쓰면 락 없이도 안전하게 순회할 수 있음
  • 이 방식을 적용하면 앞의 예제의 문제점인 예외 발생과 교착 상태 증상이 사라짐
public void notifyElementAdded(E element) {
        List<SetObserver<E>> snapshot = null;
        
        synchronized (this.observers) {
            snapshot = new ArrayList<>(this.observers);
        }

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

image

  • 사실 외계인 메서드 호출을 동기화 블록 바깥으로 옮기는 더 나은 방법이 있음
  • 자바의 동시성 컬렉션 라이브러리 CopyOnWriteArrayList가 정확히 이 목적으로 특별히 설계된 것
  • 메서드 이름을 보면 알 수 있듯 ArrayList를 구현한 클래스로 내부를 변경하는 작업은 항상 깨끗한 복사본을 만들어 수행하도록 구현
  • 내부의 배열은 절대 수정되지 않으니 순회할 때 락이 필요 없어 매우 빠름
  • 다른 용도로 쓰인다면 CopyOnWriteArrayList는 끔찍하게 느려지겠지만 수정할 일은 드물고 순회만 빈번히 일어나는 Observer 리스트 용도로는 최적
public class ObservableSet<E> extends ForwardingSet<E>{

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

    private final List<SetObserver<E>> observers = new CopyOnWriteArrayList<>();

    public void addObserver(SetObserver<E> observer) {
        this.observers.add(observer);
    }

    public boolean removeObserver(SetObserver<E> observer) {
        return this.observers.remove(observer);
    }

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

~~~
  • 위 코드는 ObservableSet을 CopyOnWriteArrayList를 사용해 다시 구현하여 변경한 것임

명시적으로 동기화한 곳이 사라졌다는 것이 중요한 포인트!

  • 코드가 동기화 영역 바깥에서 호출되는 외계인 메서드를 열린 호출 (open call) 이라 함
  • 외계인 메서드는 얼마나 오래 실행될지 알 수 없는데 동기화 영역 안에서 호출한다면 그 동안 다른 스레드는 보호된 자원을 사용하지 못하고 대기해야만 함
  • 따라서 열린 호출은 실패 방지 효과 외에도 동시성 효율을 크게 개선시켜줌

동기화 규칙

기본 규칙은 동기화 영역에서는 가능한 한 일을 적게 하는 것!

과도한 동기화와 성능

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

가변 클래스를 작성하는 2가지 선택지

  1. 동기화를 전혀 하지 말고 그 클래스를 동시에 사용해야 하는 클래스가 외부에서 알아서 동기화하게 하기
  2. 동기화를 내부에서 수행해 스레드 안전한 클래스로 만들기 (아이템 82 참고)
  • 단 클라이언트가 외부에서 객체 전체에 락을 거는 것보다 동시성을 월등히 개선할 수 있을 때만 두 번째 방법 선택해야 함
  • java.util은 (구식이 된 Vector와 Hashtable을 제외) 첫 번째 방식을 취했음
  • java.util.concurrent는 두 번째 방식을 취했음 (아이템 81 참고)

자바도 초장기에는 이 지침을 따르지 않은 클래스가 많았음

  • StringBuffer 인스턴스 경우 거의 항상 단일 스레드에서 쓰였음에도 내부적으로 동기화를 수행
  • 뒤늦게 StringBuffer가 등장한 이유기도 함 (StringBuilder는 그저 동기화하지 않은 StringBuffer)
  • 비슷한 이유로 스레드 안전한 의사 난수 발생기인 java.util.Random은 동기화하지 않는 버전인 java.util.concurrent.ThreadLocalRandom 으로 대체되었음

선택하기 어렵다면 동기화하지 말고 대신 문서에 "스레드 안전하지 않다" 라고 명시하자!

클래스 내부에서 동기화 하는 경우 다양한 기법

  • 클래스 내부에서 동기화하기로 했다면
  • 락 분할 (lock splitting)
  • 락 스트라이핑 (lock striping)
  • 비 차단 동시성 제어 (nonblocking concurrency control)
  • 등 다양한 기법을 동원해 동시성을 높여줄 수 있음
  • 위 기법들은 따로 공부하는 것을 추천

여러 스레드가 호출할 가능성이 있는 메서드가 정적 필드를 수정한다면 그 필드를 사용하기 전에 반드시 동기화해야 함! (비결정적 행동도 용인하는 클래스라면 상관 없음)

  • 그런데 클라이언트가 여러 스레드로 복제돼 구동되는 상황이라면 다른 클라이언트에서 이 메서드를 호출하는 걸 막을 수 없으니 외부에서 동기화할 방법이 없음
  • 결과적으로 이 정적 필드가 심지어 private라도 서로 관련 없는 스레드들이 동시에 읽고 수정할 수 있게 됨
  • 사실상 전역변수와 같아짐
  • 아이템 78에서 generateSerialNumber 메서드에서 쓰인 nextSerialNumber 필드가 바로 이러한 사례

정리

  • 교착 상태와 데이터 훼손을 피하려면 동기화 영역 안에서 외계인 메서드를 절대 호출하지 말자!
  • 일반화하자면 동기화 영역 안에서의 작업은 최소한으로 줄이자!
  • 가변 클래스를 설계할 때는 스스로 동기화해야 할지 고민하자!
  • 멀티코어 세상인 지금도 과도한 동기화를 피하는 게 과거 어느 때보다 중요!
  • 합당한 이유가 있을 때만 내부에서 동기화하고 동기화했는지 여부를 문서에 명확히 밝히자!
@JoisFe JoisFe added the 11장 동시성 이펙티브 자바 11장 (동시성) label Apr 2, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
11장 동시성 이펙티브 자바 11장 (동시성)
Projects
None yet
Development

No branches or pull requests

1 participant