[교재 EffectiveJava] 아이템 79. 과도한 동기화는 피하라

반응형
728x90
반응형

들어가기전

https://devfunny.tistory.com/669?category=895441 

 

[교재 EffectiveJava] 아이템 78. 공유 중인 가변 데이터는 동기화해 사용하라

synchronized 키워드 해당 메서드나 블록을 한번에 한 스레드씩 수행하도록 보장한다. 많은 프로그래머가 동기화를 배타적 실행, 즉 한 스레드가 변경하는 중이라서 상태가 일관되지 않은 순간의

devfunny.tistory.com

이번 아이템은 아이템 78의 반대 상황을 다룬다.

아이템 78에서 충분하지 못한 동기화의 피해를 다뤘다면, 이번 아이템에서는 과도한 동기화에 대한 문제점을 다룬다.

 

 

과도한 동기화

과도한 동기화는 성능을 떨어뜨리고, 교착상태에 빠뜨리고, 예측할 수 없는 동작을 일으킬 수 있다.

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

 

안전 실패

프로그램이 잘못된 결과를 계산해내는 것


예시

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

 

외계인 메서드

메서드가 무슨 일을 할지 알지 못하며 통제가 불가능하다.

외계인 메서드가 하는 일에 따라 동기화된 영역은 예외를 일으키거나, 교착 상태에 빠지거나, 데이터를 훼손할 수도 있다. 

 

 

예제코드

잘못된 코드, 동기화 블록 안에서 외계인 메서드(added)를 호출한다.

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

 

ObservableSet.java
package com.java.effective.item79;

import com.java.effective.item18.ForwardingSet;

import java.util.*;

public class ObservableSet<E> extends ForwardingSet<E> {
    /**
     * 1 ~ 99 출력
     * @param args
     */
    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);
        }
    }

    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) 콜백 인터페이스의 인스턴스를 메서드에 건넨다. 

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

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

 

SetObserver.java
package com.java.effective.item79;

@FunctionalInterface
public interface SetObserver<E> {
    // ObservableSet 에 원소가 더해지면 호출된다.
    void added(ObservableSet<E> set, E element);
}

 

예제코드 수행 결과

0부터 99까지 출력한다.

0
1
2
3
4
5
6
7
8
9
.
.
.
97
98
99

 

 

(1) 첫번째 시도. 23이 되면 자기 자신을 제거(구독해지)하는 관찰자를 추가해보자.

package com.java.effective.item79;

import java.util.HashSet;

public class Test {
    /**
     * 집합에 정숫값을 출력하다가, 그 값이 23이면 자기 자신을 제거하는 관찰자 추가
     * @param args
     */
    public static void main(String[] args) {
        ObservableSet<Integer> set = new ObservableSet<>(new HashSet<>());

        set.addObserver(new SetObserver<>() {
            public void added(ObservableSet<Integer> s, Integer e) {
                System.out.println(e);

                /**
                 * 0 ~ 23 까지 출력 후 관찰자 자신을 구독해지한 다음 조용히 종료될 것이다.
                 * 라고 생각했겠지만, 23까지 출력 후 ConcurrentModificationException 이 발생한다.
                 */
                if (e == 23) {
                    s.removeObserver(this);
                }
            }
        });

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

1) 23까지 출력한 후, ConcurrentModificationException를 던진다.

.
.
.
22
23
Exception in thread "main" java.util.ConcurrentModificationException

관찰자의 added 메서드 호출이 일어난 시점이 notifyElementAdded가 관찰자들의 리스트를 순회하는 도중이기 때문이다. 

 

ConcurrentModificationException

ConcurentModificationException은 해당 컬렉션 객체의 데이터 일관성에 대한 Exception이다. 

컬렉션의 객체가 변함으로 인한 비정상적인 환경이 발생했다는 것을 알려준다.

 

added 메서드는 ObservableSet의 removeObserver 메서드를 호출한다.

여기서 removeObserver에 매개변수로 자기 자신을 넘겨주고있다.

set.addObserver(new SetObserver<>() {
    public void added(ObservableSet<Integer> s, Integer e) {
        System.out.println(e);

        /**
         * 0 ~ 23 까지 출력 후 관찰자 자신을 구독해지한 다음 조용히 종료될 것이다.
         * 라고 생각했겠지만, 23까지 출력 후 ConcurrentModificationException 이 발생한다.
         */
        if (e == 23) {
            s.removeObserver(this);
        }
    }
});

이 메서드는 다시 observers.remove 메서드를 호출한다.

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

 

▶ 문제점

리스트에서 원소를 제거하려고 하는데, 마침 지금은 이 리스트를 순회하는 도중이다. 허용되지 않은 동작이다.

notifyElementAdded 메서드에서 수행하는 순회는 동기화 블록 안에 있으므로 동시 수정이 일어나지 않도록 보장하지만, 정작 자신이 콜백을 거쳐 되돌아와 수정하는 것까지 막지는 못한다.

 

 

(2) 두번째 시도. 구독해지를 하는 관찰자를 작성하는데, removrObserver를 직접 호출하지 않고 실행자 서비스(ExecutorService)를 사용해서 다른 스레드에 부탁해보자.

package com.java.effective.item79;

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

public class Test2 {
    /**
     * 쓸데없는 백그라운드 스레드를 사용하는 관찰자
     * @param args
     */
    public static void main(String[] args) {
        ObservableSet<Integer> set = new ObservableSet<>(new HashSet<>());

        set.addObserver(new SetObserver<>() {
            public void added(ObservableSet<Integer> s, Integer e) {
                System.out.println(e);
                if (e == 23) {
                    ExecutorService exec = Executors.newSingleThreadExecutor();

                    try {
                        // 관찰자를 잠그려 시도 (락을 얻을 수 없다. 이미 main 스레드가 락을 쥐고있다.
                        // main 스레드는 백그라운드 스레드가 관찰자를 제거하기만을 기다리고있다.
                        // -> 교착상태
                        exec.submit(() -> s.removeObserver(this)).get();
                    } catch (ExecutionException | InterruptedException ex) {
                        throw new AssertionError(ex);
                    } finally {
                        exec.shutdown();
                    }
                }
            }
        });

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

1) 교착상태에 빠진다.

백그라운드 스레드가 s.removeObserver를 호출하면 관찰자를 잠그려 시도하지만 락을 얻을 수 없다.

메인 스레드가 이미 락을 쥐고있기 때문이다. removeObserver를 수행하기 위해서는 observers 락을 획득해야하는데, 이 observer는 메인스레드에서 실행중인 notifyElementAdded 메서드에 의해 획득될 수 없다.

// main 스레드 내부 함수
private void notifyElementAdded(E element) {
    synchronized(observers) {
        for (SetObserver<E> observer : observers)
            observer.added(this, element);
    }
}

그와 동시에 메인 스레드는 백그라운드 스레드가 관찰자를 제거하기만을 기다리는 중이다. 교착상태가 발생했다.

 

위 첫번째 시도와 두번째 시도에서 다행이라고 생각할 점은, 동기화 영역이 보호하는 자원(관찰자 : observers)은 외계인 메서드(added)가 호출될때 일관된 상태라는 것이다.

똑같은 상황에서 불변식이 임시로 깨진 경우라면, 자바 언어의 락은 재진입을 허용하므로 교착 상태에 빠지지는 않는다.

 

락의 재진입 

이미 락을 획득한 스레드는 다른 synchronized 블록을 만났을때 락을 다시 검사하지 않고 진입 가능하다.

 

 

첫번째 시도로 돌아가보자.

▶ (1) 첫번째 시도. 23이 되면 자기 자신을 제거(구독해지)하는 관찰자를 추가해보자.

 

외계인 메서드를 호출하는 스레드는 이미 락을 쥐고 있으므로 다음번 락 획득도 성공한다. 그 락이 보호하는 데이터에 대해 개념적으로 관련이 없는 다른 작업이 진행중임에도 가능하다.

이는 참혹한 결과를 빚을 수 있다. 문제의 주 원인은 락이 제 구실을 하지 못했기 때문이다.

재진입 가능 락은 객체 지향 멀티스레드 프로그램을 쉽게 구현할 수 있도록 해주지만, 응답 불가(교착상태)가 될 상황을 안전 실패(데이터 훼손)로 변모시킬 수도 있다.

 

 

해결방안

외계인 메서드 호출을 동기화 블록 바깥으로 옮기면 된다.

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

이 방식을 적용하면 앞서의 첫번째 시도, 두번째 시도에서 발생한 예외, 교착상태 증상이 사라진다.

/**
 * 외계인 메서드를 동기화 블록 바깥으로 옮긴다.
 * @param element
 */
private void notifyElementAdded2(E element) {
    List<SetObserver<E>> snapshot = null;
    
    synchronized (observers) {
        snapshot = new ArrayList<>(observers);
    }

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

이처럼 동기화 영역 바깥에서 호출되는 외계인 메서드를 열린 호출(open call)이라고 한다.

외계인 메서드는 얼마나 오래 실행될지 알 수 없는데, 동기화 영역 안에서 호출된다면 그동안 다른 스레드는 보호된 자원을 사용하지 못하고 대기해야만한다. 따라서 열린 호출은 실행 방지 효과 외에도 동시성 효율을 크게 개선해준다. 

 

 

더 나은 해결방안

자바의 동시성 컬렉션 라이브러리의 CopyOnWriteArrayList가 전확히 이 목적으로 특별히 설계되었다.

ArrayList를 구현한 클래스로, 내부를 변경하는 작업은 항상 깨끗한 복사본을 만들어 수행하도록 구현했다. 

내부의 배열은 절대 수정되지 않으니 순회할때 락이 필요 없어 매우 빠르다.

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

 

 

정리

자바의 동기화 비용은 바르게 낮아져왔다. 하지만 과도환 동기화를 피하는 일은 오히려 과거 어느 때보다 중요하다.

과도한 동기화가 초래하는 진짜 비용은 락을 얻는데 드는 CPU 시간이 아닌, '경쟁하느라 낭비하는 시간, 즉 병렬로 실행할 기회를 잃고, 모든 코어가 메모리를 일관되게 보기 위한 지연시간'이 진짜 비용이다. 

 

가변 클래스를 작성하려거든 다음 두 선택지 중 하나를 따르자.

  • 1) 동기화를 전혀 하지 말고, 그 클래스를 동시에 사용해야하는 클래스가 외부에서 알아서 동기화하게 하자. ex) java.util
  • 2) 동기화를 내부에서 수행해 스레드 안전한 클래스로 만들자. ex) java.util.concurrent

단, 클라이언트가 외부에서 객체 전체에 락을 거는 것보다 동시성을 월등히 개선할 수 있을 때만 2)번의 방법을 선택해야한다.

 

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

클라이언트가 여러 스레드로 복제돼 구동되는 상황이라면 다른 클라이언트에서 이 메서드를 호출하는걸 막을 수 없으니 외부에서 동기화할 방법이 없다.

결과적으로, 이 정적 필드가 private라도 서로 관련 없는 스레드들이 동시에 읽고 수정할 수 있게 되어, 사실상 전역변수와 동일해진다는 뜻이다. 

 

 

 

반응형

Designed by JB FACTORY