티스토리 뷰

반응형

과도한 동기화

  • 과도한 동기화는 성능 저하, 교착상태, 예측할 수 없는 동작을 야기한다.
  • 응답 불가와 안전 실패를 피하려면 동기화 메서드나 동기화 블록 안에서는 제어를 절대로 클라이언트에게 양도하면 안됨
    • 예를 들어, 동기화된 영역 안에서는 재정의할 수 있는 메서드를 호출하거나 클라이언트가 넘겨준 함수 객체 [아이템 24]를 호출해서도 안됨
    • 이런 외계인 메서드에서는 무엇을 할 지 알지 못하고 통제할 수도 없음

 

 

과도한 동기화 예제

Observer Pattern 으로 구현한 Set

Observer Pattern : 객체의 상태 변화를 관찰하는 관찰자들, 즉 옵저버들의 목록을 객체에 등록하여 상태 변화가 있을 때마다 메서드 등을 통해 객체가 직접 목록의 각 옵저버에게 통지하도록 하는 디자인 패턴이다.

클라이언트는 Set 집합에 원소가 추가되면 알림을 받을 수 있다.

import java.util.*;
import java.util.concurrent.CopyOnWriteArrayList;

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> {
    void added(ObservableSet<E> set, E element);
}

addObserver와 removeObserver 메서드는 콜백 SetObserver 함수형 인터페이스를 받는다.

0~99까지 출력하는 코드를 작성해보자.

import java.util.*;

public class Main {
    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이라면 제거를 하는 관찰자를 추가해보자.

import java.util.*;

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

        set.addObserver(new SetObserver<Integer>() {
            @Override
            public void added(ObservableSet<Integer> set, Integer element) {
                System.out.println(element);
                if  (element == 23) {
                    set.removeObserver(this);
                }
            }
        });

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

이 때 결과는 23까지 출력을 하지만 ConcurrentModificationException 을 던진다.

  • 관찰자의 added 메서드가 호출이 일어난 시점이 notifyElementAdded 가 관찰자들의 리스트를 순회하는 도중이기 때문이다. 즉, added 메서드는 ObservableSet의 removeObserver을 호출하여 observers.remove 메서드를 호출한다.
  • 이 때 notifyElementAdded 에서 리스트를 순회하고 있는 도중이어서 remove시에 ConcurrentModificationException이 발생하는 것이다.
  • notifyElementAdded 메서드에서 수행하는 순회는 동기화 블록 안에 있기 때문에 동시 수정이 일어나는 것을 막아주지만, 정작 자신의 콜백을 거쳐 되돌아와 수정하는 것까진 못막음

 

 

여기서 removeObserver를 직접 호출하지 않고 다른 스레드에게 맡겨보자.

//쓸데없는 백그라운드 스레드를 사용하는 관찰자

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

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

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

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

위 코드는 예외는 나지 않지만 교착상태(deadlock)에 빠지게된다.

  • 백그라운드 스레드가 s.removeObserver를 호출하면 관찰자를 잠그려 시도하지만 락을 얻을 수 없다. 메인 스레드가 락을 이미 가지고 있기 때문이다. 그와 동시에 메인 스레드는 백그라운드 스레드가 관찰자를 remove 하기만을 기다리고 있다. → 교착상태 !!!!
  • 실제 시스템에서도 동기화된 영역안에서 외계인 메서드를 호출하여 교착상태에 빠지는 사례가 자주 있음

 

똑같은 상황에 불변식이 임시로 깨진 경우라면?

자바 언어의 락은 재진입을 허용하므로 교착상태에 빠지지는 않음

그러나 락이 제 구실을 하지 못함 → 재진입 가능한 락은 객체 지향 멀티스레드 프로그램을 쉽게 구현할 수 있도록 해주지만,

응답 불가(교착상태)가 될 상황을 안전 실패(데이터 훼손)로 변모시킬 수 있음

 

 

해결 방법

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

notifyElementAdded 메서드에서라면 관찰자 리스트를 복사해 쓰면 락 없이도 안전하게 순회할 수 있다.

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);
}
  • 열린 호출 : 위처럼 동기화 영역 바깥에서 호출되는 외계인 메서드
  • 외계인 메서드는 얼마나 오래 실행될지 알 수 없는데 동기화 영역 안에서 호출된다면 그동안 다른 스레드는 보호된 자원을 사용하지 못하고 대기해야만 함 → 열린 호출은 실패 방지 효과 외에도 동시성 효율을 크게 개선해줌

 

2) CopyOnWriteArrayList

위 해결방법보다 좋은방법은 자바의 동시성 컬렉션 라이브러리인 CopyOnWriteArrayList 를 사용하는 것이다.

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);
}
  • ArrayList를 구현한 클래스로, 내부를 변경하는 작업은 항상 깨끗한 복사본을 만들어 수행하도록 구현했다.
  • 내부의 배열은 절대 수정되지 않아 순회할 때 락이 필요 없어 매우 빠르다. (수정할 일이 드물고 순회만 빈번히 일어나는 관찰자 리스트 용도로는 최적)

 

 

동기화 영역의 기본 규칙

  • 가능한 일을 적게 하라.
  • 오래걸리는 작업이면 아이템 78을 어기지 않으면서 동기화 영역 바깥으로 옮기도록 해보자.

 

 

성능 측면

과도한 동기화는 병렬로 실행할 기회를 잃고 모든 코어가 메모리를 일관되게 보기 위한 지연시간이 진짜 비용, 가상 머신의 코드 최적화를 제한한다는 점도 과도한 동기화의 또 다른 비용이다.

가변 클래스일 때

  • 동기화를 전혀 하지말고 그 클래스를 동시에 사용해야 하는 클래스가 외부에서 알아서 동기화하게 하자.
  • 동기화를 내부에서 수행해 스레드 안전한 클래스로 만들자. [아이템 82] (외부에서 객체 전체에 락을 거는것보다 동시성을 월등히 개선할 수 있을 때만)
    • 락 분할, 락 스트라이핑, 비차단 동시성 제어 등 다양한 기법을 동원해 동시성을 높일 수 있음

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

 

 

결론

교착상태와 데이터 훼손을 피하기 위해선 동기화 영역 안에서 외계인 메서드를 절대 호출하지 말자.

동기화 영역 안에서 작업은 최소한으로 줄이자.

가변 클래스를 설계할 때는 스스로 동기화해야 할 지 고민하자.

합당한 이유가 있을 때만 내부에서 동기화 하고, 그 여부를 문서화하자.[아이템 82]

반응형
댓글
반응형
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday