IT/Programming / / 2023. 4. 26. 13:24

<Effective Java> RULE 67 과도한 동기는 피하라

반응형

 

너무 과도하게 동기화를 적용할 시 성능 저하, 교착 상태, 비결정적 동작등의 문제가 생긴다.

 

생존 오류나 안전 오류를 피하고 싶으면, 동기화 메서드나 블록 안에서 클라이언트에게 프로그램 제어 흐름을 넘기지 마라.

· 동기화가 적용된 영역 안에서는 재정의 가능 메서드나 클라이언트가 제공한 함수 객체 메서드를 호출하지 말라는 것

· 예외나 교착, 데이터 훼손이 발생할 수 있다.

 

ex) 구독 가능한 집합 포장 클래스

· 집합에 새로운 원소가 추가되었을 때 발생하는 통지를 구독할 수 있도록 한다. (구독자 패턴)

// 동기화를 블록 안에서 불가해 메서드를 호출하는 잘못된 사례!
public class ObservableSet<E> extends ForwardingSet<E>{
    pubilc ObservableSet(Set<E> set) { super(set); }
    
    private final List<SetObserver<E>> observers = new ArrayList<SetObserver<E>>();
    
    public voic addObserver(SetObserver<E> observer) {
        syncronized(observers) {
            observers.add(observer);
        }
    }
    
    public boolean removeObserver(SetObserver<E> observer){
        syncronized(observers){
            return observers.remove(observer);
        }
    }
    
    private void notifyElementAdded(E element) {
        syncronized(observers) {
            for(SetOberver<E> observer : observers)
                observer.added(this, element);
        }
    }
    
    @Override public boolean add(E element){
        boolean added = super.add(element);
        if(added)
            notifyElementAdded(element);
        return result;
    }
    
    @Override public boolean addAll(Collection<? extends E> c) {
        boolean result = false;
        for(E element : c){
            result |= add(element); // notifyEelementedAdded 호출
        return result;
    }
}
 

구독자는 addOberver메서드를 통해 호출한뒤 자신을 통지 구독자로 등록한다

removeObserver메서드를 호출해서 통지 구독을 해제한다.

두 메서드의 인자로 전달되는 객체는 아래의 역호출인터페이스의 자료형 객체이다.

public interface SetObserver<E> {
    // 구독자 집합에 새 원소가 추가되었을 때 호출됨
    void added(ObservableSet<E> set, E element);
}
 

아래의 프로그램은 0부터 99까지 찍힌다.

public static void main(){
    ObservableSet<Integer> set = new ObservableSet<Integer>(new HashSet<Integer>());
    
    set.addObserver(new SetObserver<Integer>(){
        public void added(ObservableSet<Integer> s, Integer e){
            sysout(e);
        }
    });
    
    for(int i = 0 ; i < 100 ; i++){
        set.add(i);
 }
 

위의 코드에서 addObserver를 호출하는 부분을 바꿔서 집합에 추가된 Interger값을 출력하되 그 값이 23일 때는 자기 자신을 구독자 리스트에서 삭제하는 구독자 객체가 전달되도록 변경

set.addObserver(new SetObserver<Integer>(){
    public void added(Observable<Integer> s, Integer e){
        sysout(e);
        if( e == 23 ) s.removeObserver(this);
    }
});
 

이 프로그램은 0부터 23까지 찍혀야 할 것이다 하지만 23까지 출력 후 ConcurrentModificationException이 발생한다.

· 구독자의 added메서드가 호출된 순간, notifyElementAdded메서드가 observers리스트를 순회하고 있기 때문이다.

· 위의 added 메서드는 구독자 집합에 정의된 removeObserver메서드를 호출하는데 이 메서드는 다시 observers.remove를 호출한다.

ㄴ 리스트 순회가 이루어 지고 있는 도중 리스트에서 원소를 삭제하려 한 것.

· observers리스트가 병렬적으로 수행되는일을 막기위해 otifyElementAdded메서드의 순환문은 동기화 블록 안에 있다.

ㄴ 하지만 역호출해서 리스트를 변경하는 경우까지 차단할 수 없다.

 

구독 해제를 시도하는 구독자를 만들 되 removeObserver를 직접 호출하는 대신 그일을 대신해주는 다른 스레드의 서비스를 이용한다.

// 괜히 후면 스레드를 이용하는 구독자
set.addObserver(new SetObserver<Integer>(){
    public void added(final ObservableSet<Integer> s, Integer e){
        sysout(e);
        if( e = 23 ){
            ExcutorService executor = 
                Executors.newSingleThreadExecutor();
            final SetObserver<Integer observer = this;
            try{
                executor.submit(new Runnable(){
                    public void run(){
                        s.removeObserver(observer);
                    }
                }).get();
            } catch (ExecutionException ex) {
                throw new AssertionError(ex.getCause());
            } catch (InterruptedException ex){
                throw new AssertionError(ex);
            } finally {
                executor.shutdown();
            }
        }
    }
});
 

이번에는 예외는 발생하지 않지만 교착상태가 발생한다.

· 후면 스레드는 s.removeObserver를 호출하는데 이 메서드는 observers에 락을 걸려한다. 하지만 락을 걸수 없다.

· 이미 주 스레드가 락을 걸고 있다.

 

동기화 영역안에서 불가해 메서드를 호출하는 것은 GUI 같은 툴킷같은 많은 시스템에서 교착 상태가 생기는 원인이다.

자바가 제공하는 락은 재진입이 가능하므로(reentrant) 락이 보호하는 데이터에 대해 참혹한 결과가 빚어지게 될 수 있다.

· 재 진입가능한 락은 객체 지향 다중 스레드 프로그램을 쉽게 구현 할 수 있도록 하지만, 생존 오류를 안전오류로 변모시킬 수 있다.

 

위의 문제는 불가해메서드를 호출하는 부분을 동기화영역 밖으로 옮기면 쉽게 해결 할 수 있다.

// 불가해 메서드를 호출하는 코드를 동기화 영역 밖으로 옮겼다.(open call)
private void notifyElementAdded(E element){
    List<SetOberver<E>> snapshot = null;
    syncronized(observers){
        snapshot = new ArrayList<SetObserver<E>>(observers);
    }
    for (SetObserver<E> observer : snapshot)
        observer.added(this, element);
}
 

릴리즈 1.5부터 자바 라이브러리에 CopyOnWriteArrayList라는 병행성 컬렉션이 추가되었다.

· 이 리스트는 ArrayList의 변종으로 내부 배열을 통째로 복사하는 방식으로 쓰기 연산을 지원한다.

· 내부 배열을 통째로 복사하는 방식으로 순회 연산만큼은 락을 걸 필요가 없어져서 대단히 빠르다.

· 성능은 좋지 않지만 구독자 리스트에는 딱이다.

// 다중 스레드에 안전한 구독자 집합 : CopyOnWriteArrayList이용
private final List<SetOberver<E>> observers = 
    new CopyOnWriteArrayList<SetObserver<E>>();
    
public void addObserverSetObserver<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);
}
 

동기화 영역 바깥에서 불가해 메서드를 호출하는 것을 열린 호출이라고 한다.

· 열린 호출 : 오류를 방지할 뿐 아니라, 병행성을 대단이 높여주는 기법.

동기화 영역 안에서 수행되는 작업의 양을 가능한 줄여야한다.

 

변경가능클래스의 경우 병렬적으로 이용될 클래스이거나 내부적인 동기화를 통해 외부에서 전체 객체에 락을 걸 때보다 높은 병행성을 달성할 수 있을 때만 스레드 안정성을 갖도록 구현해야 한다. 그렇지 않다면 동기화를 하지 마라.

 

클래스 내부적으로 동기화 메커니즘을 적용해야 한다면

· 락 분할(lock splitting)

· 락 스트라이핑(striping)

· 비봉쇄형 병행성 제어((nonblocking concurrency control)

 

static 필드를 변경하는 메서드가 있을 때는 해당 필드에 대한 접근을 반드시 동기화 해야 한다.

한 스레드만 이용하는 메서드라도 해야한다.

 

결론

· 데드락과 데이터 훼손 문제를 피하려면 동기화 영역안에서 불가해 메서드를 호출하지 말라.

· 동기화 영역안에서하는 작업량을 제한하라

· 변경 가능 클래스를 설계할 때는 내부적으로 동기화를 처리해야 하는지 살펴보라.

 

반응형
  • 네이버 블로그 공유
  • 네이버 밴드 공유
  • 페이스북 공유
  • 카카오스토리 공유