← System Programming DUJINLABS.COM

Thread / Synchronization · Linux userspace / kernel ABI

pthread 생성, join, detach, TLS

pthread_t handle, kernel task, user stack, thread-local storage, joinable 종료 상태의 수명을 따로 구분합니다.

Series
27 / 37
Build
cc -std=c17 -Wall -Wextra -O2 -pthread thread_join.c -o thread_join
Run
./thread_join
Kernel
Linux 6.18.37 LTS

thread 함수가 return하면 모든 thread 자원이 즉시 사라지는가?

pthread_create는 libc thread descriptor, stack/TLS mapping을 준비하고 clone을 통해 같은 mm/files 등을 공유하는 kernel task를 만든다. start routine이 return하면 kernel execution은 끝나지만 joinable thread의 반환값과 일부 userspace metadata는 pthread_join까지 남는다.

pthread_t는 숫자 TID와 같은 API가 아니다. 종료 후 join/detach한 handle을 재사용하거나 비교 외 연산을 하는 코드는 구현 detail에 의존한다.

구조 그림

그림 1. 한 process 주소 공간 안의 thread별 stack과 TLS
공유 process VAthread별 실행 자원
Thread A stack + guardpthread descriptor A · TLS A
Thread B stack + guardpthread descriptor B · TLS B
Shared mmap / heapmalloc objects · mutex · queue
Shared executable / DSOtext · global data · libc
Kernel tasksTID A · TID B · same TGID/mm

모든 thread는 heap과 global mapping을 공유하지만 stack, TLS, register, kernel task는 각각 따로 가진다.

호출 흐름

그림 2. 사용자 코드에서 관찰 가능한 결과까지
pthread_create stack/TLS/descriptor
clone shared-resource task
start routine user code
thread exit result + clear_tid
join/detach userspace 자원 회수

kernel task 종료와 pthread library가 stack·descriptor를 재사용 가능한 상태로 만드는 시점이 다르다. joinable thread에는 반드시 한 명의 join owner를 둔다.

그림 3. 커널 내부에서 지나가는 주요 지점
clone3/clone CLONE_VM 등
copy_process task_struct
set_tid_address clear_child_tid
do_exit futex wake
join waiter stack/TLS free

함수 이름을 외우기 위한 그림이 아니다. 반환값, 파일 디스크립터, 메모리 매핑, 대기 큐 가운데 무엇이 다음 단계로 전달되는지 확인한다.

Linux 6.18.37 LTS 소스 위치

glibc 함수에서 멈추지 않고 syscall 구현과 커널 객체가 만나는 파일까지 내려간다. 링크는 동일한 태그의 원본 파일을 가리킨다.

파일함수·구조체여기서 볼 것
kernel/fork.c copy_process(), copy_mm(), copy_files() CLONE flag로 process와 thread 자원 공유를 결정
kernel/exit.c do_exit() thread task 종료와 clear_child_tid 처리
kernel/futex/core.c futex_wake() join 구현이 기다리는 TID clear wakeup 기반

실행 예제 원본

아래 코드는 설명을 위해 중간 줄을 생략한 의사 코드가 아니다. 파일로 빌드해 실행할 수 있는 최소 예제다.

빌드cc -std=c17 -Wall -Wextra -O2 -pthread thread_join.c -o thread_join
01#include <pthread.h>
02#include <stdint.h>
03#include <stdio.h>
04#include <stdlib.h>
05
06struct job {
07    int begin;
08    int end;
09};
10
11static _Thread_local int jobs_done;
12
13static void *worker(void *argument)
14{
15    const struct job *job = argument;
16    long *sum = malloc(sizeof(*sum));
17    if (sum == NULL)
18        return NULL;
19    *sum = 0;
20    for (int value = job->begin; value < job->end; ++value)
21        *sum += value;
22    jobs_done++;
23    return sum;
24}
25
26int main(void)
27{
28    struct job job = { .begin = 1, .end = 1000 };
29    pthread_t thread;
30    if (pthread_create(&thread, NULL, worker, &job) != 0)
31        return 1;
32    void *result;
33    if (pthread_join(thread, &result) != 0 || result == NULL)
34        return 1;
35    printf("sum=%ld main_tls=%d\n", *(long *)result, jobs_done);
36    free(result);
37    return 0;
38}

코드 조각별 설명

실제 코드 11행static _Thread_local int jobs_done

같은 symbol 이름이라도 각 thread가 별도 instance를 가진다. worker의 증가가 main thread instance에는 반영되지 않는다.

실제 코드 15행const struct job *job

main stack 객체의 주소를 넘긴다. pthread_join 전까지 main stack과 job 수명이 유지되므로 이 예에서는 안전하다.

실제 코드 16행long *sum = malloc

worker stack local 주소를 반환하지 않고 join 뒤에도 유효한 heap object ownership을 main에 넘긴다.

실제 코드 30행pthread_create(&thread

성공 시 pthread_t handle을 얻는다. pthread 함수는 보통 -1/errno가 아니라 오류 번호를 직접 반환한다.

실제 코드 33행pthread_join(thread

thread completion과 synchronize하며 반환값을 받고 joinable thread의 library 자원을 회수한다. 같은 thread를 두 번 join하면 안 된다.

세부 동작

01

thread stack은 고정 크기 mapping이다

pthread 기본 stack 크기는 process resource limit와 implementation 설정에서 정해진다. 큰 local array나 깊은 recursion은 process 전체 heap과 별개로 stack guard page에 닿을 수 있다.

pthread_attr_setstacksize로 바꿀 때 PTHREAD_STACK_MIN과 alignment, 실제 frame 사용량을 측정한다.

02

detach는 background ownership 선언이다

detached thread는 종료 시 자동 회수되어 join할 수 없다. detached로 바꾼 뒤 caller가 넘긴 context를 먼저 free하면 use-after-free가 생긴다.

shutdown 때 완료를 기다려야 하는 worker라면 joinable 상태와 명시적 join owner가 더 단순하다.

03

process exit와 thread exit를 구분한다

worker가 return하거나 pthread_exit를 호출하면 해당 thread만 끝난다. 어떤 thread든 exit()를 호출하면 exit_group으로 전체 process가 종료된다.

main return도 exit와 같으므로 다른 thread를 계속 실행하려면 main에서 pthread_exit를 쓰거나 join 구조를 둔다.

객체와 수명

대상언제 생기고 없어지는가확인할 값
pthread descriptorpthread_create에서 생기고 join/detached exit 뒤 재사용·해제된다pthread_t, result, cancel state
thread stack/TLScreate 때 mapping/할당되고 thread 종료 cleanup 뒤 회수된다guard, stack size, TLS dtors
task_structclone에서 생기고 thread do_exit/release에서 해제된다TID, clear_child_tid, robust list

실패 조건과 오해하기 쉬운 부분

겉으로 보이는 현상실제 원인 후보확인 방법
thread 수가 계속 늘어남joinable thread 미join/proc/PID/task와 stack mappings
worker 반환값 crashworker stack 주소 반환allocation owner와 lifetime
pthread_create EAGAINtask limit, stack VA/memory 부족ulimit -u, pids cgroup, maps

직접 확인

  1. join을 제거해 반복 thread 생성 시 /proc/PID/status Threads와 memory 사용 변화를 본다.
  2. pthread_attr_getstack으로 worker stack base/size를 출력하고 /proc/PID/maps와 맞춘다.
  3. 여러 thread에서 _Thread_local 변수 주소를 출력해 서로 다른 TLS instance인지 확인한다.
실행./thread_join
추적strace -f -e trace=clone3,clone,futex,set_robust_list,exit ./thread_join

원문