본문 바로가기
Language/Effective java

동시성

by y.j 2022. 8. 27.
728x90

스레드보다는 실행자, 태스크, 스트림을 애용하라.

java.util.concurrent패키지는 실행자 프레임워크라고 하는 인터페이스 기반의 유연한 실행 기능을 담고 있다.

ExecutorService exec = Executors.newSingleThreadExecutor();
exec.execute(runnable);
exec.shutdown();

 

실행자 서비스의 주요한 기능

  • 특정 태스크가 완료되기를 기다린다.
  • 태스크 모음 중 아무것 하나(invokeAny 메서드) 혹은 모든 태스크(invoke All 메서드)가 완료되기를 기다린다.
  • 실행자 서비스가 종료하기를 기다린다(awaitTermination 메서드).
  • 완료된 태스크들의 결과를 차례로 받는다(ExecutorCompletionService 이용).
  • 태스크를 특정 시간에 혹은 주기적으로 실행하게 한다(ScheduledThreadPoolExecutor 이용).

 

큐를 둘 이상의 스레드가 처리하게 하고 싶다면 스케드 풀을 생성할 수 있다.

- 가벼운 서버 ( Executors.newCachedThreadPool )

  가용할 쓰레드가 없으면 새로운 스레드를 생성하기 때문에 무거운 서버에는 적합하지 않다.

 

- 무거운 서버 ( Executors.newFixedThreadPool )

  스레드를 완전히 통제할 수 있다.

 

사용자프레임워크의 태스크

  • 작업 큐와 스레드를 직접 만드는 일은 삼가야 한다. 하지만 사용자 프레임워크는 작업 단위와 실행 매커니즘이 분리된다.
  • 작업 단위는 나타내는 핵심 추상 개념이 태스크다. Runnable과 Callable을 사용 할 수 있다. Callable은 임의의 예외를 던질 수 있다.

 

포크-조인 풀

  • ForkJoinTask는 ForkJoinPool이라는 특별한 실행자 서비스가 실행해준다.
  • ForkJoinTask는 그 하위에 작은 태스크로 나뉘어져 ForkJoinPool이 처리한다.
  • 자세한 사항은 자바 병렬 프로그래밍을 공부하자!

 

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

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

 

java.util.concurrent의 세 범주

  • 실행자 프레임워크
  • 동시성 컬렉션 : List,Queue, Map같은 표준 컬렉션 인터페이스에 동시성을 가미해 구현한 고성능 컬렉션
  • 동기화 장치 : 스레드가 다른 스레드를 기다릴 수 있게 하여 서로 작업을 조율할 수 있게 해줌

 

동시성 컬렉션

동시성을 무력화하는 건 불가능하며, 외부에서 락을 추가로 사용하면 오히려 속도가 느려진다. 동시성 컬력션에서 동시성을 무력화하지 못하므로 여러 메서드를 원자적으로 묶어 호출하는 일이 불가능하다. 여러 기본 동작을 하나의 원자적 동작으로 묶는 '상태 의존적 수정' 메서드(putIfAbsent)들이 추가되었다. 

 

putIfAbsent예제

ConcurrentMap은 동시성이 뛰어나며 속도도 무척 빠르다. Collections.synchronizedMap보다는 ConcurrentHashMap을 사용하는게 훨씬 좋다.

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;
}
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;
}

 

작업이 성공적으로 완료될 때까지 기다리도록 확장된 인터페이스

Queue를 확장한 BlockingQueue에 추가된 메서드 중 take는 큐의 첫 원소를 꺼낸다. 큐가 비었다면 새로운 원소가 추가될 때까지 기다린다. 

  • BlockingQueue는 생성자-소비자 큐로 쓰기에 적합하다. 
  • 생성자 큐가 작업을 큐에 추가하고 소비자 스레드가 큐에 있는 작업을 꺼내 처리하는 방식

 

동기화 장치

CountDownLatch와 Semaphore를 사용가 자주 사용된다. CyclicBarrier와 Exchanger는 그보다 덜 쓰인다. 가장 강력한 동기화 장치는 바로 Phaser다.

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;
}
  • ready.countDown을 호출하면 타이머 스레드가 시작 시각을 기록한다.
  • start.countDown을 호출하면 기다리던 작업자 스레드들을 깨운다.
  • 동작을 마치고 done.countDown을 호출한다.
  • 타이머 스레드는 done래치가 열리자마자 깨어나 종료시각을 기록한다.

 

몇 가지 유의할 점

  • 스레드 기아 교착상태를 주의하자.  time메서드에 넘겨진 실행자는 concurrency 매개변수로 지정한 동시성 수준만큼의 스레드를 생성 할 수 있어야 한다. 그렇지 못하면 이 메서드는 결코 끝나지 않을 것이다.
  • Thread.currentThread().interrupy()관용구를 사용해 인터럽트를 되살리고 자신은 run메서드에서 빠져나온다.
  • 시간간격을 잴 때는 System.currentTimeMillis가 아닌 System.nanoTime을 사용한다.  System.nanoTime이 더 정밀하고 실시간 시계의 보정 영향을 받지 않는다.

 

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

레거시코드를 다룰 때 wait과 notify를 다룰 수 밖에 없는 상황이 올 수 있다. 

 

wait

  • wait은 동기화 코드 내부에서 사용해야 한다.
synchronized (obj) {
    while(<조건이 충족되지 않았다.>)
        obj.wait();  // 락을 놓고, 꺠어나면 다시 잡는다.
    
    ...  // 조건이 충족됐을 때의 동작을 수행한다.
}
  • 대기 전 조건 충족할 경우 :  notify를 먼저 호출한 후 대기 상태로 빠지면, 그 스레드를 다시 깨울수 있다고 보장 할 수 없음
  • 대기 후 조건 충족하지 못할 경우 : 다시 대기하게 하는 것을 안전 실패를 막는 조치
  • 대기 후 조건 충족할 경우 :  락이 보호하는 불변식을 깨뜨릴 위험이 있음

 

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

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

 

notify vs notifyAll

  • notifyAll을 사용하는 것이 좋다.
  • 모든 스레드를 깨우기 때문에 항상 정확한 결과를 얻고 깨어난 쓰레드들 중 조건이 중촉되지 않으면 다시 대기한다.
  • wait공격으로부터 안전하다.

 

 

 

728x90

'Language > Effective java' 카테고리의 다른 글

직렬화  (0) 2022.09.03
동시성  (0) 2022.09.02
동시성  (0) 2022.08.24
동시성  (0) 2022.08.22
예외  (0) 2022.08.19

댓글