Item 48 스트림 병렬화는 주의해서 적용하라

감자고구마·2022년 4월 14일
0

이펙티브자바3rd

목록 보기
17/58

주류 언어 중, 동시성 프로그래밍 측면에서 자바는 항상 앞서갔다.

처음 릴리스된 1996년부터 스레드, 동기화, wait/notify를 지원했다. 자바 5부터는 동시성 컬렉션인

java.util.concurrent 라이브러리와 실행자(Executor) 프레임워크를 지원했다.

자바 7부터는 고성능 병렬 분해(parrel decom-position)프레임워크인 포크-조인(fork-join)패키지를 추가했다.

그리고 자바 8부터는 parallel 메서드만 한 번 호출하면 파이프라인을 병렬 실행할 수 있는 스트림을 지원했다.

이처럼 자바로 동시성 프로그램을 작성하기가 점점 쉬워지고는 있지만, 이를 올바르고 빠르게 작성하는 일은 여전히 어려운 작업이다.

동시성 프로그래밍을 할 때는 안전성(safety)과 응답 가능(liveness) 상태를 유지하기 위해 애써야 하는데,

병렬 스트림 파이프라인 프로그래밍에서도 다를 바 없다.

코드 48-1 스트림을 사용해 처음 20개의 메르센 소수를 생성하는 프로그램

public static void main(String[] args) {
    primes().map(p -> TWO.pow(p.intValueExact()).subtract(ONE))
        .filter(mersenne -> mersenne.isProbablePrime(50))
        .limit(20)
        .forEach(System.out::println);
}

static Stream<BigInteger> primes() {
    return Stream.iterate(TWO, BigInteger::nextProbablePrime);
}

이 프로그램을 (저자의 컴퓨터에서 )실행하면 12.5초만에 완료됨. 속도를 높이고 싶어 스트림 파이프라인의 parallel()을 호출한다면?

안타깝게도 이 프로그램은 아무것도 출력하지 못하면서 CPU는 90%나 잡아먹는 상태가 무한히 계속된다.(응답 불가:liveness failure)

종국에 완료될지도 모르겠으나, 1시간 반이 지나 강제 종료할때 까지 아무 결과도 출력하지 않는다.


원인은 스트림 라이브러리가 이 파이프라인을 병렬화하는 방법을 찾아내지 못했기 때문이다.

환경이 아무리 좋더라도 데이터 소스가 Stream.iterate거나 중간 연산으로 limit를 쓰면 파이프라인 병렬화로는 성능 개선을 기대할 수 없다.

  • 대체로 스트림의 소스가 ArrayList, HashMap, HashSet, ConcurrentHashMap의 인스턴스거나 배열, int 범위, long 범위 일 때 병렬화의 효가가 가장 좋다.

  • 이 자료구조들은 모두 데이터를 원하는 크기로 정확하고 손쉽게 나눌 수 있어서 일을 다수의 스레드에 분배하기에 좋다는 특징이 있다.

  • 나누는 작업은 Spliterator가 담당하며, Spliterator 객체는 Stream이나 Iterable의 spliterator 메서드로 얻어올 수 있다.

  • 이 자료구조들의 또 다른 중요한 공통점은 원소들을 순차적으로 실행할 때의 참조 지역성(locality of reference)이 뛰어나다는 것이다.

  • 이웃한 원솓의 참조들이 메모리에 연속해서 저장되어 있다는 듯이다. 하지만 참조들이 가리키는 실제 객체가 메모리에서 서로 떨어져 있을 수 있는데, 그러면 참조 지역성이 나빠진다.

  • 참조 지역성이 낮으면 스레드는 데이터가 주 메모리에서 캐시 메모리로 전송되어 오기를 기다리면 대부분 시간을 멍하니 보내게 된다.

  • 따라서, 참조 지역성은 다량의 데이터를 처리하는 벌크 연산을 병렬화할 때 아주 중요한 요소로 작용한다.

  • 참조 지역성이 가장 뛰어난 자료구조는 기본 타입의 배열이다. 기본 타입 배열에서는 (참조가 아닌) 데이터 자체가 메모리에 연속해서 저장되기 때문이다.


스트림 파이프라인의 종단 연산의 동작 방식 역시 병렬 수행 효율에 영향을 준다.

종단 연산에서 수행하는 작업량이 파이프라인 전체 작업에서 상당 비중을 차지하면서 순차적인 연산이라면 파이프라인 병렬 수행의 효과는 제한 될 수밖에 없다.

종단 연산 중 병렬화에 가장 적합한 것은 축소(reduction)다.

축소는 파이프라인에서 만들어진 모든 원소를 하나로 합치는 작업으로, Stream의 reduce 메서드 중 한, 혹은 min, max, count, sum 같이 완성된 형태로 제공되는 메서드 중 하나를 선택해 수행한다.

anyMatch, allMatch, noneMatch처럼 조건에 맞으면 바로 반환되는 메서드도 병렬화에 적합하다.

반면, 가변 축소(mutable reduction)를 수행하는 Stream의 collect 메서드는 병렬화에 적합하지 않다. 컬렉션등를 합치는 부담이 크기 때문이다.

Stream의 collect 메서드는 병렬화에 적합하지 않다. 컬렉션들을 합치는 부담이 크기 때문이다.


직접 구현한 Stream, Iterable, Collection의 병렬화의 이점을 제대로 누리게 하고 싶다면 spliterator 메서드를 반드시 재정의하고

결과 스트림의 병렬화 성능을 강도 높게 테스트하라. 고효율 spliterator를 작성하기란 상당한 난이도의 일.

  • 스트림을 잘못 병렬화하면 (응답 불가를 포함해) 성능이 나빠질 뿐만 아니라 결과 자체가 잘못되거나 예상 못한 동작이 발생할 수 있다.

핵심 정리

  • 계산도 올바로 수행하고 성능도 빨라질 거라는 확신 없이는 스트림 파이프라인 병렬화는 시도조차 하지 말라
  • 스트림을 잘못 병렬화하면 프로그램을 오동작하게 하거나 성능을 급격히 떨어뜨린다.
  • 병렬화하는 편이 낫다고 믿더라도, 수정 후의 코드가 여전히 정확한지 확인하고 운영 환경과 유사한 조건에 수행해보며 성능지표를 유심히 관찰하라.
  • 계산도 정확하고 성능도 좋아졌음이 확실해졌을 때, 오직 그럴 때만 병렬화 버전 코드를 운영 코드에 반영하라.
profile
파릇파릇한개발자

0개의 댓글

관련 채용 정보