[Effective Java] 과도한 동기화는 피하라

들어가며

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

예를 들어 동기화된 영역 안에서는 재정의할 수 있는 메소드는 호출하면 안되며 클라이언트가 넘겨준 함수 객체를 호출해서도 안된다. 동기화된 영역을 포함한 클래스 관점에서는 이런 메소드는 바깥 세상에서 온 외계인이다. 그 메소드가 무슨 일을 할지 알지 못하며 통제할 수도 없다는 의미이다. 다음 코드를 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
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;
}
}
1
2
3
4
@FunctionalInterface
public interface SetObserver<E> {
void added(ObservableSet<E> set, E element);
}

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

1
2
3
4
5
6
7
8
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);
}

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

1
2
3
4
5
6
7
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 메소드는 ObservableSet의 removeObserver를 호출하고 이 메소드는 observers.remove 메소드를 호출하는데 여기서 원소를 제거하려고 할 때 리스트가 순회중이라 문제를 일으킨다. 동기화 블록 내라서 서로 다른 스레드에서 동시 수정이 일어나는것은 막아주지만 자신이 콜백을 거쳐 되돌아와 수정하는 것은 막지 못한다.

그럼 다음은 어떨까?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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 메소드에서는 리스트를 복사해 사용하면 락 없이도 안전한 순회가 가능하다.

1
2
3
4
5
6
7
8
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);
}

메소드 호출을 동기화 블록 바깥으로 옮기는 방법보다 괜찮은 방법이 있다. CopyOnWriteArrayList를 사용하는 것이다. 이 클래스는 정확히 위와 같은 목적으로 설계되었다. 내부를 변경하는 작업은 항상 복사본을 만들어 수행하기 때문에 내부 배열은 수정되지 않아 순회할 때 락이 필요없어 빠르다. 다음은 CopyOnWriteArrayList를 사용한 코드이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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);
}
}

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

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

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

Author: Song Hayoung
Link: https://songhayoung.github.io/2020/08/23/Languages/Effective%20JAVA/item79/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.