[Java] 23. 스레드풀(Threadpool)를 사용하는 방법


Study/Java  2020. 5. 25. 17:59

안녕하세요. 명월입니다.


이 글은 Java에서 스레드풀(Threadpool)를 사용하는 방법에 대한 글입니다.


예전에 제가 Java에서 스레드에 관해 설명한 적이 있습니다.

링크 - [Java] 22. 스레드(Thread)를 사용하는 방법


스레드는 생성할 때 무한히 생성됩니다. 예를 들면 for문에 1부터 100까지 지정하고 안에 스레드를 생성하면 스레드는 100개까지 생성됩니다.

적절한 스레드 갯수 생성을 이용해 병렬처리를 하면 꽤 빠른 연산과 처리를 할 수 있습니다만, 우리의 하드 웨어의 물리적 용량은 한계가 있습니다. 무한히 스레드를 생성한다고 프로그램이 기하급수적으로 빨라지는 것이 아니고 용량 한계에 오면 결국에는 스레드를 돌리기 위해 사용되는 리소스로 싱글 스레드보다 더 느려지는 상태가 될 수 있습니다.

그러나 자바에서는 스레드 갯수를 제어하는 방법이 있는데, 그것이 바로 스레드풀(Threadpool)입니다.

스레드풀(Threadpool)를 통해 풀(pool)안에서 스레드를 만들고 그 풀안의 스레드 갯수를 제한하고 재사용하므로써 스레드 리소스를 아낄 수 있습니다.

또 스레드풀의 또 하나의 특징은 전체 스레드 갯수를 제한할 수도 있지만 미리 스레드를 생성해서 대기하게 할 수도 있습니다. 프로그램에서 리소스(Resource)의 생성과 소멸은 꽤 시간이 걸립니다. 즉, 스레드를 사용할 것이라면 미리 만들어놔서 사용하는 것이 사용할 때마다 생성하는 것보다 성능이 향상될 수 있습니다. 즉, 그 생성하는 시간을 아낄 수 있다는 뜻입니다.

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class Example {
  // 스레드 sleep 함수 (sleep 함수의 Exception 제거용)
  private static void sleep() {
    try {
      // 스레드 1초 대기
      Thread.sleep(1000);
    } catch (InterruptedException e) {
      e.printStackTrace();
    }
  }
  // 실행 함수
  public static void main(String[] main) {
    // single 스레드풀 -> 스레드풀안에 스레드가 하나만 움직인다.
    ExecutorService service = Executors.newSingleThreadExecutor();
    // 스레드풀에 스레드를 돌린다.
    service.execute(() -> {
      // 0부터 9까지 반복문
      for (int i = 0; i < 10; i++) {
        // 콘솔 출력
        System.out.println(Thread.currentThread().getName() + "  " + i);
        // 1초 대기
        sleep();
      }
    });
    // 스레드풀에 스레드를 돌린다.
    service.execute(() -> {
      // 0부터 9까지 반복문
      for (int i = 0; i < 10; i++) {
        // 콘솔 출력
        System.out.println(Thread.currentThread().getName() + "  " + i);
        // 1초 대기
        sleep();
      }
    });
    // 스레드풀에 스레드를 돌린다.
    service.execute(() -> {
      // 0부터 9까지 반복문
      for (int i = 0; i < 10; i++) {
        // 콘솔 출력
        System.out.println(Thread.currentThread().getName() + "  " + i);
        // 1초 대기
        sleep();
      }
    });
    // 스레드풀 안의 스레드가 모두 정상 종료되면 스레드풀 종료하기
    service.shutdown();
  }
}

위는 스레드풀안에 한개의 스레드를 사용하는 스레드풀입니다. 결과 이미지를 보면 첫번째 두번째 세번째 스레드를 걸어도 순서대로 스레드가 끝나고 실행됩니다.

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class Example {
  // 스레드 sleep 함수 (sleep 함수의 Exception 제거용)
  private static void sleep() {
    try {
      // 스레드 1초 대기
      Thread.sleep(1000);
    } catch (InterruptedException e) {
      e.printStackTrace();
    }
  }
  // 실행 함수
  public static void main(String[] main) {
    // 스레드를 제한없이 생성, 삭제하는 스레드풀를 생성한다.
    ExecutorService service = Executors.newCachedThreadPool();
    // 스레드풀에 스레드를 돌린다.
    service.execute(() -> {
      // 0부터 9까지 반복문
      for (int i = 0; i < 10; i++) {
        // 콘솔 출력
        System.out.println(Thread.currentThread().getName() + "  " + i);
        // 1초 대기
        sleep();
      }
    });
    // 스레드풀에 스레드를 돌린다.
    service.execute(() -> {
      // 0부터 9까지 반복문
      for (int i = 0; i < 10; i++) {
        // 콘솔 출력
        System.out.println(Thread.currentThread().getName() + "  " + i);
        // 1초 대기
        sleep();
      }
    });
    // 스레드풀에 스레드를 돌린다.
    service.execute(() -> {
      // 0부터 9까지 반복문
      for (int i = 0; i < 10; i++) {
        // 콘솔 출력
        System.out.println(Thread.currentThread().getName() + "  " + i);
        // 1초 대기
        sleep();
      }
    });
    // 스레드풀 안의 스레드가 모두 정상 종료되면 스레드풀 종료하기
    service.shutdown();
  }
}

위는 스레드풀안에 스레드의 개수를 제한없이(int크기의 값까지 제한) 사용하는 스레드풀입니다. 솔직히 이 스레드풀은 그냥 main에서 new Thread로 만드는 것과 별반 다름 없다라는 느낌이 듭니다.

단지 스레드풀안에서 돌리니 스레드 제어가 가능하다는 정도??

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class Example {
  // 스레드 sleep 함수 (sleep 함수의 Exception 제거용)
  private static void sleep() {
    try {
      // 스레드 1초 대기
      Thread.sleep(1000);
    } catch (InterruptedException e) {
      e.printStackTrace();
    }
  }
  // 실행 함수
  public static void main(String[] main) {
    // 스레드를 2개만 사용 가능한 스레드풀을 생성한다.
    ExecutorService service = Executors.newFixedThreadPool(2);
    // 스레드풀에 스레드를 돌린다.
    service.execute(() -> {
      // 0부터 9까지 반복문
      for (int i = 0; i < 10; i++) {
        // 콘솔 출력
        System.out.println(Thread.currentThread().getName() + "  " + i);
        // 1초 대기
        sleep();
      }
    });
    // 스레드풀에 스레드를 돌린다.
    service.execute(() -> {
      // 0부터 9까지 반복문
      for (int i = 0; i < 10; i++) {
        // 콘솔 출력
        System.out.println(Thread.currentThread().getName() + "  " + i);
        // 1초 대기
        sleep();
      }
    });
    // 스레드풀에 스레드를 돌린다.
    service.execute(() -> {
      // 0부터 9까지 반복문
      for (int i = 0; i < 10; i++) {
        // 콘솔 출력
        System.out.println(Thread.currentThread().getName() + "  " + i);
        // 1초 대기
        sleep();
      }
    });
    // 스레드풀 안의 스레드가 모두 정상 종료되면 스레드풀 종료하기
    service.shutdown();
  }
}

스레드 2개만 사용하는 스레드풀을 생성했기 때문에, 결과를 보면 처음에는 스레드를 2개 사용하다가 둘의 스레드가 종료가 되자 스레드 한개로 사용되는 모습을 확인할 수 있습니다.


다른 또 newScheduledThreadPool, newWorkStealingPool 등이 존재합니다. 솔직히 이 둘의 스레드풀은 안 사용해봐서 잘 모르겠네요.


그리고 스레드풀 안에 스레드를 실행할 때 위 execute 함수를 사용합니다. execute 함수의 경우는 인터페이스 Runnable을 받기 때문에 람다식으로 작성이 가능합니다.

그러나 스레드풀 안에서 만든 데이터를 병렬처리가 끝나고 나서 그 데이터를 알고 싶을 때는 submit 함수를 써서 return값을 받을 수 있습니다.

Callable 인터페이스는 제네릭 타입으로 설정하고 결과값은 설정한 제네릭 타입으로 받을 수 있습니다.

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
public class Example {
  // 스레드 sleep 함수 (sleep 함수의 Exception 제거용)
  private static void sleep() {
    try {
      // 스레드 1초 대기
      Thread.sleep(1000);
    } catch (InterruptedException e) {
      e.printStackTrace();
    }
  }
  // 실행 함수
  public static void main(String[] main) {
    // 스레드를 2개만 사용 가능한 스레드풀을 생성한다.
    ExecutorService service = Executors.newFixedThreadPool(2);
    // 스레드풀에 스레드를 돌린다.
    Future<Integer> data1 = service.submit(() -> {
      // 합산 결과
      int sum = 0;
      // 0부터 9까지 반복문
      for (int i = 0; i < 10; i++) {
        // 콘솔 출력
        System.out.println(Thread.currentThread().getName() + "  " + i);
        // 합산
        sum += i;
        // 스레드 1초 대기
        sleep();
      }
      // 결과 리턴
      return sum;
    });
    // 스레드풀에 스레드를 돌린다.
    Future<Integer> data2 = service.submit(() -> {
      // 합산 결과
      int sum = 0;
      // 10부터 19까지 반복문
      for (int i = 10; i < 20; i++) {
        // 콘솔 출력
        System.out.println(Thread.currentThread().getName() + "  " + i);
        // 합산
        sum += i;
        // 스레드 1초 대기
        sleep();
      }
      // 결과 리턴
      return sum;
    });
    // 스레드풀에 스레드를 돌린다.
    Future<Integer> data3 = service.submit(() -> {
      // 합산 결과
      int sum = 0;
      // 20부터 29까지 반복문
      for (int i = 20; i < 30; i++) {
        // 콘솔 출력
        System.out.println(Thread.currentThread().getName() + "  " + i);
        // 합산
        sum += i;
        // 스레드 1초 대기
        sleep();
      }
      // 결과 리턴
      return sum;
    });
    try {
      // 세번째 스레드 값 결과를 콘솔 출력
      System.out.println(data3.get());
      // data3의 스레드가 끝날때까지 기다린다.
      // 두번째 스레드 값 결과를 콘솔 출력
      System.out.println(data2.get());
      // 첫번째 스레드 값 결과를 콘솔 출력
      System.out.println(data1.get());
    } catch (Throwable e) {
      // 에러 발생시 콘솔 출력
      e.printStackTrace();
    }
    // 스레드풀 안의 스레드가 모두 정상 종료되면 스레드풀 종료하기
    service.shutdown();
  }
}

사실 execute나 submit이나 어느 것을 사용해도 상관없습니다. execute를 사용하고 변수를 클로져를 이용해서 값을 공유해도 크게 문제는 없습니다. 그러나 클로져 코드가 많으면 가독성이 떨어지니 용도에 맞게 사용하는 것이 좋습니다.


여기까지 정리하겠습니다.

스레드 종류 설명
newSingleThreadExecutor 1개의 스레드를 사용하는 스레드풀
newCachedThreadPool 갯수의 제한이 없는 스레드풀
newFixedThreadPool 갯수를 지정하여 사용하는 스레드풀
newScheduledThreadPool 특정 시간 등을 지정하여 사용할 수 있는 스레드풀
newWorkStealingPool 1.8에 새로 나온 스레드풀인데 완전한 parallel 형태(병렬 처리)로 사용되는(?) 스레드풀이라고 합니다.

출처 - https://m.blog.naver.com/PostView.nhn?blogId=mals93&logNo=220743747346


그리고 스레드풀안에서 스레드를 호출할 수 있는 함수는 2개가 있습니다.

함수명 설명
execute 파라미터는 Runable 타입으로 리턴 값을 보낼 수 없다.
submit 파라미터는 Runable 타입과 Callable 타입 모두 오버로드되어 있기 때문에 리턴형식이 void, 제네릭 타입 어느쪽으로 가능하다.

참고로 스레드풀을 종료하기 위한 함수가 세개가 있습니다.

일반적으로 사용되는 shutdown 이는 모든 스레드풀의 스레드가 종료되면 스레드풀을 종료하는 함수입니다.

shutdownNow의 경우는 스레드풀 안의 모든 스레드를 강제 종료시키고 풀을 종료합니다. awaitTermination는 거의 사용하지 않지만 시간 안에 종료되지 않으면 강제종료하고 스레드를 종료하는 함수입니다.

함수명 설명
shutdown

스레드풀 안의 모든 스레드가 종료되면 스레드풀을 종료

shutdownNow

스레드풀 안의 모든 스레드를 강제 종료시키고 풀을 종료
awaitTermination 시간 안에 종료되지 않으면 강제종료하고 스레드를 종료

여기까지 Java에서 스레드풀(Threadpool)를 사용하는 방법에 대한 글이었습니다.


궁금한 점이나 잘못된 점이 있으면 댓글 부탁드립니다.