Skip to content

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

@KKambi

Description

@KKambi

결론

synchronized 블록에서 사용되던 동시성 제어 메소드

  • wait : 갖고 있던 락을 해제하고, 대기한다.
  • notify : 임의의 스레드를 깨운다.

이거 말고 고수준 동시성 유틸리티를 사용하자!


고수준 유틸리티

java.util.concurrent의 고수준 유틸리티는 세 종류


동시성 컬렉션이란?

List, Queue, Map과 같은 표준 컬렉션 인터페이스에 동시성을 더한 컬렉션

  • 각자 내부에서 동기화 수행
  • 동시성을 무력화할 수 없음 = 외부에서 추가 락을 사용하면 속도만 ↓

각자 동기화 수행 -> 여러 기본 메소드를 하나의 원자적 동작으로 묶을 수 없음

  • 이를 위해 상태 의존적 수정 메소드 지원
  • ex) Map의 putIfAbsent(key, value)
    1. 주어진 키에 매핑된 값이 아직 없을 때만' 값을 넣는다.
    2. 기존 값이 있으면 기존 값을, 없으면 null 반환

ConcurrentHashMap

Collections.synchronizedMap보다 ConcurrentHashMap의 성능이 더욱 좋다.

  • Hash를 사용한다.
  • read는 non-blocking
  • write는 특정 세그먼트나 버킷에 락을 걸어 효율적

작업 완료를 기다리는 컬렉션

BlockingQueue

  • take 메소드는 큐의 첫번째 원소를 꺼낸다. 만약 큐가 비어있다면 새로운 원소가 추가될 때까지 기다린다.
  • Producer & Consumer 큐로 사용하기 좋다.
  • ThreadPoolExecutor를 포함한 대부분의 실행자 서비스가 BlockingQueue를 사용

동기화 장치란?

스레드가 다른 스레드를 기다릴 수 있게 해서, 서로의 작업을 조율할 수 있게 한다.

  • 자주 사용 : CountDownLatch / Semaphore
  • 덜 사용 : CyclicBarrier / Exchanger
  • 가장 강력 : Phaser

CountDownLatch (latch; 걸쇠)

일회성 장벽으로, 하나 이상의 스레드가 또 다른 하나 이상의 스레드 작업이 끝날 때까지 대기하도록 한다.

  • 생성자 파라미터의 int 값은 latch의 countDown 메소드를 몇 번 호출해야 대기 중인 스레드들을 깨우는지 결정
public class CountDownLatch {
    public CountDownLatch(int count) {
        if (count < 0) throw new IllegalArgumentException("count < 0");
        this.sync = new Sync(count);
    }
}

CountDownLatch를 사용한 시간 체크

동일한 작업을 여러 실행자가 동시에 시작해 모두 완료하는 시작을 체크하는 메소드

  • wait와 notify만으로 구현하려면 매우 어렵다.
  • 파라미터: 동작을 실행할 실행자(Executor) / 동시에 몇 개의 작업을 수행할지 동시성 수준(int) / 수행할 동작(Runnable)
  • 순서
    1. 타이머 스레드가 시계를 시작하기 전에, 모든 작업자 스레드는 동작을 수행할 준비를 마친다.
    2. 마지막 작업자 스레드가 준비를 마치면, 타이머 스레드가 시작 신호를 보낸다.
    3. 마지막 작업자 스레드가 동작을 마치자마자 타이머 스레드는 시계를 멈춘다.
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;
}

참고할 점

  1. Executor 객체는 concurrency로 지정한 동시성 수준만큼의 스레드를 생성할 수 있어야 한다.
    그렇지 않으면 latch가 풀리지 않아, 스레드 기아 교착상태(thread starvation deadlock)에 빠진다.
  2. InterruptedException catch 절에서 Thread.currentThread().interrupt()를 사용해야 실행자가 인터럽트를 적절히 처리한다.
    작업자 스레드는 run 메소드에서 빠져나오면서 실행자에게 되살린 interrupt를 알릴 수 있기 때문
  3. 시간 간격을 잴 땐 System.currentTimeMills가 아닌 System.nanoTime을 사용하자.
    더 정밀하며 시스템의 시간 보정에 영향받지 않기 때문
  4. 1초 이하의 정밀한 시간 측정은 jmh 같은 특수 프레임워크를 사용해야 한다.

레거시의 wait & notify를 만났을 때

wait 메소드는 스레드가 어떤 조건이 충족되기를 기다리게 한다.

  • wait는 반드시 그 객체를 잠근 동기화 영역 안에서 호출해야 한다.
  • 반드시 대기 반복문(wait loop) 관용구를 사용하여, 이미 조건이 충족되었을 경우 wait를 건너뛰게 하자. 반복문 밖에서 호출하지 말자.
    • 조건을 검사하지 않는 경우 상황에 따라 대기 상태의 스레드를 깨울 수 없을지도 모른다.
synchronized (obj) {
    while (조건이 충족되지 않았다) {
        obj.wait(); // 락을 놓고, 깨어나면 다시 잡는다. 누군가 notify를 해줘야 다음 동작 수행.
    }

    ... // 조건이 충족됐을 때의 동작을 수행한다.
}

또한 조건이 충족되지 않았을 때 다시 대기함으로써, 안전 실패를 막을 수 있다.
조건을 반복해서 검사하지 않으면, 조건이 만족되지 않아도 스레드가 깨어날 수 있는 상황이 몇 가지 있기 때문이다.

  1. 스레드가 notify를 호출해서 대기중인 스레드가 깨어나는 사이에, 다른 스레드가 락을 얻어 동작을 수행한다.
  2. 조건이 만족되지 않았음에도 다른 스레드가 악의적으로 notify를 호출한다 (공개된 객체를 락으로 사용하는 경우)
  3. 다른 스레드가 조건의 일부만 충족이 되었음에도, notifyAll을 호출한다.
  4. 대기 중인 스레드가 (드물게) notify 없이도 깨어나는 경우가 있다 (허위 각성; spurious wakeup; 1번처럼 시간 간극으로 인한 것)
synchronized (obj) {
    obj.wait(); // 락을 놓고, 깨어나면 다시 잡는다. 누군가 notify를 해줘야 다음 동작 수행.

    ... // 조건이 충족됐을 때의 동작을 수행한다.
}

notify vs notifyAll

  • 일반적으로 notifyAll (조건 확인 반복문이 걸려있어서 OK)
  • 모든 스레드가 같은 조건을 기다리고, 조건이 한 번 충족될 때 단 하나의 스레드만 수행한다면 notify로 최적화
  • 공개된 객체를 락으로 사용하는 경우 위험을 대비하기 위해 notify

참고

  1. String의 intern 메소드는 String pool에서 리터럴 문자열이 이미 존재하는지 체크한다. 존재하면 해당 문자열을 반환하고, 아니면 리터럴을 String pool에 넣고 그 참조를 반환. 책에서는 ConcurrentHashMap으로 이 기능을 구현하고 있음.
    String a = "apple";
    String b = new String("apple");
    String c = b.intern()
    
    System.out.println(a==b); // false
    System.out.println(a==c); // true

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions