멀티스레드와 동시성

Ch12. 스레드 풀과 Executor 프레임워크 - Excutor 전략

webmaster 2024. 10. 29. 00:13
728x90

ThreadPoolExecutor를 사용하면 스레드 풀에 사용되는 숫자와 블로킹 큐 등 다양한 속성을 조절할 수 있다.

  • corePoolSize : 스레드 풀에서 관리되는 기본 스레드의 수
  • maximumPoolSize : 스레드 풀에서 관리되는 최대 스레드 수
  • keepAliveTime , TimeUnit unit : 기본 스레드 수를 초과해서 만들어진 스레드가 생존할 수 있는 대기 시간, 이 시간 동안 처리할 작업이 없다면 초과 스레드는 제거된다.
  • BlockingQueue workQueue : 작업을 보관할 블로킹 큐

이런 속성들을 조절하면 자신에게 맞는 스레드 전략을 사용할 있다.

자바는 Executors 클래스를 통해 3가지 기본 전력을 제공한다.

  • newSingleThreadPool(): 단일 스레드 풀 전략
  • newFixedThreadPool(nThreads): 고정 스레드 풀 전략
  • newCachedThreadPool(): 캐시 스레드 풀 전략

newSingleThreadPool()

 new ThreadPoolExecutor(1, 1,0L, TimeUnit.MILLISECONDS,new LinkedBlockingQueue<Runnable>())
  • 스레드 풀에 기본 스레드 1개만 사용한다.
  • 큐 사이즈에 제한이 없다.
  • ( LinkedBlockingQueue ) 주로 간단히 사용하거나, 테스트 용도로 사용한다.

newFixedThreadPool(nThreads)

 new ThreadPoolExecutor(nThreads, nThreads, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>())
  • 스레드 풀에 nThreads 만큼의 기본 스레드를 생성한다. 초과 스레드는 생성하지 않는다.
  • 큐 사이즈에 제한이 없다. ( LinkedBlockingQueue )
  • 스레드 수가 고정되어 있기 때문에 CPU, 메모리 리소스가 어느 정도 예측 가능한 안정적인 방식이다.

실행 코드 - newFixedThreadPool
실행 결과 - newFixedThreadPool

  • 2개의 스레드가 안정적으로 작업을 처리하는 것을 확인할 수 있다.

이 전략은 다음과 같은 특징이 있다.

특징
스레드 수가 고정되어 있기 때문에 CPU, 메모리 리소스가 어느 정도 예측 가능한 안정적인 방식이다. 큐 사이즈도 제한 이 없어서 작업을 많이 담아두어도 문제가 없다.

주의
이 방식의 가장 큰 장점은 스레드 수가 고정되어서 CPU, 메모리 리소스가 어느 정도 예측 가능하다는 점이다. 따라서 일 반적인 상황에 가장 안정적으로 서비스를 운영할 수 있다. 하지만 상황에 따라 장점이 가장 큰 단점이 되기도 한다.

  • 상황 1) 점진적인 사용자 확대
    • 개발한 서비스가 잘 되어서 사용자가 점점 늘어난다. 고정 스레드 전략을 사용해서 서비스를 안정적으로 잘 운영했는데, 언젠가부터 사용자들이 서비스 응답이 점점 느려진다고 항의한다.
  • 상황 2) - 갑작스러운 요청 증가
    • 마케팅 팀의 이벤트가 대성공하면서 갑자기 사용자가 폭증했다. 고객은 응답을 받지 못한다고 항의한다.
  • 결과
    • 개발자는 급하게 CPU, 메모리 사용량을 확인해 보는데, 아무런 문제 없이 여유 있고, 안정적으로 서비스가 운영되고 있다.
    • 고정 스레드 전략은 실행되는 스레드 수가 고정되어 있다. 따라서 사용자가 늘어나도 CPU, 메모리 사용량이 확 늘어나지 않는다.
    • 큐의 사이즈를 확인해보니 요청이 수 만 건이 쌓여있다. 요청이 처리되는 시간보다 쌓이는 시간이 더 빠른 것이다. 참고로 고정 풀 전략의 큐 사이즈는 무한이다.
    • 예를 들어서 큐에 10000건이 쌓여있는데, 고정 스레드 수가 10이고, 각 스레드가 작업을 하나 처리하는데 1초가 걸린다면 모든 작업을 다 처리하는 데는 1000초가 걸린다. 만약 처리 속도보다 작업이 쌓이는 속도가 더 빠른 경우에는 더 문제가 된다.
    • 서비스 초기에는 사용자가 적기 때문에 이런 문제가 없지만, 사용자가 늘어나면 문제가 될 수 있다.
    • 갑작스러운 요청 증가도 물론 마찬가지이다.

결국 서버 자원은 여유가 있는데, 사용자만 점점 느려지는 문제가 발생한 것이다.

newCachedThreadPool()

 new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS, new SynchronousQueue<Runnable>());
  • 기본 스레드를 사용하지 않고, 60초 생존 주기를 가진 초과 스레드만 사용한다.
  • 초과 스레드의 수는 제한이 없다.
  • 큐에 작업을 저장하지 않는다. ( SynchronousQueue )
    • 대신에 생산자의 요청을 스레드 풀의 소비자 스레드가 직접 받아서 바로 처리한다.
  • 모든 요청이 대기하지 않고 스레드가 바로바로 처리한다. 따라서 빠른 처리가 가능하다.

SynchronousQueue 는 아주 특별한 블로킹 큐이다.

  • BlockingQueue 인터페이스의 구현체 중 하나이다.
  • 이 큐는 내부에 저장 공간이 없다. 대신에 생산자의 작업을 소비자 스레드에게 직접 전달한다.
  • 쉽게 이야기해서 저장 공간의 크기가 0이고, 생산자 스레드가 큐가 작업을 전달하면 소비자 스레드가 큐에서 작업 꺼낼 까지 대기한다.
  • 소비자 작업을 요청하면 기다리던 생산자가 소비자에게 직접 작업을 전달하고 반환된다. 반대의 경우도 같다.
  • 이름 그대로 생산자와 소비자를 동기화하는 큐이다.
  • 쉽게 이야기해서 중간에 버퍼를 두지 않는 스레드간 직거래라고 생각하면 된다.

실행 코드
실행 결과

  • 모든 작업이 대기하지 않고 작업의 수만큼 스레드가 생기면서 바로 실행되는 것을 확인할 수 있다.
  • "maximumPoolSize 대기 시간 초과" 로그를 통해 초과 스레드가 대기 시간이 지나서 모두 사라진 것을 확인할 수 있다.

이 전략은 다음과 같은 특징이 있다.

특징
캐시 스레드 풀 전략은 매우 빠르고, 유연한 전략이다.

이 전략은 기본 스레드도 없고, 대기 큐에 작업도 쌓이지 않는다. 대신에 작업 요청이 오면 초과 스레드로 작업을 바로바로 처리한다. 따라서 빠른 처리가 가능하다. 초과 스레드의 수도 제한이 없기 때문에 CPU, 메모리 자원만 허용한다면 시스템의 자원을 최대로 사용할 수 있다.
추가로 초과 스레드는 60초간 생존하기 때문에 작업 수에 맞추어 적절한 수의 스레드가 재사용된다. 이런 특징 때문에 요청이 갑자기 증가하면 스레드도 갑자기 증가하고, 요청이 줄어들면 스레드도 점점 줄어든다.


이 전략은 작업의 요청 수에 따라서 스레드도 증가하고 감소하므로, 매우 유연한 전략이다. 그런데 어떻게 기본 스레드 없이 초과 스레드만 만들 수 있을까? Executor 스레드 풀 기본 관리 정책을 다시 확인해 보자.
Executor 스레드 풀 관리

  1. 작업을 요청하면 core 사이즈만큼 스레드를 만든다.
    • core 사이즈가 없다. 바로 core 사이즈를 초과한다.
  2. core 사이즈를 초과하면 큐에 작업을 넣는다.
    • 큐에 작업을 넣을 수 없다.( SynchronousQueue는 큐의 저장 공간이 0인 특별한 큐이다.) 
  3. 큐를 초과하면 max 사이즈만큼 스레드를 만든다. 임시로 사용되는 초과 스레드가 생성된다.
    • 초과 스레드가 생성된다. 물론 풀에 대기하는 초과 스레드가 있으면 재사용된다.
  4. max 사이즈를 초과하면 요청을 거절한다. 예외가 발생한다.
    • 참고로 max 사이즈가 무제한이다. 따라서 초과 스레드를 무제한으로 만들 수 있다.

결과적으로 이 전략의 모든 작업은 초과 스레드가 처리한다.

 

주의
이 방식은 작업 수에 맞추어 스레드 수가 변하기 때문에, 작업의 처리 속도가 빠르고, CPU, 메모리를 매우 유연하게 사용할 수 있다는 장점이 있다. 하지만 상황에 따라서 장점이 가장 큰 단점이 되기도 한다.

 

상황 1 - 점진적인 사용자 확대

  • 개발한 서비스가 잘 되어서 사용자가 점점 늘어난다.
  • 캐시 스레드 전략을 사용하면 이런 경우 크게 문제가 되지 않는다.
  • 캐시 스레드 전략은 이런 경우에는 문제를 빠르게 찾을 수 있다. 사용자가 점점 증가하면서 스레드 사용량도 함께 늘어난다. 따라서 CPU 메모리의 사용량도 자연스럽게 증가한다.
  • 물론 CPU, 메모리 자원은 한계가 있기 때문에 적절한 시점에 시스템을 증설해야 한다. 그렇지 않으면 CPU, 메모리 같은 시스템 자원을 너무 많이 사용하면서 시스템이 다운될 수 있다.

상황 2 - 갑작스러운 요청 증가

  • 마케팅 팀의 이벤트가 대성공하면서 갑자기 사용자가 폭증했다. 고객은 응답을 받지 못한다고 항의한다.
  • 개발자는 급하게 CPU, 메모리 사용량을 확인해 보는데, CPU 사용량이 100%이고, 메모리 사용량도 지나치게 높아져있다.
  • 스레드 수를 확인해 보니 스레드가 수 천 개 실행되고 있다. 너무 많은 스레드가 작업을 처리하면서 시스템 전체가 느려지는 현상이 발생한다.
  • 캐시 스레드 풀 전략은 스레드가 무한으로 생성될 수 있다.
  • 수 천 개의 스레드가 처리하는 속도 보다 더 많은 작업이 들어온다.
  • 시스템은 너무 많은 스레드에 잠식당해서 거의 다운된다. 메모리도 거의 다 사용되어 버린다.
  • 시스템이 멈추는 장애가 발생한다.

고정 스레드 전략은 서버 자원은 여유가 있는데, 사용자만 점점 느려지는 문제가 발생할 있다. 반면에 캐시 스레드 전략은 서버의 자원을 최대한 사용하지만, 서버가 감당할 있는 임계점을 넘는 순간 시스템이 다운될 있다.

 

사용자 정의 풀 전략

  • 상황 1 - 점진적인 사용자 확대
    • 개발한 서비스가 잘 되어서 사용자가 점점 늘어난다.
  • 상황 2 - 갑작스러운 요청 증가
    • 마케팅 팀의 이벤트가 대성공하면서 갑자기 사용자가 폭증했다.

다음과 같이 세분화된 전략을 사용하면 상황 1, 상황 2를 모두 어느 정도 대응할 수 있다.

  • 일반: 일반적인 상황에는 CPU, 메모리 자원을 예측할 수 있도록 고정 크기의 스레드로 서비스를 안정적으로 운영한다.
  • 긴급: 사용자의 요청이 갑자기 증가하면 긴급하게 스레드를 추가로 투입해서 작업을 빠르게 처리한다.
  • 거절: 사용자의 요청이 폭증해서 긴급 대응도 어렵다면 사용자의 요청을 거절한다.

방법은 평소에는 안정적으로 운영하다가, 사용자의 요청이 갑자기 증가하면 긴급하게 스레드를 투입해서 급한 불을 끄는 방법이다.
물론 긴급 상황에는 CPU, 메모리 자원을 사용하기 때문에 적정 수준을 찾아야 한다. 일반적으로는 여기까지 대응이 되겠지만, 시스템이 감당할 없을 정도로 사용자의 요청이 폭증하면, 처리 가능한 수준의 사용자 요청만 처리하고 나머지 요청은 거절해야 한다. 어떤 경우에도 시스템이 다운되는 최악의 상황은 피해야 한다.

사용자 정의 풀

ThreadPoolExecutor es = 
	new ThreadPoolExecutor(100, 200, 60, TimeUnit.SECONDS, new ArrayBlockingQueue<>(1000));
  • 100개의 기본 스레드를 사용한다.
  • 추가로 긴급 대응 가능한 긴급 스레드 100개를 사용한다. 긴급 스레드는 60초의 생존 주기를 가진다.
  • 1000개의 작업이 큐에 대기할 있다.

실행 코드

  • 하나의 작업은 1초 걸린다고 가정한다.
  • 주석을 변경하면 다음 시나리오를 확인할 있다.
    • 일반: 1000개 이하의 작업이 큐에 담겨있다. 100개의 기본 스레드가 처리한다.
    • 긴급: 큐에 담긴 작업이 1000개를 초과한다. 100개의 기본 스레드 + 100개의 초과 스레드가 처리한다.
    • 거절: 초과 스레드를 투입했지만, 큐에 담긴 작업 1000개를 초과하고 또 초과 스레드도 넘어간 상황이다. 이 경우 예외를 발생시킨다.

일반 케이스(TaskSize = 1100)

 [     main] task1100 -> [pool=100, active=100, queuedTasks=1000,
 completedTasks=0]
 ...
 [     main] time: 11073
  • 1000개 이하의 작업이 큐에 담겨있다. 100개의 기본 스레드가 처리한다.
  • 최대 1000개의 작업이 큐에 대기하고 100개의 작업이 실행중일 수 있다. 따라서 1100개 까지는 기본 스레드로 처리할 수 있다.
  • 작업을 모두 처리하는데 11초가 걸린다. 1100 / 100 => 11초

긴급 케이스(TaskSize = 1200)

 [     main] task1200 -> [pool=200, active=200, queuedTasks=1000,
 completedTasks=0]
 ...
 [     main] time: 6086
  • 큐에 담긴 작업이 1000개를 초과한다. 100개의 기본 스레드 + 100개의 초과 스레드가 처리한다. 최대 1000개의 작업이 대기하고 200개의 작업이 실행중일 수 있다.
  • 작업을 모두 처리하는데 6초가 걸린다. 1200 / 200 => 6초
  • 긴급 투입한 스레드 덕분에 풀의 스레드 수가 2배가 된다. 따라서 작업을 2배 빠르게 처리한다.
  • 물론 CPU, 메모리 사용을 하기 때문에 이런 부분은 감안해서 긴급 상황에 투입할 최대 스레드를 정해야 한다

거절 케이스(TaskSize = 1201)

 [     main] task1200 -> [pool=200, active=200, queuedTasks=1000,
 completedTasks=0]
 [     main] task1201 -> java.util.concurrent.RejectedExecutionException: Task
 thread.executor.RunnableTask@7f13d6e rejected from
 java.util.concurrent.ThreadPoolExecutor@7f690630[Running, pool size = 200,
 active threads = 200, queued tasks = 1000, completed tasks = 0]
  • 긴급 투입한 스레드로도 작업이 빠르게 소모되지 않는다는 것은, 시스템이 감당하기 어려운 많은 요청이 들어오고 있다는 의미이다.
  • 여기서는 큐에 대기하는 작업 1000개 + 스레드가 처리 중인 작업 200개 총 1200개의 작업을 초과하면 예외가 발생한다.
    • 따라서 1201번에서 예외가 발생한다.
  • 이런 경우 요청을 거절한다. 고객 서비스라면 시스템에 사용자가 너무 많으니 나중에 다시 시도해달라고 해야 한다.
  • 나머지 1200개의 작업들은 긴급 상황과 같이 정상 처리된다.

참고 - 만약 다음과 같이 설정하면?

 new ThreadPoolExecutor(100, 200, 60, TimeUnit.SECONDS, new LinkedBlockingQueue());
  • 기본 스레드 100개
  • 최대 스레드 200개
  • 큐 사이즈: 무한대

이렇게 설정하면 절대로 최대 사이즈만큼 늘어나지 않는다. 왜냐하면 큐가 가득 차야 긴급 상황으로 인지 되는데, LinkedBlockingQueue를 기본 생성자를 통해 무한대의 사이즈로 사용하게 되면, 큐가 가득 찰 수가 없다. 결국 기본 스레드 100개만으로 무한대의 작업을 처리해야 하는 문제가 발생한다. 실무에서 자주하는 실수 중에 하나이다.

 

 

728x90