자바(SE)

[Thread] 05. 스레드 풀

은서파 2024. 7. 7. 12:09

이번 포스트에서는 스레드 폭증에 따른 애플리케이션 성능 저하를 막기 위한 스레드 풀에 대해 살펴보자.

 

스레드 풀

 

스레드 풀(Thread Pool)

본격적으로 멀티 스레드르 사용하다 보면 많은 스레드의 생성/소멸에 따라 메모리 사용이 늘어나고 스케줄링 관리 등 해야할 일들이 많다보니 CPU도  바빠지는 등 애플리케이션 성능에 악영향을 끼치게 된다. 이럴 때 스레드를 한번 쓰고 버리는 것이 아니고 재사용할 수 있다면 어떨까? 이를 위해 스레드 풀이라는 개념이 사용된다.

기존에는 Thread와 스레드에서 처리할 작업(Runnable)을 하나로 만들었다면 스레드 풀에서는 둘이 분리되서 관리된다. 스레드 풀은 미리 만들어서 재사용하는 N개의 스레드와 스레드에서 처리할 작업 큐로 구성된다.

스레드 풀의 동작

  1. 원하는 개수만큼의 스레드를 관리할 스레드 풀을 생성한다.
  2. 외부에서 Runnable 타입으로 작업을 생성 후 스레드 풀에 전달하면 스레드 풀은 이를 작업 큐에 넣는다.
  3. 작업 큐에서 하나씩 작업을 빼서 한가한 스레드에 전달해서 처리하게 한다.
  4. 작업이 끝난 스레드는 종료되지 않고 다음 작업을 기다린다.

 

스레드 풀의 생성

자바에서 스레드 풀은 ExecutorService interface로 선언되며 생성은 Executors에서 제공하는 static 메서드를 이용한다.

메서드명 초기 스레드 수 최대 스레드 수 코어 스레드 수
newCachedThreadPool() 0 Integer.MAX_VALUE 0
newFixedThreadPool(threadCnt) 0 threadCnt threadCnt
newScheduledThreadPool(coreThread) 0 Integer.MAX_VALUE coreThread
newSingleThreadExecutor() 0 1 1

코어 스레드 라는 것은 요청이 폭주할 때 스레드를 생성했다가 평상시로 요청이 떨어졌을 때 최소한으로 유지할 스레드를 의미한다.

또는 세부적으로 스레드의 개수를 제어하기 위해서는 ThreadPoolExecutor를 사용할 수도 있다.

ExecutorService tpool = new ThreadPoolExecutor(
                3, // core thread count
                10, // max thread count
                1000, // 대기 시간(이 시간이 지나면 core thread 이상의 thread 는 소멸)
                TimeUnit.SECONDS, new LinkedBlockingQueue<>() // 작업 큐
        );

 

스레드 풀의 종료

스레드 풀을 구성하고 있는 스레드들은 데몬 스레드들이 아니기 때문에 명시적으로 종료시켜줘야 한다. 이때 사용할 수 있는 메서드들은 다음과 같다. 모든 메서드에서 새로운 작업의 제출은 중단되며 남은 작업의 처리 여부는 메서드마다 다르다.

메서드명 설명
void shutdown() 현재 실행중인 작업은 물론 작업 큐에 들어온 작업까지는 처리 후 종료된다.
List<Runnable> shutdownNow() 현재 실행중인 작업에 대해서는 InterruptedException을 발생시켜 중지시키고 작업 큐에 남은 작업들을 List로 반환한다.
boolean awaitTermination(long timeout, TimeUnit unit) timeout 까지 작업을 진행한다. 이후 남은 작업에 대해서 InterruptedException을 발생시키며 모든 작업이 완료되었는지 여부를 boolean으로 반환한다.

  

작업의 생성과 처리 요청

 

작업의 생성

스레드 풀에서 처리할 작업은 작업 완료 후 반환할 값의 유/무에 따라 Callable 또는 Runnable로 정의한다.

Runnable  task = ()-> {
            // do something
        };
Callable<T> task = () -> {
            // do something
            return T;
        };

 

작업의 처리 요청

스레드 풀에 작업 처리를 요청할 때는 execute와 submit을 사용할 수 있다.

메서드 설명
void execute(Runnable task) 리턴 없이 Runnable을 작업 큐에 저장
Future<?> submit(Runnable task) 리턴된 Future를 통해 작업 처리 결과를 얻을 수 있음
Future는 작업이 완료될 때까지 기다렸다가 최종 결과를 얻는데 사용
Future<V> submit(Runnable task, V result)
Future<V> submit(Callable<V> task)

 

사용 예

이제 스레드 풀을 이용해서 구구단을 출력하는 작업을 처리해보자.

public class MakeThreadPool {

    public static void main(String[] args) throws InterruptedException, ExecutionException {
        ThreadPoolExecutor tpool = (ThreadPoolExecutor) Executors.newScheduledThreadPool(5);
        printPoolInfo(tpool);
        for (int i = 1; i < 10; i++) {
            int temp = i;
            Callable<List<Integer>> task = () -> {
                List<Integer> list = new ArrayList<>();
                for (int j = 1; j < 10; j++) {
                    list.add(temp * j);
                }
                System.out.printf("\tthread: %s, content: %s\n", Thread.currentThread().getName(), list);

                return list;
            };
            Future<List<Integer>> result = tpool.submit(task); // 할당만 받았지 사용은 안함
            printPoolInfo(tpool);
        }
        tpool.shutdown();
        
        Thread.sleep(1000);
        System.out.println("DONE");        
    }

    private static void printPoolInfo(ThreadPoolExecutor tpool) {
        System.out.printf("pool size:  %d, completed task cnt: %d, active thread size: %d%n", 
            tpool.getPoolSize(), tpool.getCompletedTaskCount(), tpool.getActiveCount());
    }
}
// 실행 결과 예시
pool size:  0, completed task cnt: 0, active thread size: 0
pool size:  1, completed task cnt: 0, active thread size: 1
pool size:  2, completed task cnt: 0, active thread size: 1
pool size:  3, completed task cnt: 0, active thread size: 3
	thread: pool-1-thread-3, content: [3, 6, 9, 12, 15, 18, 21, 24, 27]
	thread: pool-1-thread-1, content: [1, 2, 3, 4, 5, 6, 7, 8, 9]
	thread: pool-1-thread-2, content: [2, 4, 6, 8, 10, 12, 14, 16, 18]
	thread: pool-1-thread-4, content: [4, 8, 12, 16, 20, 24, 28, 32, 36]
pool size:  4, completed task cnt: 0, active thread size: 4
pool size:  5, completed task cnt: 4, active thread size: 1
	thread: pool-1-thread-3, content: [5, 10, 15, 20, 25, 30, 35, 40, 45]
	thread: pool-1-thread-2, content: [6, 12, 18, 24, 30, 36, 42, 48, 54]
pool size:  5, completed task cnt: 4, active thread size: 1
pool size:  5, completed task cnt: 6, active thread size: 0
	thread: pool-1-thread-1, content: [7, 14, 21, 28, 35, 42, 49, 56, 63]
	thread: pool-1-thread-4, content: [8, 16, 24, 32, 40, 48, 56, 64, 72]
pool size:  5, completed task cnt: 6, active thread size: 1
pool size:  5, completed task cnt: 8, active thread size: 0
	thread: pool-1-thread-5, content: [9, 18, 27, 36, 45, 54, 63, 72, 81]

completed task 개수나 active thread 개수는 출력 시점들이 사실 정확할 수 없기 때문에 큰 의미를 둘 필요 없다. 다만 변하고 있구나 정도로 의미를 두자.

작업 완료 결과의 활용

 

Future

작업을 요청할 때 execute와 submit을 활용할 수 있는데 submit은 Future 타입의 객체를 반환한다. Future 같은 객체를 지연 완료 객체라고 하는데 Future의 get()을 호출하면 작업이 완료될 때까지 blocking 하고 있다가 완료되면 최종 결과를 얻는데 사용된다. 

작업이 완료될 때까지 blocking 된다는 점은 매우 중요하다. 다른 코드를 실행할 수 없기 때문이다. 만약 UI를 변경하고 이벤트를 처리하는 스레드가 get()을 호출하면 작업이 완료될 때까지 다른 작업을 일절 처리할 수 없다. 따라서 get()을 호출하는 역할은 또 다른 스레드가 담당해야 한다.

따라서 위의 기존의 코드에서 구구단의 결과를 받아서 사용하려면 다음과 같이 코드를 변경해야 한다.

public static void main(String[] args) throws InterruptedException, ExecutionException {
    ThreadPoolExecutor tpool = (ThreadPoolExecutor) Executors.newScheduledThreadPool(5);
    printPoolInfo(tpool);
    for (int i = 1; i < 10; i++) {
        int temp = i;
        Callable<List<Integer>> task = () -> {
            Thread.sleep(1000);
            List<Integer> list = new ArrayList<>();
            for (int j = 1; j < 10; j++) {
                list.add(temp * j);
            }
            //System.out.printf("\tthread: %s, content: %s\n", Thread.currentThread().getName(), list);
            return list;
        };
        Future<List<Integer>> result = tpool.submit(task);
        //System.out.printf("%d단 처리 완료: %s\n", i, result.get()); // 이렇게 처리하면 block 되어버림.
       
       new Thread(()-> {
            try {
                System.out.printf("%d단 처리 완료: %s\n", temp, result.get());
            } catch (InterruptedException | ExecutionException e) {
                e.printStackTrace();
            }  
        }).start();;            
        printPoolInfo(tpool);
        
    }
    tpool.shutdown();
    Thread.sleep(1000*10);
    System.out.println("DONE");
}
// 출력 예시
pool size:  0, completed task cnt: 0, active thread size: 0
pool size:  1, completed task cnt: 0, active thread size: 1
pool size:  2, completed task cnt: 0, active thread size: 1
pool size:  3, completed task cnt: 0, active thread size: 3
pool size:  4, completed task cnt: 0, active thread size: 4
pool size:  5, completed task cnt: 0, active thread size: 5
pool size:  5, completed task cnt: 0, active thread size: 5
pool size:  5, completed task cnt: 0, active thread size: 5
pool size:  5, completed task cnt: 0, active thread size: 5
pool size:  5, completed task cnt: 0, active thread size: 5
4단 처리 완료: [4, 8, 12, 16, 20, 24, 28, 32, 36]
3단 처리 완료: [3, 6, 9, 12, 15, 18, 21, 24, 27]
2단 처리 완료: [2, 4, 6, 8, 10, 12, 14, 16, 18]
5단 처리 완료: [5, 10, 15, 20, 25, 30, 35, 40, 45]
1단 처리 완료: [1, 2, 3, 4, 5, 6, 7, 8, 9]
7단 처리 완료: [7, 14, 21, 28, 35, 42, 49, 56, 63]
9단 처리 완료: [9, 18, 27, 36, 45, 54, 63, 72, 81]
8단 처리 완료: [8, 16, 24, 32, 40, 48, 56, 64, 72]
6단 처리 완료: [6, 12, 18, 24, 30, 36, 42, 48, 54]
DONE

위의 코드에서 만약 new Thread를 사용하지 않고 주석처리된 문장을 바로 사용했다면 main 스레드가 블로킹 되면서 다음과 같은 결과가 출력될 것이다.

pool size:  0, completed task cnt: 0, active thread size: 0
1단 처리 완료: [1, 2, 3, 4, 5, 6, 7, 8, 9]
pool size:  1, completed task cnt: 1, active thread size: 0
2단 처리 완료: [2, 4, 6, 8, 10, 12, 14, 16, 18]
pool size:  2, completed task cnt: 2, active thread size: 0
3단 처리 완료: [3, 6, 9, 12, 15, 18, 21, 24, 27]
pool size:  3, completed task cnt: 3, active thread size: 0
4단 처리 완료: [4, 8, 12, 16, 20, 24, 28, 32, 36]
pool size:  4, completed task cnt: 4, active thread size: 0
5단 처리 완료: [5, 10, 15, 20, 25, 30, 35, 40, 45]
pool size:  5, completed task cnt: 5, active thread size: 0
6단 처리 완료: [6, 12, 18, 24, 30, 36, 42, 48, 54]
pool size:  5, completed task cnt: 6, active thread size: 0
7단 처리 완료: [7, 14, 21, 28, 35, 42, 49, 56, 63]
pool size:  5, completed task cnt: 7, active thread size: 0
8단 처리 완료: [8, 16, 24, 32, 40, 48, 56, 64, 72]
pool size:  5, completed task cnt: 8, active thread size: 0
9단 처리 완료: [9, 18, 27, 36, 45, 54, 63, 72, 81]
pool size:  5, completed task cnt: 9, active thread size: 0
DONE