안동민 개발노트

본문 시작

스레드 프로그래밍

C pthread와 Python threading으로 스레드를 생성·합류하고 인자를 전달하며 스레드 풀과 GIL의 영향을 실습합니다.

이론으로 이해한 스레드를 코드로 직접 다루어 보겠습니다.

C의 POSIX Threads(pthread)와 Python의 threading 모듈을 사용하여, 스레드 생성·종료·합류, 반환 값 수집, 동기화 기초, 스레드 풀을 실습합니다.


C — pthread 기본

스레드 생성과 합류

pthread_basic.c
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>

void *thread_func(void *arg) {
    int id = *(int *)arg;
    printf("Thread %d running (tid=%lu)\n", id, (unsigned long)pthread_self());
    return NULL;
}

int main() {
    pthread_t threads[3];
    int ids[3] = {1, 2, 3};

    for (int i = 0; i < 3; i++) {
        int ret = pthread_create(&threads[i], NULL, thread_func, &ids[i]);
        if (ret != 0) {
            fprintf(stderr, "pthread_create failed: %d\n", ret);
            exit(1);
        }
    }

    for (int i = 0; i < 3; i++) {
        pthread_join(threads[i], NULL);
    }

    printf("All threads done\n");
    return 0;
}

위 예제의 pthread_t 숫자 출력은 Linux 구현을 전제로 합니다. pthread_t는 이식 가능한 정수 ID가 아니며 실행 순서도 보장되지 않습니다. 각 join 호출이 성공해야 마지막 완료 문장의 의미가 성립합니다.

pthread_create()의 네 가지 인자를 정리하겠습니다.

첫 번째는 스레드 식별자를 저장할 pthread_t 포인터, 두 번째는 스레드 속성(NULL이면 기본값), 세 번째는 스레드가 실행할 함수(void* (*)(void*)), 네 번째는 함수에 전달할 인자입니다.

pthread_join()은 해당 스레드가 종료될 때까지 호출 스레드를 블로킹하고, 종료된 joinable 스레드의 자원을 회수합니다.

join() 없이 main()이 끝나 프로세스가 종료되면 아직 실행 중인 스레드도 함께 사라집니다.

반대로 프로세스는 계속 살아 있는데 joinable 스레드만 종료된 경우에는, pthread_join()이나 pthread_detach() 전까지 일부 스레드 자원이 남을 수 있습니다.

컴파일
gcc -o thread_basic pthread_basic.c -pthread

-pthread 플래그는 POSIX 스레드 라이브러리를 링크합니다.

-lpthread와 비슷하지만, 컴파일 단계에서도 스레드 관련 전처리 매크로를 활성화하므로 -pthread를 권장합니다.

스레드에서 결과 받기

스레드 함수가 반환하는 void*를 pthread_join()의 두 번째 인자로 받을 수 있습니다.

pthread_return.c
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>

void *compute(void *arg) {
    int input = *(int *)arg;
    int *result = malloc(sizeof(int));
    if (result == NULL) return NULL;
    *result = input * input;
    return result;  /* 힙에 할당한 결과를 반환 */
}

int main() {
    pthread_t tid;
    int input = 7;
    void *retval = NULL;

    int ret = pthread_create(&tid, NULL, compute, &input);
    if (ret != 0) {
        fprintf(stderr, "pthread_create failed: %d\n", ret);
        return 1;
    }
    ret = pthread_join(tid, &retval);
    if (ret != 0) {
        fprintf(stderr, "pthread_join failed: %d\n", ret);
        return 1;
    }
    if (retval == NULL) {
        fprintf(stderr, "result allocation failed\n");
        return 1;
    }

    int *result = (int *)retval;
    printf("7^2 = %d\n", *result);  /* 49 */

    free(result);  /* 스레드가 할당한 메모리를 해제 */
    return 0;
}

주의할 점: 스레드 함수 안의 지역 변수 주소를 반환하면 안 됩니다.

스레드가 종료되면 그 스레드의 스택이 해제되므로, 반환된 포인터는 댕글링 포인터(dangling pointer)가 됩니다.

결과를 읽을 때까지 살아 있는 저장소를 사용합니다. 이 예제는 힙을 사용하며, 호출자가 제공하고 join까지 유지하는 저장소나 전역·정적 저장소도 소유권과 동기화 조건을 지키면 가능합니다.

인자 전달의 함정

흔한 실수를 살펴보겠습니다.

잘못된 인자 전달 (위험)
/* 이렇게 하면 안 됩니다 */
for (int i = 0; i < 3; i++) {
    pthread_create(&threads[i], NULL, thread_func, &i);
    /* 모든 스레드가 같은 &i를 공유!
       i가 변경되면 스레드가 잘못된 값을 읽을 수 있음 */
}

루프 변수 i의 주소를 직접 전달하면, 스레드가 실행되는 시점에 i가 이미 변경되어 있을 수 있습니다.

이 잘못된 예제에는 i의 동시 읽기·쓰기와 수명 문제가 있어 C에서 정의되지 않은 동작이 생깁니다. 앞의 예제처럼 값을 별도 배열에 두고 join까지 유지합니다. 정수를 포인터로 인코딩하는 별도 방식은 수신 함수도 맞게 바꿔야 하므로, 현재 int 포인터를 역참조하는 함수에 그대로 적용하지 않습니다.

detach vs join

pthread_join()은 스레드가 끝날 때까지 기다리고 자원을 회수합니다.

만약 스레드의 결과가 필요 없고, 끝나면 자동으로 자원이 해제되기를 원한다면 pthread_detach()를 사용합니다.

detached 스레드
pthread_t tid;
int ret = pthread_create(&tid, NULL, background_task, NULL);
if (ret != 0) return ret;
ret = pthread_detach(tid);
if (ret != 0) return ret;
/* 이후 join 불가, 스레드 종료 시 자동 자원 회수 */

위 코드는 background_task와 오류를 반환할 호출 함수가 따로 필요한 조각입니다. detached는 결과를 join으로 회수하지 않겠다는 뜻이며, 중요한 기록 작업의 완료를 보장하는 기능은 아닙니다.

단, detached 스레드는 main()이 먼저 종료되면 함께 종료되므로, 필요하다면 main()이 끝나지 않도록 해야 합니다.

join/detach는 pthread의 회수 정책입니다. 아래에서 다룰 Python daemon의 종료 대기 규칙과는 다른 축입니다.


Python — threading 모듈

기본 스레드 생성

threading_basic.py
import threading
import time

def worker(name, delay):
    tid = threading.current_thread().ident
    print(f"Thread {name} starting (tid={tid})")
    time.sleep(delay)
    print(f"Thread {name} done")

threads = []
for i in range(3):
    t = threading.Thread(target=worker, args=(i, 1))
    threads.append(t)
    t.start()

for t in threads:
    t.join()

print("All threads done")

Python의 threading.Thread는 target으로 실행할 함수, args로 위치 인자, kwargs로 키워드 인자를 전달합니다.

start()를 호출해야 스레드가 실행되고, join()으로 종료를 기다립니다.

GIL의 영향 — CPU 바운드 vs I/O 바운드

일반적인 CPython 빌드의 GIL(Global Interpreter Lock)은 한 번에 하나의 스레드만 Python 바이트코드를 실행하도록 제한합니다.

다음 코드는 사용자가 조건을 비교할 실험 예제입니다. 이번 문서 수정에서 실행하거나 시간을 측정하지 않았습니다. 선택적인 free-threaded CPython 빌드와 GIL을 해제하는 확장은 결과를 별도로 판단합니다.

gil_test.py
import threading
import time

def cpu_bound(n):
    """CPU 연산 집약적 작업"""
    total = 0
    for i in range(n):
        total += i * i
    return total

def io_bound(seconds):
    """타이머로 I/O 대기를 모사 (실제 네트워크 요청 없음)"""
    time.sleep(seconds)

# CPU 바운드: 싱글 스레드
start = time.perf_counter()
cpu_bound(10_000_000)
cpu_bound(10_000_000)
print(f"CPU 바운드 순차: {time.perf_counter() - start:.2f}s")

# CPU 바운드: 멀티 스레드
start = time.perf_counter()
t1 = threading.Thread(target=cpu_bound, args=(10_000_000,))
t2 = threading.Thread(target=cpu_bound, args=(10_000_000,))
t1.start(); t2.start()
t1.join(); t2.join()
print(f"CPU 바운드 2스레드: {time.perf_counter() - start:.2f}s")

# I/O 바운드: 싱글 스레드
start = time.perf_counter()
io_bound(1); io_bound(1)
print(f"I/O 바운드 순차: {time.perf_counter() - start:.2f}s")

# I/O 바운드: 멀티 스레드
start = time.perf_counter()
t1 = threading.Thread(target=io_bound, args=(1,))
t2 = threading.Thread(target=io_bound, args=(1,))
t1.start(); t2.start()
t1.join(); t2.join()
print(f"I/O 바운드 2스레드: {time.perf_counter() - start:.2f}s")

순수 Python CPU 바운드 작업에서는 멀티스레드가 빨라지지 않거나 오히려 느려질 수 있습니다.

GIL을 두 스레드가 번갈아 잡으면서 경합(contention)과 전환 비용이 발생하기 때문입니다.

반면 I/O 바운드 작업에서는 time.sleep()이나 socket.recv() 같은 대기 함수가 호출될 때 GIL이 풀리므로, 여러 스레드가 동시에 I/O 완료를 기다릴 수 있습니다.

CPU 바운드 병렬 처리에는 multiprocessing을 사용합니다.

multiprocessing_example.py
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, [10_000_000] * 4)
        print(sum(results))

multiprocessing은 별도의 프로세스를 생성하므로 각 프로세스가 독립된 Python 인터프리터와 메모리 공간을 가집니다.

일반적인 GIL 환경의 순수 Python 계산을 여러 코어로 나눌 수 있습니다. spawn/forkserver가 파일을 다시 import할 때 Pool이 재생성되지 않도록 main 가드를 둡니다. 프로세스 생성·전송 비용을 포함한 성능은 측정해야 합니다.

데몬 스레드

Python의 데몬 스레드는 인터프리터가 종료될 때 기다리지 않는 백그라운드 스레드입니다.

Python daemon은 join 가능 여부가 아니라 인터프리터 종료를 막는지의 규칙입니다. daemon 스레드도 정상 동작 중 join할 수 있습니다.

daemon_thread.py
import threading
import time

def background_monitor():
    while True:
        print("모니터링 중...")
        time.sleep(2)

t = threading.Thread(target=background_monitor, daemon=True)
t.start()

time.sleep(5)
print("메인 종료")
# 이 예제는 메인이 마지막 non-daemon 스레드이므로 종료가 진행됨

데몬 스레드는 daemon=True로 설정합니다.

Python은 살아 있는 non-daemon 스레드가 더 없으면 종료합니다. 데몬 스레드는 정리(cleanup)를 완료하지 못하고 끊길 수 있으므로 중요한 파일 쓰기에 기대지 않습니다.

클린업이 필요한 작업에는 일반 스레드 + Event 플래그로 종료 신호를 보내는 것이 안전합니다.

스레드 회수와 인터프리터 종료 정책
API별로 무엇을 기다리고 회수하는가
선택보장 범위
pthread joinjoinable 스레드가 종료할 때까지 기다리고 결과·자원을 회수합니다.
pthread detach종료 시 자원을 자동 회수하도록 설정합니다. 이후 join으로 결과를 받을 수 없습니다.
Python Thread.joindaemon 여부와 별개로 대상 스레드의 완료를 기다립니다.
Python daemon살아 있는 non-daemon 스레드가 없을 때 인터프리터 종료를 막지 않습니다. 정리를 마치지 못할 수 있습니다.
pthread join
보장 범위: joinable 스레드가 종료할 때까지 기다리고 결과·자원을 회수합니다.
pthread detach
보장 범위: 종료 시 자원을 자동 회수하도록 설정합니다. 이후 join으로 결과를 받을 수 없습니다.
Python Thread.join
보장 범위: daemon 여부와 별개로 대상 스레드의 완료를 기다립니다.
Python daemon
보장 범위: 살아 있는 non-daemon 스레드가 없을 때 인터프리터 종료를 막지 않습니다. 정리를 마치지 못할 수 있습니다.

pthread detach는 Python daemon과 같은 기능이 아닙니다. 종료 때 반드시 마쳐야 하는 작업에는 완료를 기다리고 실패를 확인할 수 있는 수명 관리가 필요합니다.


스레드 풀

왜 스레드 풀이 필요한가

매 요청마다 스레드를 생성하고 파괴하면 두 가지 문제가 있습니다.

첫째, 생성/파괴 비용이 누적됩니다.

실제 비용은 OS와 환경에 따라 다르지만, 초당 수천 요청이면 이것만으로도 상당한 오버헤드가 됩니다.

둘째, 동시에 만들 수 있는 스레드 수에 제한이 없으면, 트래픽 폭주 시 수만 개의 스레드가 생성되어 메모리가 고갈되고 시스템이 다운됩니다.

스레드 풀(Thread Pool)은 이 두 문제를 해결합니다.

제한된 수의 워커를 재사용하며, 필요할 때 워커를 생성하는 구현도 있습니다. 큐가 무제한이면 작업 적체로 메모리가 고갈될 수 있어 제출량 제한은 별도로 필요합니다.

모든 스레드가 바쁘면 작업은 큐에서 대기합니다.

Python — ThreadPoolExecutor

thread_pool.py
from concurrent.futures import ThreadPoolExecutor, as_completed
import time

def fetch_url(url):
    """URL 내용을 가져오는 I/O 바운드 작업 (시뮬레이션)"""
    time.sleep(1)  # 네트워크 지연 시뮬레이션
    return f"{url} 완료 (URL 문자 수: {len(url)})"

urls = [f"https://example.com/page/{i}" for i in range(8)]

with ThreadPoolExecutor(max_workers=4) as executor:
    # submit()은 Future 객체를 반환
    future_to_url = {executor.submit(fetch_url, url): url for url in urls}

    # 완료되는 순서대로 결과 수집
    for future in as_completed(future_to_url):
        url = future_to_url[future]
        result = future.result()
        print(f"{url} → {result}")

ThreadPoolExecutor(max_workers=4)는 최대 4개의 워커를 필요에 따라 만들어 재사용합니다. 이 코드는 HTTP 요청을 하지 않으며 URL 문자열 길이만 반환합니다.

8개의 작업을 제출하면, 4개가 먼저 각 스레드에 할당되어 실행됩니다.

하나가 끝날 때마다 큐에서 다음 작업을 가져와 실행합니다.

as_completed()는 완료되는 순서대로 Future를 반환하므로, 빨리 끝난 결과부터 처리할 수 있습니다.

with 블록을 벗어나면 executor.shutdown(wait=True)가 자동으로 호출되어, 모든 작업이 완료될 때까지 기다립니다.

C — 간단한 스레드 풀 구조

POSIX pthread API 자체에는 작업 큐 기반 풀이 없습니다. 기존 라이브러리를 쓰거나 다음처럼 필요한 구조를 구성할 수 있습니다.

핵심 구조는 작업 큐 + 조건 변수 + 뮤텍스입니다.

thread_pool_concept.c
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>

#define POOL_SIZE 4
#define QUEUE_SIZE 16

typedef struct {
    void (*function)(void *);
    void *arg;
} task_t;

typedef struct {
    task_t queue[QUEUE_SIZE];
    int head, tail, count;
    pthread_mutex_t lock;
    pthread_cond_t not_empty;
    pthread_cond_t not_full;
    int shutdown;
    pthread_t workers[POOL_SIZE];
} thread_pool_t;

void *worker_thread(void *arg) {
    thread_pool_t *pool = (thread_pool_t *)arg;

    while (1) {
        pthread_mutex_lock(&pool->lock);

        while (pool->count == 0 && !pool->shutdown) {
            pthread_cond_wait(&pool->not_empty, &pool->lock);
        }
        if (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;
}

이 코드는 워커 루프만 보여 주며 초기화·enqueue·shutdown 통지·join은 생략했습니다. shutdown이 설정되면 큐에 작업이 남아 있어도 루프를 끝내는 정책입니다. 남은 작업을 모두 처리하는 graceful drain 구현으로 해석하지 않습니다.

워커 스레드는 큐가 비어 있으면 pthread_cond_wait()으로 대기합니다.

작업이 큐에 추가되면 pthread_cond_signal()로 워커 하나를 깨웁니다.

뮤텍스로 큐 접근을 보호합니다.

이 패턴은 6장의 동기화에서 더 자세히 다룹니다.

풀 크기 결정 가이드라인

스레드 풀의 크기를 어떻게 정해야 할까요?

다음은 부하 측정을 시작할 때 사용할 수 있는 추정식입니다.

CPU 바운드: 스레드 수 = CPU 코어 수.

실제로 쓸 수 있는 논리 CPU·쿼터와 작업의 병렬성을 기준으로 시작합니다. 스레드를 더 늘리면 전환·경합 비용이 커질 수 있지만 모든 작업에서 손해만 난다고 단정하지 않습니다.

I/O 바운드: 스레드 수 = CPU 코어 수 × (1 + 대기시간/처리시간).

예를 들어, 4코어에서 타스크의 대기/처리 비율이 9:1이라면, 4 × (1 + 9) = 40을 초기 후보로 계산합니다. CPU 활용 목표·DB 연결 수·메모리와 서비스 제한을 반영하기 전의 추정값입니다.

실무에서는 이 공식을 시작점으로 삼고, 부하 테스트로 조정합니다.

스레드 수가 너무 적으면 CPU가 놀고, 너무 많으면 컨텍스트 스위칭과 메모리가 낭비됩니다.


스레드 안전성 미리보기

멀티스레드 코드에서 가장 빠지기 쉬운 함정을 미리 경험해 봅시다.

race_condition.py
import threading

counter = 0

def increment():
    global counter
    for _ in range(1_000_000):
        counter += 1  # 이 한 줄이 원자적이지 않음

t1 = threading.Thread(target=increment)
t2 = threading.Thread(target=increment)
t1.start(); t2.start()
t1.join(); t2.join()

print(f"기대값: 2000000, 실제값: {counter}")

counter += 1은 Python 수준에서 한 줄이지만, 내부적으로는 값을 읽고 더하고 다시 저장하는 여러 단계로 나뉩니다.

두 스레드가 동시에 읽기 → 더하기 → 저장을 수행하면, 하나의 증가가 덮어써질 수 있습니다.

다만 Python 구현·버전·GIL 설정에 따라 이 짧은 예제는 반복해서 2000000을 출력할 수도 있습니다. 원하는 실패값이 나오도록 재실행하는 예제가 아니며, 한 번 또는 여러 번 맞았다고 언어 차원의 원자성을 입증하지는 못합니다. 공유 갱신을 동기화해야 한다는 설계 원칙을 확인합니다.

이것을 해결하려면?

6장에서 다룰 뮤텍스(Mutex)를 사용합니다.

race_condition_fixed.py
import threading

counter = 0
lock = threading.Lock()

def increment():
    global counter
    for _ in range(1_000_000):
        with lock:
            counter += 1

t1 = threading.Thread(target=increment)
t2 = threading.Thread(target=increment)
t1.start(); t2.start()
t1.join(); t2.join()

print(f"기대값: 2000000, 실제값: {counter}")
# 두 스레드가 정상적으로 모두 완료하면 2000000

with lock:이 임계 구역(Critical Section)을 보호합니다.

lock.acquire() → 코드 실행 → lock.release()를 자동으로 처리합니다.

한 스레드가 락을 잡고 있는 동안 다른 스레드는 대기합니다.

이것이 6장의 핵심 주제입니다.


실무 패턴 정리

패턴선택할 상황과 조건
직접 생성적은 수의 작업을 시작하고 join 지점을 명시할 때
데몬 스레드종료 중 끊겨도 되는 보조 작업; 필수 기록·정리는 제외
스레드 풀반복 작업과 워커 수를 제한할 때; 큐 적체·제출량은 별도 관리
multiprocessing일반 GIL 환경의 순수 Python CPU 계산; 전송·생성 비용 확인
async/await높은 I/O 동시성; 실제 호출도 비동기이며 루프를 막지 않아야 함

다음 장에서는 여러 스레드(또는 프로세스) 중 누구에게 CPU를 줄지 결정하는 CPU 스케줄링을 다루겠습니다.

스케줄링 알고리즘의 선택이 시스템 성능과 응답시간에 어떤 영향을 미치는지 분석합니다.