[Java] Thread Pool 개념과 동작원리

H43RO·2021년 10월 19일
8

CS 뿌셔먹기

목록 보기
13/17

Thread Pool 이 등장하게 된 이유

우리는 프로그램을 개발할 때 다양한 이유로 쓰레드를 생성하곤 한다. 그것은 비동기 처리의 일환일 수도 있고, 연산 효율을 높이기 위함일 수도 있다. 그러나 쓰레드를 계속하여 생성하고 회수하는 것은, 시스템적으로 오버헤드가 상당히 큰 작업이다.

쓰레드를 한 번 생성할 때마다 OS 가 해당 쓰레드를 위한 메모리 영역 (스택 등) 을 확보해주고, 쓰레드가 더이상 필요 없을 땐 다시 이 메모리 영역을 회수하는 작업이 일어난다. 이는 상당히 비용이 큰 작업이기 때문에, 쓰레드를 계속하여 생성하고 수거했다간 프로그램의 퍼포먼스에 분명히 영향을 끼치게 되어있다.

그렇다고 '음.. 걍 쓰레드 많이 만들지 마세요 ㅋㅋ' 할 순 없는 노릇이다. 그래서 등장하게 된 아이디어는 '여러 쓰레드를 미리 만들어두고 작업이 들어올 때마다 쓰레드들에게 작업을 적절히 분배해주자'는 것이다.

핵심 아이디어

작업 들어올 때마다 쓰레드 생성 (X)

작업 들어올 때마다 미리 만들어져 있는 쓰레드들 중 하나에 작업 할당 (O)

이렇게 되면 쓰레드 생성 및 수거에 대한 오버헤드를 대폭 줄일 수 있기 때문에, 꽤나 효율적인 대안이다. 따라서 자바에서는 이를 적극 지원하기 위해 ExecutorService 라는 인터페이스와 Executor 클래스를 제공한다. 이들을 사용하여 쓰레드 풀을 구현할 수 있다.


동작원리

아래 그림으로 대략적인 동작을 유추해볼수 있다. Thread Pool많은 쓰레드를 미리 생성해두고, 새로운 작업이 들어올 때마다 Task Queue 에 이를 Enqueue 한다. 그리고 작업을 큐에서 하나씩 꺼내어 적절한 쓰레드로 할당하게 된다. 그리고 만약 작업이 끝나면, 이를 콜백 형태로 작업을 요청한 주체에게 결과를 알려준다.

쓰레드에 작업을 할당하는 동작을 조금 더 명료하게 나타낸 그림도 첨부한다. 새로운 작업을 처리할 때 적절한 쓰레드를 선택하여 해당 작업을 할당한다는 의미는, 작업을 수행하지 않고 있는 쓰레드에 작업을 할당한다는 뜻으로 받아들여도 될 것같다.


장점

퍼포먼스 저하 방지

앞서말했던 것처럼, 쓰레드를 계속하여 생성하고 수거하는 것은 비용이 상당히 큰 작업이다. 따라서 쓰레드 풀을 사용하게 되면 비용이 대폭 줄어들어 성능 저하를 방지할 수 있다.

다수의 요청을 효율적으로 처리

이곳 저곳에서 작업 요청이 들어올 수 있다. 서비스 측면에서 빗대어 본다면, 여러 사용자들이 한 번에 작업 요청을 보낼 때 쓰레드풀을 사용한다면 빠르고 효율적으로 동시 작업을 수행할 수 있다.

그런데 결국 마냥 좋은 것도 아니다. 쓰레드 풀이 가지는 단점에 대해 살펴보자면 아래와 같다.


단점

자칫 메모리 낭비로 이어질 수 있음

계속 말하지만 쓰레드 풀은 일정 가량 쓰레드를 미리 만들어두고 이들을 적절히 활용하는 솔루션이다. 결국 얼만큼 쓰레드를 만들어 둘지에 대해 결정해야 하는데, 이 과정에서 '에잇 언젠간 다 쓰겠지 머' 하고 너무 많은 쓰레드를 생성해두게 되면 메모리만 차지하고 아무것도 하지 않는 쓰레드가 존재할 가능성이 높아진다.

놀고먹는 쓰레드가 발생할 수 있음

위 단점과는 조금 다르다. 병렬적으로 A, B, C 쓰레드가 작업을 처리하는 과정에서, 각각 분배된 작업의 소요시간이 서로 다른 경우, A 는 아직 허덕이고 있는데 나머지는 '음 쟤 고생하네 ㅋㅋ'하면서 유휴 시간이 발생하는 상황이 발생할 수 있다. 유휴한 쓰레드들이 A 의 작업을 조금만 도와준다면 좋을텐데.

자바에선 이러한 상황을 방지하기 위해 forkJoinPool 이라는 것을 지원한다.


종류

Single Thread Executor

  • 단일 쓰레드를 생성
  • 실패 시 새로 쓰레드를 생성하진 않음
ExecutorService executorService = Executors.newSingleThreadExecutor();

Fixed Thread Executor

  • 고정된 개수의 쓰레드를 생성하고, 모든 쓰레드가 작업 중이라면 Task Queue 에 작업 적재
  • 실패 시 새로운 쓰레드를 생성하여 대체함
ExecutorService executorService = Executors.newFixedThreadPool(int nThreads);

Cached Thread Pool

  • 필요에 따라 새로운 쓰레드를 생성하며, 이전에 생성했던 쓰레드가 존재한다면 이를 재사용함
  • 비동기 작업에 사용하기 좋은 녀석
  • 기본적으로 60초 동안 쓰레드가 유지됨
ExecutorService executorService = Executors.newCachedThreadPool();

Scheduler Thread Pool

  • 지정된 Delay 후에 실행하거나 주기적으로 실행하도록 작업 예약
ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(int corePoolSize);

Work Stealing Thread Pool

  • 자바 8에서 새로 생긴 녀석

  • 쓰레드를 동적으로 늘리고 줄일 수 있음

  • 요청된 병렬 동작을 지원할만큼 충분한 쓰레드를 유지하고, 쓰레드마다 독립적인 큐를 사용

  • 따라서 작업 큐가 비어있는 쓰레드가 존재한다면, 다른 쓰레드의 작업 큐에서 작업을 훔쳐(?)올 수 있음

    놀고 먹는 쓰레드에게 다른 쓰레드의 일을 좀 도와줄 수 있도록 함

  • 위 특성때문에, 작업이 실행되는 순서를 보장하진 않음. (큐잉 순서와 무관하게 동작)

ExecutorService executorService = Executors.newWorkStealingPool(int parallelism);

쓰임새

쓰레드 풀은 알게 모르게 상당히 유용하게 사용되고 있다. 우리에게 가장 친숙한(?) 녀석으로는 RxJava 가 있다. Observable 데이터 스트림의 메소드인 subscribeOn() 을 통해 해당 데이터 스트림이 어떤 쓰레드에서 데이터를 발행할지 지정해줄수 있다. 이 때 매번 쓰레드를 생성하여 데이터 스트림을 할당하는 것이 아닌, 쓰레드 풀에서 적절한 쓰레드를 선택하여 작업을 할당해주게 된다.


참고자료

https://limkydev.tistory.com/55
https://en.wikipedia.org/wiki/Thread_pool

profile
어려울수록 기본에 미치고 열광하라

0개의 댓글