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

신명철·2022년 5월 10일
0

Effective Java

목록 보기
75/80

과도한 동기화의 단점

과도한 동기화는 성능을 떨어뜨리고 교착상태에 빠뜨리고, 예측할 수 없는 동작을 수행한다. 응답 불가와 안전 실패를 피하려면 동기화 메서드나 동기화 블록 안에서는 제어를 절대로 클라이언트에게 양도하면 안된다.

예를 들어서, 동기화된 영역 안에서는 재정의할 수 있는 메서드는 호출하면 안되고, 클라이언트가 넘겨준 함수 객체를 호출해서도 안된다. 동기화된 영역을 포함한 클래스 관점에서는 이런 메서드는 바깥 세상에서 온 외계인이다.

그 메서드가 무슨 일을 할지 알지 못하며 통제할 수도 없다는 의미다. 다음 코드를 보자.

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

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

다음 코드는 0부터 99까지 출력하는 코드다.

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

addedObsever를 다음과 같이 바꾸면 어떨까?

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

위 코드는 ConcurrentModificationException을 던진다. added 메서드의 호출 시점이 notifyElementAdded 가 리스트를 순회하는 도중 일어났기 때문이다.

added메서드는 OberserverSetremoveObeserver를 호출했고, 이 메서드는 oberservers.remove메서드를 호출하는데 여기서 원소를 제거하려고 할 때 리스트가 순회중이라 문제를 일으킨다.

동기화 블록 내라서 서로 다른 스레드에게 동시 수정이 일어나는 것은 막아주지만 자신이 콜백을 거쳐서 되돌아와 수정을 하는 것은 막아주지 못한다.

그럼, removeObserver를 직접 호출하지 않고 실행자 서비스를 사용해 다른 스레드에게 호출을 부탁하면 어떨까?

set.addObserver(new SetObserver<>() {
    public void added(ObservableSet<Integer> s, Integer e) {
        System.out.println(e);
        if(e == 23) {
            ExecutorService exec = Executors.newSingleThreadExecutor();
            try {
                exec.submit(() -> s.removeObserver(this)).get();
            } catch(ExecutionException | InterruptedException ex) {
                throw new AssertionError(ex);
            } finally {
                exec.shutdown();
            }
        }
    }
});

위 코드는 교착상태에 빠진다. s.removeObserver를 호출하면 락을 획득하려고 시도하지만, 획득할 수 없기 때문이다. 이 락은 메인스레드가 이미 쥐고 있고 백그라운드 스레드는 락을 얻을 때까지 무한 대기를 하기 때문이다.

자바의 락은 재진입성을 허용하므로 첫 번째 예에서는 교착상태에 빠지지 않고removeObserver를 호출할 수 있었지만, 그 락이 보호하는 개념적 행동에 반하는 행동을 하고있어서 문제가 생겼다.

이런 문제는 외계인 메서드 호출을 동기화 블록 바깥으로 옮기면 된다. 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);
}

메서드 호출을 동기화 바깥으로 옮기는 방법보다 괜찮은 방법이 있다. CopyOnWirteArrayList를 사용하는 것이다.

이 클래스는 내부를 변경하는 작업에 대해 항상 복사본을 만들어 수행하기 때문에, 내부 배열은 수정되지 않아 순회할 때 락이 필요없어 빠르다.

CopyOnWirteArrayList를 사용해 구현한 스레드 안전하고 관찰 가능한 집합

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

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

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

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

동기화 영역 바깥에서 호출되는 외계인 메서드를 열린 호출이라고 한다. 외계인 메서드는 얼마나 오래 실행될지 알 수 없어서 동기화 영역에서 호출된다면 성능 저하를 유발한다. 열린 호출로 바꾼다면 실패 방지 효과 외에도 동시성 효율을 개선해준다. 기본 규칙은 동기화 영역에서는 가능한 한 일을 적게 하는 것이다.

멀티코어가 일반화된 요즘 과도한 동기화가 초래하는 진짜 비용은 락을 획득하는데 얻는 비용이 아닌, 경쟁하느라 낭비하는 시간이다. 즉, 병렬로 실행할 기회를 잃고 몯느 코어가 메모리를 일관되게 보기 위한 지연시간이 진짜 비용이다. VM의 코드 최적화를 제한한다는 점도 과도한 동기화의 또다른 숨은 비용이다.

가변 클래스를 작성하려거든 다음 두 선택지중 하나를 따르자. 첫째로 동기화를 전혀 하지 않고 그 클래스를 동시에 사용해야 하는 클래스가 외부에서 알아서 동기화하게 하자. 두번째는 동기화를 내부에서 수행해 스레드 안전한 클래스로 만들자. 단, 클라이언트가 외부에서 객체 전체에 락을 거는 것보다 동시성을 월등히 개선할 수 있을 때만 두번째 방법을 선택해야 한다. 두번째 방법을 선택했다면 락 분할, 락 스트라이핑, 비차단 동시성 제어 등 다양한 기법을 사용할 수 있다.

profile
내 머릿속 지우개

0개의 댓글