Skip to content

Latest commit

 

History

History
100 lines (85 loc) · 7.25 KB

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

File metadata and controls

100 lines (85 loc) · 7.25 KB

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

자바는 동시성 프로그래밍을 위해 다양한 기능을 지원한다.
wait/notify, fork-join 패키지, parallel 메서드 등을 지원하며 자바 사용자에게 편의를 제공했다.
하지만, 다양한 기능을 제공해준다고 해서, 올바르고 빠른 병렬화를 구현한다는게 쉽다는 것은 아니다.

  1. safety - 안정성
  2. liveness - 응답 가능 상태 유지

프로그래머는 동시성 프로그래밍을 위해 위 두가지 요소를 충족시키기 위해 노력 해야만 한다.
병렬 스트림 파이프라인 프로그래밍에서 liveness를 충족시키지 못한 사례를 살펴보자.

1. 스트림 파이프라인 병렬화는 신중해라

public static void main(String[] args) {
  primes().parallel()
    .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);
}

메르센 수란 2^n - 1 형태의 수를 가리킨다. (프로그래머들에겐 친숙한) 그리고 메르센 소수란 메르센 수 중에서 소수인 것들을 가리키며, 메르센 수 2^n - 1이 소수이면 n도 소수이다.

그리고 위의 코드는 메르센 소수를 생성하는 프로그램이다.
얼핏 보면 순진무구한 평범한 프로그램 같지만, 실제로는 지옥의 무한 루프를 돌아버린다.

그 이유는 스트림 라이브러리가 위 코드에서 파이프라인 병렬화 방법을 알아내지 못했기 때문이다.
중간 연산으로 limit를 쓰거나, 데이터 소스가 Stream.iterate라면, 환경이 암만 좋아도 병렬화를 통한 성능 개선이 불가능하다.
그리고 위 코드는 두 문제를 모두 지니고 있다.
또한 병렬화를 진행하면서 오히려 성능이 나빠지는 경우에도 해당한다.
파이프라인 병렬화에서 limit를 다룰 때, 남는 CPU코어가 있다면 괜히 놀리지 않고, 남은 코어 갯수만큼 일을 시킨 다음 limit에 맞게 결과를 버리는 방법을 택한다.
이때 문제가 발생하는데, 기본적으로 n+1번째 메르센 소수를 찾을 때 걸리는 시간은 n번째를 찾는 시간의 2배이다.
만약 77번째 소수를 막 찾았는데 CPU 코어는 4개나 남은 상황을 생각해보자.
그럼 병렬로 78, 79, 80, 81번째 소수를 찾는 여정이 시작되는데...
기본적으로 2배씩 늘어나니, 2배 4배 8배 16배의 시간이 걸리게 되는 것이다.
그런데 시간이 2배씩 늘어나는 상황이므로, 가장 처음 소수를 찾는 시간이 k였다고 가정하면 77번째 소수를 찾기까지의 모든 경과 시간의 합은 k*(2^77 - 1) 시간일 것이다.
그런데 78번째를 구하는 시간은 k*(2^77)이므로, 사실상 이제까지 걸린 시간을 다 합쳐도 겨우 다음 숫자를 찾는 시간 만큼 걸리는 상황에서 4회차 이후의 소수를 구하고 앉아 있으니.. 엄청난 시간 소요는 당연하다.

결국 교훈은 하나다 스트림 파이프라인을 병렬화 할 때는 신중해라. 끔찍한 성능 악화를 불러올 수도 있다.

2. 병렬화를 적용하는 것이 효과적인 경우들

자료구조 기준

그렇다면 어떨 때 병렬화를 적용하는 것이 효과적일까?
나누기 쉽고, 참조 지역성이 높은 경우에 병렬화가 효과적이다.
그런 나누기 쉽고 참조 지역성이 높은 스트림의 소스로는 ArrayList, HashSet, HashMap, ConcurrentHashMap 등이 있다.
그 외에도 소스가 배열이거나, int 혹은 long 범위를 가졌을 때 병렬화의 효과가 가장 좋다.


  1. 나누기 쉽다는 것.
    나누기 쉽다는 것은 정확하고 쉽게 데이터를 원하는 크기로 나눌 수 있다는 것을 의미한다.
    나누기 쉬운 경우, 여러 스레드들에 일을 분배해주기가 좋다.
    나누는 작업은 Stream이나 Iterable의 spliterator 메서드 등을 통해 얻어올 수 있다.
  2. 참조 지역성이 높다는 것.
    참조 지역성은 배열과 같은 자료구조를 생각하면 쉽게 이해할 수 있는데, 배열은 자료가 일정한 크기로 연속적으로 배치되어 있기 때문에 데이터의 위치를 특정하기 쉬워 가져오는 것이 쉽다.
    배열과 같은 경우를 참조 지역성이 높다고 말한다.

연산 수 기준

병렬화에도 추가 비용이 든다.
때문에 병렬화로 인한 이득이 추가 비용보보다 그리 크지 않다면, 병렬화를 하나 마나일 수 있다. 병렬화를 적용하는 것이 효과적인 경우를 사이즈를 기준으로 살펴보자면,
연산 틱수가 수십만 이상인 경우 부터 적합하다고 한다.
그러니까 스트림 원소 수와 원소당 수행되는 연산 수를 곱하는 등의 방법으로 연산량을 추정해서 최소 수십만은 되어야 성능에 향상에 유의미한 효과가 있다고 한다.

3. 더 나은 병렬화를 위한 조언들

종단 연산 메서드

스트림 파이프라인의 종단 연산은 파이프라인 전체 작업에서 상당한 비중을 차지하기 때문에, 종단 연산에 따라 효율이 다르다.
병렬화에 적합한 연산으로는 '축소'가 있는데, 파이프라인에서 만들어진 원소들을 합치는 작업을 의미한다.
예를 들어, reduce류, min, max, count, sum등이 있다.
조건에 맞으면 바로 결과를 반환한는 메서드도 병렬화에 적합하다.
예를 들어 anyMatch, allMatch, noneMaych와 같은 메서드들이 있다.
병렬화에 적합하지 않은 메서드 또한 존재하는데, 컬렉션들을 합치는 collect와 같은 메서드들이다.

safty 실패에 유의

병렬화를 통해 연산을 진행했더니.. 아예 결과 자체가 잘못되거나, 예상 못한 동작이 발생할 수 있는데
이를 안전 실패라고 부른다.
Stream 명세에서는 함수 객체에 관한 엄중한 규약을 정의해 놓았는데, 이를 지키지 않는 경우 오작동이 발생할 수 있다.

병렬화의 진짜 목적을 잊지말라.

병렬화의 진정한 목적은 그냥 성능 향상이다.
그것 외엔 관심이 없다.
때문에, 병렬화를 도입하기 전후로 꼭 성능 테스트를 진행해야 한다.
만약에 성능 테스트를 했는데 기존 코드보나 느리다면, 쓸 이유가 전혀 없다.
그러니 병렬화의 목표를 잊지 말고, 항상 테스트 해야 한다.

무작위 수의 병렬화

무작위 수를 병렬화 하려면 ThreadLocalRandom 말고, SplittableRandom 인스턴스를 이용하자.
SplittableRandom은 무작위 스트림을 병렬화 하기 위해 만들어졌으므로 꼭 사용하자.
성능이 코어 것수에 비례하여 증가해버린다.
ThreadLocalRandom은 단일 스레드용이다. 그냥 Random은 병렬화시 끔찍한 성능을 제공하니 주의하다.

Reference

  • Effective Java <조슈아 블로크>