DEV ℧ Developer Diary

[EffectiveJava] item48 - 스트림 병렬화는 주의해서 적용하라

주류 언어 중, 동시성 프로그래밍측면에서는 자바는 항상 앞서갔다.
java.util.concurrent, 실행자(Executor), 스트림 API의 parallel 메서드와 같이 여러 개선사항과 함께, 자바로 동시성 프로그램을 작성하기가 점점 쉬워지고는 있지만, 이를 올바르고 빠르게 작성하는 일은 여전히 어려운 작업이다.

동시성 프로그래밍을 할 때는 안정성(safety)와 응답 가능(liveness) 상태를 유지하기 위해 애써야 하기 때문이다.

스트림 병렬화

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

위의 코드를 실행시키면 즉각 소수를 찍기 시작해서 약 6초 정도의 시간이 소요되었다.

3
7
31
127
8191
...
소요시간(ms) : 6080

만약 속도를 높이고 싶어 스트림 파이프라인의 parallel()을 호출한다면 어떻게 될까?

프로그램은 아무것도 출력하지 못하면서 CPU는 90%나 잡아먹는 상태가 무한히 계속된다.

병렬화의 주의할 점

프로그램이 오작동한 이유는, 이 파이프라인을 병렬화 하는 방법을 찾아내지 못했기 때문이다.

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

병렬화의 효율이 좋은 자료구조

아래의 자료구조들이 병렬화의 효과가 가장 좋다.

  • ArrayList
  • HashMap
  • HashSet
  • ConcurrentHashMap
  • 배열
  • int 범위
  • long 범위

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

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

참조지역성

위의 자료구조들들의 또 다른 중요한 공통점은 원소들을 순차적으로 실핼할 때의 참조 지역성(locality of reference)이 뛰어나다는 것이다. 이웃한 원소의 참조들이 메모리에 연속해서 저장되어있다는 뜻이다.

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

병렬 수행 효율에 영향을 주는 스트림 종단연산

스트림 파이프라인의 종단 연산의 동작방식 역시 종단방식에서 수행하는 작업량이 전체 작업에서 상당 비중을 차지하다보니 병렬수행 효율에 영향을 준다.

병렬수행에 적합한 종단연산들은 아래와 같다.

축소(reduction) 작업

  • reduce
  • min
  • max
  • count
  • sum

조건 판별

  • anyMatch
  • allMatch
  • noneMatch

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

spliterator

직접 구현한 Stream, Iterable, Collection이 병렬화의 이점을 제대로 누리게 하고 싶다면 spliterator 메서드를 반드시 재정의하고 경과 스트림의 병렬화 성능을 강도 높게 테스트 해야한다.

스트림의 병렬화의 명세

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

Stream 명세는 이때 사용되는 함수 객체에 관한 엄중한 규약을 정의해 두었는데,
예를 들면 Stream의 reduce 연산에 건네지는 accumulator(누적기)와 combiner(결합기) 함수는 반드시 결합법칙을 만족하고(associative), 간섭받지 않고(non-interfering), 상태를 갖지 않아야(stateless) 만족한다.

non-interfering : 연산자 혹은 함수 op가 다음 관계를 성립할 때 결합법칙을 만족한다고 한다. (a op b) op c == a op (b op c)
associative : 파이프라인이 수행되는 동안 데이터 소스가 변경되지 않아야 한다.

스트림 병렬화의 순서 보장

출력 순서를 순차 버전처럼 정렬하고 싶다면 종단연산 forEach를 forEachOrdered로 바꿔주면 된다.

스트림 병렬화의 기준

실제로 성능이 향상될지를 추정해보는 간단한 방법은 스트림 안의 원소 수와 원소당 수행되는 코드 줄 수 를 곱할 시, 최소 수십만은 되어야 성능 향상을 맛볼 수 있다.

만약 조건이 잘 갖춰진다면 parallel 메서드 호출 하나로 거의 프로세서 코어 수에 비례하는 성능 향상을 만끽할 수 있다.

아래 간단한 예시를 살펴보자.

소수 계산 스트림 파이프라인 - 병렬화에 적합

static long pi(long n) {
    return LongStream.rangeClosed(2, n)
        .mapToObj(BigInteger::valueOf)
        .filter(i -> i.isProbablePrime(50))
        .count();
}

예시의 코드를 pi(10⁸)로 호출해보니 컴퓨터에서는 약 소요시간(ms) : 196142의 시간이 소요 되었다.

소수 계산 스트림 파이프라인 - 병렬화 버전

static long pi(long n) {
    return LongStream.rangeClosed(2, n)
        .mapToObj(BigInteger::valueOf)
        .filter(i -> i.isProbablePrime(50))
        .count();
}

여기에 parallel() 호출을 추가한 뒤 다시 호출 해보니 약 소요시간(ms) : 65681의 시간으로 단축 되었다.

무작의 수들의 스트림 병렬화

위와 같은 예시의 무작의 수들로 이뤄진 스트림을 병렬화 하려거든 ThreadLocalRandom(혹은 Random)보다는 SplittableRandom 인스턴스를 이용하자.

SplittableRandom는 동시성 프로그램에 대한 설계가 되어있지만, ThreadLocalRandom는 단일 스레드에서 쓰고자 만들어졌기 때문에 스트림 병렬화에 어울리지 않다.