[OS] 스레드 풀(Thread Pool)이 은근 깊다?

wannabeing·2025년 9월 8일

CS

목록 보기
9/12
post-thumbnail

(1) 배경 및 사전지식

스레드에 대한 사전지식은 아래 링크를 참고하자!
[OS] 컨텍스트 스위칭(Context Switching)에도 속도차이가 있는걸 아셨나요?

1-1) 멀티스레드에서 발생하는 문제는 뭐가 있을까?

  1. 스레드는 메모리를 공유하므로 안정성이 비교적 떨어진다.
    하나의 스레드 오류가 다른 스레드에게까지 영향이 가기 때문이다.

  2. 요즘은 멀티코어 환경이라 프로세스 단위 병렬 실행도 충분히 빠르기 때문에 쓰지 않는 이유도 있다고 한다.

  3. 디버깅이 더 복잡하다.
    예를 들어 공유자원의 경쟁상태가 발생할 경우, 어디 스레드 작업에서 문제가 발생했는지 문제원인을 찾기 쉽지 않아진다.

  4. 많은 비용이 발생한다.
    스레드 생성비용이 크기 때문에 요청에 대한 응답시간이 늘어날 수도 있다.
    스레드 자체 생성 비용도 비싸다.
    싱글코어 환경에서의 멀티스레드 연산을 하게 되면, 싱글스레드 연산보다 비용이 비싸다고 한다.

  5. 잦은 컨텍스트 스위칭이 발생한다.
    스레드 컨텍스트 스위칭이 빠르다고 하더라도, 컨텍스트 스위칭을 자주 한다는 것은 CPU 오버헤드가 증가할 수밖에 없다. 서비스 장애까지 발생할 수 있다.


1-2) 스레드는 유저스레드와 커널스레드로 구분할 수 있다.

커널 스레드 (Kernel Threads)

: 운영 체제의 "커널"이 관리하는 스레드.
프로세스가 생성되면 커널은 하나의 커널 스레드를 만드는데, "프로세스"가 아닌 이 "커널 스레드"가 실질적인 스케쥴링 대상이 되며 하나의 프로세스는 하나 이상의 커널 스레드를 가진다.

유저 스레드 (User Threads)

: 유저 공간(코드실행 영역)에서 라이브러리에 의해 관리되는 스레드.
커널은 유저 스레드의 존재를 모른다. 모든 관리는 유저 공간에서 이루어진다.
유저 스레드의 실질적인 작업은 자신이 속한 프로세스의 커널 스레드와 연결돼서 진행된다.


1-3) 자바는 커널스레드를 원투원(One-to-One)으로 생성한다!

JVM은 운영체제로부터 시스템 콜을 통해 커널스레드를 할당받는다.
커널스레드 구현체에는 Windows Thread(WindowsOS), POSIX Thread(리눅스계열) 등이 있다.

생성된 커널스레드는 OS 커널이 직접 스케줄링을 담당한다.
JVM은 OS 스레드 위에 Java Thread 추상화를 제공하여 준다.

따라서 요청마다 새로운 스레드가 생성된다는 것은 커널의 작업이 늘어나는 것이고,
이는 생성비용이 필요하므로 요청처리 시간이 증가할 수 있게 된다.
→ 커널의 작업(생성비용) 증가, 요청처리 시간 증가


(2) 스레드 풀(Thread Pool) 등장

위와 같은 요청마다 스레드를 생성할 때, 멀티 스레드 환경에서 발생하는 문제를 해결하기 위한 방법 중 하나로 스레드 풀(Thread Pool)이 등장하게 되었다.

스레드 풀(Thread Pool)은 미리 일정 개수의 스레드를 생성해 두고
작업이 발생하면 사용 가능한 스레드를 할당하여 작업을 처리한 후에
작업이 완료되면 스레드는 풀에 반환되는 방식이다.

따라서 스레드의 반복 생성/소멸을 피하고, 시스템 자원을 효율적으로 활용하는 방법이다!

2-1) Thread Pool의 간단 구조

작업 큐(Task Queue)에 작업요청이 들어오면 먼저 작업 큐에 저장된다.
Thread Pool 안에 있는 Thread들이 해당 큐를 조회하여 작업을 처리하게 된다.
작업을 마친 Thread는 큐를 다시 조회하여 작업을 처리한다.


2-2) Java에서 Thread Pool

자바에서는 java.util.concurrent 패키지에서 Executors 클래스를 제공한다.

ThreadPoolExecutor를 직접 사용하여 세부 옵션을 커스터마이징할 수 있고,
Executors의 정적 메서드를 이용하면 ExecutorService 인터페이스 타입으로 손쉽게 여러 성격의 스레드 풀을 만들 수 있다.

ExecutorService myThreadPool = new ThreadPoolExecutor(
        3, // 코어 스레드 수 (corePoolSize)
        200, // 최대 스레드 수 (maximumPoolSize)
        120, // 최대 유휴 시간 (keepAliveTime)
        TimeUnit.SECONDS, // 최대 유휴 시간 단위
        new SynchronousQueue<>() // 작업 큐
);

corePoolSize는 해당숫자만큼은 항상 스레드를 유지한다는 말이다.

import java.util.concurrent.Executors;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.ScheduledExecutorService;

// 운용하는 Thread 갯수가 고정되어있는 Thread Pool
ExecutorService threadPool1 = Executors.newFixedThreadPool(4);

// 운용하는 Thread 갯수가 1개로 고정되어있는 Thread Pool
ExecutorService threadPool2 = Executors.newSingleThreadExecutor();

// 일정시간 주기적으로 실행해야 하는 작업이 있는 경우 사용하는 Thread Pool
ScheduledExecutorService threadPool3 = Executors.newScheduledThreadPool(4);

// 운용하는 Thread의 갯수를 정하지 않고 상황에 따라서 생성 및 해제하는 Thread Pool
ExecutorService threadPool4 = Executors.newCachedThreadPool();

더 자세한 예제코드는 자바에서 쓰레드 풀 다뤄보기, [OS] 스레드 풀(Thread Pool), Thread Pool - 코드라떼 에서 볼 수 있다.


2-3) Tomcat에서 Thread Pool

톰캣은 스프링부트의 내장 서블릿컨테이너로 사용되고 있다.
톰캣에도 Java의 스레드풀과 유사한 자체 스레드풀 구현체를 갖고 있다.

application.yml 또는 application.properties 파일로
아래처럼 Tomcat의 Thread Pool을 설정할 수 있다.

server:
  tomcat:
    threads:
      max: 200 ## 최대 스레드 개수
      min-spare: 10 ## 최소 유지 스레드 개수
    max-connections: 8192 ## 동시요청 최대 Connections 개수
    accept-count: 100 # 대기큐 사이즈
    connection-timeout: 20000 ## 20초 타임아웃

톰캣은 기본값으로 10개 스레드를 만들고, 최대 200개 스레드, 100개 대기 큐, 동시요청커넥션 개수로 8192개를 생성한다.

maxConnections: 톰캣이 최대로 동시에 처리할 수 있는 Connection 개수
acceptCount: maxConnections 이상의 요청이 들어왔을 때, 대기열 작업 큐의 사이즈


2-4) 톰캣의 NIO Connector

웹 요청이 들어오면 톰캣의 Connector 컴포넌트가 Connection을 생성하면서
클라이언트와 Connection을 연결하면서, 요청된 작업을 스레드와 연결해서 작업하게 된다.
톰캣(8버전이상)에서 NIO Connector(구현체)로 사용된다.

여기서 NIoEndPoint 객체내에 생성되는 Acceptor, Poller, Workers 3개의 중요한 흐름이 있다.

1. Acceptor
클라이언트와 연결(TCP, 3way-handshake)되면 SocketChannel 객체를 받는다.
해당 객체를 PollerEvent라는 객체로 래핑하여 Poller에게 넘겨준다.
이 때, evnets라고 하는 Poller가 관리하는 큐에 저장된다.

2. Poller (with. Selector)
Selector는 해당 evnets를 모니터링하면서 작업준비가 되었는지 감시하고,
(정확히는 Selector 채널에 등록)
Poller는 Selector를 통해 준비된 객체들을 SocketProcessor로 래핑해서 ThreadPool에게 넘긴다.
이 때, WorkerQueue라고 하는 Worker 작업큐에 저장된다.

3. Workers(Tomcat Thread Pool)
ThreadPool은 Worker(Thread)와 WorkQueue(작업큐)로 구성되어 있다.
WorkQueue에는 작업준비가 완료된 작업들만 있기 때문에 효율적으로 실행된다.

SocketProcessor라는 객체는 CoyoteAdpator와 CatalinaMapper로 구성되어있다.
Worker는 SocketProcessor의 Adaptor를 호출하고, Adaptor는 Mapper를 통해 알맞는 서블릿(Dispatcher Servlet)을 찾는다. 그리고 알맞는 Controller로 매핑되어 작업을 처리한다.

여기서의 Thread Pool이 Tomcat의 Thread Pool 설정과 연결된다!


(3) 궁금거나 헷갈리는 것들

3-1) 자바와 톰캣의 스레드 풀은 다른건가요?

톰캣 스레드풀은 서버에서 HTTP 요청을 받아 컨트롤러까지 실행하는 요청 처리에 스레드 풀을 적용하는 성격이고,
자바 스레드풀은 실제 코드내에서 실행되는 무거운 작업에 스레드 풀을 적용하는 것이므로 다르다!

3-2) 둘다 커널 스레드로 만들어지는거죠?

둘다 커널 스레드로 만들어진다고 한다!

3-3) Tomcat 스레드풀에서 WorkerQueue에 저장된다는 건 무엇을 의미하나요?

  • 최초 min-spare만큼의 최소 스레드 운영
  • 모든 Worker(스레드)들이 일하고 있다면 thread.max까지 추가 스레드 생성
  • 최대 스레드 생성했는데도 모든 Worker가 작업중이라면
  • 이후 요청은 WorkerQueue에 저장된다.

WorkerQueue에 저장된다는 것은 이미 최대스레드까지 생성되었다는 뜻!

3-4) Tomcat 스레드 풀에서 WorkerQueue도 꽉차면 어떻게 되요?

  • max-connections가 8192일 때를 가정하자.
  • 8193번째 요청은 Tomcat에 연결조차 되지않는다.
    즉, 웹서버에 요청자체가 오지 않고, 운영체제(OS)측에서 관리된다.

출처

자바에서 쓰레드 풀 다뤄보기
[OS] 스레드 풀(Thread Pool)
CPU와 스레드 - 코드라떼
Thread Pool - 코드라떼
[Spring Boot] Tomcat 알아보기 (3) Tomcat Thread Pool
커널 스레드 vs 유저 스레드(사용자 스레드)

[10분 테코톡] 조조그린의 Thread Pool
[10분 테코톡] 푸우의 Tomcat Thread Pool

profile
wannabe---ing

0개의 댓글