java / / 2023. 10. 26. 13:15

자바의 쓰레드 풀 (ExecutorService)

1. 쓰레드 풀 (Thread Pool)

자바에서, 쓰레드는 운영체제 리소스인 시스템 수준의 쓰레드와 관련이 있다. 만일 무분별하게 쓰레드를 생성한다면, 빠르게 리소스 고갈을 만나게 될 것이다.

운영체제는 병렬 수행을 위해 쓰레드 간 컨텍스트 스위칭(context switching)을 한다. 간단히 생각하면, 쓰레드를 더 많이 생성할수록, 쓰레드가 실제 하는 작업 시간은 줄어들게 된다.

쓰레드 풀(Thread Pool) 패턴은 멀티쓰레드 애플리케이션에서 리소스를 절약하는데 도움을 주고 사전에 정의된 수를 제한하여 병렬성을 유지할 수 있게 한다.

쓰레드 풀을 사용할 때, 병렬 작업의 형태로 동시성 코드를 작성하고 쓰레드 풀 인스턴스에서 실행한다. 이 인스턴스는 작업을 실행하는데 여러 개의 쓰레드를 재사용하도록 제어한다.

2. 자바에서 쓰레드 풀 (Thread Pool)

2.1 Executors, Executor, ExecutorService

Executors helper 클래스는 사전에 구성된 쓰레드 풀 인스턴스를 생성하는 몇 가지 방법을 가지고 있다. 이런 클래스들은 처음 시작할 때 사용하기 좋다. 일일히 튜닝할 필요없이 바로 사용해 볼 수 있다.

자바에서 쓰레드 풀을 구현하는데 ExecutorExecutorService를 사용한다. 일반적으로 우리는 코드를 실제 쓰레드풀 구현체로부터 디커플링해야 하고 애플리케이션에서는 인터페이스를 사용해야 한다.

2.1.1 Executor

Executor 인터페이스는 Runnable 인스턴스를 실행하는 하나의 executor 메소드를 가지고 있다.

하나의 쓰레드 풀을 가지고 있고 순차적으로 실행하는 큐를 가진 Executor 인스턴스를 가져오는 Executors API를 사용하는 간단한 예제를 한번 보자.

여기에서는 화면에 "Hello World"를 출력하는 하나의 작업을 실행한다.

Executor executor = Executors.newSingleThreadExecutor();
executor.execute(() -> System.out.println("Hello World"));

2.1.2 ExecutorService

ExecutorService 인터페이스는 작업의 진행을 통제하고 서비스 종료를 관리하기 위한 많은 메소드를 가지고 있다. 이 인터페이스를 사용하여, 작업을 실행할 수 있고 리턴된 Future 인스턴스를 사용하여 실행을 제어할 수 있다.

ExecutorService executorService = Executors.newFixedThreadPool(10);
Future<String> future = executorService.submit(() -> "Hello World");
// some operations
String result = future.get();

물론 실제 시나리오에서는 future.get()을 호출할 필요가 없고 연산 결과가 실제 필요한 시점에 호출하면 된다.

Runnable의 단일 메소드는 예외를 던지지 않고 어떤 값을 리턴하지 않는다.
Callable 인터페이스가 예외를 던지고 값을 리턴하기 때문에 좀 더 편리하게 사용할 수 있다.

2.2 ThreadPoolExecutor

ThreadPoolExecutor는 많은 파리미터를 가진 확장 가능한 쓰레드 풀이다.

여기서 논의할 메인 구성 파라미터는 corePoolSize, maximumPoolSize, keepAliveTime이다

풀(pool)은 항시 유지되는 고정된 크기의 코어 쓰레드로 구성된다. 또한 몇 개의 여유 쓰레드를 가지고 있고 더 이상 필요하지 않을 때 종료된다.

corePoolSize 파라미터는 초기화될 때 풀에 유지되는 쓰레드 수이다. 새 작업이 들어올 때, 모든 쓰레드가 가용중(busy)이면 풀은 maximumPoolSize까지 증가된다.

keepAliveTime 파라미터는 비활성 상태로 존재하는 여유 쓰레드(corePoolSize 넘는 값)의 시간 간격이다. 기본적으로 ThreadPoolExecutor 는 사용중이 아닌 쓰레드만을 삭제 대상으로 한다. 실행 쓰레드를 삭제하기 위해 allowCoreThreadTimeout(true) 메소드를 사용할 수 있다.

2.2.1 newFixedThreadPool

newFixedThreadPool 메소드는 corePoolSizemaximumPoolSize 가 같고 keepAliveTime이 0인 ThreadPoolExecutor를 생성한다. 이것은 쓰레드 풀의 수는 항상 같다는 것을 의미한다.

ThreadPoolExecutor executor =   (ThreadPoolExecutor) Executors.newFixedThreadPool(2);

executor.submit(() -> {
    Thread.sleep(1000);
    return null;
});
executor.submit(() -> {
    Thread.sleep(1000);
    return null;
});
executor.submit(() -> {
    Thread.sleep(1000);
    return null;
});

assertEquals(2, executor.getPoolSize());
assertEquals(1, executor.getQueue().size());

여기서, 두 개의 고정된 쓰레드 수로 ThreadPoolExecutor를 초기화한다. 이것은 동시에 실행되는 작업의 수가 2보다 작거나 같다면 곧바로 실행된다는 것을 나타낸다. 그렇지 않으면 이 작업 중 몇 개는 차례를 기다리기 위해 큐(queue)에 남아 있을 수 있다.

우리는 1000ms 동안 대기(sleep)하게 함으로써 과도한 작업을 흉내내는 세 개의 Callable 작업을 생성했다. 첫 번째 두 작업은 즉시 실행될 것이며 세번째 작업은 큐에서 대기해야만 할 것이다. getPoolSize()와 getQueue().size() 메소드를 호출함으로써 확인할 수 있다.

2.2.2 Executors.newCachedThreadPool()

Executors.newCachedThreadPool() 메소드를 가진 또 다른 ThreadPoolExecutor 를 생성할 수 있다. 이 메소드는 쓰레드 수를 정하지 않는다. corePoolSize를 0으로, maximumPoolSizeInteger.MAX_VALUE로 설정한다. 마지막으로 keepAliveTime은 60초이다.

ThreadPoolExecutor executor =   (ThreadPoolExecutor) Executors.newCachedThreadPool();

executor.submit(() -> {
    Thread.sleep(1000);
    return null;
});
executor.submit(() -> {
    Thread.sleep(1000);
    return null;
});
executor.submit(() -> {
    Thread.sleep(1000);
    return null;
});

assertEquals(3, executor.getPoolSize());
assertEquals(0, executor.getQueue().size());

이 파라미터는 캐쉬된 쓰레드 풀이 어떤 작업을 수행하기 위해 특별한 제한없이 증가할 수 있다는 것을 의미한다. 하지만 쓰레드가 더 이상 필요하지 않을 때, 유휴상태가 60초가 지난 후 처분될 것이다. 짧게 실행되는 작업이 많을 때 일반적으로 사용된다.

큐(queue) 사이즈는 내부적으로 SynchronousQueue 인스턴스가 사용되기 때문에 항상 0이 될 것이다. insert와 remove 작업이 항상 동시에 발생한다. 그래서 큐(queue)는 어떤 것도 가지고 있지 않는다.

2.2.3 Executors.newSingleThreadExecutor()

Executors.newSingleThreadExecutor() 는 단일 쓰레드를 가지는 또 다른 형태의 ThreadPoolExecutor를 생성한다. 단일 쓰레드 실행기는 이벤트 루프를 생성하는데 이상적이다. corePoolSizemaximumPoolSize는 1이고 keepAliveTime는 0이다.

예제는 순차적으로 실행이 되기 때문에 flag 값은 작업 완료 후에 2가 될 것이다.

AtomicInteger counter = new AtomicInteger();

ExecutorService executor = Executors.newSingleThreadExecutor();

executor.submit(() -> {
    counter.set(1);
});
executor.submit(() -> {
    counter.compareAndSet(1, 2);
});

2.3 ScheduledThreadPoolExecutor

ScheduledThreadPoolExecutorThreadPoolExecutor 클래스를 상속하고 또한 ScheduledExecutorService 인터페이스를 구현한다.

  • schedule 메소드는 특정 시간 대기 후 한번 작업을 실행한다.
  • scheduleAtFixedRate 메소드는 초기 대기시간 후에 실행하고 특정 기간 동안 반복해서 실행한다. period 인수는 작업의 시작 시간 사이에 측정되는 시간이어서 실행 간격은 고정이다.
  • scheduleWithFixedDelay 메소드는 scheduleAtFixedRate와 비슷하고 주어진 작업을 반복적으로 실행한다. 하지만 대기 시간은 이전 작업이 끝난 시간과 다음 작업의 시작 시간 사이의 시간이다. 실행은 주어진 작업을 실행하는데 걸리는 시간에 따라 다르다.

일반적으로 특정 corePoolSize, 무한 maximumPoolSize 그리고 0인 keepAliveTime 을 가진 ScheduledThreadPoolExecutor 를 생성하기 위해 Executors.newScheduledThreadPool() 메소드를 사용한다.

이것은 500ms 후에 실행하는 작업을 스케쥴링하는 방법이다.

ScheduledExecutorService executor = Executors.newScheduledThreadPool(5);

executor.schedule(() -> {
    System.out.println("Hello World");
}, 500, TimeUnit.MILLISECONDS);

다음 코드는 500ms 대기 후 실행하고 매 100ms마다 반복하는 작업이다. 작업 스케쥴링 후에 CountDownLatch lock을 사용하여 3번 실행할 때까지 대기한다. 그리고 나서 Future.cancel() 메소드를 사용하여 취소한다.

CountDownLatch lock = new CountDownLatch(3);

ScheduledExecutorService executor = Executors.newScheduledThreadPool(5);
ScheduledFuture<?> future = executor.scheduleAtFixedRate(() -> {
    System.out.println("Hello World");
    lock.countDown();
}, 500, 100, TimeUnit.MILLISECONDS);

lock.await(1000, TimeUnit.MILLISECONDS);
future.cancel(true);

참고

https://www.baeldung.com/thread-pool-java-and-guava

반응형
  • 네이버 블로그 공유
  • 네이버 밴드 공유
  • 페이스북 공유
  • 카카오스토리 공유