멀티 스레딩과 병렬 처리는 성능 최적화와 좌원 사용에 있어서 중요한 요소입니다.
이를 효율적으로 관리하기 위해서 자바에서는 Thread Pool, ForkJoinPool, ExecutorService, parallelStream과 같은 다양한 병렬 처리 기법을 제공합니다.
🚩 이번 글에서는 각각의 개념과 내부 동작 방식, 그리고 상황에 따라 어떤 방식을 선택할지에 대해서 공부해보겠습니다.
멀티스레딩 vs. 병렬 처리
멀티 스레딩은 동시성을 구현하는 방식 중 하나로, 하나의 CPU에서 여러 작업을 번갈아가며 실행합니다. 속도가 빨라 여러 스레드가 동시에 실행되는 것처럼 보이지만, 실제로는 운영체제가 컨텍스트 스위칭을 통해 교대로 실행합니다.
병렬 처리는 여러 CPU 코어에서 여러 작업이 동시에 실행되는 방식입니다. 하드웨어 자원을 최대로 이용하여 작업의 속도를 최적화하며, 멀티코어 CPU, 분산 시스템 등 여러 물리적 자원을 활용하여 실행합니다.
스레드의 생성과 소멸
동시성 작업을 처리할 때 , 시스템은 동시 요청 수에 따라 스레드를 생성하여 요청을 처리하고 소멸하는 과정을 거칩니다. 하지만, 동시 요청 수가 많아질수록 매번 이 과정을 반복하는 것은 비효율적입니다.
스레드 생성과 소멸은 메모리와 CPU자원을 소모하며, 요청이 많아지면 스레드 생성 시간이 길어져 응답 속도가 느려질 수 있습니다. 또한, 너무 많은 스레드를 생성할 경우 메모리 부족이나 시스템 과부하로 이어질 수 있습니다.
스레드를 매번 새로 생성하는 대신, 스레드를 재사용할 수 있는 방법이 필요했고, 그래서 Thread Pool이 나오게되었습니다.
Thread Pool
정해진 수의 스레드를 미리 생성하여, 필요할 때 대기열에 있는 작업을 스레드에 할당해서 실행하는 방식입니다. 미리 생성된 스레드를 재사용함으로써 매번 스레드를 생성하고 소멸하는 오버헤드를 줄일 수 있습니다.
자바는 스레드 풀을 생성하고 사용할 수 있도록 java.util.concurrent 패키지에서 ExecutorService 인터페이스와 Executors 클래스를 제공합니다.
Java에서 제공하는 주요 스레드 풀
- newFixedThreadPool : 고정된 스레드 수로 작업 처리
- newCachedThreadPool: 동적으로 스레드 수 관리
- newSingleThreadExecutor: 단일 스레드로 순차 처리
- newScheduledThreadPool: 주기적 작업 실행
ExecutorService fixedThreadPool = Executors.newFixedThreadPool(3); // 3개의 스레드를 갖는 스레드 풀 생성
스레드 풀은 기본적으로 재사용을 위해 스레드를 계속 유지하기 때문에 명시적으로 종료하지 않으면 프로그램이 계속 실행될 수 있습니다.특히 데몬 스레드가 아닌 일반 스레드는, main() 메서드가 종료되어도 프로그램이 종료되지 않습니다. 기본 스레드 풀의 스레드는 일반 스레드로 main 스레드가 종료되면 해당 스레드 풀을 종료시켜 주어야 합니다.
하지만 스레드 풀이 모든 경우에 최적화된 것은 아닙니다. 대규모 병렬 처리나 재귀적 작업에는 ForkJoinPool이 더 효과적일 수 있습니다.
ForkJoinPool
ForkJoinPool은 다른 종류의 ExecutorService와 다르게 work-stealing 방식을 사용합니다.
하나의 큰 작업을 여러 개의 작은 Sub Task로 나누어(Fork) 병렬로 처리한 후, 각 결과를 합쳐(Join) 최종적인 결과를 만드는 방식입니다.
ExecutorService
Executor을 확장한 구조로 ExecutorService는 execute()메서드를 제공하는 Executor과 비동기 작업의 진행을 추적할 수 있는 Future 객체를 생성하는 메서드로 이루어집니다.
작업의 상태 추적 및 종료를 제어할 수 있는 메서드
- void shutdown() : 새로운 작업을 더 받지 않고, 현재 진행 중인 작업이 끝나면 종료
- Future<?> submit() : 작업을 제출하고, 작업의 결과를 Future로 반환하는 메서드
- boolean awaitTermination(long timeout, TimeUnit unit) : shutdown() 호출 후, 작업이 완료될 때까지 기다리는 메서드
- <T> List<Future<T>> invokeAny() : 여러개의 작업을 실행하고, 완료된 후 결과를 반환하는 메서
Executor : 비동기 작업을 실행하는 기능을 제공하는 인터페이스
작업을 새로운 스레드에서 실행하거나, 스레드 풀에 할당합니다.
Executor executor = Executors.newSingleThreadExecutor();
executor.execute(() -> System.out.println("task execute!"));
Future : 비동기 작업의 결과와 상태를 추적할 수 있는 객체
ExecutorService executorService = Executors.newFixedThreadPool(2);
Future<Integer> future = executorService.submit(() -> {
System.out.println("task execute!");
Thread.sleep(1000);
return 42;
});
executorService.shutdown(); // 모든 작업이 완료되면 스레드 풀 종료
void shutdownAndAwaitTermination(ExecutorService pool) {
pool.shutdown(); // 새로운 task 받지말고, 현재 작업이 완료되면 종료
try {
// 60초 대기 : 시간 내에 모든 작업이 종료되면 -> true
if (!pool.awaitTermination(60, TimeUnit.SECONDS)) {
pool.shutdownNow(); // 현재 실행 중인 모든 작업을 즉시 중단하고 취소 -> 중단이 보장되지 않음
// 강제 중단 후, 작업이 완전히 종료되기를 기다림
if (!pool.awaitTermination(60, TimeUnit.SECONDS))
System.err.println("Pool did not terminate");
}
} catch (InterruptedException ie) {
pool.shutdownNow();
// 현재 스레드의 인터럽트 상태를 복원 -> 상위 호출자
Thread.currentThread().interrupt();
}
}
다시 ForkJoinPool로 돌아와서
Work-stealing 방식을 이용하여 병렬 처리를 제공하는 기능입니다. (Java7 도입)
"A ForkJoinPool differs from other kinds of ExecutorService mainly by virtue of employing work-stealing:"
work-stealing
스레드가 자신에게 할당된 작업을 모두 완료하면, 다른 스레드의 작업 큐에 남은 작업을 가져와 병렬로 실행하는 메커니즘입니다.
작업이 고르지 않게 분산되어 있을 때, 빈 스레드가 다른 스레드의 남은 작업을 stealing하여 처리합니다.
일부 스레드가 idle 상태로 남는 것을 방지하여 모든 스레드를 최대한 효율적으로 사용할 수 있도록 합니다. 큰 작업을 작은 하위 작업을 쪼갠 후 각 스레드에 할당하고 유휴 스레드가 남은 작업을 가져가 처리합니다.
Common Pool
자동으로 사용되는 JVM 공통 스레드 풀.
공유되는 공간으로, ForkJoinTask가 명시적으로 특정 풀에 제출되지 않았을 때 사용됩니다. idle 상태일 때 스레드 자원을 자동으로 회수하여 자원의 낭비를 방지하고, 필요할 때 자동으로 스레드를 생성하여 사용합니다.
Custom Pool
사용자가 명시적으로 생성한 ForkJoinPool로, 생성 시 병렬성 수준(parallelism level) 등 을 설정할 수 있습니다. 기본 병렬 수준은 가용 프로세서의 수와 동일하며, 필요한 경우 I/O 바운드 작업, 긴 대기 작업 등에도 맞게 스레드 풀을 커스텀 할 수 있습니다. 작업 스레드를 추가, 중지, 재개할 수 있지만, Blocking I/O나 관리되지 않은 동기화에서는 이러한 조정이 제한될 수 있습니다.
각각 언제 사용될까?
// (1)
System.out.println("(1) ForkJoinPool[parallelism : 5]=====================");
ForkJoinPool pool5 = new ForkJoinPool(5);
pool5.submit(() -> nums.parallelStream()
.forEach(num -> {
System.out.println(Thread.currentThread().getName() + ", num: " + num);
})
).join();
// (2)
System.out.println("(2) ForkJoinPool[parallelism : 5]=====================");
ForkJoinPool pool0 = new ForkJoinPool();
pool0.submit(() -> nums.parallelStream()
.forEach(num -> {
System.out.println(Thread.currentThread().getName() + ", num: " + num);
})
).join();
// (3)
System.out.println("(3)=====================");
nums.parallelStream()
.forEach(num -> {
System.out.println(Thread.currentThread().getName() + ", num: " + num);
});
출력
(1) ForkJoinPool-2 라는 커스텀 풀 이용, 병렬성 5로 설정
- I/O 바운드 작업이 많거나 고정된 스레드 수로 제어하고 싶을 때 유용합니다.
(2) ForkJoinPool-3 커스텀 풀 이용, 기본 병렬성
- 시스템의 CPU 자원에 맞게 자동 최적화된 병렬성을 사용하고 싶을 때 유용합니다.
(3) JVM이 관리하는 공통 풀 이용. 기본 병렬성
- 대부분의 일반적인 병렬 작업
- parallelStream()을 사용할 때, 명시적으로 ForkJoinPool을 생성하지 않으면, ForkJoinPool.commonPool이 사용됩니다.
병렬성 관리
ForkJoinPool은 병렬성 관리를 위해 동적으로 작업 스레드를 최적화를 시도합니다.
작업이 일시적으로 멈추거나 대기 중일 때에도 풀은 Idle 상태의 스레드에 다른 작업을 할당하여 효율성을 유지합니다. 그러나 블로킹 I/O 등의 외부 요인으로 인해 병렬성이 저하될 수 있습니다. 블로킹 I/O는 입출력 작업 시 자원의 응답이 완료될 때까지 스레드가 다른 작업을 수행하지 못하게 됩니다. 그 결과, 다른 서브 작업은 스레드 부족으로 인해 대기하게되고, 병렬성이 저하됩니다.
Java의 ForkJoinPool.ManagedBlocker 인터페이스를 사용하면 ForkJoinPool이 블로킹 상태를 감지하고 동적으로 추가적인 스레드를 생성하여 병렬성을 유지할 수 있습니다.
일반적인 Thread Pool과의 차이
작업 처리 방식
- 일반적인 Thread Pool : 중앙 대기열에서 순차적으로 각 스레드에 할당
- ForkJoinPool : 각 스레드가 자신의 큐를 관리하며, 워크 스틸링을 통해 다른 스레드의 작업 처리 가능
병렬성
- 일반적인 Thread Pool : 사용자가 고정된 스레드 수 사용
- ForkJoinPool : CPU 코어 수에 따라 동적을 최적화 가능, availableProcessors()에 기반하여 자동으로 설정됨
적합한 작업
- 일반적인 Thread Pool : 네트워크 요청, I/O 작업 등
- ForkJoinPool : 대규모 데이터 처리, 병렬 계산, 재귀적 작업
적합한 작업
- 일반적인 Thread Pool : execute(), submit()
- ForkJoinPool : invoke(), fork(), join()
🙋♂️ 일반적인 스레드 풀이 I/O 작업에 적합한 이유
일반적인 Thread Pool(ThreadPoolExecutor)이 I/O 작업에 효율적인 이유는, 스레드가 블로킹 상태에서 대기하는 것이 큰 문제가 되지 않기 때문입니다. I/O 바운드 작업은 특성상 외부 자원(디스크, 네트워크 등)의 응답을 기다리는 시간이 많습니다. 예를 들어, 네트워크에서 응답이 돌아오기까지 스레드는 아무것도 하지 않고 대기하게 됩니다.
일반적인 스레드 풀은 대기 상태가 되는 것을 허용하며, 나머지 스레드가 대기열의 다른 작업을 계속 처리할 수 있도록 설계되었습니다. 예를 들어, 하나의 스레드가 파일 읽기 작업에서 응답을 기다리는 동안, 다른 스레드들은 다른 작업을 계속 처리할 수 있습니다.
"스레드가 잠시 대기하더라도 다른 스레드가 대기열의 작업을 처리할 수 있다."는 전제로 설계되었기 때문에, I/O 작업에 적합합니다.(Blocking 허용)
예) CPU가 한 번에 모든 작업을 끝내는 것보다, 놀지 않고 꾸준히 일을 하는 게 중요해~!
🙋♂️ 반대로 ForkJoinPool이 I/O 작업에 적합하지 않은 이유
ForkJoinPool은 CPU 코어를 최대한 활용하여 작업을 병렬로 빠르게 처리하는 것을 목표로 설계되었습니다.
병렬 처리의 목적은 하드웨어 자원을 최대한 활용하여 작업 속도를 높이는 것입니다. 따라서, 스레드가 중간에 멈추거나, 대기하게 되면 성능이 떨어집니다.
블로킹 I/O는 CPU를 사용하지 않고 외부 자원의 응답을 기다리는 동안, 스레드가 멈춰 있게 하기 때문에, ForkJoinPool의 설계와 맞지 않습니다. (Blocking 비허용)
예) 모든 스레드가 쉬지 않고 일을 해서 성과를 최대로 끌어내야해! 쉬지 말고 계속 일 해!
ParellelStream
데이터 스트림을 병렬로 처리하기 위해 제공되는 기능으로, 내부적으로 Fork/Join 프레임워크를 사용합니다.
컬렉션 인터페이스에서 기본적으로 제공되며, 스트림 내의 작업을 병렬로 처리할 수 있도록 도와줍니다.
Java SE는 병렬 처리를 위해 Fork/Join 프레임워크를 제공합니다. Parellel이 내부적으로 Fork/Join 프레임워크를 사용함을 알 수 있습니다.
순차 스트림과 병렬 스트림 : 내부 동작 비교
System.out.println("Sequential Stream: ");
List.of(1,2,3,4,5).stream()
.forEach(i -> {
System.out.println(Thread.currentThread().getName() + ", value: " + i);
});
System.out.println("Parellel Stream: ");
List.of(1,2,3,4,5).parallelStream()
.forEach(i -> {
System.out.println(Thread.currentThread().getName() + ", value: " + i);
});
출력
순차 스트림에서는 따로 스레드 풀을 사용하지 않고, main 스레드가 모든 데이터를 순차적으로 처리하고 있는 것을 볼 수 있습니다.
병렬 스트림에서는 ForkkJoinPool.Ccommon-worker 스레드들이 각각 데이터를 처리하는 것을 확인할 수 있습니다. 이를 통해 내부적으로 Fork/Join 프레임워크와 Common Pool을 사용하는 것을 알 수 있습니다. 추가적으로, 병렬 스트림을 사용한 경우 데이터의 순서가 보장되지 않습니다. 순서 보장이 필요한 경우, forEachOrdered()를 사용하여 입력 순서를 보장할 수 있습니다.
Parallel Stream을 사용하면 대량의 데이터를 처리할 때 속도가 향상되며, 멀티 코어 시스템에서 CPU를 최대로 활용할 수 있습니다. 하지만 항상 좋은 것은 아니기 때문에 상황에 맞게 사용해야 합니다.
Sequential Stream을 사용할 때
작은 데이터 셋이나 병렬 처리가 필요하지 않은 경우 적합합니다.
- 데이터의 양이 적은 경우 (병렬 처리를 위한 스레드 생성 및 관리로 인해 성능 저하로 이어질 수 있음)
- 작업의 성격이 단순하고, 빠른 경우
- 순차적으로 처리해야 하는 경우
- I/O 바운드 작업인 경우 (파일 읽기/쓰기, 네트워크 요청 I/O 작업은 병렬 처리가 큰 이점을 주지 못할 수 있음)
Parallel Stream을 사용할 때
병렬 스트림은 멀티코어 CPU에서 여러 스레드를 사용하여 대량의 데이터를 빠르게 처리하기 위해 사용됩니다.
- 대량의 데이터를 처리해야 할 경우
- CPU 바운드 작업인 경우 (계산이 복잡한 연산, 처리 시간이 오래 걸리는 작업, 데이터 변환 등 )
- 독립적인 작업인 경우(각 요소 간 의존성이 없고, 독립적으로 처리가 가능한 경우)
- stateless 작업일 때 (데이터 변경 주의)
※ CPU 비운드 : CPU 에서 연산이 집중적으로 이루어지는 작업으로 CPU가 바쁘게 일을 처리합니다. 따라서 스레드 수를 CPU 코어수로 젲한하는 것이 효율적입니다.
※ I/O 바운드 : 네트워크 대기, 디스크 입출력 대기 등으로 인해 CPU가 바쁘지 않은 경우가 있습니다. 하나의 스레드가 I/O 대기 상태에 있을 때, 다른 스레드가 다른 작업을 수행하는 것이 효율적이기 때문에 스레드 수를 크게 설정할 필요가 있습니다.
순차 스트림과 병렬 스트림 : 성능 비교
List<Long> numbers = LongStream.rangeClosed(0, 100_000_000).boxed().toList();
System.out.println("Sequential Stream: ");
start = System.currentTimeMillis();
long sequentialSum = numbers.stream()
.reduce(0L, Long::sum);
end = System.currentTimeMillis();
System.out.println("Sequential sum: " + sequentialSum + ", Duration: " + (end - start) + " ms\n");
System.out.println("Parellel Stream: ");
start = System.currentTimeMillis();
long parellelSum = numbers.parallelStream()
.reduce(0L, Long::sum);
end = System.currentTimeMillis();
System.out.println("parellel sum: " + parellelSum + ", Duration: " + (end - start) + " ms");
0부터 100,000,000까지의 데이터의 합계를 계산할 때 속도차이를 확인하는 코드입니다.
출력
1) 100,000,000개의 데이터
100,000,000개의 데이터에서 병렬 스트림이 순차스트림보다 처리 속도가 빠른 것을 확인할 수 있습니다.
2) 100,00개의 데이터 합계
100,000개의 데이터에서 병렬 스트림이 순차스트림보다 속도가 느린 것을 확인할 수 있습니다.
💁♂️ 데이터 크기와 작업 복잡성에 따라 달라지기 때문에, 주의해야 합니다.
정리
스레드 풀은 언제 사용될까?
병렬 스트림, 비동기 작업, 웹 요청
[참고]
'Java' 카테고리의 다른 글
🙋♂️ 왜 enum 생성자는 자동으로 private로 설정될까? (1) | 2025.02.07 |
---|---|
Stack 사용을 왜 지양해야 할까? (0) | 2025.01.20 |
GC(Garbage Collection)에 대하여 (2) | 2025.01.09 |
자바의 실행과 JIT(Just-In-Time) 컴파일러 (1) | 2025.01.08 |
JVM 내부 동작 원리 (1) | 2025.01.08 |