JAVA/Effective Java

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

꾸준함. 2024. 3. 6. 08:55

자바의 동시성 프로그래밍

주류 언어 중에서 자바는 항상 동시성 프로그래밍 측면에서 선두를 달려왔습니다.

  • 첫 릴리즈 된 1996년부터 쓰레드, 동기화, wait()/notify() 메서드 지원
  • 자바 5 버전부터 동시성 컬렉션인 java.util.concurrent 라이브러리와 Executor 프레임워크 지원
  • 자바 7 버전부터 고성능 병렬 분해(parallel decom-position) 프레임워크인 fork-join 패키지 추가
  • 자바 8 버전부터 parallel 메서드만 한 번 호출하면 파이프라인을 병렬 실행할 수 있는 Stream 지원

 

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

  • 동시성 프로그래밍을 할 때는 안전성응답 가능 상태를 유지하기 위해 애써야 하며
  • 병렬 스트림 파이프라인 프로그래밍에서도 위 원칙을 따라야 함

 

파이프라인 병렬화로 성능 개선을 기대할 수 없는 케이스

아이템 45에서 소개한 메르센 소수 코드로 예를 들겠습니다.

 

 

 

코드 부연 설명

  • 두 코드 모두 스트림을 이용해 처음 20개의 메르센 소수를 출력하는 프로그램
  • mersennePrimes 테스트는 아이템 45 코드
  • parallelMersennePrimes 테스트는 parallel() 메서드를 적용해 스트림 파이프라인 병렬화 적용
    • 예상: 병렬화로 인해 성능 개선
    • 실제 결과: 스트림 라이브러리가 해당 파이프라인을 병렬화 하는 방법을 찾지 못해 무한 루프

 

 

스트림 파이프라인 병렬화로 성능 개선을 할 수 없는 경우는 두 가지인데 parallelMersennePrimes 테스트 코드는 다음 두 가지 조건을 모두 충족하기 때문에 문제가 발생했습니다.

  • 데이터 소스가 Stream.iterate인 경우
  • 중간 연산으로 limit를 사용하는 경우
    • 파이프라인 병렬화는 limit을 다룰 때 CPU 코어가 남는다면 원소를 몇 개 더 처리한 후 제한된 개수 이후의 결과를 버려도 아무런 해가 없다고 가정
    • parallelMersennePrimes 코드의 경우 새롭게 메르센 소수를 찾을 때마다 그전 소수를 찾을 때보다 두 배 정도 오래 걸림
    • 원소 하나를 계산하는 비용이 대략 이전까지의 원소 전부를 계산한 비용을 합친 것만큼 들기 때문에 자동 병렬화 알고리즘이 제 기능을 못하고 마비됨

 

정리하자면 예상과 달리 성능이 나빠질 수 있기 때문에 스트림 파이프라인 병렬화는 제약 조건을 잘 확인하고 적용해야 합니다.

 

병렬화 효과가 극대화되는 케이스

일반적으로 다음의 인스턴스거나 배열, int 범위, long 범위일 때 병렬화가 가장 효과적입니다.

  • ArrayList
  • HashMap
  • HashSet
  • ConcurrentHashMap

 

위 자료구조들은 모두 데이터를 원하는 크기로 정확하고 손쉽게 나눌 수 있어 다수의 쓰레드에 분배하기 용이하며 참조 지역성이 뛰어납니다.

 

1. Spliterator

 

Spliterator는 Java 8에서 소개된 인터페이스로, 스트림을 병렬로 처리하기 위한 역할을 합니다.

"Splitable Iterator"의 줄임말로, 컬렉션의 원소들을 병렬로 처리하기 위한 반복자(iterator)의 확장된 개념입니다.

Spliterator는 주로 스트림 연산에서 사용되며, 컬렉션의 원소들을 여러 부분으로 분할(split)하여 병렬로 처리할 수 있도록 합니다.

이는 병렬 처리 시 데이터를 효율적으로 분할하고 각각의 부분을 병렬로 처리하는 데 도움이 되며 Spliterator 객체는 Stream, Iterable의 spilterator() 메서드를 통해 얻어올 수 있습니다.

 

 

2. 참조 지역성 (locality of reference)

 

참조 지역성이 뛰어나다는 것은 이웃한 원소의 참조들이 메모리에 연속해서 저장되어 있다는 의미입니다.

다르게 말하면, 참조들이 가리키는 실제 객체가 메모리에서 서로 떨어져 있으면 참조 지역성이 나빠진다고 설명할 수 있습니다.

참조 지역성이 낮을 경우, 쓰레드는 데이터가 주 메모리에서 캐시 메모리로 전송될 때까지 대기해야 하는 시간이 증가하므로 병렬화 효과가 미미할 것입니다.

이 때문에 참조 지역성은 다량의 데이터를 처리하는 벌크 연산을 병렬화할 때 아주 중요한 요소로 작동합니다.

 

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

스트림 파이프라인의 종단 연산의 동작 방식 역시 병렬 수행 효율에 영향을 끼칩니다.

 

1. 종단 연산 중 병렬화에 적합한 연산

 

종단 연산 중 병렬화에 적합한 연산은 다음과 같습니다.

  • reduction
  • xxMatch

 

1.1 reduction

  • reduction 연산은 파이프라인에서 생성된 모든 원소를 하나로 합치는 작업을 수행
  • 이는 병렬로 처리될 때 여러 스레드에서 각각의 부분 결과를 생성한 후에 그 결과를 합치는 데에 적합
    • Stream 인터페이스에서는 reduce 메서드를 통해 이러한 작업을 수행
    • 또한, min, max, count, sum과 같이 완성된 형태로 제공되는 메서드들도 이에 해당


 

1.2 xxMatch

  • anyMatch, allMatch, noneMatch처럼 조건에 맞으면 바로 반환되는 메서드 또한 병렬화에 적합


 

2. 종단 연산 중 병렬화에 적합하지 않은 연산

 

자바의 스트림(Stream) API에서 제공하는 collect 메서드를 사용할 때, 가변 축소(mutable reduction)를 수행하는 경우에는 병렬화가 적합하지 않을 수 있습니다.

여기서 가변 축소란, 컬렉션을 변경하면서 요소를 수집하는 작업을 의미합니다.

가변 축소를 수행하는 collect 메서드는 컬렉션들을 합치는 과정에서 동기화(synchronization)의 부담이 큽니다.

병렬로 수행되는 스트림 처리에서는 여러 스레드가 동시에 컬렉션을 변경하려고 할 수 있으며, 이러한 상황에서 동기화 오버헤드가 발생하게 됩니다.

이로 인해 병렬화 효과가 오히려 감소하거나 성능이 저하될 수 있습니다.

 

병렬화를 잘 공부하고 적용하자

스트림을 적절하게 병렬화하지 못하면 성능뿐만 아니라 결과 자체가 부정확하게 나타날 수 있으며, 예상치 못한 동작이 발생할 수 있습니다.

결과가 부정확하거나 오동작하는 것을 안전 실패(safety failure)로 정의하며, 이러한 안전 실패는 병렬화된 파이프라인에서 사용되는 매퍼, 필터 또는 다른 개발자가 제공한 함수 객체가 명시한 동작을 수행하지 않을 때 발생할 수 있습니다.

 

Stream 명세는 이 때 사용되는 함수 객체에 관한 엄중한 규약을 다음과 같이 정의했습니다.

  • Stream의 reduce 연산에 건네지는 accumulator(누적기)와 combiner(결합기) 함수는 반드시 결합 법칙을 만족
  • 파이프라인이 수행되는 동안 데이터소스가 변경되지 않아야 함 (non-interfering)
  • 상태를 가지 않아야 함 (stateless)

 

요구사항을 지키지 못하더라도 순차적으로 실행하면 여전히 올바른 결과를 얻을 수 있지만, 병렬로 수행할 경우 기대한 결과가 나오지 않을 수 있습니다.

따라서 반드시 위 원칙을 지키도록 노력해야 합니다.

 

원칙을 지키지 않아 오동작하는 예시 코드

 

 

스트림 병렬화는 오직 성능 최적화 수단

다른 최적화와 마찬가지로 변경 전후로 반드시 성능 테스트를 진행하여 병렬화를 적용할 가치가 있는지 확인해야 합니다.

보통은 병렬 스트림 파이프라인도 같은 쓰레드 풀인 fork-join 풀에서 수행되므로 잘 못된 파이프라인 하나가 다른 부분의 성능에까지 악영향을 줄 수 있음을 유념해야 합니다.

조건이 적절하게 갖춰진다면 parallel 메서드 호출만으로 거의 프로세서 코어 수에 비례하는 성능 향상을 경험할 수 있지만, 일반적으로 스트림 파이프라인을 병렬화하는 경우는 극히 드물 것입니다.

  • 스트림을 많이 사용하는 수백만 줄짜리 코드를 여러 개 관리하는 개발자가 아닌 이상 거의 없다고 저자는 표현했음

 

스트림 파이프라인 병렬화가 효과적인 예


 

 

코드 부연 설명

parallelPi 메서드의 스트림 파이프라인은 안전한 병렬화를 위해 필요한 규약을 지키고 있습니다.

  • reduce 연산의 결합 법칙
    • count() 메서드는 reduce 연산 중 하나
    • 여기서는 filter 연산을 통해 소수를 찾고, 최종적으로 count 연산을 수행
    • count 연산은 내부적으로 스트림 요소를 누적하지 않고 요소의 개수만을 반환하는 연산이므로, 결합 법칙에 직접적으로 영향을 미치지 않음

 

  • 데이터소스 변경 여부 (non-interfering)
    • 코드에서는 스트림 파이프라인에서 사용되는 데이터소스가 변경되지 않음
    • LongStream.rangeClosed(2, n)을 통해 생성된 스트림은 변경되지 않고 그대로 사용

 

  • 상태를 가지 않음 (stateless)
    • 코드에서는 스트림 파이프라인에서 중간 연산인 filter를 통해 소수를 찾고 최종 연산인 count를 수행
    • 이 중간 및 최종 연산에서 사용되는 함수 객체들은 상태를 가지지 않는 stateless 한 람다식 또는 메서드 참조로 구현

 

SplittableRandom

무작위 수로 이루어진 스트림을 병렬화할 때는 ThreadLocalRandom이나 Random보다는 SplittableRandom 인스턴스를 사용하는 것이 권장됩니다.

SplittableRandom은 병렬화에 특화된 설계로, 병렬 처리 시 성능이 선형으로 향상됩니다.

한편, ThreadLocalRandom은 주로 단일 스레드에서 사용하기 위해 만들어진 것이며, 비록 병렬 스트림용 데이터 소스로도 사용할 수 있지만 SplittableRandom만큼의 빠른 성능을 보여주지는 않을 것입니다.

마지막으로, 일반적인 Random은 모든 연산을 동기화하므로 병렬 처리 시 최악의 성능을 보일 수 있습니다.

 

참고

이펙티브 자바

반응형