Effective Java : 아이템 81. wait, notify보다는 동시성 유틸리티를 애용하라.

    아이템 81. wait(), notify()보다는 동시성 유틸리티를 애용하라.

    • 동시성 유틸리티(Concurrent 패키지)는 세 가지 맥락을 제공함. 
      • Exeuctor Service 관련 (ExeutorService)
      • Concurrent Collection 관련 (ConcurrentHashMap 같은 것들)
      • 동기화 장치 관련 (Semaphour, CountdownLatch 같은 것들)
    • Concurrent Collection 관련
      • 여러 메서드의 원자적인 동작은 필요한 경우 default 메서드로 제공됨. (putIfAbsent)
      • 없는 경우 외부에서 락을 이용한 동기화 필요함. (그러나 느림) 
      • 동기화 컬렉션(Collections.synchronizedMap) 대신 동시성 컬렉션(ConcurrentHashMap)을 사용하는게 훨씬 좋음.
    • 동기화 장치 관련
      • wait(), notify()보다는 동기화 장치(CountdownLatch, Semaphore...)를 이용해라
    • wait(), notify() 관련
      • wait()는 반드시 while문 안에서 호출해라.
        • 외부 쓰레드의 악의적인 notify() 공격으로부터 보호. 
        • while문 밖에서 호출되면 해당 쓰레드의 wait()를 누군가 notify() 해주지 않을 수도 있음.
      • wait(), notify()에서 조건이 만족되지 않아도 쓰레드가 깨어나는 경우
        • 외부 스레드가 조건 불충분 상태에서 악의적으로 notify() 호출. 
        • 허위 각성
        • 조건 만족해 쓰레드 A가 notify() 했는데, 쓰레드 B가 깨어나는 사이 쓰레드 C가 락을 얻어서 값을 수정해 조건 불충분 상태로 만드는 경우 
      • notify()보다는 notifyAll()을 사용하라.
        • 외부 쓰레드가 악의적으로 notify()를 호출하는 경우, 필요한 notify()가 잠식되어 호출되지 않을 수 있음. 이걸 방지하기 위해 notifyAll()을 호출하라.
    • wait() : 호출하면 현재 가지고 있는 모니터락을 반납하고, 누군가 notify() 해줄 때까지 기다림.
    • notify() : 모니터락을 획득하기 위한 쓰레드 중 하나만 깨움(랜덤)
    • notifyAll() : 모니터락을 획득하려고 대기하는 쓰레드 전체를 깨움.

    wait() / notify()는 올바르게 사용하기 어려움 → 고수준 동시성 유틸리티 사용

    레거기 코드가 아니라면 wait() / notify()를 직접적으로 사용 하지말고 고수준 동시성 유틸리티(java.util.concurrent)를 사용하는 것이 좋다. Concurrent 패키지는 크게 세 가지의 쓰임으로 나눌 수 있다.

    • 실행자 프레임워크(Executor) : ExecutorService 같은 것들
    • 동시성 컬렉션 (Concurrent Collection) : ConcurrentHashMap 같은 것들
    • 동기화 장치 (synchronized) : Countdown Latch 같은 것들 

    wait() / notify()를 써서 동시성을 처리하는 대신, 위 라이브러리에서 필요한 것을 찾아서 사용하는 것이 강력히 권장된다.


    동시성 컬렉션(Concurrent Collection)에 대한 설명

    Concurrent 패키지에서는 ConcurrentHashMap 같은 동시성 컬렉션을 제공해준다. 개발자가 직접 동기화 하는 것보다 이 컬렉션을 사용하는 것이 더 성능적으로 좋기 때문에 이것을 적극적으로 사용하자. 

    만약 동시성 컬렉션의 메서드들 여러 개가 원자적으로 실행되어야 하는 경우가 있다면 외부에서 동시성을 제어해야 할 수도 있다. 예를 들어 아래 코드가 원자적으로 실행되려면 map에 대한 동기화가 필요하다.

    if (!map.containsKey(key)) {
        map.put(key, value);
    }

    그러나 일반적으로 동시성 컬렉션들은 내부적으로 동기화가 이루어져 있기 때문에 외부에서 락을 이용할 경우 더 느려진다. 대신에 동시성 컬렉션들은 자주 사용하는 원자적인 연산들은 default 메서드로 제공하고 있다. 대표적인 예시로는 ConcurrentHashMap의 putIfAbsent(key, value) 메서드다.

    또한 동기화 컬렉션들은(Collections.synchronizedMap)은 이제는 동시성 컬렉션(ConcurrentHashMap)으로 바꿔주는 것이 성능적으로 더욱 이득이다. 

     


    동기화 장치 

    Concurrent 패키지에서는 고수준의 동기화 장치를 제공한다. 개발자는 notify(), wait() 대신 Concurrent 패키지가 제공하는 동기화 장치를 이용하면 좀 더 손쉽게 코드를 작성해 볼 수 있다. 자주 쓰이는 동기화 장치는 다음과 같다

    • CountdownLatch
    • Semaphore
    • CyclicBarrier
    • Exchanger
    • Phaser

    wait(), notify()를 이용해서 아주 복잡하게 구현해야 하는 코드를 CountdownLatch를 이용하면 아래처럼 정말 손쉽고 명확하게 구현할 수 있다.

    public class ConcurrentExample {
    
        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;
        }
    }

    위 코드를 요약하면 다음과 같다.

    • 3개의 CountdownLatch를 이용해서 동시성을 처리한다.
    • action.run()을 호출하면 각 Exeuctor는 Task를 처리한다. 이 때, Action은 Callable 인터페이스를 구현한 함수 객체다.
    • 만약 action.run()을 수행하다가 InterruptedException을 만나게 되면, ExecutorService에게 쓰레드 정지를 알리기 위해 interrupt() 메서드를 명시적으로 호출해준다. 

     


    어쩔 수 없이 wait(), notify()를 써야한다면?

    레거시 코드의 경우 wait(), notify()를 쓰고 있을 것이고, 어쩔 수 없이 이 코드를 유지보수 해야할 수도 있다. 이 때, wait(), notify()를 올바르게 사용하기 위해서는 아래 룰을 따르는 것이 좋다.

    syncrhonized (obj) { 
       while (<조건이 충족되지 않았다>)
         obj.wait(); // obj 모니터 락을 놓고, 누군가 notify() 해주면 깨어나서 다시 락을 획득.
         
       ... // 조건이 충족되었을 때의 동작을 수행한다. 
    }

    한 가지 알아두면 좋은 부분은 synchronized()로 획득한 모니터 lock은 wait()가 호출되었을 때 일시적으로 release 된다는 것이다. 이것은 데드락을 방지하기 위함이다. wait() 메서드는 현재 쓰레드가 가지고 있는 해당 객체의 모니터락을 반납하고 다른 스레드가 다시 notify()를 호출해주길 기다린다. 그리고 누군가 notify()를 호출했을 때, 해당 쓰레드가 깨어나면 다시 obj 인스턴스의 모니터락을 가지도록 동작한다. 

    반드시 wait()는 while문 안에서 처리해야한다. 그 이유는 다음과 같다.

    이미 필요한 조건을 만족했는데 쓰레드가 wait()를 호출해서 락을 놓고, 다른 쓰레드가 깨워줄 때까지 기다린다고 가정해보자. 이미 필요한 조건이 만족되었기 때문에 또 다른 쓰레드가 조건을 만족시킨 후 notify()를 호출해주리라는 보장이 없다. 즉, while문 밖에서 wait()가 호출되면 쓰레드는 영원히 호출되지 않을지도 모른다. 

    반드시 wait()는 while문 안에서 처리해야하는 또 다른 이유는 '안전 실패'를 방지하기 위함이다. 안전 실패는 '예외를 던져야 할 경우인데, 실패하지 않는다. 그리고 실패하지 않았기 때문에 실패했어야 했던 인스턴스의 값은 기대하지 않은 값으로 바뀔 것이다. 그리고 이 실패는 언젠가 예상치 못한 곳에서 예외를 던질 것이다. 즉, while 문 내에서 wait()를 하는 이유는 '조건이 만족되지 않았을 때 동작하는 것을 막아 안전실패를 방지'하기 위함이기도 하다. 


    wait(), notify()에서 조건이 만족되지 않아도 쓰레드가 깨어나는 경우.

    • 스레드(쓰레드A)가 notify()를 호출한 다음 대기 중이던 스레드(쓰레드B)가 깨어나는 사이에 다른 스레드(쓰레드 C)가 락을 얻어 그 락이 보호하는 상태를 변경함. (즉, 조건 만족하다가 쓰레드 C가 값을 바꿔서 조건이 만족하지 않을 수 있음)
    • 조건이 만족되지 않았음에도 다른 스레드가 실수, 악의적으로 notify()를 호출함. 
    • 지나치게 관대한 쓰레드는 대기 중인 스레드 중 일부만 조건이 충족되어도 notifyAll()을 호출해서 모든 스레드를 깨울 수도 있다.
    • 대기 중인 스레드가 드물게 notify() 없이도 깨어나는 경우가 있음. 허위각성이라는 현상.

    notify()보다는 notifyAll()을 사용하라

    외부에 공개된 객체에 대해 실수 / 악의적으로 notify()를 호출하는 경우가 있다. 이 부분을 보호하기 위해서 while (조건문) 내에서 wait() 메서드를 호출했다.

    마찬가지로 notifyAll()을 호출하면 위의 공격을 방어하는 수단이 된다. 예를 들어 쓰레드 A가 조건이 불충분한 상태에서 악의적으로 notify()를 호출해서 모니터락을 위해 대기하던 특정 쓰레드를 깨웠다고 하자. 이 경우, 중요한 notify()가 악의적인 notify()에 의해서 무시되는 경우가 될 것이다. 이렇게 notify()가 잠식되는 것을 방지하기 위해 notifyAll()을 호출해서 wait()로 대기중인 모든 쓰레드가 깨어나도록 수정해야한다. 

    댓글

    Designed by JB FACTORY