스레드 프로그래밍
C의 pthread와 Python의 threading으로 스레드를 만들고, 인자를 넘기고, 결과를 받고, join과 detach로 정리하는 방법을 익힙니다. GIL이 계산 작업과 대기 작업에 주는 영향, 데몬 스레드, 스레드 풀과 풀 크기를 정하는 기준, 공유 변수를 잠금 없이 고칠 때 생기는 문제를 예제로 확인합니다.
앞의 두 글에서 스레드가 무엇이고 운영체제가 스레드를 어떻게 관리하는지 살펴보았습니다. 이 글에서는 직접 코드를 써 봅니다. C에서는 POSIX 스레드(POSIX threads, pthread) API를, Python에서는 threading 모듈을 씁니다. 스레드를 만들고 끝나기를 기다리는 기본에서 시작해, 스레드에 인자를 넘기고 결과를 돌려받는 방법, 스레드를 여러 개 재사용하는 스레드 풀, 그리고 여러 스레드가 같은 변수를 고칠 때 생기는 문제까지 차례로 다룹니다.
C 예제는 Linux의 glibc와 gcc를 기준으로 하며, Python 예제는 Python 3.14와 3.15에서 실행해 본문의 출력과 같은지 확인했습니다.
pthread로 스레드 만들고 기다리기
다음 C 프로그램은 스레드 셋을 만들어 각자 자기 번호를 출력하게 하고, 세 스레드가 모두 끝나기를 기다립니다. main의 앞쪽 반복은 pthread_create()로 스레드를 하나씩 만들면서 배열 ids의 원소 주소를 넘기고, 뒤쪽 반복은 pthread_join()으로 세 스레드가 끝나기를 차례로 기다립니다. 코드 뒤의 gcc 명령은 이 파일을 컴파일하는 방법입니다.
#include <pthread.h>
#include <stdio.h>
#include <string.h>
static void *thread_func(void *arg)
{
int id = *(int *) arg;
printf("스레드 %d 실행 중\n", id);
return NULL;
}
int main(void)
{
pthread_t threads[3];
int ids[3] = {1, 2, 3};
for (int i = 0; i < 3; i++) {
int err = pthread_create(&threads[i], NULL, thread_func, &ids[i]);
if (err != 0) {
fprintf(stderr, "pthread_create: %s\n", strerror(err));
return 1;
}
}
for (int i = 0; i < 3; i++)
pthread_join(threads[i], NULL);
printf("모든 스레드 종료\n");
return 0;
}gcc -o pthread_basic pthread_basic.c -pthreadpthread_create()의 네 인자는 차례로 새 스레드의 식별자를 받을 pthread_t 변수의 주소, 스레드 속성(NULL이면 기본값), 스레드가 실행할 함수, 그 함수에 넘길 인자입니다. 스레드 함수는 void *를 하나 받아 void *를 돌려주는 모양이어야 하므로, 넘길 값은 포인터로 감싸 보냅니다. pthread 함수들은 실패할 때 errno를 설정하지 않고 오류 번호를 반환값으로 돌려주므로, 반환값을 strerror()로 바꾸어 출력했습니다.
pthread_join()은 지정한 스레드가 끝날 때까지 기다린 뒤, 끝난 스레드의 자원을 회수합니다. 세 스레드의 "실행 중" 줄이 어떤 순서로 출력될지는 실행할 때마다 다를 수 있지만, 마지막 줄은 언제나 세 스레드가 모두 끝난 뒤에 출력됩니다. join 루프가 1번부터 기다린다고 1번이 먼저 끝나는 것은 아니며, 2번이 먼저 끝났다면 2번의 join은 기다리지 않고 바로 돌아올 뿐입니다.
컴파일할 때 붙인 -pthread는 스레드를 쓰는 데 필요한 전처리 설정과 링크 설정을 함께 해 주는 옵션입니다. glibc 2.34부터는 pthread 함수들이 C 라이브러리 본체에 들어가 -lpthread 없이도 링크되지만, 다른 환경과의 이식성을 위해 -pthread를 붙이는 것이 좋습니다.
여기서 눈여겨볼 곳은 각 스레드에 &ids[i], 곧 배열 원소의 주소를 넘겼다는 점입니다. 배열은 main의 지역 변수이지만, main은 모든 스레드를 join하기 전에는 반환하지 않으므로 스레드들이 읽는 동안 배열은 살아 있습니다. 또 main은 스레드가 실행되는 동안 이 배열을 고치지 않습니다. 스레드에 주소를 넘길 때는 이처럼 그 저장소가 얼마나 오래 살아 있는지와, 누가 동시에 고치지 않는지를 함께 따져야 합니다.
스레드에서 결과 받기
스레드 함수가 반환한 void * 값은 pthread_join()의 둘째 인자로 받을 수 있습니다. 다음 프로그램은 스레드가 입력의 제곱을 계산해 돌려줍니다.
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
static void *compute(void *arg)
{
int input = *(int *) arg;
int *result = malloc(sizeof *result); /* 스레드가 끝나도 살아 있는 힙에 둔다. */
if (result != NULL)
*result = input * input;
return result;
}
int main(void)
{
pthread_t tid;
int input = 7;
void *retval = NULL;
if (pthread_create(&tid, NULL, compute, &input) != 0)
return 1;
if (pthread_join(tid, &retval) != 0 || retval == NULL)
return 1;
int *result = retval;
printf("7의 제곱 = %d\n", *result);
free(result); /* 결과를 받은 쪽이 해제한다. */
return 0;
}스레드 함수 안의 지역 변수 주소를 반환하면 안 됩니다. 스레드가 끝나면 그 스레드의 스택이 사라지므로, 반환된 주소는 이미 사라진 메모리를 가리키는 댕글링 포인터(dangling pointer)가 됩니다. 그래서 이 예제는 결과를 malloc으로 힙에 두고, join으로 결과를 받은 main이 다 쓴 뒤 해제합니다. 스레드가 할당하고 main이 해제한다는 이 약속을 양쪽이 지켜야 메모리가 새거나 두 번 해제되지 않습니다. 결과를 담을 저장소를 호출한 쪽이 미리 마련해 인자로 넘겨주는 방법도 있습니다.
인자를 넘길 때의 흔한 실수
다음은 스레드를 만들 때 자주 저지르는 실수입니다. 앞의 예제와 달라진 곳은 pthread_create()의 마지막 인자 하나뿐으로, 스레드마다 다른 원소 &ids[i]를 넘기는 대신 반복 변수 i의 주소를 넘깁니다.
for (int i = 0; i < 3; i++)
pthread_create(&threads[i], NULL, thread_func, &i); /* 세 스레드가 모두 같은 i를 가리킨다. */세 스레드는 모두 같은 변수 i의 주소를 받습니다. 스레드가 실제로 *(int *) arg를 읽는 시점에는 루프가 이미 i를 1이나 2, 3으로 바꾸어 놓았을 수 있으므로, 여러 스레드가 같은 번호를 읽거나 3을 읽을 수 있습니다. 게다가 루프가 끝나면 i는 사라지므로 수명 문제도 있습니다. C에서는 한 스레드가 쓰는 변수를 다른 스레드가 동기화 없이 읽는 것 자체가 정의되지 않은 동작이므로, 어떤 값이 나올지 기대할 수도 없습니다. 앞의 예제처럼 스레드마다 별도의 저장소를 두고, 스레드가 쓰는 동안 그 저장소가 살아 있게 해야 합니다.
join과 detach
스레드가 끝나도 pthread_join()으로 거두기 전까지는 스레드의 반환값과 일부 자원이 남아 있습니다. 3장에서 본 좀비 프로세스와 비슷한 상황입니다. 결과가 필요 없고 끝나면 알아서 정리되기를 원한다면, pthread_detach()로 스레드를 분리(detach)합니다.
pthread_t tid;
int err = pthread_create(&tid, NULL, background_task, NULL);
if (err != 0)
return err;
err = pthread_detach(tid); /* 이제 join할 수 없고, 끝나면 자원이 자동으로 회수된다. */
if (err != 0)
return err;분리한 스레드는 join할 수 없으므로, 그 스레드가 일을 끝냈는지 알려면 따로 신호를 주고받아야 합니다. 또 분리했다고 해서 프로세스가 그 스레드를 기다려 주는 것은 아닙니다. main이 반환하면 exit()가 불려 프로세스 전체가 끝나므로, 분리한 스레드도 하던 일과 상관없이 함께 사라집니다. main이 다른 스레드를 기다리지 않고 자기만 끝나려면 return 대신 pthread_exit()를 부르면 되는데, 이 경우 마지막 스레드가 끝날 때 프로세스가 끝납니다.
아래 단계 개념도는 join이 무엇을 기다리고 무엇을 회수하는지 보여 줍니다.
스레드 회수와 인터프리터 종료 정책
joinable 대상이 아직 실행 중인 대표 경로이다. 결과 저장소의 소유권 계약을 적용한다. 각 단계의 값과 전환 이유를 비교합니다.
스레드 수명 · 기다림과 회수
pthread detach와 Python daemon은 서로 다른 정책입니다
pthread의 join/detach는 종료한 스레드의 자원 회수 정책입니다. Python daemon은 살아 있는 non-daemon 스레드가 더 없을 때 인터프리터 종료를 막는지의 규칙이며 daemon도 join할 수 있습니다.
| 선택 | 보장 범위 |
|---|---|
| pthread join | joinable 스레드가 종료할 때까지 기다리고 결과·자원을 회수합니다. |
| pthread detach | 종료 시 자원을 자동 회수하도록 설정합니다. 이후 join으로 결과를 받을 수 없습니다. |
| Python Thread.join | daemon 여부와 별개로 대상 스레드의 완료를 기다립니다. 여기서는 timeout 없는 join을 가정합니다. |
| Python daemon | 살아 있는 non-daemon 스레드가 없을 때 인터프리터 종료를 막지 않습니다. 정리를 마치지 못할 수 있습니다. |
- pthread join
- 보장 범위: joinable 스레드가 종료할 때까지 기다리고 결과·자원을 회수합니다.
- pthread detach
- 보장 범위: 종료 시 자원을 자동 회수하도록 설정합니다. 이후 join으로 결과를 받을 수 없습니다.
- Python Thread.join
- 보장 범위: daemon 여부와 별개로 대상 스레드의 완료를 기다립니다. 여기서는 timeout 없는 join을 가정합니다.
- Python daemon
- 보장 범위: 살아 있는 non-daemon 스레드가 없을 때 인터프리터 종료를 막지 않습니다. 정리를 마치지 못할 수 있습니다.
pthread detach는 Python daemon과 같은 기능이 아닙니다. 종료 때 반드시 마쳐야 하는 작업에는 완료를 기다리고 실패를 확인할 수 있는 수명 관리가 필요합니다.
이 예의 조건
- 본문과 전체 도표의 예를 따라 join 대기부터 반환값 처리까지 진행합니다.
관계도의 보충 설명
| 구분 | 전체 도표의 내용 |
|---|---|
| 제목 | 스레드 회수와 인터프리터 종료 정책 |
| 관계 설명 | joinable 대상 종료, join 성공 때 스레드 자원 회수, 이후 반환값 처리의 관계와 detached 대상 종료, 자동 회수의 관계를 분리한다. join 호출은 종료 전에 이루어져 기다릴 수도 있다. |
| 관계 설명 | joinable의 실행, 종료, join 성공 때 스레드 자원 회수, 이후 반환값 처리의 네 상태와 detached의 실행, 종료, 자동 회수의 세 상태를 세로 배치한다. join 호출은 종료 전에 이루어져 대기할 수도 있다. 프로세스 종료와 Python daemon 규칙은 별개이다. |
| 표의 값 | pthread join. 보장 범위: joinable 스레드가 종료할 때까지 기다리고 결과·자원을 회수합니다. pthread detach. 보장 범위: 종료 시 자원을 자동 회수하도록 설정합니다. 이후 join으로 결과를 받을 수 없습니다. Python Thread.join. 보장 범위: daemon 여부와 별개로 대상 스레드의 완료를 기다립니다. 여기서는 timeout 없는 join을 가정합니다. Python daemon. 보장 범위: 살아 있는 non-daemon 스레드가 없을 때 인터프리터 종료를 막지 않습니다. 정리를 마치지 못할 수 있습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| join 대기 joinable 대상이 아직 실행 중인 대표 경로이다. |
|
|
| 대상 종료 결과는 준비됐지만 아직 수거하지 않았다. |
|
|
| join 성공 종료 스레드의 자원을 회수한다. |
|
|
| 반환값 처리 결과 저장소의 소유권 계약을 적용한다. |
|
|
스레드 회수와 인터프리터 종료 정책
joinable 대상이 아직 실행 중인 대표 경로이다. 결과 저장소의 소유권 계약을 적용한다. 각 단계의 값과 전환 이유를 비교합니다.
스레드 수명 · 기다림과 회수
pthread detach와 Python daemon은 서로 다른 정책입니다
pthread의 join/detach는 종료한 스레드의 자원 회수 정책입니다. Python daemon은 살아 있는 non-daemon 스레드가 더 없을 때 인터프리터 종료를 막는지의 규칙이며 daemon도 join할 수 있습니다.
| 선택 | 보장 범위 |
|---|---|
| pthread join | joinable 스레드가 종료할 때까지 기다리고 결과·자원을 회수합니다. |
| pthread detach | 종료 시 자원을 자동 회수하도록 설정합니다. 이후 join으로 결과를 받을 수 없습니다. |
| Python Thread.join | daemon 여부와 별개로 대상 스레드의 완료를 기다립니다. 여기서는 timeout 없는 join을 가정합니다. |
| Python daemon | 살아 있는 non-daemon 스레드가 없을 때 인터프리터 종료를 막지 않습니다. 정리를 마치지 못할 수 있습니다. |
- pthread join
- 보장 범위: joinable 스레드가 종료할 때까지 기다리고 결과·자원을 회수합니다.
- pthread detach
- 보장 범위: 종료 시 자원을 자동 회수하도록 설정합니다. 이후 join으로 결과를 받을 수 없습니다.
- Python Thread.join
- 보장 범위: daemon 여부와 별개로 대상 스레드의 완료를 기다립니다. 여기서는 timeout 없는 join을 가정합니다.
- Python daemon
- 보장 범위: 살아 있는 non-daemon 스레드가 없을 때 인터프리터 종료를 막지 않습니다. 정리를 마치지 못할 수 있습니다.
pthread detach는 Python daemon과 같은 기능이 아닙니다. 종료 때 반드시 마쳐야 하는 작업에는 완료를 기다리고 실패를 확인할 수 있는 수명 관리가 필요합니다.
이 예의 조건
- 본문과 전체 도표의 예를 따라 join 대기부터 반환값 처리까지 진행합니다.
관계도의 보충 설명
| 구분 | 전체 도표의 내용 |
|---|---|
| 제목 | 스레드 회수와 인터프리터 종료 정책 |
| 관계 설명 | joinable 대상 종료, join 성공 때 스레드 자원 회수, 이후 반환값 처리의 관계와 detached 대상 종료, 자동 회수의 관계를 분리한다. join 호출은 종료 전에 이루어져 기다릴 수도 있다. |
| 관계 설명 | joinable의 실행, 종료, join 성공 때 스레드 자원 회수, 이후 반환값 처리의 네 상태와 detached의 실행, 종료, 자동 회수의 세 상태를 세로 배치한다. join 호출은 종료 전에 이루어져 대기할 수도 있다. 프로세스 종료와 Python daemon 규칙은 별개이다. |
| 표의 값 | pthread join. 보장 범위: joinable 스레드가 종료할 때까지 기다리고 결과·자원을 회수합니다. pthread detach. 보장 범위: 종료 시 자원을 자동 회수하도록 설정합니다. 이후 join으로 결과를 받을 수 없습니다. Python Thread.join. 보장 범위: daemon 여부와 별개로 대상 스레드의 완료를 기다립니다. 여기서는 timeout 없는 join을 가정합니다. Python daemon. 보장 범위: 살아 있는 non-daemon 스레드가 없을 때 인터프리터 종료를 막지 않습니다. 정리를 마치지 못할 수 있습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| join 대기 joinable 대상이 아직 실행 중인 대표 경로이다. |
|
|
| 대상 종료 결과는 준비됐지만 아직 수거하지 않았다. |
|
|
| join 성공 종료 스레드의 자원을 회수한다. |
|
|
| 반환값 처리 결과 저장소의 소유권 계약을 적용한다. |
|
|
join을 부른 스레드는 대상 스레드가 끝날 때까지 기다립니다. 대상이 끝나면 결과가 준비되지만 아직 거두지 않은 상태이고, join이 성공해야 결과를 받고 스레드의 자원이 회수됩니다. 반환된 포인터가 가리키는 저장소는 스레드의 자원과 별개이므로, 그것을 언제 해제할지는 앞에서 본 약속에 따라 따로 처리합니다. 이 그림의 join과 detach는 pthread의 회수 규칙이고, 아래에서 볼 Python의 데몬 스레드는 인터프리터가 종료할 때 기다릴지에 대한 규칙이므로 서로 다른 이야기입니다.
Python의 threading
Python에서는 threading.Thread에 실행할 함수(target)와 인자(args)를 넘겨 스레드를 만들고, start()로 실행하고 join()으로 기다립니다. 결과를 돌려받는 전용 통로는 없으므로, 보통 결과를 담을 자료구조를 인자로 넘깁니다.
import threading
def worker(name, results):
results[name] = f"{threading.current_thread().name}에서 처리"
results = {}
threads = [threading.Thread(target=worker, args=(i, results), name=f"worker-{i}")
for i in range(3)]
for t in threads:
t.start()
for t in threads:
t.join()
for name in sorted(results):
print(name, results[name])
print("모든 스레드 종료, 살아 있는 스레드 수:", threading.active_count())0 worker-0에서 처리
1 worker-1에서 처리
2 worker-2에서 처리
모든 스레드 종료, 살아 있는 스레드 수: 1각 스레드는 서로 다른 키에 결과를 기록하므로 덮어쓰지 않습니다. 세 스레드가 끝나는 순서는 실행할 때마다 다를 수 있으므로 키를 정렬해 출력했고, 모두 join한 뒤에는 메인 스레드 하나만 남습니다. name으로 스레드에 이름을 붙여 두면 로그나 디버거에서 어느 스레드인지 구분하기 쉽습니다.
GIL과 두 종류의 작업
일반적인 CPython에는 전역 인터프리터 잠금(global interpreter lock, GIL)이 있어서, 한 순간에 한 스레드만 Python 바이트코드를 실행할 수 있습니다. 그래서 스레드를 나누었을 때의 효과가 작업의 종류에 따라 크게 달라집니다. 다음 코드는 같은 일을 두 번 차례로 할 때와 스레드 둘로 나누어 할 때의 시간을 비교합니다. 걸린 시간은 컴퓨터마다 다르므로 비율로 판정해 출력합니다.
import sys
import threading
import time
def cpu_bound(n):
total = 0
for i in range(n):
total += i * i
return total
def io_bound(seconds):
time.sleep(seconds) # 입출력 대기를 타이머로 흉내 낸다
def elapsed(func, arg, use_threads):
start = time.perf_counter()
if use_threads:
threads = [threading.Thread(target=func, args=(arg,)) for _ in range(2)]
for t in threads:
t.start()
for t in threads:
t.join()
else:
func(arg)
func(arg)
return time.perf_counter() - start
print("GIL 사용:", sys._is_gil_enabled())
for label, func, arg in [("CPU 계산", cpu_bound, 3_000_000), ("입출력 대기", io_bound, 0.3)]:
ratio = elapsed(func, arg, True) / elapsed(func, arg, False)
verdict = "시간이 거의 그대로" if ratio > 0.75 else "시간이 줄어듦"
print(f"{label}: 스레드 2개로 나누면 {verdict}")GIL 사용: True
CPU 계산: 스레드 2개로 나누면 시간이 거의 그대로
입출력 대기: 스레드 2개로 나누면 시간이 줄어듦순수 Python으로 계산하는 cpu_bound는 스레드 둘로 나누어도 빨라지지 않습니다. 두 스레드가 GIL을 번갈아 쥐며 한 번에 하나씩만 실행되기 때문이며, 경우에 따라 GIL을 주고받는 비용 때문에 조금 더 느려지기도 합니다. 반면 time.sleep()이나 소켓의 recv()처럼 기다리는 함수는 기다리는 동안 GIL을 놓아주므로, 두 스레드의 대기가 겹쳐 시간이 거의 절반으로 줄어듭니다. 첫 줄의 sys._is_gil_enabled()는 Python 3.13부터 있는 함수로, GIL 없이 빌드한 free-threaded Python에서는 False를 돌려주며 이때는 계산 작업도 스레드로 나누어 빨라질 수 있습니다.
GIL이 있는 Python에서 계산을 여러 코어로 나누려면 3장에서 본 것처럼 프로세스를 씁니다. multiprocessing.Pool은 워커 프로세스 여러 개를 띄워 두고 작업을 나누어 맡깁니다.
from multiprocessing import Pool
def cpu_bound(n):
total = 0
for i in range(n):
total += i * i
return total
if __name__ == "__main__":
with Pool(processes=4) as pool:
results = pool.map(cpu_bound, [1_000_000] * 4)
print(results[0], sum(results))333332833333500000 1333331333334000000pool.map은 목록의 각 원소를 워커 프로세스에 나누어 넘기고, 결과를 넘긴 순서대로 모아 돌려줍니다. 0부터 999,999까지의 제곱의 합이 333,332,833,333,500,000이므로, 같은 계산 넷의 합은 그 네 배입니다. 각 프로세스는 자기 인터프리터와 GIL을 가지므로 네 계산이 서로 다른 코어에서 동시에 실행될 수 있습니다. 다만 프로세스를 띄우고 인자와 결과를 주고받는 데도 비용이 들므로, 작업이 아주 작으면 오히려 느려질 수 있습니다.
더 깊이: GIL을 끈 빌드는 어디까지 왔나
GIL을 없앤 CPython은 PEP 703에 따라 Python 3.13에서 별도의 빌드로 처음 들어왔고, Python 3.14에서는 PEP 779가 받아들여져 공식 지원 단계가 되었습니다. 다만 이 단계는 free-threaded 빌드를 공식으로 지원하되 쓸지 말지는 선택으로 남겨 두는 단계이며, 기본 빌드는 여전히 GIL이 있는 빌드입니다. free-threaded 빌드를 기본으로 바꾸는 일은 앞으로 나올 별도의 PEP에서 정하기로 했습니다.
GIL을 끄는 데에도 비용이 있습니다. Python 3.14의 free-threading 안내서에 따르면 스레드 하나로 실행하는 코드가 pyperformance 벤치마크에서 평균적으로 macOS aarch64에서는 약 1%, x86-64 Linux에서는 약 8% 느립니다. 또 C API로 만든 확장 모듈이 free threading을 지원한다고 표시하지 않았다면, 그 모듈을 import하는 순간 GIL이 자동으로 다시 켜지고 경고가 출력됩니다.
그래서 빌드와 실행 상태는 따로 확인해야 합니다. 빌드가 free threading을 지원하는지는 sysconfig.get_config_var("Py_GIL_DISABLED")가 1인지로 알 수 있고, 지금 실행 중인 프로세스에서 GIL이 정말 꺼져 있는지는 위 예제의 sys._is_gil_enabled()로 확인합니다. free-threaded 빌드에서도 환경 변수 PYTHON_GIL이나 -X gil 옵션으로 GIL을 켜고 실행할 수 있으므로, 같은 실행 파일이라도 두 값이 다르게 나올 수 있습니다.
데몬 스레드
Python 프로그램은 데몬(daemon)이 아닌 스레드가 모두 끝나야 종료합니다. daemon=True로 만든 데몬 스레드는 이 계산에서 빠지므로, 다른 스레드가 모두 끝나면 데몬 스레드가 실행 중이어도 프로그램이 끝납니다.
import threading
import time
def monitor():
while True:
print("모니터링 중...")
time.sleep(0.3)
threading.Thread(target=monitor, daemon=True).start()
time.sleep(0.75)
print("메인 종료")모니터링 중...
모니터링 중...
모니터링 중...
메인 종료데몬 스레드는 0초, 0.3초, 0.6초에 한 번씩 출력하고, 메인 스레드는 0.75초 뒤에 끝납니다. 데몬 스레드는 무한 루프를 돌고 있지만, 남은 일반 스레드가 없으므로 프로그램은 바로 종료합니다. 이때 데몬 스레드는 하던 일을 마무리할 기회 없이 끊기므로, 파일 쓰기처럼 중간에 끊기면 안 되는 일을 맡겨서는 안 됩니다. 정리가 필요한 백그라운드 작업은 일반 스레드로 만들고, threading.Event 같은 표시로 종료를 알린 뒤 join으로 마무리를 기다리는 것이 안전합니다. 데몬 여부는 join할 수 있는지와는 상관이 없어서, 데몬 스레드도 join할 수 있습니다. 자세한 규칙은 threading 문서에 있습니다.
스레드 풀
요청이 올 때마다 스레드를 만들고 끝나면 없애는 방식에는 두 가지 문제가 있습니다. 첫째, 스레드를 만들고 없애는 비용이 요청마다 쌓입니다. 둘째, 요청이 몰리면 스레드가 끝없이 늘어나 메모리가 바닥나거나 전환 비용 때문에 오히려 모두가 느려집니다. 스레드 풀(thread pool)은 정해진 수의 워커 스레드를 만들어 두고 작업을 차례로 맡겨 재사용하는 방식으로 이 문제를 풉니다. 워커가 모두 바쁘면 새 작업은 큐에서 기다립니다.
Python의 ThreadPoolExecutor
Python의 concurrent.futures.ThreadPoolExecutor가 스레드 풀입니다. 다음 코드는 0.2초씩 기다리는 작업 8개를 워커 4개와 2개로 각각 처리합니다.
from concurrent.futures import ThreadPoolExecutor
import time
DELAY = 0.2
def fetch(page):
time.sleep(DELAY) # 네트워크 응답을 기다린다고 가정한다
return page * 10
for workers in (4, 2):
start = time.perf_counter()
with ThreadPoolExecutor(max_workers=workers) as pool:
results = list(pool.map(fetch, range(8)))
rounds = (time.perf_counter() - start) / DELAY
print(f"워커 {workers}개: 대기 {rounds:.0f}번 분량, 결과 {results}")워커 4개: 대기 2번 분량, 결과 [0, 10, 20, 30, 40, 50, 60, 70]
워커 2개: 대기 4번 분량, 결과 [0, 10, 20, 30, 40, 50, 60, 70]워커가 4개면 작업 8개를 4개씩 두 묶음으로 처리하므로 대기 시간의 두 배가 걸리고, 워커가 2개면 네 묶음이 되어 네 배가 걸립니다. pool.map은 작업이 끝나는 순서와 상관없이 결과를 넘긴 순서대로 돌려줍니다. 끝나는 순서대로 결과를 받고 싶다면 submit()으로 작업을 넣고 as_completed()로 끝난 것부터 꺼내면 됩니다. with 블록을 벗어나면 shutdown(wait=True)가 불려 남은 작업이 모두 끝날 때까지 기다립니다.
워커 수를 제한해도 기다리는 작업의 수까지 제한되지는 않는다는 점에 주의해야 합니다. ThreadPoolExecutor의 큐에는 크기 제한이 없으므로, 작업이 처리되는 속도보다 빨리 들어오면 큐가 끝없이 길어집니다. 들어오는 작업을 제한해야 한다면 세마포어로 제출 수를 막는 등의 방법을 따로 써야 합니다.
C로 만드는 스레드 풀의 뼈대
pthread에는 스레드 풀이 들어 있지 않으므로, C에서는 라이브러리를 쓰거나 직접 만듭니다. 직접 만드는 스레드 풀의 핵심은 작업 큐 하나와 이를 보호하는 뮤텍스, 그리고 큐가 비었을 때 워커를 재우는 조건 변수입니다. 다음은 그 가운데 워커가 실행하는 루프만 떼어 낸 것으로, 풀을 초기화하고 작업을 넣고 종료하는 코드는 생략했습니다.
#include <pthread.h>
#define QUEUE_SIZE 16
typedef struct {
void (*function)(void *);
void *arg;
} task_t;
typedef struct {
task_t queue[QUEUE_SIZE];
int head, tail, count;
int shutdown;
pthread_mutex_t lock;
pthread_cond_t not_empty;
pthread_cond_t not_full;
} thread_pool_t;
static void *worker_thread(void *arg)
{
thread_pool_t *pool = arg;
for (;;) {
pthread_mutex_lock(&pool->lock);
while (pool->count == 0 && !pool->shutdown)
pthread_cond_wait(&pool->not_empty, &pool->lock); /* 일이 생길 때까지 잠든다. */
if (pool->count == 0 && pool->shutdown) {
pthread_mutex_unlock(&pool->lock);
break; /* 남은 일이 없으면 끝낸다. */
}
task_t task = pool->queue[pool->head];
pool->head = (pool->head + 1) % QUEUE_SIZE;
pool->count--;
pthread_cond_signal(&pool->not_full); /* 작업을 넣으려 기다리는 쪽을 깨운다. */
pthread_mutex_unlock(&pool->lock);
task.function(task.arg); /* 잠금을 놓은 뒤에 실행한다. */
}
return NULL;
}워커는 잠금을 쥔 채 큐를 확인하고, 큐가 비어 있으면 pthread_cond_wait()로 잠듭니다. 이 함수는 잠드는 동시에 잠금을 놓아 주므로, 그동안 다른 스레드가 큐에 작업을 넣을 수 있습니다. 작업을 넣은 쪽이 not_empty로 신호를 보내면 워커가 깨어나 잠금을 다시 쥐고 작업을 하나 꺼냅니다. 실제 작업은 잠금을 놓은 뒤에 실행하는데, 잠금을 쥔 채 오래 걸리는 작업을 하면 다른 워커가 큐에 접근하지 못하기 때문입니다. 이 코드는 종료가 요청되어도 큐에 남은 작업을 모두 처리한 뒤에 끝나도록 했습니다. 조건 변수를 while로 다시 확인하는 이유는 6장에서 자세히 다룹니다.
풀의 크기는 어떻게 정할까
풀의 크기는 처음에 추정값으로 정하고 부하 시험으로 조정합니다. 계산이 대부분인 작업이라면 동시에 실행될 수 있는 수가 CPU 수를 넘지 못하므로, 쓸 수 있는 논리 CPU 수 정도에서 시작합니다. 컨테이너라면 컴퓨터 전체의 CPU 수가 아니라 컨테이너에 배정된 CPU 한도를 기준으로 삼아야 합니다.
기다리는 시간이 긴 작업이라면, 한 스레드가 기다리는 동안 다른 스레드가 CPU를 쓸 수 있으므로 더 많은 스레드가 필요합니다. 흔히 쓰는 추정식은 CPU 수 × (1 + 대기 시간 ÷ 처리 시간)입니다. 예를 들어 CPU가 4개이고 작업마다 90ms를 기다리고 10ms를 계산한다면 4 × (1 + 90 ÷ 10) = 40개가 출발점입니다. 하지만 이 수는 CPU만 생각한 값입니다. 스레드마다 데이터베이스 연결을 하나씩 쓴다면 연결 풀의 크기를 넘는 스레드는 연결을 기다리기만 하므로, 실제로는 외부 자원의 한도와 메모리를 함께 보고 정해야 합니다. 스레드가 너무 적으면 CPU가 놀고, 너무 많으면 전환 비용과 메모리가 낭비됩니다.
공유 변수를 고칠 때 생기는 일
스레드가 메모리를 함께 쓰면 무엇이 문제가 되는지 직접 확인해 보겠습니다. counter += 1은 한 줄이지만 실제로는 값을 읽고, 1을 더하고, 다시 쓰는 단계로 나뉩니다. 두 스레드가 이 단계를 동시에 진행하면 둘 다 같은 옛 값을 읽어 한 번의 증가가 사라질 수 있습니다. 이런 겹침은 운이 나쁠 때만 일어나므로 평범하게 실행해서는 보기 어렵습니다. 다음 코드는 Barrier로 두 스레드가 모두 값을 읽은 다음에 쓰도록 맞추어, 운이 나쁜 순서를 일부러 재현합니다.
import threading
counter = 0
lock = threading.Lock()
both_read = threading.Barrier(2)
def unsafe_increment():
global counter
value = counter # 1. 읽기
both_read.wait() # 두 스레드가 모두 읽은 뒤에 쓰도록 맞춘다
counter = value + 1 # 2. 더해서 쓰기
def safe_increment():
global counter
with lock: # 읽기와 쓰기 사이에 다른 스레드가 끼어들 수 없다
value = counter
counter = value + 1
for label, func in [("잠금 없이", unsafe_increment), ("잠금 사용", safe_increment)]:
counter = 0
threads = [threading.Thread(target=func) for _ in range(2)]
for t in threads:
t.start()
for t in threads:
t.join()
print(f"{label}: 두 스레드가 1씩 더한 결과 {counter}")잠금 없이: 두 스레드가 1씩 더한 결과 1
잠금 사용: 두 스레드가 1씩 더한 결과 2잠금 없이 실행하면 두 스레드가 모두 0을 읽은 뒤 각자 1을 쓰므로, 두 번 더했는데 결과는 1입니다. Barrier는 이 순서를 확실히 재현하려고 넣은 것일 뿐이고, 실제 프로그램에서는 운영체제가 읽기와 쓰기 사이에서 스레드를 전환할 때 같은 일이 가끔 일어납니다. 가끔 일어나기 때문에 시험할 때는 통과하다가 운영 중에만 틀린 값이 나오는, 찾기 어려운 버그가 됩니다. with lock: 블록은 한 스레드가 잠금을 쥐고 읽고 쓰는 동안 다른 스레드가 같은 블록에 들어오지 못하게 하므로, 결과가 2가 됩니다.
반대로 잠금 없는 counter += 1 반복문을 여러 번 실행해 매번 맞는 값이 나왔다고 해서 안전하다고 결론 내릴 수는 없습니다. 이런 문제는 Python 버전, GIL 설정, 컴퓨터의 부하에 따라 드러나기도 하고 숨기도 하기 때문입니다. 여러 스레드가 함께 고치는 값은 처음부터 잠금 같은 동기화로 보호해야 하며, 이 내용은 6장에서 본격적으로 다룹니다.
정리
- pthread에서는
pthread_create()로 스레드를 만들고pthread_join()으로 기다려 자원을 회수합니다. 결과가 필요 없으면pthread_detach()로 분리할 수 있지만,main이 반환하면 분리한 스레드도 함께 끝납니다. - 스레드에 넘기는 인자와 스레드가 돌려주는 결과는 쓰는 동안 살아 있는 저장소에 두어야 하며, 지역 변수의 주소를 넘기거나 돌려줄 때는 수명을 확인해야 합니다.
- GIL이 있는 CPython에서 순수 Python 계산은 스레드로 나누어도 빨라지지 않고, 기다리는 작업은 대기가 겹쳐 빨라집니다. 계산을 나누려면
multiprocessing을 씁니다. - 데몬 스레드는 프로그램 종료를 막지 않지만 마무리 없이 끊기므로, 정리가 필요한 작업에는 일반 스레드와 종료 표시를 씁니다.
- 스레드 풀은 정해진 수의 워커를 재사용해 생성 비용과 스레드 폭증을 막습니다. 풀의 크기는 CPU 수와 대기 비율로 추정한 뒤, 외부 자원의 한도와 부하 시험으로 조정합니다.
- 여러 스레드가 같은 변수를 읽고 고치고 쓰면 갱신이 사라질 수 있으므로, 잠금으로 보호해야 합니다.
연습 문제
1. 작업 9개와 워커 4개
0.2초 걸리는 작업 9개를 워커 4개짜리 ThreadPoolExecutor로 처리합니다. 준비와 스케줄링 비용을 무시할 때 걸리는 시간은 대기 시간의 몇 배일까요? 또 이 풀에 작업을 1초에 100개씩 계속 넣으면 어떤 일이 생길지 설명하세요.
해설 보기
워커 4개가 작업을 4개, 4개, 1개의 세 묶음으로 처리하므로 대기 시간의 세 배, 약 0.6초가 걸립니다. 마지막 묶음에서는 워커 하나만 일하고 셋은 쉬는데, 작업 수가 워커 수의 배수가 아니면 이렇게 시간이 남습니다. 1초에 100개씩 넣으면, 워커 4개는 1초에 4 ÷ 0.2 = 20개만 처리하므로 1초마다 80개씩 큐에 쌓입니다. ThreadPoolExecutor의 큐에는 크기 제한이 없으므로 기다리는 작업이 끝없이 늘어 메모리를 차지하고, 작업이 실제로 처리되기까지의 대기 시간도 계속 길어집니다. 워커 수의 제한은 동시에 실행되는 작업만 제한할 뿐이므로, 제출하는 쪽을 따로 제한하거나 처리 능력을 늘려야 합니다.
2. 결과를 해제하는 시점
앞의 pthread_return.c에서 compute 함수가 return result; 바로 앞에 free(result);를 넣었다고 합시다. main의 pthread_join()은 성공하고 retval도 NULL이 아닙니다. 이 프로그램은 올바를까요? 지역 변수 int value = input * input;의 주소를 반환하는 경우와 비교해 설명하세요.
해설 보기
올바르지 않습니다. compute가 해제한 뒤 그 주소를 반환했으므로, main이 *result를 읽는 것은 이미 해제된 메모리를 읽는 것이고, 마지막의 free(result)는 같은 메모리를 두 번 해제하는 것입니다. join이 성공했다는 것은 스레드가 끝나 반환값을 받았다는 뜻일 뿐, 그 주소가 가리키는 메모리가 아직 살아 있다는 뜻이 아닙니다. 지역 변수의 주소를 반환하는 경우도 같은 종류의 문제입니다. 스레드가 끝나면 그 스레드의 스택이 사라지므로 반환된 주소는 사라진 메모리를 가리킵니다. 결과를 담은 저장소는 받는 쪽이 다 쓸 때까지 살아 있어야 하고, 해제는 정해 둔 한쪽만 한 번 해야 합니다.
3. 풀 크기 추정하기
CPU 8개를 쓸 수 있는 서버에서, 요청 하나를 처리하는 데 계산이 20ms, 데이터베이스 응답 대기가 180ms 걸립니다. 추정식으로 스레드 풀의 출발점을 구하세요. 데이터베이스 연결 풀의 크기가 30이고 요청마다 연결을 하나 쓴다면 이 값을 그대로 써도 될까요?
해설 보기
추정식은 CPU 수 × (1 + 대기 시간 ÷ 처리 시간)이므로 8 × (1 + 180 ÷ 20) = 8 × 10 = 80개가 출발점입니다. 그러나 요청마다 데이터베이스 연결을 하나씩 쓰는데 연결이 30개뿐이라면, 동시에 데이터베이스를 기다릴 수 있는 요청은 30개가 최대입니다. 나머지 50개의 스레드는 연결을 얻으려고 기다리기만 하면서 메모리와 전환 비용만 쓰게 됩니다. 이 경우에는 30 근처에서 시작해 부하 시험으로 조정하거나, 처리량이 더 필요하다면 데이터베이스가 감당할 수 있는 범위에서 연결 풀을 늘리는 것을 함께 검토해야 합니다.
다음 장의 스케줄링의 개념과 기준에서는 실행할 수 있는 스레드가 여럿일 때 운영체제가 누구에게 CPU를 줄지 정하는 기준을 살펴봅니다.