wait와 notify보다는 동시성 유틸리티를 애용하자

2021-10-16

지금은 wait와 notify를 사용해야 할 이유가 많이 줄었다.

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

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

java.util.concurrent 패키지

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

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

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

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

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

동시성 컬렉션에서 동시성을 무력화하지 못하므로 여러 메서드를 원자적으로 묶어 호출하는 일 역시 불가능하다.

할 수 있기는 한데 성능이 엄청 느려질 것이다.

그래서 여러 기본 동작을 하나의 원자적 동작으로 묶는 ‘상태 의존적 수정’ 메서드들이 추가되었다.

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

예를 들어 Map의 putIfAbsent(key, value) 메서드는 주어진 키에 맵핑된 값이 아직 없을 때만 새 값을 집어넣는다.

그리고 기존 값이 있었다면 그 값을 반환하고, 없었다면 null을 반환한다.

이 메서드 덕에 스레드 안전한 정규화 맵(canonicalizing map)을 쉽게 구현할 수 있다.

다음은 String.intern의 동작을 흉내 내어 구현한 메서드다.

ConcurrentMap으로 구현한 동시성 정규화 맵 - 최적은 아니다.

private static final ConcurrentMap<String, String> map = new ConcurrentHashMap<>();

public static String intern(String s) {
  String previousValue = map.putIfAbsent(s, s);
  return previousValue == null ? s : previousValue;
}

아직 개선할 게 남았다.

ConcurrentHashMap 은 get 같은 검색 기능에 최적화되었다.

따라서 get을 먼저 호출하여 필요할 때만 putIfAbsent를 호출하면 더 빠르다.

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은 동시성이 뛰어나며 속도도 무척 빠르다.

이 메서드는 String.intern보다 6배나 빠르다.

String.intern에는 오래 실행되는 프로그램에서 메모리 누수를 방지하는 기술도 들어가 있음을 감안하자.

동시성 컬렉션은 동기화한 컬렉션을 낡은 유산으로 만들어 버렸다.

대표적으로 이제는 Collections.synchronizedMap 보다는 ConcurrentHashMap 을 사용하는게 훨씬 좋다.

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

BlockingQueue

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

예를 살펴보자.

Queue를 확장한 BlockingQueue에 추가된 메서드 중 take는 큐의 첫 원소를 꺼낸다.

이때 만약 큐가 비었다면 새로운 원소가 추가될 때까지 기다린다.

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

작업 큐는 하나 이상의 생산자(producer) 스레드가 작업을 큐에 추가하고,

하나 이상의 소비자(consumer) 스레드가 큐에 있는 작업을 꺼내 처리하는 형태다.

짐작하다시피 ThreadPoolExecutor를 포함한 대부분의 실행자 서비스 구현체에서 이 BlockingQueue를 사용한다.

동기화 장치의 종류

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

가장 자주 쓰이는 동기화 장치는 CountDownLatch와 Semaphore다.

CyclicBarrier와 Exchanger는 그보다 덜 쓰인다.

그리고 가장 강력한 동기화 장치는 바로 Phaser다.

CountDownLatch

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

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

옳바른 wait 메서드 사용방법

새로운 코드라면 언제나 wait와 notify가 아닌 동시성 유틸리티를 써야 한다.

하지만 어쩔 수 없이 레거시 코드를 다뤄야 할 때도 있을 것이다.

wait 메서드는 스레드가 어떤 조건이 충족되기를 기다리게 할 때 사용한다.

락 객체의 wait 메서드는 반드시 그 객체를 잠근 동기화 영역 안에서 호출해야 한다.

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

synchronized(obj) {
  while (<조건이 충족되지 않음>)
    obj.wait(); // 락을 놓고, 깨어나면 다시 잡는다.
  
  ...// 조건이 충족됐을 때 동작을 수행
}

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

이 반복문은 wait 호출 전후로 조건이 만족하는지를 검사하는 역할을 한다.

대기 전에 조건을 검사하여 조건이 이미 충족되었다면 wait를 건너뛰게 한 것은 응답 불가 상태를 예방하는 조치다.

만약 조건이 이미 충족되었는데 스레드가 notify 혹은 notifyAll 메서드를 먼저 호출한 후 대기 상태로 빠지면,

그 스레드를 다시 깨울 수 있다고 보장할 수 없다.

한편, 대기 후에 조건을 검사하여 조건이 충족되지 않았다면 다시 대기하게 하는 것은 안전 실패를 막는 조치다.

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

Object.wait(long mils) 문서에는 다음과 같이 명시되어 있다.

스레드는 허위 각성(spurious wakeup)때문에 notify, interrupte, timeout 과 상관없이 깨어날 수 있다.

하지만 허위 각성은 빈번하게 일어나지 않기 때문에, 애플리케이션에서 조건이 만족하지 않을 경우에 계속 기다릴 수 있도록 코드를 작성해야 한다.

즉, 루프문 안에서 wait() 메서드가 항상 일어나야 한다.

조건이 만족되지 않아도 스레드가 깨어날 수 있는 상황이 몇 가지 있다.

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

이와 관련하여 notify와 notifyAll중 무엇을 선택하느냐 하는 문제도 있다.

  • notify는 스레드 하나만 깨운다.
  • notifyAll은 모든 스레드를 깨운다.

일반적으로 언제나 notifyAll을 사용하는 게 합리적이고 안전하다.

깨어나야 하는 모든 스레드가 깨어남을 보장하니 항상 정확한 결과를 얻을 것이다.

다른 스레드까지 깨어날수는 있겠지만, 깨어난 스레드들은 기다리던 조건이 충족되었는지 확인하여, 충족되지 않았다면 다시 대기 할 것이므로

코드의 정확성에는 영향을 주지 않을 것이다.

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

하지만 이상의 전제조건들이 만족될지라고 notify 대신 notifytAll을 사용해야하는 이유가 있다.

외부로 공개된 객체에 대해 실수로 혹은 악의적으로 notify를 호출하는 상황에 대비하기 위해 wait를 반복문 안에서 호출했듯,

notify 대신 notifyAll을 사용하면 관련 없는 스레드가 실수로 혹은 악의적으로 wait를 호출하는 공격으로부터 보호할 수 있다.

그런 스레드가 중요한 notify를 삼켜버린다면 꼭 깨어났어야 할 스레드들이 영원히 대기하게 될 수도 있다.