사전 스레드 풀
패턴

스레드 풀

gabury1고친 사람 github-actions[bot]

스레드 풀은 일할 스레드를 미리 만들어 두고 여러 작업이 번갈아 빌려 쓰게 합니다. 작업이 들어올 때마다 스레드를 새로 만들지 않습니다. 놀고 있는 스레드에 그 일을 맡깁니다. 일을 끝낸 스레드는 없어지지 않고 풀로 돌아와 다음 작업을 기다립니다.

쉽고 빠른 이해

스레드 풀은 미리 만들어 둔 스레드 몇 개에 작업을 나눠 주는 장치입니다. 웹 서버가 들어온 요청을 그 스레드들에게 하나씩 맡기는 것이 그런 예입니다.

이게 없으면 작업이 들어올 때마다 스레드를 새로 만들었다가 끝나면 없애게 됩니다. 만들고 없애는 데 드는 시간이 정작 그 작업을 하는 시간에 맞먹습니다. 작업이 한꺼번에 몰리면 스레드도 같이 몰려 늘어나 기계가 버티지 못합니다.

어떻게 도나:

  1. 시작할 때 스레드를 정해진 개수만큼 만들어 둡니다
  2. 새 작업은 대기 줄에 넣습니다. 노는 스레드가 줄에서 하나씩 꺼내 처리합니다
  3. 처리를 마친 스레드는 없어지지 않고 줄을 다시 들여다봅니다

대가는 동시에 처리하는 개수가 스레드 수에 묶인다는 점입니다. 스레드가 전부 차 있으면 새 작업은 줄에서 기다려야 합니다. 줄이 길어지면 응답이 늦어집니다. 그래서 오래 걸리는 작업 몇 개에는 안 씁니다.

상세

스레드 풀이 푸는 문제는 둘입니다. 스레드를 만들고 없애는 비용과, 스레드가 몇 개까지 늘어나느냐입니다. 아래는 그 둘을 스레드 하나의 생애로 따라갑니다. 마지막에는 이 방식을 고를 때와 안 고를 때를 가릅니다.

스레드를 새로 만드는 비용

스레드는 프로세스 안에서 코드를 따로 실행해 나가는 흐름입니다. 프로그램 하나가 여러 일을 동시에 하려면 흐름이 여럿 있어야 합니다. 그 흐름 하나가 스레드입니다.

스레드를 하나 만드는 일은 공짜가 아닙니다. 운영체제에 요청해서 실행에 쓸 스택 메모리를 잡아야 합니다. 스택은 함수를 부를 때마다 그 함수의 지역 변수와 돌아갈 곳을 쌓아 두는 메모리 공간입니다.

잡아 둘 것이 하나 더 있습니다. 어느 스레드를 언제 실행할지 정하는 스케줄러가 돌볼 항목이 하나 늘어납니다. 스레드를 없앨 때도 이것들을 되돌립니다.

작업이 짧을수록 이 비용이 눈에 띕니다. 요청 하나를 처리하는 시간이 짧다고 해 봅시다. 스레드를 만들고 없애는 데 비슷한 시간이 든다면, 절반은 일이 아니라 준비와 뒷정리에 쓰인 것입니다.

개수는 더 곤란한 문제입니다. 요청이 올 때마다 스레드를 만들면 요청이 몰릴 때 스레드도 같이 몰려서 늘어납니다. 스레드가 늘수록 스케줄러가 실행을 이 스레드에서 저 스레드로 넘기는 문맥 교환이 잦아집니다. 그러다 보면 일하는 시간보다 서로 실행을 넘겨받는 시간이 더 길어집니다.

스레드 풀은 이 둘을 한 번에 막습니다. 스레드를 정해진 개수만큼 미리 만들어 두고 그 개수를 늘리지 않습니다. 만들고 없애는 비용이 사라지고 동시에 도는 스레드 수에도 천장이 생깁니다.

flowchart TD
    subgraph A["요청마다 새 스레드를 만들 때"]
        A1["요청 3개"] --> A2["스레드 3개"]
        A2 --> A3["요청 30개"]
        A3 --> A4["스레드 30개 · 문맥 교환 폭증"]
    end
    subgraph B["스레드 풀을 쓸 때"]
        B1["요청 3개"] --> B2["스레드 4개"]
        B2 --> B3["요청 30개"]
        B3 --> B4["스레드 4개 그대로 · 나머지 26개는 기다림"]
    end

작업이 풀을 지나는 길

스레드 풀은 두 부분으로 되어 있습니다. 하나는 아직 처리되지 않은 작업을 담아 두는 작업 큐입니다. 다른 하나는 그 큐에서 작업을 꺼내 실행하는 워커 스레드 여럿입니다.

큐는 들어온 순서대로 하나씩 꺼내는 줄입니다. 먼저 들어온 작업이 먼저 실행됩니다.

flowchart TD
    C1["작업 1"]
    C2["작업 2"]
    C3["작업 3"]
    Q["작업 큐 · 먼저 온 것부터"]
    subgraph P["스레드 풀 · 스레드 개수 고정"]
        W1["워커 스레드 1"]
        W2["워커 스레드 2"]
    end
    C1 --> Q
    C2 --> Q
    C3 --> Q
    Q --> W1
    Q --> W2
    W1 -. 끝나면 다시 큐로 .-> Q
    W2 -. 끝나면 다시 큐로 .-> Q

작업을 맡기는 쪽은 스레드를 직접 만들지 않습니다. 할 일을 큐에 넣고 바로 다음 일을 하러 갑니다. 그 일을 언제 어느 스레드가 집어 갈지는 풀이 정합니다.

워커 스레드 하나만 떼어 보면 두 상태를 오갑니다. 큐를 들여다보다가 작업이 있으면 꺼내 실행합니다. 끝나면 다시 큐를 들여다봅니다.

stateDiagram-v2
    [*] --> 대기: 풀을 만들 때
    대기 --> 실행: 큐에서 작업을 꺼냄
    실행 --> 대기: 작업을 끝냄
    대기 --> [*]: 풀을 닫을 때

실행에서 대기로 되돌아오는 화살표가 이 방식의 핵심입니다. 스레드가 작업에 딸린 것이 아니라 작업보다 오래 살아서, 스레드 하나가 수많은 작업을 차례로 처리합니다.

스레드를 몇 개 둘지 정하는 기준

풀 크기는 반드시 정해야 합니다. 모든 경우에 맞는 숫자가 하나 있는 것이 아니라, 작업이 시간을 어디에 쓰느냐에 따라 갈립니다.

작업이 계산 위주라면 셈이 간단합니다. 계산 위주란 CPU(Central Processing Unit, 중앙처리장치)를 쉬지 않고 쓴다는 뜻입니다. 코어는 CPU 안에서 실제로 명령을 실행하는 단위입니다. 코어 하나는 한 번에 하나씩만 실행합니다.

그래서 계산 위주 작업은 스레드를 코어 수보다 많이 둬도 소용이 없습니다. 남는 스레드는 차례를 기다리기만 합니다. 문맥 교환만 늘어납니다.

작업이 기다림 위주라면 이야기가 달라집니다. 데이터베이스 응답이나 디스크 읽기를 기다리는 동안 그 스레드는 코어를 쓰지 않고 놀립니다. 그래서 코어 수보다 스레드를 많이 둬야 한 스레드가 기다리는 동안 다른 스레드가 코어를 씁니다.

sequenceDiagram
    participant W1 as 워커 1
    participant W2 as 워커 2
    participant C as 코어
    participant DB as 데이터베이스
    W1->>DB: 조회를 보낸다
    Note over W1: 응답을 기다리는 동안 코어를 안 쓴다
    W2->>C: 그사이 계산을 돌린다
    DB-->>W1: 응답
    W1->>C: 이어서 계산을 돌린다

작업이 도는 시간 중 기다리는 몫이 클수록 스레드를 더 둡니다. 그래야 한 스레드가 기다리는 동안 코어가 놀지 않습니다.

출발점을 잡는 어림은 있습니다. 기다리는 시간이 일하는 시간의 몇 배인지를 셉니다. 거기에 1을 더해 코어 수에 곱합니다. 기다리는 시간이 일하는 시간의 세 배라면 코어 수의 네 배쯤에서 시작합니다. 재 보고 늘리거나 줄이는 출발점이지 정답은 아닙니다.

풀이 막히는 조건

스레드 풀은 동시에 도는 작업 수를 스레드 개수로 묶습니다. 이 천장이 곧 대가입니다. 스레드가 전부 차 있으면 새 작업은 큐에서 기다립니다. 기다린 만큼 응답이 늦어집니다.

큐 길이에 제한을 두지 않으면 이 지연이 눈에 안 보이는 채로 커집니다. 작업이 들어오는 속도가 처리하는 속도를 넘어서면 큐가 계속 길어집니다. 메모리를 다 쓸 때까지 쌓입니다.

그래서 큐 길이에 상한을 둡니다. 상한을 넘으면 새 작업을 거절하거나 넣는 쪽을 기다리게 합니다. 이 조절이 백프레셔입니다.

풀 안의 작업이 같은 풀의 다른 작업을 기다리면 더 까다로워집니다. 작업 하나가 딸린 작업을 같은 풀에 넣고 그 결과를 기다립니다. 스레드가 전부 그런 작업으로 차 있으면 딸린 작업을 꺼낼 스레드가 하나도 남지 않습니다. 서로를 기다리며 아무도 나아가지 못하는 이 상태가 교착입니다.

flowchart TD
    subgraph P["스레드 풀 · 2개 모두 점유"]
        W1["워커 스레드 1 · 작업 A 실행 중 · A-1 결과 대기"]
        W2["워커 스레드 2 · 작업 B 실행 중 · B-1 결과 대기"]
    end
    Q["작업 큐 · A-1 · B-1"]
    W1 -- 넣음 --> Q
    W2 -- 넣음 --> Q
    Q -. 꺼낼 워커 없음 · 교착 .-> W1

스레드가 작업 사이에 살아남는다는 점 자체도 함정을 만듭니다. 스레드에는 그 스레드만 쓰는 저장 공간이 따로 있습니다. 이 공간이 스레드 로컬입니다.

한 작업이 스레드 로컬에 남긴 값을 지우지 않으면, 그 스레드가 다음에 집은 작업이 남이 쓰던 값을 읽습니다. 요청을 보낸 사용자가 누구인지를 거기 넣어 두었다면, 다음 요청이 앞 사용자의 것으로 처리됩니다. 작업마다 스레드를 새로 만들 때는 없던 문제입니다.

언제 쓰고 언제 안 쓰나

짧은 작업이 끊임없이 많이 들어오고 동시에 도는 수에 천장을 두고 싶을 때 씁니다. 웹 서버의 요청 처리가 대표적입니다. 요청 하나하나는 금방 끝나지만 수가 많습니다. 몰릴 때 기계가 버티는 선을 넘지 않아야 합니다.

반대로 작업 하나가 아주 오래 걸리고 개수가 몇 안 되면 풀이 줄 것이 적습니다. 스레드를 만드는 비용이 전체 시간에 견줘 미미합니다. 오래 걸리는 작업 하나가 스레드를 오래 붙들고 있으면 풀은 그저 막히는 통로가 됩니다.

기다림이 대부분이고 동시 연결이 아주 많은 일에는 스레드 풀 대신 이벤트 루프나 코루틴을 고르기도 합니다. 스레드 하나마다 스택 메모리가 필요해서 수만 개를 동시에 두기 어렵기 때문입니다.

flowchart TD
    S["작업이 짧고 수가 많나"]
    S -- 아니오 --> L["작업이 길고 수가 적다 · 풀이 줄 것이 적다"]
    S -- 예 --> N["동시 연결이 수만인가"]
    N -- 아니오 --> T["스레드 풀"]
    N -- 예 --> E["이벤트 루프 · 코루틴"]

관련 항목

스레드 풀을 이루는 구성 요소

스레드 · 워커 스레드 · 작업 큐 · 태스크 · 큐

스레드 풀과 같은 방식으로 자원을 돌려 쓰는 다른 풀

커넥션 풀 · 객체 풀 · 풀 · 버퍼 풀 · 메모리 풀

스레드 풀을 대신할 수 있는 다른 동시성 수단

이벤트 루프 · 코루틴 · 고루틴 · 그린 스레드 · 프로세스 풀 · 비동기 입출력

스레드 풀에서 자주 나는 장애

교착 · 기아 · 스레드 누수 · 큐 적체 · 헤드 오브 라인 블로킹 · 포화

스레드 풀 크기를 정할 때 보는 지표

처리량 · 지연 시간 · 문맥 교환 · 리틀의 법칙 · 큐잉 이론

스레드 풀에 들어오는 일의 양을 조절하는 수단

백프레셔 · 타임아웃 · 유계 큐 · 거부 정책 · 속도 제한

스레드 풀이 올라타는 실행 바탕

프로세스 · 운영체제 · 스케줄러 · CPU · 코어 · 스택 · 메모리 · 스레드 로컬

스레드 풀이 속하는 상위 분류

동시성 · 병렬성 · 동시성 모델 · 비동기 처리 · 자원 관리

다른 이름: 스레드풀 · thread pool · thread pooling · 워커 풀