Skip to content

[아이템 81] wait과 notify보다는 동시성 유틸리티를 애용하라 #96

@ideasidus

Description

@ideasidus

📌 [아이템 81] wait과 notify보다는 동시성 유틸리티를 애용하라

✨ 핵심 내용

  1. 코드를 새로 작성하게 되면 wait와 notify를 쓸 이유가 거의(전혀) 없다.
  2. 유지보수를 위해 이들이 필요하다면 wait은 while 문 내에서 호출하고, notify 보다는 notifyAll을 사용하자. 혹시라도 notify를 사용한다면 응답 불가 상태에 빠지지 않도록 각별히 주의하자. (how?)

    Answer By GPT

    • wait()는 반드시 while 루프 안에서 사용하여 조건을 반복 확인하세요.
    • notify()보다는 notifyAll()을 사용하여 데드락을 방지하세요.
    • wait()와 notify()는 동일한 객체의 모니터를 사용해야 합니다.
    • notify()를 호출하기 전에 반드시 조건을 변경하세요.
    • 가능하면 java.util.concurrent의 고수준 동기화 도구를 사용하세요.

💡 새롭게 알게 된 점

📚 정리

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

  2. java.util.concurrent의 고수준 유틸리티는 실행자 프레임워크, 동시성 컬렉션, 동기화 장치로 나눌 수 있다.

    1. 동시성 컬렉션에서 동시성을 무력화 하는 건 불가능하며, 외부에서 락을 추가로 사용하면 오히려 속도가 느려진다.
    2. 외부에서 동시성을 무력화 하지 못하므로 여러 메서드를 묶어 원자적으로 호출하는 행위가 불가하다
    3. 그렇기에 여러 기본 동작을 하나의 원자적 동작으로 묶는 '상태 의존적 수정' 메서드 들이 추가되었습니다. 이들은 매우 유용해 java 8 부터 일반 컬렉션 인터페이스에도 디폴트 메서드 형태로 추가되었습니다.
      Map.putIfAbset(key, value) 메서드의 경우, 키에 매핑된 값이 없을때만 집어넣고, 기존 값이 있었을 경우 기존값, 없었을 경우 null을 반환합니다. 이 메서드 덕에 스레드 안전한 정규화 맵을 쉽게 구현할 수 있습니다.
    private static final ConcurrentMap<String, String> map = new ConcurrentHashMap<>();
    
    public static String intern(String s) {
        String previousValue = map.putIfAbset(s,s);
        return previousValue == null ? s : previousValue;
    }
    
    // ConcurrentHashMap은 get 과 같은 검색 기능에 최적화 돼 있다 필요할 때만 호출하면 더 빠르다.
    // 아래는 개선된 코드
    public static string intern(String s) {
        String result = map.get(s);
        if(result == null) {
            result = map.putIfAbsent(s,s);
            if (result == null)
                return s;
        }
        return result;
    }
    // putIfAbsent는 쓰기 락을 동반하므로 성능에 영향을 줄 수 있다. 그러므로 get 으로 null인 경우에만 코드를 실행한다.
    
    //그렇다면 왜 get으로 null이 확인 되었는데도 put을 사용하지 않고 putIfAbsent를 사용하냐? 그것은 get 이후에 다른 스레드가 값을 업데이트 했을 수 있기 때문이다.
  3. 동시성 애플리케이션에서 동기화 된 컬렉션을 동시성 컬렉션으로 교체하는 것 만으로 성능이 개선된다.

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

    1. Queue를 extend 한 BlockingQueue의 take 메서드는 큐의 첫 원소를 꺼낸다. 이때 큐가 비어 있다면 새로운 원소가 추가될 때 까지 기다린다. 이런 특성덕에 BlockingQueue는 작업 큐 (생산자-소비자 큐)로 쓰기에 적합하다. (pub-sub 구조)
  5. CountDownLatch와 Semaphore 가 가장 많이 쓰이고, CyclicBarrier와 Exchange는 그보다 덜 쓰인다. 가장 강력한 동기화 장치는 Phaser다.

     // CountDownLatch 예제 코드
     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) {
                 // 인터럽트가 일어나는 경우 스레드에 인터럽트를 전달하고 run 메서드에서 빠져나온다.
                 Thread.currentThread().interrupt();
             } finally {
                 // 타이머에게 작업을 마쳤음을 알린다.
                 done.countDown();
             }
         });
     }
    
     ready.await(); // ready.countDown()이 concurrency 번 일어 날때까지 await!
     long startNanos = System.nanoTime(); // 현재 시간 기록
     start.countDown(); // start들은 CountDownLatch가 1 이므로 시작 신호를 준다.
     done.await(); // 다시 done.countDown()이 concurrency 번 일어날 때 까지 await!
     return System.nanoTime() - startNanos;
     // 시간 간격을 잴때는 nanoTime을 사용 하는것이 더 정확하고 시스템의 시간보정에 영향 받지 않는다.
    }
    
     // add-on: 전달된 executor 가 concurrency개 이상의 스레드를 생성할 수 있어야 한다. 풀 크기가 모자라는 경우 deadlock이 생길 수 있다.
    1. 위와 같은 CountDownLatch 3개 코드는 CyclicBarrier(혹은 Phaser) 인스턴스 하나로 대체할 수 있다.
    2. 새 코드라면 언제나 동시성 유틸리티를 사용해야한다. 레거시를 다룬다면 어쩔 수 없을때가 있다.
    // wait 메서드는 스레드가 어떤 조건이 충족되기를 기다리게 할 때 사용한다. 락 객체의 wait 메서드는 반드시 그 객체를 잠근 동기화 영역 안에서 호출해야 한다.
    synchronized (obj) {
     while (!조건충족) {
          obj.wait();
     }
    }
    
    // wait 메서드를 사용할 때는 반드시 대기 반복문(wait loop) 관용구를 사용하라. 반복문 밖에서는 절대로 호출하지 말자.
    // 대기전에 조건을 검사해 조건이 충족 됐다면 wait을 건너 뛰게 하는 것은 응답 불가 상태를 예방하는 조치. 조건이 충족됐는데 스레드가 notify 메서드를 먼저 호출한 뒤 wait 하게 되면 그 스레드를 다시 깨울 수 있다고 보장할 수 없다.
    // 대기 후에 조건을 검사해 조건이 충족되지 않았다면 다시 대기하게 하는 것은 안전 실패를 막는 조치. 조건이 충족되지 않았는데 진행된다면 락이 보호하는 불변식을 깨뜨릴 위험이 있다.
    // 조건이 만족되지 않아도 스레드가 깨어날 수 있는 상황
    // 1. 스레드가 notify를 호출한 다음 대기중이던 스레드가 깨어나는 사이에 다른 스레드가 락을 얻어 해당 락이 보호하는 상태
    // 2. 조건이 만족되지 않았음에도 다른 스레드가 실수/악의적으로 notify 호출 (공개된 객체를 락으로 사용해 대기하는 클래스는 이런 위험에 노출 / 외부에 노출된 객체의 동기화 된 메서드 안에서 호출하는 wait는 모두 이 문제에 영향)
    // 3. 깨우는 스레드는 지나치게 관대하다. 대기 중인 스레드 중 일부만 조건이 충족되어도 notifyAll을 호출해 모든 스레드를 깨울 수 있다.
    // 4. 대기중인 스레드가 (드물게) notify 없이도 깨어난다. 허위각성(spurious wakeup)
    1. 위와 관련하여 notify와 notifyAll 중 어떤걸 써야하나? (notify는 하나의 스레드, notifyAll은 모든 스레드를 깨운다.)
      1. while 반복문으로 사용했다면, 언제나 notifyAll을 사용하는게 합리적으로 안전한 조언
      2. 깨어나야 하는 모든 스레드가 깨어남을 보장하여 항상 정확한 결과를 얻을 수 있다.
      3. 다른 스레드까지 깨어난 경우 다시 조건 검사를 해 wait 할 것이다.
      4. 모든 스레드가 같은 조건을 기다리고, 조건이 한 번 충족 될 때마다 단 하나의 스레드만 혜택을 받을 수 있다면 notifyAll 대신 notify를 사용해 최적화 할 수도 있다.
        1. 그럼에도 notifyAll을 사용해야 하는 이유가 있다.
        2. 외부로 공개된 객체에 대해 실수/악의적 으로 notify를 호출하는 상황에 대비해 wait을 반복문 안에서 호출 했던 것 처럼, notify 대신 notifyAll을 사용하면 관련 없는 스레드가 실수/악의적으로 wait을 호출하는 공격으로부터 보호할 수 있다. 그런 스레드가 notify를 삼켜버린다면 깨어 났어야 할 필요한 스레드들이 영원히 대기하게 될 수 있다.

📢 댓글로 각자의 학습 내용을 공유해주세요!

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions