맞는데 왜 틀릴까..?

Java/Effective Java

[Effective Java] Item 48. 스트림 병렬화는 주의해서 사용하라

안도일 2023. 7. 9. 19:10

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

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

 

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

 

 

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

 

 

  • 해당 프로그램의 속도를 높이고 싶어 스트림 파이프라인의 parallel()을 호출하면 CPU를 90%나 잡아먹고 아무것도 출력하지 못하는 상태가 무한히 계속된다.
  • 스트림 라이브러리가 이 파이프라인을 병렬화하는 방법을 찾아내지 못했기 때문이다.

 

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

 

즉,  성능이 오히려 끔찍하게 나빠질 수 있으므로, 스트림 파이프라인을 마구잡이로 병렬화하면 안 된다.

 


이에 반해 병렬화 하기 좋은 3가지 조건이 있다.

 

 

1. 병렬화 하기 좋은 자료구조

 

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

 

  1. 해당 자료구조들은 모두 데이터를 원하는 크기로 정확하고 손쉽게 나눌 수 있어서 일을 다수의 스레드에 분배하기에 좋다. 나누는 작업은 Spliterator가 담당한다.
  2. 해당 자료구조들은 원소들을 순차적으로 실행할 때의 참조 지역성이 뛰어나다. 이웃한 원소의 참조들이 메모리에 연속해서 저장되어 있다는 뜻이다.

 

 

참조 지역성

참조 지역성이 낮으면 스레드는 데이터가 주 메모리에서 캐시 메모리로 전송되어 오기를 기다리면서 시간을 보낸다. 따라서 참조 지역성은 다량의 데이터를 처리하는 벌크 연산을 병렬화할 때 아주 중요한 요소로 작용한다.
기본 타입의 배열이 참조 지역성이 가장 뛰어나다.

 

 

2. 스트림 파이프라인의 종단 연산

 

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

 

 

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

 

  • 축소는 파이프라인에서 만들어진 모든 원소를 하나로 합치는 작업으로, Stream의 reduce 메서드 중 하나, 혹은 min, max, count, sum 같이 완성된 형태로 제공되는 메서드 중 하나를 선택해 수행한다.
  • anyMatch, allMatch, noneMatch처럼 조건에 맞으면 바로 반환되는 메서드도 병렬화에 적합하다.

 

 

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

 

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

 

 

3. 함수 객체 간섭

 

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

결과가 잘못되거나 오동작하는 것은 안전 실패(safety failure)라 한다.

안전 실패는 병렬화한 파이프 라인이 사용하는 mappers, filters, 혹은 프로그래머가 제공한 다른 함수 객체가 명세대로 동작하지 않을 때 벌어질 수 있다.

 

Stream 명세는 이때 사용되는 함수 객체에  관한 엄중한 규약을 정의해 놨다.

Ex) Stream의 reduce 연산에 건네지는 accumulator와 combiner 함수는 반드시 결합법칙을 만족하고 간섭받지 않고, 상태를 갖지 않아야 한다.

이 요구를 지키지 못했을 때 병렬로 수행하면 실패로 이어진다.

 

 


 

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

스트림 안의 원소 수와 원소당 수행되는 코드 줄 수를 곱한 값이 최소 수십만은 되어야 성능 향상을 맛볼 수 있다.

 

  • 스트림 병렬화는 오직 성능 최적화 수단임으로, 다른 최적화와 마찬가지로 변경 전후로 반드시 성능을 테스트하여 병렬화를 사용할 가치가 있는지 확인해야 한다.
  • 스트림 파이프라인을 병렬화 할 일이 적긴 하지만 조건이 잘 갖춰지면 parallel 메서드 호출 하나로 거의 프로세서 코어 수에 비례하는 성능 향상을 만끽할 수 있다.

 

 

  • n보다 작거나 같은 소수의 개수를 계산하는 함수를 병렬화 버전으로 작성한 프로그램이다.
  • 책에서 저자의 컴퓨터로 실행했을 때 31초가 걸렸던 프로그램이 병렬화 하였을 때 9.2초로 단축되었다고 한다.

 

 

무작위 수들로 이뤄진 스트림을 병렬화 하려거든 ThreadLocalRandm 또는 Random보다는 이에 특화된 SplittableRandom 인스턴스를 이용하자.

 

 

 

결론

 

  • 계산도 올바로 수행하고 성능도 빨리질 거라는 확신 없이는 스트림 파이프라인 병렬화는 시도조차 하지 말자.
  • 스트림을 잘못 병렬화하면 프로그램을 오동작하게 하거나 성능을 급격히 떨어뜨린다.
  • 병렬화하는 편이 낫다고 믿더라도, 수정 후의 코드가 여전히 정확한지 확인하고 성능지표를 관찰하자.