task_struct and the process lifecycle

On this page

Starting a program from a shell usually involves creating a child, arranging redirection, replacing its program image, and waiting for termination. This path exposes three boundaries in Linux process management: task identity, resource sharing, and reaping.

QuestionWhere to look
How can threads share memory yet execute simultaneously?Section 1: tasks and resource objects
Why does fork share a file offset but isolate memory writes?Sections 1–2: references and COW
How can exec replace a program without changing its PID?Section 4: image replacement
Why does an exited child still appear as Z?Section 5: exit and reaping

Read in order to follow the lifecycle. CPU allocation belongs to the scheduler; handing over execution belongs to context switching.

1. task_struct connects identity to resources

Each schedulable thread has its own task_struct, kernel stack, and scheduling state. Threads in a thread group can share an address space, descriptor table, and signal dispositions while retaining separate TIDs, register contexts, signal masks, and thread-directed pending signals. Sharing resources does not merge them into one scheduling entity: two threads can execute on two CPUs at once.

Establish what “PID” means in a log. getpid() returns the thread group's TGID; gettid() returns the calling thread's TID. These coincide for the thread-group leader and normally differ for other threads. PID namespaces also affect the numbers: PID 1 in a container can have a different ID on the host. Correlation requires both the task identity and the observing namespace.

task_struct does not embed every part of a process. Its pointers and references connect objects with different lifetimes:

Object or fieldResponsibilityWhat it does not imply
mmUser address space, page tables, and VMAsA shared mm does not mean a shared kernel stack
filesMapping descriptor numbers to open filesSeparate tables can reference the same open file description
fsWorking directory, root directory, umaskThis is separate from the descriptor table
sighand / signalSignal dispositions and thread-group signal/accounting stateThe signal mask remains per-thread
Scheduling fields and entitiesPolicy, priority, run-queue accountingTASK_RUNNING does not mean currently on a CPU
cred and namespace referencesCredentials and resource viewsCreating a task does not automatically create namespaces

Why do two descriptor tables advance one offset?

After fork, parent and child have separate descriptor tables, but their descriptors numbered 3 can refer to one struct file. Starting at offset 0:

OperationDescriptor tablesShared object
Parent successfully reads 100 bytesBoth descriptors remainOffset advances to 100
Child reads nextUses descriptor 3 in its own tableContinues from offset 100
Child closes descriptor 3Removes only the child's referenceParent still references the open file description

Both arrows point to the same object, explaining the shared offset.

fork copies the table, retaining one open file description Parent task own task_struct Child task own task_struct Parent fd table fd 3 Child fd table fd 3 Same struct file Shared offset and status flags close drops one reference; CLOEXEC belongs to the descriptor, the offset to the open file description.

Independent open() calls normally produce independent offsets. dup() and fork() retain references to the original open file description.

Preparing the visual
Where does the next read begin?

Inherited descriptors share one offset, advanced by either reader. Separate opens have independent offsets. Read in any order and compare both models using the same operation history.

Reading kernel code: current identifies the current task through an architecture-dependent implementation. Do not depend on a kernel-stack rounding formula or hard-coded task_struct offsets.

2. Creation copies relationships; data need not be copied immediately

fork() creates a child with an independent execution flow. It returns the child's PID to the parent and 0 to the child; neither is guaranteed to run first. Creation establishes identity, resource references, scheduling entities, and an initial execution context. Copy-on-write usually reduces the immediate cost of copying private user data pages, but page-table and other metadata work still costs time. Fork does more than copy one task_struct.

Private pages: share initially, isolate writes

Copy-on-write combines delayed copying with isolation. Suppose both processes initially map one physical page. A parent's first write to its private mapping triggers a write-protection fault, and the kernel establishes a mapping that satisfies private-write semantics. The child still sees the original contents. Sharing a physical page is an implementation technique; isolated writes are the promise. Analyze MAP_SHARED mappings separately. Page-table and fault details continue in virtual memory.

clone flags choose resource relationships

clone() and clone3() express sharing through flags with dependencies: CLONE_THREAD requires CLONE_SIGHAND, which requires CLONE_VM.

Creation patternAddress space and file tableStill separate for each task
Typical POSIX threadsShared mm, file table, and signal dispositionsStack, thread identity, execution context
Ordinary forkSeparate mm and table, with shared underlying objectsChild identity, stack, execution context

Namespaces are normally inherited. New namespaces require the appropriate flags, permissions, and other conditions.

Distinguish the glibc clone() wrapper, which accepts a function and stack, from the raw system call, whose ABI varies by architecture. Names such as kernel_clone() and copy_process() help locate implementations; they are not application interfaces. clone(2)

Multithreaded fork: a lock without its owner

Suppose thread B holds a user-space lock when thread A forks. The child contains only A but inherits memory in which the lock may still be held. If A tries to acquire it, it may wait forever for B, which does not exist in the child.

After fork: the lock survives, its owning thread does not Parent process Child process Thread A Thread B Lock held Only A Still held fork No B to unlock A user-space lock without an atfork repair protocol. After multithreaded fork, proceed promptly to exec.

Between fork and exec in a multithreaded program, the child must observe restrictions such as calling only async-signal-safe functions. posix_spawn() often makes preparation easier to control. fork(2)

vfork is more restrictive: the child temporarily shares the address space and suspends the calling thread, but other parent threads may continue. Arbitrary ordinary C logic in the child is unsafe here. vfork(2)

3. Running and waiting describe scheduling conditions

TASK_RUNNING includes tasks executing now and tasks ready to run. Ten such tasks on one CPU normally still mean only one executes at a time. Tasks waiting for I/O, locks, or events may sleep. A wakeup restores eligibility to run; the scheduler decides when execution actually begins.

ObservationMeaningInterpretation limit
RRunning or runnableDoes not establish CPU saturation or queueing time
SInterruptible sleepWhether a signal produces EINTR also depends on the interface and restart rules
DUninterruptible-class waitNot limited to disk I/O; inspect the kernel wait point
T / tJob-control stop / tracing stopDifferent from ordinary sleep or exit
ZExited, awaiting reapingNo longer executes, but retains wait-related information

Kernel task states involve bits and protocols; fields such as exit_state separately track exit. The visible letters are not a simple enum to assign directly. TASK_KILLABLE permits fatal signals to interrupt certain waits; it does not mean all D-state tasks became killable in some newer release.

Waiting must close the gap between checking a condition and registering to sleep. A READ_ONCE() followed by schedule() is insufficient. Use interfaces such as wait_event*() and publish the condition's data with the required locking or memory ordering. See the lost-wakeup explanation in context switching.

4. exec preserves identity and replaces the image

A successful execve() neither creates another process nor returns to the old call site. It installs the new address space, user stack, and entry state under the existing process identity. A dynamically linked ELF usually enters its interpreter/dynamic linker first, then runtime initialization reaches main(); the kernel does not directly call main.

ObjectAfter successful execConsequence
Private address spaceOld heap, stacks, and mappings are replacedOld pointers do not refer to the new program's data
Other threadsOther threads in the old group terminateA nonleader caller's TID is also adjusted
File descriptorsDescriptors without FD_CLOEXEC are normally retainedThe new program can use prepared standard streams
Signal stateHandlers reset; ignored dispositions normally survive; mask survivesOld function addresses cannot serve as new-image callbacks
CredentialsChanges depend on file properties and execution restrictionsConsider no_new_privs, mount attributes, and tracing

Shell redirection uses descriptor inheritance. The child duplicates a pipe end onto standard output before exec. The new program can write to that same open file object without knowing how the parent created the pipe.

Preparing the visual
A new program. The same process?

exec replaces user code, data, and stack under the same process identity. Standard output without CLOEXEC retains its pipe connection for the new program.

Applications normally treat a return from exec as failure and inspect errno. Rare failures beyond the point of no return can instead terminate the process. execve(2)

5. exit and wait: why does a zombie remain?

The parent may be busy when its child exits. Discarding the result immediately would prevent the parent from learning whether the child succeeded, returned an error, or died from a signal. Exit therefore releases references to resources such as address spaces and files while retaining termination information and associated metadata for an eligible waiter.

A zombie no longer runs user code but still uses some kernel memory. Exit also does not destroy every formerly referenced object: the parent may hold the same open file description, and other processes may map shared memory. For many objects, destruction depends on releasing the last reference.

Execution ends before reaping completes Create the child Establish identity, copy or share resources, become runnable 1 Run and wait Run, sleep, wake up; exec can replace the program image 2 Exit and release resource references Stop execution; retain exit status and wait-related kernel information 3 Parent observes and reaps WNOWAIT observes; a consuming wait reaps the exit result 4 Ordinary waitable child shown; automatic reaping, thread groups, and ptrace can change the path.

User-space cleanup versus kernel exit

exit() invokes exit handlers and flushes standard I/O; _exit() skips that user-space cleanup. On Linux, glibc's _exit() wrapper normally uses exit_group to terminate the thread group, whereas raw SYS_exit terminates only its caller. Distinguish the libc interface from the raw system call when analyzing why other threads also disappeared.

Observation versus consumption

waitpid() or waitid() can consume an exit result. waitid(..., WNOWAIT) observes it while preserving a later wait. The interaction below separates those operations; its calls include WNOHANG so trying them before exit does not block.

Preparing the visual
Does reading the exit code reap the child?

Exit leaves a retained result. WNOWAIT obtains the exit code without consuming that result. Reaping consumes it, while the parent keeps the value it obtained.

Explicit SIGCHLD = SIG_IGN or SA_NOCLDWAIT changes automatic reaping behavior. If the parent exits first, the applicable subreaper or PID namespace init adopts the orphan.

SIGCHLD's default action of ignoring the signal is not equivalent to explicitly selecting SIG_IGN for reaping behavior. waitpid(2)

Runnable example: follow the lifecycle through a pipe

This single-threaded Linux example combines fork, redirection, exec, observation, and reaping. Save it as lifecycle.c and compile with cc -std=c11 -O2 -Wall -Wextra -Werror lifecycle.c -o lifecycle. The reported PID comes from the new image. Unlike the interaction above, the first two waits here block until an exit result is available: first observe exit code 7, then consume it, then verify that the child is no longer waitable.

#define _GNU_SOURCE
#include <assert.h>
#include <errno.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <unistd.h>
#include <fcntl.h>

int main(int argc, char **argv) {
    (void)argv;
    if (argc == 2) {
        printf("%ld\n", (long)getpid());
        return 7;
    }
    struct sigaction sa = { .sa_handler = SIG_DFL };
    sigemptyset(&sa.sa_mask);
    assert(sigaction(SIGCHLD, &sa, NULL) == 0);
    int p[2];
    assert(pipe2(p, O_CLOEXEC) == 0);
    /* Standard input, output, and error must already be open. */
    assert(p[0] > STDERR_FILENO && p[1] > STDERR_FILENO);
    pid_t child = fork();
    assert(child >= 0);
    if (child == 0) {
        close(p[0]);
        if (dup2(p[1], STDOUT_FILENO) < 0) _exit(126);
        close(p[1]);
        execl("/proc/self/exe", "lifecycle", "child", (char *)NULL);
        _exit(127);
    }
    close(p[1]);
    FILE *in = fdopen(p[0], "r");
    assert(in != NULL);
    long reported;
    assert(fscanf(in, "%ld", &reported) == 1);
    assert(reported == (long)child);
    assert(fclose(in) == 0);
    siginfo_t info = {0};
    assert(waitid(P_PID, child, &info, WEXITED | WNOWAIT) == 0);
    assert(info.si_code == CLD_EXITED && info.si_status == 7);
    int status;
    assert(waitpid(child, &status, 0) == child);
    assert(WIFEXITED(status) && WEXITSTATUS(status) == 7);
    errno = 0;
    assert(waitpid(child, &status, WNOHANG) == -1 && errno == ECHILD);
    puts("exec preserved PID; exit 7 observed; child reaped");
}

Here dup2() creates a standard-output descriptor without CLOEXEC. The original pipe descriptors have CLOEXEC and are explicitly closed. If final standard output also had CLOEXEC, the new program could not report its PID through it. Assertions express the example's assumptions; compile without NDEBUG. Production code also needs ordinary error handling for interruption, resource exhaustion, and cleanup.

6. Kernel threads and live diagnosis

Kernel threads normally have mm == NULL and may borrow active_mm. They still have a task, kernel stack, scheduling policy, and can block. Do not assume their address-space context always uses init_mm.

Stopping requires cooperation. kthread_stop() requests termination, wakes the thread, and waits. The function must check kthread_should_stop(), and its wait paths must give it that opportunity. A request alone cannot finish shutdown if the thread is stuck in a wait that never completes.

Workqueues run work items on managed worker pools; each submission does not create a dedicated kernel thread. During module or device shutdown, prevent new work, cancel or wait for in-flight work, then free its memory. A stop request alone does not establish that data is no longer accessed.

Start diagnosis with Pid, Tgid, NSpid, and Threads in /proc/PID/status, then examine /proc/PID/task/TID/. The parenthesized comm field in /proc/PID/stat can contain spaces, so naive whitespace splitting and fixed field indexes are unreliable. For R, D, and Z, investigate queueing time, the wait object, and the parent's reaping logic respectively.