Thread / Synchronization · Linux userspace / kernel ABI

pthread creation, join, detach, and TLS

Separately tracks the lifetimes of a pthread_t handle, kernel task, user stack, thread-local storage, and joinable termination state.

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

When a thread function returns, do all thread resources disappear immediately?

pthread_create prepares a libc thread descriptor plus stack/TLS mappings, then uses clone to create a kernel task that shares the same mm, files, and other resources. When the start routine returns, kernel execution ends, but a joinable thread's return value and some userspace metadata remain until pthread_join.

pthread_t is not the same API as a numeric TID. Code that reuses a handle after join/detach or performs operations other than comparison depends on implementation details.

Structure diagram

Figure 1. Per-thread stacks and TLS inside one process address space
shared process VAper-thread execution resources
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

Every thread shares heap and global mappings, but each has its own stack, TLS, registers, and kernel task.

Call path

Figure 2. From userspace code to observable results
pthread_create stack/TLS/descriptor
clone shared-resource task
start routine user code
thread exit result + clear_tid
join/detach reclaim userspace resources

The kernel task terminates at a different time from when the pthread library can reuse the stack and descriptor. Assign exactly one join owner to every joinable thread.

Figure 3. Major points along the kernel-internal path
clone3/clone CLONE_VM and related flags
copy_process task_struct
set_tid_address clear_child_tid
do_exit futex wake
join waiter stack/TLS free

This diagram is not for memorizing function names. Follow which return value, file descriptor, memory mapping, or wait queue is passed to the next stage.

Linux 6.18.37 LTS source locations

Go beyond the glibc function to the files where the syscall implementation meets kernel objects. Each link points to the original file at the same tag.

FileFunction / structureWhat to inspect
kernel/fork.c copy_process(), copy_mm(), copy_files() Use CLONE flags to select process/thread resource sharing
kernel/exit.c do_exit() Thread-task termination and clear_child_tid handling
kernel/futex/core.c futex_wake() The TID-clear wakeup used by the join implementation

Complete runnable example

The code below is not pseudocode with explanatory lines omitted. It is a minimal example that can be built and run as a file.

Buildcc -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}

Code notes

Source line 11static _Thread_local int jobs_done

Even with the same symbol name, each thread has a separate instance. Incrementing the worker's instance does not affect the main thread's instance.

Source line 15const struct job *job

Passes the address of an object on the main stack. In this example it is safe because the main stack and job lifetime continue until pthread_join.

Source line 16long *sum = malloc

Transfers ownership of a heap object that remains valid after join instead of returning the address of a worker-stack local.

Source line 30pthread_create(&thread

Returns a pthread_t handle on success. pthread functions commonly return an error number directly rather than -1/errno.

Source line 33pthread_join(thread

Synchronizes with thread completion, receives the return value, and reclaims library resources for the joinable thread. The same thread must not be joined twice.

Detailed behavior

01

A thread stack is a fixed-size mapping

The default pthread stack size comes from process resource limits and implementation settings. A large local array or deep recursion can reach the stack guard page independently of the process-wide heap.

When changing it with pthread_attr_setstacksize, measure PTHREAD_STACK_MIN, alignment, and actual frame use.

02

detach declares background ownership

A detached thread is reclaimed automatically at exit and cannot be joined. Freeing context supplied by the caller immediately after detaching can cause use-after-free.

For a worker whose completion must be awaited during shutdown, a joinable state and explicit join owner are simpler.

03

Distinguish process exit from thread exit

When a worker returns or calls pthread_exit, only that thread ends. If any thread calls exit(), exit_group terminates the whole process.

Returning from main is also equivalent to exit, so use pthread_exit in main or a join structure if other threads must continue running.

Objects and lifetimes

ObjectCreation and releaseValues to inspect
pthread descriptorCreated by pthread_create and reused/released after join or detached exitpthread_t, result, cancel state
thread stack/TLSMapped/allocated at creation and reclaimed after thread-exit cleanupguard, stack size, TLS dtors
task_structCreated by clone and released by thread do_exit/releaseTID, clear_child_tid, robust list

Failure conditions and common misconceptions

Observed symptomLikely causesHow to verify
Thread count keeps increasingJoinable threads are not joinedInspect /proc/PID/task and stack mappings
The worker return value crashesAn address on the worker stack was returnedInspect allocation ownership and lifetime
pthread_create EAGAINA task limit or insufficient stack VA/memoryulimit -u, pids cgroup, maps

Verify it yourself

  1. Remove join, repeatedly create threads, and observe Threads in /proc/PID/status and memory usage.
  2. Print the worker stack base/size with pthread_attr_getstack and match it against /proc/PID/maps.
  3. Print the address of a _Thread_local variable in several threads to confirm separate TLS instances.
Run./thread_join
Tracestrace -f -e trace=clone3,clone,futex,set_robust_list,exit ./thread_join

Primary sources