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

동시성, 네 번째 아이템

Posted by SungBeom on May 28, 2020 · 5 mins read

Intro

지금은 자바 5에서 도입된 고수준의 동시성 유틸리티가 이전이라면 wait와 notify로 하드코딩해야 했던 전형적인 일들을 대신 처리해주기 때문에 wait와 notify를 사용해야 할 이유가 많이 줄었다. wait와 notify는 올바르게 사용하기가 아주 까다로우니 고수준 동시성 유틸리티를 사용하자.

동시성 컬렉션

java.util.concurrent의 고수준 유틸리티는 실행자 프레임워크, 동시성 컬렉션(concurrent collection), 동기화 장치(synchronizer)로 나눌 수 있다. 그 중 동시성 컬렉션은 List, Queue, Map 같은 표준 컬렉션 인터페이스에 동시성을 가미해 구현한 고성능 컬렉션이다. 높은 동시성에 도달하기 위해 동기화를 각자의 내부에서 수행한다. 따라서 동시성 컬렉션에서 동시성을 무력화하는 건 불가능하며, 외부에서 락을 추가로 사용하면 오히려 속도가 느려진다.

동시성 컬렉션에서 동시성을 무력화하지 못하므로 여러 메소드를 원자적으로 묶어 호출하는 일 역시 불가능하다. 그래서 여러 기본 동작을 하나의 원자적 동작으로 묶는 '상태 의존적 수정' 메소드들이 추가되었다(이 메소드들은 자바 8에서는 일반 컬렉션 인터페이스에도 추가되었다). 예를 들어 Map의 putIfAbsent(key, value) 메소드는 주어진 키에 매핑된 값이 아직 없을 때만 새 값을 집어넣는데, 기존 값이 있었다면 그 값을 반환하고, 없었다면 null을 반환한다. 이 메소드 덕에 스레드 안전한 정규화 맵(canonicalizing map)을 쉽게 구현할 수 있다.

ConcurrentMap은 동시성이 뛰어나며 속도도 무척 빠르다. 동시성 컬렉션은 동기화한 컬렉션을 낡은 유산으로 만들어버렸는데, 대표적인 예로 이제는 Collections.synchronizedMap 보다는 ConcurrentMap을 사용하는 게 훨씬 좋다. 동기화된 맵을 동시성 맵으로 교체하는 것만으로 동시성 애플리케이션의 성능은 극적으로 개선된다.

컬렉션 중 일부는 작업이 성공적으로 완료될 때까지 기다리도록 확장되었다. 예를 들어 Queue를 확장한 BlockingQueue에 추가된 메소드 중 take는 큐의 첫 원소를 꺼내는데, 만약 큐가 비었다면 새로운 원소가 추가될 때까지 기다린다. 이런 특성 덕에 BlockingQueue는 작업 큐(생산자-소비자 큐)로 쓰기에 적합하다.

동기화 장치는 스레드가 다른 스레드를 기다릴 수 있게 하여, 서로 작업을 조율할 수 있게 해준다. 가장 자주 쓰이는 동기화 장치는 CountDownLatch와 Semaphore이고, CyclicBarrier와 Exchanger는 그보다 덜 쓰인다. 가장 강력한 동기화 장치는 바로 Phaser다.

카운트다운 래치(latch, 걸쇠)는 일회성 장벽으로, 하나 이상의 스레드가 또 다른 하나 이상의 스레드 작업이 끝날 때까지 기다리게 한다. CountDownLatch의 유일한 생성자는 int 값을 받으며, 이 값이 래치의 countDown 메소드를 몇 번 호출해야 대기 중인 스레드들을 깨우는지를 결정한다. 이 간단한 장치를 활용하면 유용한 기능들을 놀랍도록 쉽게 구현할 수 있다.

시간 간격을 잴 때는 항상 System.currentTimeMillis가 아닌 System.nanoTime을 사용하자. System.nanoTime은 더 정확하고 정밀하며 시스템의 실시간 시계의 시간 보정에 영향받지 않는다.

동시성 유틸리티

새로운 코드라면 언제나 wait와 notify가 아닌 동시성 유틸리티를 써야 하지만, 어쩔 수 없이 레거시 코드를 다뤄야 할 때도 있을 것이다. wait를 사용하는 표준 방식은 다음과 같다.

1
2
3
4
5
6
7
// wait 메소드를 사용하는 표준 방식
synchronized (obj) {
    while (<조건이 충족되지 않았다>)
        obj.wait();  // (락을 놓고, 깨어나면 다시 잡는다.)
 
    ...  // 조건이 충족됐을 때의 동작을 수행한다.
}
cs

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

대기 전에 조건을 검사하여 조건이 이미 충족되었다면 wait를 건너뛰게 한 것은 응답 불가 상태를 예방하는 조치다. 만약 조건이 이미 충족되었는데 스레드가 notify(혹은 notifyAll) 메소드를 먼저 호출한 후 대기 상태로 빠지면, 그 스레드를 다시 깨울 수 있다고 보장할 수 없다.

한편, 대기 후에 조건을 검사하여 조건이 충족되지 않았다면 다시 대기하게 하는 것은 안전 실패를 막는 조치다. 만약 조건이 충족되지 않았는데 스레드가 동작을 이어가면 락이 보호하는 불변식을 깨뜨릴 위험이 있다. 조건이 만족되지 않아도 스레드가 깨어날 수 있는 상황이 몇 가지 있긴 하다.

notify와 notifyAll 중 무엇을 선택하느냐 하는 문제도 있으나, 일반적으로 언제나 notifyAll을 사용하라는 게 합리적이고 안전한 조언이 될 것이다. 모든 스레드가 같은 조건을 기다리고, 조건이 한 번 충족될 때마다 단 하나의 스레드만 혜택을 받을 수 있다면 notifyAll 대신 notify를 사용해 최적화할 수 있긴 하다. 하지만 notifyAll을 사용하면 깨어나야 하는 모든 스레드가 깨어남을 보장하니 항상 정확한 결과를 얻을 것이다. 다른 스레드까지 깨어날 수도 있긴 하지만, 프로그램의 정확성에는 영향을 주지 않을 것이고, 깨어난 스레드들은 기다리던 조건이 충족되었는지 확인하여, 충족되지 않았다면 다시 대기할 것이다. 또한 관련 없는 스레드가 실수로 혹은 악의적으로 wait를 호출하는 공격으로부터 보호할 수 있다.


핵심 정리

wait와 notify를 직접 사용하는 것을 동시성 '어셈블리 언어'로 프로그래밍하는 것에 비유할 수 있다. 반면 java.util.concurrent는 고수준 언어에 비유할 수 있다. 코드를 새로 작성한다면 wait와 notify를 쓸 이유가 거의(어쩌면 전혀) 없다. 이들을 사용하는 레거시 코드를 유지보수해야 한다면 wait는 항상 표준 관용구에 따라 while 문 안에서 호출하도록 하자. 일반적으로 notify보다는 notifyAll을 사용해야 한다. 혹시라도 notify를 사용한다면 응답 불가 상태에 빠지지 않도록 각별히 주의하자.