Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
ruthetum opened this issue Jun 26, 2022 · 0 comments
Assignees
Labels
11장 동시성

Comments

@ruthetum
Copy link
Member

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

예전에는 wait, notify를 많이 사용하고 중요했지만, 고수준의 동시성 유틸리티가 제공되면서 지금은 wait와 notify를 사용해야 할 이유가 많이 줄었다.

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

wait, notify

  • 스레드의 상태 제어에 활용된다.
  • 동기화된 블록안에서 사용되며, wait를 만나게 되면 해당 쓰레드는 해당 객체의 모니터링 락에 대한 권한을 가지고 있는 경우 모니터링 락의 권한을 놓고 대기한다.
  • notify 메소드를 호출하는 경우 wait하고 있는 쓰레드를 깨운다.

동시성 유틸리티

  • java.util.concurrent의 유틸리티는 크게 세 가지 범주로 나눌 수 있다.
    • 실행자 프레임워크 (item80)
    • 동시성 컬렉션
    • 동기화 장치

실행자 프레임워크는 item80에서 다루기 때문에 이번 아이템에서는 동시성 컬렉션과 동기화 장치에 대해 이야기한다.

동시성 컬렉션

  • List, Queue, Map 같은 표준 컬렉션 인터페이스에 동시성을 가미해 구현한 고성능 컬렉션이다.
  • 높은 동시성을 위해 동기화를 각자의 내부에서 수행한다.
  • 동시성 컬렉션에서 동시성을 무력화하는 건 불가능하며, 외부에서 락을 추가로 사용하면 오히려 속도가 느려진다.

상태 의존적 수정 메서드

  • 동시성 컬렉션에서 동시성을 무력화할 수 없기 때문에 여러 메서드를 원자적으로 호출이 불가능하다.
  • 따라서 여러 동작을 하나의 원자적 동작으로 묶기 위해 '상태 의존적 수정' 메서드들이 추가되었다.

putIfAbsent

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

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

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

    • Ex. ConcurrentHap
  • 아래 방법과 같이 ConcurrentHap으로 동시성 정규화 맵을 구현할 수 있다.

    // ConcurrentHap으로 구현한 동시성 정규화 맵 - - 최적은 아니다.
    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;
    }
  • Collections.synchronizedMap보다는 ConcurrentHashMap 사용하는 게 훨씬 좋다.

BlockingQueue

  • 컬렉션 인터페이스 중 일부는 작업이 성공적으로 완료될 때까지 기다리도록­ 확장되었다.
  • 예를 들어 Queue를 확장한 BlockingQueue에 추가된 메서드 중 take는 큐의 첫 원소를 꺼내고, 만약 큐가 비었다면 새로운 원소가 추가될 때까지 기다린다.
  • 이러한 특성 때문에 BlockingQueue는 작업 큐(생산자-소비자 큐)로 쓰기에 적합하다.
    • 생산자(producer) 스레드가 작업을 큐에 추가하고, 소비자(comsumer) 스레드가 큐에 있는 작업을 꺼내 처리하는 형태
    • ThreadPoolExecutor를 포함한 대부분의 실행자 서비스 구현체에서 BlockingQueue를 활용

동기화 장치

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

CountDownLatch

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

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

    // 동시 실행 시간을 재는 간단한 프레임워크
    public static long time(Executo executor, int concurrency, Runnable action) throws InterruptedException { 
        CountDownlatch ready = new CountDownLatch(concurrency); 
        CountDownlatch start = new CountDownLatch(l);
        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를 쓰면 직관적으로 구현할 수 있다.

  • 위 예제는 CountDownlatch를 3개 사용했지만 CyclicBarrier(혹은 Phaser) 인스턴스 하나로 대체할 수 있다.

    • 하지만 이렇게 하면 코드가 더 명료해지겠지만 이해하기는 어려울것이다.

wait을 사용하는 표준 방식

  • 새로운 코드라면 wait이나 notify가 아닌 동시성 유틸리티를 사용하면 된다.

  • 하지만 레거시 코드를 수정하는 경우에는 작성된 wait을 수정해야하는데 wait 메서드를 사용할 때는 반드시 대기 반복문(wait loop) 관용구를 사용하고, 반복문 밖에서는 절대로 호출하지 말자.

    • wait 메서드는 스레드가 어떤 조건이 충족되기를 기다리게 할 때 사용한다.
    • 락 객체의 wait 메서드는 반드시 그 객체를 잠근 동기화 영역 안에서 호출해야 한다.
    // wait 메서드를 사용하는 표준 방식 
    synchronized (obj) { 
        while (<조건이 충족되지 않았다>) 
            obj.wait() ; // 락을 고, 깨어나면 다시 잡는다.
            
        ... // 조건이 충족됐을 때의 을 수행한다. 
    }   
  • 이 반복문은 wait 호출 전후로 조건이 만족하는지를 검사한다. 만약 대기 전에 조건을 검사하여 조건이 이미 만족되었다면 wait를 건너뛰게 한 것은 응답 불가 상태를 예방하는 조치이다.

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

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

  • 하지만 아래의 몇 가지 예의 경우 조건이 만족되지 않아도 스레드가 깨어날 수 있다.

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

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

notify vs notifyAll

  • 일반적으로 언제나 notifyAll을 사용하는 게 합리적이고 안정적이다.
    • notify는 스레드 하나, notifyAll은 모든 스레드를 깨운다.
    • 깨어나야 하는 모든 스레드를 깨우기 때문에 항상 정확한 결과를 기대할 수 있다.
  • 다만 모든 스레드가 같은 조건을 기다리고, 조건이 한 번 충족될 때마다 단 하나의 스레드만 혜택을 받을 수 있다면 notifyAll 대신 notify를 사용해 최적화할 수 있다.
  • 이외에 외부로 공개된 객체에 대해 실수 혹은 악의적으로 notify를 호출하는 상황에 대비하기 위해 wait를 반복문 안에서 호출했듯, notify 대신 notifyAll을 사용하면 관련 없는 스레드가 실수 혹은 악의적인 wait 호출로부터 공격받는 상황을 예방할 수 있다.

정리

  • wait와 notify를 직접 사용하는 것을 동시성 ‘어셈블리 언어’로 프로그래밍하는 것에 비유할 수 있다.
  • 반면 java.util.concurrent는 고수준 언어에 비유할 수 있다.
  • 따라서 코드를 새로 작성한다면 wait와 notify틀 쓸 이유가 거의 없고 동시성 유틸리티를 사용하면 된다.
  • 만약 레거시 코드를 유지보수해야 한다면 wait는 항상 표준 관용구에 따라 while 문 안에서 호출하도록 하자.
  • 추가로 일반적으로 notify보다는 notifyAll을 사용해야 한다.
  • 만약 notify를 사용한다면 응답 불가 상태에 빠지지 않도록 주의하자.
@ruthetum ruthetum added the 11장 동시성 label Jun 26, 2022
@ruthetum ruthetum self-assigned this Jun 26, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
11장 동시성
Projects
None yet
Development

No branches or pull requests

1 participant