Study/이펙티브 자바 / / 2024. 2. 21. 18:55

[Effective Java] 스트림 병렬화는 주의해서 적용하라

💥 개요

자바에서 동시성 프로그램을 작성하기가 점점 쉬워지고 있지만, 이를 올바르고 빠르게 작성하는 일은 어렵다.

동시성 프로그래밍을 할 때는 안정성(safety)과 응답 가능(liveness) 상태를 유지하기 위해 애써야 하는데, 병렬 스트림 파이프라인 프로그래밍에서도 다를 바 없다.

 

☠️ 스트림을 잘못 사용하는 예

아이템 45에서 다룬 메르센 소수를 생성하는 프로그램을 살펴보자

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

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

이 프로그램을 실행하면 약 12.5초만에 완료된다. 속도를 향상 시키려 파이프라인에 parallel()을 호출하면 어떻게 될까?

안타깝게 아무것도 출력하지 못하고 CPU를 90% 점유하며 무한히 계속된다.(응답불가, liveness failure)

이유는 스트림 라이브러리가 이 파이프라인을 병렬화하는 방법을 찾지 못해서다.

 

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

그리고 파이프라인 병렬화는 limit을 다룰 때 CPU 코어가 남으면 원소를 몇개 처리한 후 제한된 개수 이후의 결과를 버려도 아무런 해가 없다고 가정한다. 하지만 이 코드는 새롭게 메르센 소수를 찾을 때마다 그 전 소수를 찾을때보다 두 배 정도 오래 걸린다.

즉 원소 하나를 계산하는 비용이 그 이전까지 원소를 전부 계산하는 것 만큼 오래 걸린다. 그래서 이 코드는 자동 병렬화 알고리즘이 제 기능을 못하게 마비시킨다. 절대 마구잡이로 병렬화하면 안된다. 성능이 오히려 끔찍하게 나빠진다.

 

📎 최대 효과

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

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

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

또한 참조 지역성이 뛰어나다.(메모리에 연속적으로 저장되어있음) 참조 지역성이 낮으면, 스레드는 주 메모리에서 캐시 메모리로 전송되어 오기를 기다리며 대기한다.

기본 타입 배열에서는 데이터 자체가 메모리에 연속해서 저장된다.

또 종단 연산 동작 방식 역시 병렬 수행 효율에 도움을 준다. 종단 연산 병렬화 중 축소를 통해(Stream의 reduce 메소드 중 하나, 혹은 min, max, count, sum) 파이프라인에서 만들어진 모든 원소를 합쳐 효율을 높인다.

그리고 바로 반환되는 anyMatch, allMatch, noneMatch도 병렬화에 적합하다. 하지만, 가변 축소(Stream의 collect)는 합칠때 부담이 있어 적합하지 않다.

🔍 스트림의 병렬화는 쉽게 작성하기 힘들다.

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

그리고 결과가 잘못되거나 오동작하는(안전 실패, Safety failure)가 발생하기도 쉽다. 만약 mappers, filters 와 같은 병렬화 파이프라인이 제공하는 api부터 개발자가 직접 정의한 명세를 지키지 않으면 참혹한 실패로 이어진다.

따라서 앞서 병렬화한 메르센 소수 프로그램은 설혹 완료해도 출력된 소수의 순서가 올바르지 않거나, 이를 모두 만족하여 잘 동작한다고 하여도 기존 프로그램에 비해 효율적으로 개선되기는 어렵다.

데이터 소스 스트림이 효율적으로 나눠지고, 병렬화하거나 빨리 끝나는 종단 연산을 사용하고, 함수 객체들도 간섭하지 않더라도 파이프라인이 수행하는 진짜 작업이 병렬화에 드는 추가 비용을 상쇄하지 못하는 경우가 많다.

이를 확인하는 방법은 스트림 안의 원소수 * 원소당 실행하는 코드 줄 수가 최소 수십만은 되어야 성능 향상을 맛볼 수 있다.

병렬화를 지양하는건 아니다, 조건이 잘 갖춰지면 parallel 메서드 호출 하나로 거의 프로세서 코어 수에 비례하는 성능 향상이 일어난다.

🎈 병렬화에 적합한 예시

public class ParallelPrimeCounting {
    // Prime-counting stream pipeline - parallel version (Page 225)
    static long pi(long n) {
        return LongStream.rangeClosed(2, n)
                //.parallel()
                .mapToObj(BigInteger::valueOf)
                .filter(i -> i.isProbablePrime(50))
                .count();
    }

    public static void main(String[] args) {
        System.out.println(pi(10_000_000));
    }
}

n보다 작거나 같은 소수의 개수를 계산하는 함수다.

이 코드는 병렬화에 적합하다. 위에서 말한 모든 조건을 갖추고 있다.

이 코드의 성능은 10^^8을 계산하는데 31초가 걸린다. 하지만 //.parallel()의 주석을 해제한다면 9.2초로 단축된다.

쿼드코어 기준으로 병렬화 덕에 3.37배가 빨라졌다.

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

SplittableRandom은 병렬화하면 성능이 선형으로 증가한다. 한편 전자의 경우 단일 스레드에서 쓰고자 만들어져서 SplittableRandom만큼 빠르지는 않다.

마지막으로 그냥 Rnadom은 모든 연산을 동기화하기 때문에 병렬 처리시 최악의 성능을 보일 것이다.

 

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

  • 네이버 블로그 공유
  • 네이버 밴드 공유
  • 페이스북 공유
  • 카카오스토리 공유