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

Effective Java 3e 아이템 48를 요약한 내용 입니다.

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

  • 1996년 → 스레드, 동기화, wait/notify 지원

  • 자바 5 → 동시성 컬렉션인 java.util.concurrent 라이브러리와 실행자(Executor) 프레임워크 지원

  • 자바 7 → 고성능 병렬 분해 프레임워크인 포크-조인(fork-join) 패키지 추가

  • 자바 8 → parallel 메서드만 한 번 호출하면 파이프라인을 병렬 실행할 수 있는 스트림 지원

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

아이템 45에서 다루었던 메르센 소수를 생성하는 프로그램을 다시 살펴보자. (메르센 수 M(n)은 2n -1 형태인 숫자를 말한다. 메르센 소수는 메르센 수 중 소수인 것들을 가리킨다.)

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

안타깝게도 이 프로그램은 아무것도 출력하지 못하면서 CPU90%나 잡아먹는 상태가 무한히 계속된다.

무슨 일이 벌어진 걸까?

프로그램이 이렇게 느려진 원인은 사실 어이없게도 스트림 라이브러리가 이 파이프라인병렬화하는 방법을 찾아내지 못했기 때문이다. 환경이 아무리 좋더라도 데이터 소스가 Stream.iterate거나 중간 연산으로 limit를 쓰면 파이프라인 병렬화로는 성능 개선을 기대할 수 없다.

소 하나를 계산하는 비용이 대략 그 이전까지의 원소 전부를 계산한 비용을 합친 것만큼 든다는 뜻이다. 스트림 파이프라인을 마구잡이로 병렬화하면 안 된다. 성능이 오히려 끔찍하게 나빠질 수도 있다.

대체로 스트림의 소스가 ArrayList, HashMap, HashSet, ConcurrentHashMap의 인스턴스거나 배열, int 범위, long 범위일 때 병렬화의 효과가 가장 좋다. 이 자료구조들은 모두 데이터를 원하는 크기로 정확하고 손쉽게 나눌 수 있어서 일을 다수의 스레드에 분배하기에 좋다는 특징이 있다. 나누는 작업은 Spliterator가 담당하며, Spliterator 객체는 Stream이나 Iterablespliterator 메서드로 얻어올 수 있다.

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

참조 지역성이란? 한번 참조한 데이터는 다시 참조될 가능성이 높고 참조된 데이터 주변의 데이터 역시 같이 참조될 가능성이 높은 성질이다.

하지만 참조들이 가리키는 실제 객체가 메모리에서 서로 떨어져 있을 수 있는데, 그러면 참조 지역성이 나빠진다. 참조 지역성이 낮으면 스레드는 데이터가 주 메모리에서 캐시 메모리로 전송되어 오기를 기다리며 대부분 시간을 멍하니 보내게 된다.

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

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

(min, max, count, sum), anyMatch, allMatch, noneMatch처럼 조건에 맞으면 바로 반환되는 메서드도 병렬화에 적합하다. 반면, 가변 축소를 수행하는 Stream의 collect 메서드는 병렬화에 적합하지 않다.

가변 축소 메서드란? 결과값을 가공하여 새로운 리스트로 추출하는 메서드이다.

List<String> list = Arrays.asList("Mike", "Nicki", "John");
String s = list.stream().collect(StringBuilder::new,
            (sb, s1) -> sb.append(" ").append(s1),
            (sb1, sb2) -> sb1.append(sb2.toString())).toString();
System.out.println(s);

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

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

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

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

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

스트림 병렬화는 오직 성능 최적화 수단임을 기억해야 한다. 다른 최적화와 마찬가지로 변경 전후로 반드시 성능을 테스트하여 병렬화를 사용할 가치가 있는지 확인해야 한다. (아이템 67)

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

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

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

정리

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

참조자료

http://www.nextree.co.kr/p6506/

Last updated