ExecutorService? Thread Pool Size는 어떻게 정해야할까

kindof

·

2023. 8. 6. 16:25

0. 들어가면서

멀티쓰레드를 사용하면 여러 작업을 동시에 처리하거나 병렬로 실행하여 시스템 자원을 효율적으로 활용할 수 있습니다.
 
Java에서는 가장 근본적인 개념으로 Runnable 인터페이스를 구현하는 Thread 객체를 제공하는데요.
 
아래와 같은 코드를 보겠습니다.

public class XXX {
    private static final int coreThreadCount = Runtime.getRuntime().availableProcessors();
    
    static {
        System.out.println("coreThreadCount = " + coreThreadCount);
    }
    
    public static void main(String[] args) {
        for (int i = 0; i < 100; i++) {
            Thread thread = new Thread(
                    () -> System.out.println("ThreadName = " + Thread.currentThread().getName())
            );
            thread.start();
        }
        System.out.println("ThreadName = " + Thread.currentThread().getName());
    }
}

// Output 
coreThreadCount = 8
ThreadName = Thread-0
ThreadName = Thread-1
ThreadName = Thread-2
ThreadName = Thread-3
...
ThreadName = Thread-97
ThreadName = Thread-98
ThreadName = main
ThreadName = Thread-99

해당 코드는 for문 안에서 100개의 쓰레드를 생성하고 작업을 수행한 뒤 Kill 하는 작업을 반복합니다. 이 때, 하나의 자바 Thread는 OS 관점에서 하나의 Thread를 사용하는 것과 같습니다.
 
만약 1000개의 쓰레드를 생성하여 작업을 한다면 Thread의 생성과 소멸에 따른 리소스가 그만큼 필요하다는 것을 의미하죠.
 

1. Java ExecutorSerivce 기본

ExecutorService는 자바의 java.util.concurrent 패키지에서 제공되는 인터페이스로, 멀티쓰레드 환경에서 쓰레드 풀(Thread Pool)을 관리하고 작업을 스케줄링하는 기능을 제공합니다.
 
ExecutorService를 사용하면 쓰레드 생성 및 소멸과 같은 관리 작업을 개발자가 직접 처리할 필요 없이, 쓰레드 풀을 통해 효율적으로 작업을 실행할 수 있습니다. 
 
간단한 실험을 해보겠습니다.
 
먼저 아래는 100,000개의 쓰레드를 직접 생성하여 작업을 처리할 때 속도입니다.

...   
        long startTime, endTime;
        startTime = System.currentTimeMillis();

        for (int i = 0; i < 100000; i++) {
            Thread thread = new Thread(
                    () -> System.out.println("hi"));
            thread.start();
        }
        endTime = System.currentTimeMillis();
        System.out.println("endTime - startTime = " + (endTime - startTime)+"ms");
...

// Output
...
hi
hi
hi
hi
endTime - startTime = 4014ms

약 4초의 시간이 걸렸습니다.
 
이제, ExecutorService를 사용하여 쓰레드 풀을 통해 같은 작업을 진행해보겠습니다.

...
        ExecutorService executorService = Executors.newFixedThreadPool(10);
        startTime = System.currentTimeMillis();
        for (int i = 0; i < 100000; i++) {
            executorService.execute(() -> System.out.println("hi"));
        }
        executorService.shutdown(); // 스레드 풀 종료
        awaitTermination(executorService);
        endTime = System.currentTimeMillis();
        System.out.println("endTime - startTime = " + (endTime - startTime)+"ms");
...

// Output
...
hi
hi
hi
endTime - startTime = 174ms

같은 작업을 수행하는 데 1초도 걸리지 않았습니다.
 
ExecutorService를 사용하면 쓰레드 생성과 소멸, 쓰레드 풀 크기 조절 등을 자동으로 처리할 수 있으며, 많은 작업을 동시에 처리할 때 쓰레드의 생성과 소멸 비용을 절감하여 성능을 향상시킬 수 있다는 것을 시사합니다.
 
그렇다면, ExecutorService에서 사용하는 쓰레드 풀의 크기는 성능에 어떤 영향을 미칠까요?
 
일반적으로 생각하는 것처럼 CPU의 개수만큼 혹은 CPU 개수의 2배와 같은 값으로 조정할 때 성능이 최적화될까요?
 

2. CPU Bounded VS IO Bounded Task와 ThreadPool Size

특정 작업이 CPU 리소스를 많이 사용하는지, IO 작업 리소스를 많이 사용하는지에 따른 CPU Bounded, IO Bounded 작업으로 구분됩니다. 예를 들어, 많은 연산을 수행하는 작업은 CPU Bounded 일 것이며 파일을 읽고 쓰는 작업은 IO Bounded 작업이겠죠.
 
이 때, 적정 쓰레드 풀의 사이즈는 각 쓰레드가 어떤 작업을 수행하느냐에 따라 달라질 수 있습니다.
 
Core 개수가 8개라고 해보겠습니다.
 
만약 특정 태스크가 CPU Bounded 작업이라면 1000개의 쓰레드가 있다해도 8개를 넘어간 쓰레드들은 CPU의 자원을 할당받아 사용하기가 어렵습니다.
 
반면, 특정 태스크가 IO Bounded 작업이라면 Core 개수보다 많은 쓰레드를 생성해도 CPU 자원을 사용할 수 있기 때문에 작업 처리 속도가 빨라질 수 있죠.
 
실험을 통해 확인해보겠습니다.
 

2-1. CPU Bounded Task

아래와 같이 많은 연산을 수행하는 CPU Bounded Task가 존재합니다.

public static class CPUBoundedTask implements Runnable {
    @Override
    public void run() {
        System.out.println("threadName = " + Thread.currentThread().getName());
        double result = 0;
        for (int i = 0; i < 100000000; i++) {
            // 반복적인 계산이 많이 포함되어 CPU 자원을 많이 사용하는 작업 수행
            result += Math.sqrt(i);
        }
    }
}

이 때, Thread Pool 사이즈 변화에 따른 성능 차이를 확인해보겠습니다.

public static void main(String[] args) {
    long startTime, endTime;

    for (int tc = 1; tc <= 128; tc*=2) {
        ExecutorService executorService = Executors.newFixedThreadPool(tc);
        startTime = System.currentTimeMillis();
        for (int i = 0; i < 20; i++) {
            executorService.execute(new IOBoundedTask());
        }
        executorService.shutdown(); // 스레드 풀 종료
        awaitTermination(executorService);
        endTime = System.currentTimeMillis();
        System.out.println("tc = " + tc + " " + "endTime - startTime = " + (endTime - startTime) + "ms");
    }
}

// Output
coreThreadCount = 8
tc = 1 endTime - startTime = 1925ms
tc = 2 endTime - startTime = 961ms
tc = 4 endTime - startTime = 491ms
tc = 8 endTime - startTime = 339ms
tc = 16 endTime - startTime = 306ms
tc = 32 endTime - startTime = 305ms
tc = 64 endTime - startTime = 309ms
tc = 128 endTime - startTime = 305ms

쓰레드 풀의 사이즈가 Core의 개수인 8개까지 증가할 때는 성능 향상이 눈에 보이지만, 그 이후로는 쓰레드의 개수가 늘어나도 성능 향상에는 도움이 되지 않는 것을 볼 수 있습니다.
 

2-2. I/O Bounded Task

이제, IO Bounded Task를 같은 방법으로 테스트 해보겠습니다.
 
아래는 I/O 연산을 수행한다고 가정하는 Task입니다. 보시다시피, CPU 리소스를 거의 사용하지 않습니다.


public static class IOBoundedTask implements Runnable {
    @Override
    public void run() {
        try {
            Thread.sleep(500); // Simulate I/O operation
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

 
이 때, Thread Pool 사이즈에 따른 성능을 비교해보겠습니다.

public static void main(String[] args) {
    long startTime, endTime;

    for (int tc = 1; tc <= 128; tc*=2) {
        ExecutorService executorService = Executors.newFixedThreadPool(tc);
        startTime = System.currentTimeMillis();
        for (int i = 0; i < 20; i++) {
            executorService.execute(new IOBoundedTask());
        }
        executorService.shutdown(); // 스레드 풀 종료
        awaitTermination(executorService);
        endTime = System.currentTimeMillis();
        System.out.println("tc = " + tc + " " + "endTime - startTime = " + (endTime - startTime) + "ms");
    }
}

// Output
coreThreadCount = 8
tc = 1 endTime - startTime = 10083ms
tc = 2 endTime - startTime = 5044ms
tc = 4 endTime - startTime = 2522ms
tc = 8 endTime - startTime = 1509ms
tc = 16 endTime - startTime = 1011ms
tc = 32 endTime - startTime = 509ms
tc = 64 endTime - startTime = 507ms
tc = 128 endTime - startTime = 507ms

CPU Bounded 작업과는 다르게 쓰레드 풀의 사이즈가 32정도가 될 때까지 성능 향상이 있다는 것을 볼 수 있습니다. 다만, 그 이후로는 성능 향상이 크게 보이진 않는데 이는 쓰레드 풀 사이즈에 따른 컨텍스트 스위칭 비용이 있기 때문으로 추정됩니다.
 
그럼에도 불구하고, I/O bounded 작업이라면 쓰레드의 개수를 Core 개수보다 더 많이 할당하는 것이 유리해질 수 있다는 것을 확인할 수 있습니다.
 
 

3. 결론

이번 글에서는 Java에서 Thread를 관리하는 방법 중 하나인 ExecutorService에 대해 알아봤습니다.
 
그리고 태스크의 작업 특징에 따라 쓰레드 풀 사이즈를 어떻게 관리하는 것이 효율적인지에 대한 간단한 테스트를 진행해봤습니다.
 
여기서 실험한 내용이 쓰레드 풀 사이즈를 정하는 절대적인 기준이 되는 것은 아닙니다.
 
다만, 쓰레드 풀 사이즈를 정할 때 무작정 Core의 개수 혹은 Core * 2 만큼의 개수를 선택하는 것이 아닌, 여러 환경을 고려해보고 쓰레드 개수에 따른 성능을 직접 관찰해보면서 조율하는 과정이 필요하다는 것은 분명합니다.
 
감사합니다.
 
 

4. Reference

What are some best practices for using a thread pool executor service in Java?

Learn how to use a thread pool executor service in Java to manage concurrent tasks with optimal performance and reliability. Follow these best practices with examples and tips.

www.linkedin.com