Process and Thread Identifiers in Linux
Linux’s identifier model is the source of endless confusion because the words “process” and “thread” mean something subtly different inside the kernel than they do to userspace. The pivotal fact: every thread has its own unique kernel-level identifier (the PID), and a “process” is just a thread group identified by its leader’s PID — the thread-group ID (TGID). So
getpid()does not return the calling thread’s kernel ID; it returns the TGID shared by all threads in the process, whilegettid()returns the thread’s own kernel PID. Beneath the numericpid_tvalues sits a richer kernel object,struct pid, a reference-counted, hash-table-resident handle that survives PID reuse and projects different numbers into different PID namespaces — so one task can legitimately be PID 4711 to the host and PID 1 inside a container. This note unpacks that model against Linux 6.12 LTS (2024-11-17) and the per-namespacepid_maxchange that landed for 6.18 LTS (2025-11-30). It is the identity half of The task_struct Process Descriptor.
Mental Model
Hold three layers apart. The top layer is what userspace sees: small integers of type pid_t — a PID, a TID, a process-group ID (PGID), a session ID (SID). The middle layer is the kernel’s real object, struct pid, which those integers index into; a struct pid is what actually links to the tasks that use it. The bottom layer is the PID namespace: the same struct pid carries an array of (number, namespace) pairs, so it has one numeric value per namespace it is visible in.
flowchart TB subgraph US["Userspace sees pid_t integers"] GPID["getpid() → TGID"] GTID["gettid() → PID (kernel TID)"] end subgraph KERN["Kernel objects"] TGL["task_struct: leader<br/>pid==tgid"] T2["task_struct: thread #2<br/>pid != tgid"] SP[("struct pid<br/>refcounted, in hash<br/>numbers[]: (nr, ns) per level")] end subgraph NS["PID namespaces (nested)"] HOST["init_pid_ns (level 0)<br/>nr = 4711"] CHILD["child ns (level 1)<br/>nr = 1"] end GPID --> TGL GTID --> T2 TGL -- thread_pid --> SP T2 -- thread_pid --> SP SP --> HOST SP --> CHILD
The three layers. What it shows: userspace integers (top) name kernel task_structs; those tasks point at a struct pid (middle), which itself holds an array of (number, namespace) pairs (bottom). The insight: the same struct pid is simultaneously number 4711 in the host namespace and number 1 in a child namespace — the number is a function of which namespace is asking, not an intrinsic property of the task.
PID, TID, TGID — what each call returns
In the kernel, the field task_struct.pid is the globally-unique kernel thread identifier, and task_struct.tgid is the thread-group ID — the pid of the group’s leader (sched.h L1018–1019). For a single-threaded process the two are equal. When that process spawns a second thread via clone(..., CLONE_THREAD), the new thread gets its own unique pid but inherits the leader’s tgid. Userspace then sees:
getpid()returnstgid— the value shared by every thread in the process. Thegetpid(2)manual states it plainly: “the PID (which is shared by all of the threads in a multithreaded process) is sometimes also known as the thread group ID (TGID)” (getpid(2)).gettid()returns the calling thread’spid— its unique kernel TID. Thegettid(2)manual: “all threads have the same PID, but each one has a unique TID” (gettid(2)).
The kernel exposes these through tiny inline accessors: task_pid_nr(tsk) simply returns tsk->pid, and task_tgid_nr(tsk) returns tsk->tgid (pid.h L230–249). The “nr” suffix means “the numeric id in the initial (host) namespace.”
The terminology trap, stated once and for all:
| Userspace word | Userspace call | task_struct field | Kernel word |
|---|---|---|---|
| Thread ID | gettid() | pid | PID |
| Process ID | getpid() | tgid | TGID |
So the thing userspace calls a “PID” is the kernel’s “TGID,” and the kernel’s “PID” is what userspace calls a “TID.” This inversion is the thing to remember. The gettid(2) manual adds the boundary case: “In a new thread group created by a clone(2) call that does not specify the CLONE_THREAD flag (or, equivalently, a new process created by fork(2)), the new process is a thread group leader, and its thread group ID … is the same as its thread ID” (gettid(2)). The glibc gettid() wrapper is relatively recent — added in glibc 2.30 (2019); before that you called the raw syscall via syscall(SYS_gettid).
struct pid vs pid_t — why the kernel needs both
A pid_t is “just a number,” and the kernel’s own header explains exactly why a number is not enough to hold a reference to a process (pid.h L22–38):
“Storing pid_t values in the kernel and referring to them later has a problem. The process originally with that pid may have exited and the pid allocator wrapped, and another process could have come along and been assigned that pid.”
That is the PID-reuse / ABA problem: by the time you act on a stored number, it may name a different process. The alternative — pinning the task_struct itself — is rejected for a different reason: “A task_struct plus a stack consumes around 10K of low kernel memory … By comparison a struct pid is about 64 bytes” (pid.h L29–38). So the kernel introduces struct pid as the middle ground: a small, reference-counted object that lives in a hash table while any task references it, and — critically — a fresh struct pid is allocated whenever a numeric value is reused. Holding a get_pid() reference therefore guarantees you still mean the same process, and it costs ~64 bytes, not ~10 KiB.
The structure itself (pid.h L55–69):
struct pid {
refcount_t count; /* lifetime: freed at zero */
unsigned int level; /* deepest namespace level */
spinlock_t lock;
struct hlist_head tasks[PIDTYPE_MAX]; /* tasks using this pid, by type */
struct hlist_head inodes;
wait_queue_head_t wait_pidfd; /* pidfd readiness notification */
struct rcu_head rcu;
struct upid numbers[]; /* one (nr, ns) per ns level */
};Walking the load-bearing fields: count is the refcount whose drop to zero frees the object via RCU; tasks[PIDTYPE_MAX] is an array of hash-list heads — one per identifier role — linking the actual task_structs that use this pid as their thread-PID, group-leader, process-group, or session ID; wait_pidfd is the wait queue that wakes a pidfd poller when the process dies (the modern, reuse-safe way to refer to a process from userspace, via pidfd_open); and the flexible-array numbers[] of struct upid { int nr; struct pid_namespace *ns; } (pid.h L50–53) is what gives one struct pid a different number in each namespace. A task_struct reaches its struct pid through task->thread_pid (sched.h L1054; task_pid() at pid.h L212–215).
The four identifier roles come from the enum pid_type (pid_types.h):
enum pid_type { PIDTYPE_PID, PIDTYPE_TGID, PIDTYPE_PGID, PIDTYPE_SID, PIDTYPE_MAX };PIDTYPE_PID is the per-thread identity; PIDTYPE_TGID the process; PIDTYPE_PGID the process group; PIDTYPE_SID the session. One struct pid can serve several roles at once: a session leader’s struct pid is simultaneously that task’s PIDTYPE_PID, its PIDTYPE_PGID (it leads a process group), and its PIDTYPE_SID (it leads a session).
PGID and SID — process groups and sessions
Above the thread/process layer sits the job-control hierarchy. A process group (PGID) is a set of processes — typically the stages of a shell pipeline — that receive terminal-generated signals (SIGINT from Ctrl-C, SIGTSTP from Ctrl-Z) together; the group is named by the PID of its leader. A session (SID) groups process groups under one controlling terminal; a login shell is a session leader. The kernel models both as pid_type roles on struct pid, and exposes them through the same accessor family: task_pgrp_nr_ns() and task_session_nr_ns() resolve the PGID and SID in a given namespace (pid.h L266–285). Userspace reads/writes them with getpgid/setpgid and getsid/setsid. The mechanism is the same struct pid machinery; only the role differs.
PID namespaces — one task, several numbers
A PID namespace is a kernel mechanism that gives a subtree of processes a private, independent PID number space (the foundation of container PID isolation). Namespaces nest: the initial init_pid_ns is level 0; creating one (via clone(CLONE_NEWPID) or unshare) makes a child at the next level. The decisive design choice is that a task is visible in its own namespace and all ancestor namespaces, with a distinct number at each level — which is exactly what the numbers[] array in struct pid stores. The level field is the deepest level the pid is valid in, and numbers[level] is the value seen from the namespace where the task was created (pid.h L55–69, L145–151).
The kernel provides two families of translators (pid.h L164–184, L217–243):
pid_nr(pid)→numbers[0].nr, the global id as seen from the init namespace.pid_vnr(pid)/task_pid_vnr(tsk)→ the virtual id as seen from the current task’s namespace.pid_nr_ns(pid, ns)/task_pid_nr_ns()→ the id as seen from a named namespace.
The “v” in vnr means “virtual” — relative to the observer. __task_pid_nr_ns() is the workhorse: it takes RCU read lock, defaults the namespace to the caller’s active one, and resolves the struct pid to a number for that namespace, returning 0 if the task is not visible there (kernel/pid.c L505–518). This is why a container’s PID 1 (its init) appears as some large unrelated number on the host: it is the same struct pid, projected through two different namespaces. And is_child_reaper() recognizes a namespace’s init by numbers[level].nr == 1 (pid.h L159–162) — every PID namespace has its own PID 1 that reaps orphans within it.
pid_max — the wraparound limit, and a 6.12 → 6.18 change
pid_max is the exclusive upper bound on PID allocation: the allocator hands out numbers cyclically in [RESERVED_PIDS .. pid_max) and wraps back to RESERVED_PIDS (300) once it passes that watermark, which is why fresh PIDs climb monotonically until they wrap (pid.c alloc_pid L230–241; RESERVED_PIDS 300 at pid.h L48). The compile-time default is PID_MAX_DEFAULT = 0x8000 = 32768 on a normal build, and the hard ceiling is PID_MAX_LIMIT, which on 64-bit (sizeof(long) > 4) is 4 * 1024 * 1024 ≈ 4 million (threads.h L28–35). At boot pid_idr_init() raises the default on big machines: pid_max = max(pid_max, PIDS_PER_CPU_DEFAULT * num_possible_cpus()) with PIDS_PER_CPU_DEFAULT = 1024, so a 64-CPU box starts with pid_max of at least 65,536 rather than 32,768 (pid.c L805–809, threads.h L43).
Here is a genuine, citable structural difference between the two LTS kernels:
- In 6.12,
pid_maxis a single globalint pid_max = PID_MAX_DEFAULT;(pid.c L63), andstruct pid_namespacehas nopid_maxfield (pid_namespace.h v6.12). One limit for the whole machine. - In 6.18,
pid_maxhas become a per-namespace field:struct pid_namespacenow containsint pid_max;(pid_namespace.h v6.18 L33),init_pid_nsinitializes it toPID_MAX_DEFAULT(pid.c v6.18 L84), thekernel/pid_maxsysctl points atinit_pid_ns.pid_max, and child namespaces register their ownpid_maxsysctl viaregister_pidns_sysctls()(pid.c v6.18 L743–773). The allocator readsREAD_ONCE(tmp->pid_max)from the namespace being allocated in (pid.c v6.18 L193–199).
This per-namespace pid_max was merged in Linux 6.14 (Phoronix, “Linux To Allow Adjusting pid_max Per PID Namespace”) and is therefore present in 6.18 but absent in 6.12. Its motivation: container hosts want a large global pid_max for scale, but some legacy software inside a container assumes PIDs fit in 16 bits (≤ 65,535); a per-namespace pid_max lets that container be capped without lowering the host’s limit.
Uncertain
Verify: the exact upstream merge release of per-namespace
pid_max(stated as 6.14 above). Reason: the Phoronix headline says “Linux 6.14” and the source diff between v6.12 and v6.18 confirms the field exists in 6.18 and not 6.12, but I did not pull the merge commit’s exact tag from git.kernel.org. The presence/absence in 6.18 vs 6.12 is verified from source; only the precise “first appeared in 6.14” point rests on the (secondary) Phoronix article. To resolve:git log --oneline v6.13..v6.14 -- include/linux/pid_namespace.hon git.kernel.org. uncertain
Configuration and Inspection
# The (init-namespace) PID wraparound limit:
cat /proc/sys/kernel/pid_max # e.g. 4194304 on a tuned host, 32768 default
sysctl kernel.pid_max
# A process's IDs from userspace:
echo $$ # the shell's PID (= TGID)
ls /proc/$$/task # one directory per *thread* (each a TID)
cat /proc/$$/status | grep -E 'Pid|Tgid|PPid|NSpid'
# Pid: the kernel TID (== Tgid for the main thread)
# Tgid: the process id userspace knows
# NSpid: the pid in each nested namespace, host → innermostThe NSpid: line in /proc/<pid>/status is the cleanest demonstration of the namespace model: for a containerized process it prints multiple numbers — the host PID followed by the in-container PID — directly reflecting the struct pid numbers[] array. Each subdirectory under /proc/<pid>/task/ is a thread (its name is the TID), making visible that a “process” is a thread group.
Failure Modes and Common Misunderstandings
- Using
getpid()to identify a thread. It returns the TGID, identical for every thread — useless for per-thread logging ortgkill. Usegettid(). - Storing a
pid_tand acting on it later. Subject to the reuse/ABA problem the kernel header warns about: the process may have died and the number been recycled. The reuse-safe userspace primitive is a pidfd (pidfd_open/CLONE_PIDFD), which is backed by astruct pidreference, not a bare number. - Assuming PIDs stay ≤ 65535. Legacy software encodes PIDs in 16 bits; with
pid_maxraised to millions (common on container hosts and the systemd default of 2²²) such code breaks. The 6.14+ per-namespacepid_maxexists precisely to contain this. - Confusing host and container PID.
kill 1on the host does not hit a container’s init; the container’s PID 1 has a large, unrelated host PID. Always resolve via the right namespace (/proc/<hostpid>/statusNSpid). tgid == pidmyth for all tasks. True only for the thread-group leader. Non-leader threads havepid != tgid.- Signals to a process group vs a thread.
kill(-pgid, sig)targets a process group;tgkill(tgid, tid, sig)targets one specific thread. Mixing PGID and TID arguments silently signals the wrong set.
Alternatives and Comparison
Other systems take simpler-but-less-flexible paths. Classic Unix had a flat, single-namespace PID space with no per-thread kernel ID (threads, where they existed, were a userspace library concept). Plan 9 and the BSDs likewise predate Linux’s namespace projection. Linux’s struct pid + namespace design is what makes container PID isolation a first-class kernel feature rather than a userspace fiction: the same kernel object honestly is “PID 1” inside the container and “PID 4711” outside, with no translation table to keep in sync — the translation is intrinsic to the numbers[] array. The cost is conceptual: four kinds of “id,” three layers (pid_t / struct pid / namespace), and the nr/vnr/nr_ns accessor zoo. The reward is that the same machinery cleanly serves threads, processes, job control, and container isolation.
For the userspace M:N contrast: Go’s runtime gives each Goroutine a goroutine-id (goid) that is purely a runtime bookkeeping number with no kernel meaning — the kernel never sees goroutines, only the task_structs of the OS threads (Ms) the runtime schedules them onto. Linux PIDs/TIDs name kernel-scheduled entities; goids name runtime-scheduled ones.
Production Notes
top -H,ps -L,htopshow threads (TIDs) rather than collapsing them to one process row — the practical face of “a thread is a task with its own PID.”perf/bpftracereport bothpid(TGID, confusingly named) andtid(the kernel PID); when correlating per-thread CPU usage, key ontid.- systemd raises
kernel.pid_maxtoward 2²² on modern distros; combined with the cgrouppidscontroller (which caps the count of tasks, a separate mechanism frompid_max’s value ceiling) this is how hosts bound runaway fork storms. - 6.12 vs 6.18. The PID/TID/TGID semantics,
struct pid, namespaces, and the accessor API are identical across both LTS lines. The one behavioral change relevant here is the move ofpid_maxfrom a single global to a per-PID-namespace field (6.14+, present in 6.18), letting operators cap a container’s PID range independently of the host.
See Also
- The task_struct Process Descriptor — where
pid,tgid, andthread_pidlive; the structure these IDs name - Threads as Tasks and the CLONE Flags — how
CLONE_THREADmakes a new task sharetgidbut get its ownpid - fork clone and exec System Calls —
alloc_pid()and how a newstruct pidis created - The Process Tree and Reparenting —
real_parent/parentand per-namespace reaping by PID 1 - Process Exit wait and Zombie Reaping —
wait()and the PID lifetime - Linux Containers and Isolation MOC — PID namespaces in the container picture
- Goroutine — userspace runtime-id contrast
- Linux Process Scheduling MOC — the parent map