DEV ℧ Developer Diary

[EffectiveJava] item81 - wait와 notify보다는 동시성 유틸리티를 애용하라

wait와 notify는 Object 객체의 메서드로 스레드를 제어하는 기능이다.

wait() : wait()을 호출하면 현 스레드의 객체의 lock을 풀고 대기상태에 들어가면서 제어권을 다른 쓰레드에게 양보하게 됩니다. synchronized 블럭 내에서만 호출이 가능하며, 객체에 락이 걸려있지 않을 경우 에러를 발생합니다.

notify() : 대기하고 있는 스레드중 임의의 스레드를 깨웁니다.

자바 5 이후로 도입된 고수준의 동시성 유틸리티가 wait와 notify로 하드코딩 했던 전형적인 일들을 대신 처리해 주기 때문에 많이 사용하지 않는다.

wait와 notify는 올바르게 사용하기가 아주 까다로우니 고수준 동시성 유틸리티를 사용하자.

java.util.concurrent

java.util.concurrent의 고수준 유틸리티는 세 범주로 나눌 수 있다.

  1. 실행자 프레임워크
  2. 동시성 컬렉션 (concurrent collection)
  3. 동기화 장치 (synchronizer)

이번 이전 Item80에서 실행자 프레임워크에 다뤄봤으니, 이번 Item에서는 동시성 컬렉션과 동기화 장치를 다뤄보자.

동시성 컬렉션 (concurrent collection)

동시성 컬렉션은 List, Queue, Map 같은 표준 컬렉션 인터페이스에 동시성을 가미해 구현한 고성능 컬렉션이다.

높은 동시성에 도달하기 위해 동기화를 각자의 내부에서 수행한다.

동시성 컬렉션에서 동시성을 무력화하는 건 불가능하며, 외부에서 락을 추가로 사용하면 오히려 속도가 느려진다.

상태 의존적 수정

동시성 컬렉션에서 동시성을 무력화하지 못해 여러 메서드를 원자적으로 묶어서 호출할수 없다보니, 이런 기본동작을 하나의 원자적 동작으로 묶는 ‘상태 의존적 수정’ 메서드들이 추가되었다.

자바 8 에서는 일반 컬렉션 인터페이스에서도 디폴트 메서드형태로 추가되었다.

예를 들어 Map의 putIfAbsent(key, value) 메서드는 주어진 키에 매핑된 값이 없을 때만 값을 넣는다. 기존 값이 있었다면 그 값을 반환하고 없었다면 null을 반환한다.
이 메서드 덕에 안전한 정규화 맵을 쉽게 구현할 수 있다.

한번 예시코드를 작성해보자. String.intern의 동작을 흉내내어 구현한 메서드이다.

ConcurrentMap으로 구현한 동시성 정규화 맵

public static String intern(String s) {
    String result = map.get(s);
    if (result == null) {
        result = map.putIfAbsent(s, s);
        if (result == null)
            result = s;
    }
    return result;
}

ConcurrentHashMap은 get같은 검색 기능에 최적화 되었으므로, get을 먼저 호출하여 필요할 때만 putIfAbsent를 호출하면 더 빠르다.

ConcurrentHashMap

ConcurrentHashMap은 동시성이 뛰어나며 속도도 무척 빠르다.

동시성 컬렉션은 동기화한 컬렉션을 낡은 유산으로 만들어 버렸다. 대표적인 예로 이제는 Collections.synchronizedMap 보다는 ConcurrentHashMap을 사용하는게 훨씬 좋다.

동기화된 맵을 동시성 맵으로 교체하는 것만으로 동시성 애플리케이션의 성능은 극적으로 개선된다.

BlockingQueue

컬렉션 인터페이스중 일부는 작업이 성공적으로 완료될때까지 기다리도록 확장되었다.

Queue를 확장한 BlockingQueue에 추가된 메서드 중 take는 큐의 첫 원소를 꺼낸다. 이때 만약 큐가 비었다면 새로운 원소가 추가될때 까지 기다린다.

이런 특성 덕에 BlockingQueue는 작업 큐(생산자(producer)-소비자(consumer) 큐)로 쓰기가 적합하다.

작업 큐(producer-consumer 큐)
하나 이상의 생산자(producer) 스레드가 작업(work)을 큐에 추가하고, 하나 이상의 소비자(consumer) 스레드가 큐에있는 작업을 꺼내 처리하는 형태다.

동기화 장치 (synchronizer)

동기화 장치는 스레드가 다른 스레드를 기다릴 수 있게 하여, 서로 작업을 조율할 수 있게 해준다.

가장 자주 쓰이는 동기화 장치는 CountDownLatchSemaphore다. CyclicBarrieExchanger는 그보다 덜쓰이며, 가장 강력한 동기화 장치는 바로 Phaser다.

카운트다운 래치 (CountDownLatch)

카운트다운 래치(latch; 걸쇠)는 일회성 장벽으로, 하나 이상의 스레드가 또다른 하나 이상의 스레드 작업이 끝날 때까지 기다리게 한다.

CountDownLatch의 유일한 생성자는 int 값을 받으며, 이 값이 래치의 countDown 메서드를 몇번 호출해야 대기중인 스레드를 깨우는지 결정한다.

예를 들어 어떤 동작들을 동시에 시작해 모두 완료하기까지의 시간을 재는 간단한 프레임워크를 구축할 수 있다.

동시 실행 시간을 재는 간단한 프레임워크

public static long time(Executor executor, int concurrency, Runnable action) throws InterruptedException {
    CountDownLatch ready = new CountDownLatch(concurrency);
    CountDownLatch start = new CountDownLatch(1);
    CountDownLatch done  = new CountDownLatch(concurrency);

    for (int i = 0; i < concurrency; i++) {
        executor.execute(() -> {
            /** 타이머에게 준비를 마쳤음을 알린다. */
            ready.countDown();
            try {
                /** 모든 작업자 스레드가 준비될 때까지 기다린다. */
                start.await();
                action.run();
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            } finally {
                /** 타이머에게 작업을 마쳤음을 알린다. */
                done.countDown();
            }
        });
    }

    ready.await(); /** 모든 작업자가 준비될 때까지 기다린다. */
    long startNanos = System.nanoTime();
    start.countDown(); /** 작업자들을 깨운다. */
    done.await(); /** 모든 작업자가 일을 끝마치기를 기다린다. */
    return System.nanoTime() - startNanos;
}

이상의 기능을 wait와 notify만으로 구현하려면 아주 난해하고 지저분한 코드가 탄생하지만, CountDownLatch를 쓰면 놀랍도록 직관적으로 구현할 수 있다.

time 메서드에 넘겨진 실행자(excutor)는 concurrency 매개변수로 지정한 동시성 수준만큼의 스레드를 생성할 수 있어야 한다. 그렇지 못하면 이 메서드는 결코 끝나지 않는다.
이런 상태를 스레드 기아 교착상태(thread starvation deadlock)라 한다.

이 코드에서 System.nanoTime 메서드를 사용해 시간을 잰 것에 주목하자. 시간 간격을 잴 때는 항상 System.currentTimeMillis가 아닌 System.nanoTime을 사용하자.
System.nanoTime은 더 정확하고 정밀하며 시스템의 실시간 시계의 시간 보정에 영햘받지 않는다.

마지막으로 이 예제의 코드는 1초 이상 걸리지 않는다면 정확한 시간을 측정할 수 없으니 정밀한 시간측정이 필요하다면 jmh같은 특수 프레임워크를 사용하자.

wait, notify

새로운 코드라면 언제나 동시성 유틸리티를 써야하지만 wait, notify가 사용된 레거시 코드를 다룰 수도 있을 것이다.

wait 메서드를 사용하는 표준 방식

synchronized (obj) {
    while (<조건이 충족되지 않았다.>)
        obj.wait(); /** (락을 놓고, 깨어나면 다시 잡는다.) */

  ... /** 조건이 충족됐을 때의 동작을 수행한다. */
}

wait 메서드를 사용할 때는 반드시 대기 반복문(wait loop) 관용구를 사용하라. 반복문 밖에서는 절대로 호출하지 말자.

만약 조건이 이미 충족되었는데 스레드가 notify(혹은 notifyAll) 메서드를 먼저 호출한 후 대기상태로 빠지면, 그 스레드를 다시 깨울 수 있다고 보장할 수 없다.

조건이 충족되지 않았는데 스레드가 동작을 이어가면 락이 보호하는 불변식을 깨뜨릴 위험이 있다.

조건이 만족되지 않아도 스레드가 깨어나는 상황

  • 스레드가 notify를 호출한 다음 대기 중이던 스레드가 깨어나는 사이에 다른 스레드가 락을 얻어 그 락이 보호하는 상태를 변경한다.
  • 조건이 만족되지 않았음에도 다른 스레드가 실수로 혹은 악의적으로 notify를 호출한다. 공개된 객체를 락으로 사용해 대기하는 클래스는 이런 위험에 노출된다.
  • 깨우는 스레드는 지나치게 관대해서, 대기 중인 스레드 중 일부만 조건이 충족되어도 notifyAll을 호출해 모든 스레드를 깨울 수 있다.
  • 대기 중인 스레드가 (드물게) notify 없이도 깨어나는 경우가 있다. 허위 각성(spurious wakeup)이라는 현상이다.

notify, notifyAll

nofify와 notifyAll 중 무엇을 선택하느냐 하는 문제도 있다.

일반적으로 언제나 notifyAll을 사용하라는게 합리적이고 안전한 조언이 될것이다. 깨어난 스레드들은 기다리던 조건이 충족되었는지 확인하여, 충족되지 않았다면 다시 대기할 것이다.

모든 스레드가 같은 조건을 기다리고, 조건이 한번 충족될 때마다 단 하나의 스레드만 헤택을 받을 수 있다면 notifyAll 대신 notify를 사용해 최적화할 수 있다.

하지만 notify 대신 notifyAll을 사용해야 하는 경우도 있다. 모든 스레드를 깨움으로써 외부로 공개된 객체에 대해 실수로 혹은 악의적으로 wait을 호출하는 공격으로 부터 보호할 수 있다. notify를 호출하게 된다면 꼭 깨어났어야할 스레드들이 영원히 대기하게 될 수 있다.