task_struct, clone, exit, and wait

Follow task creation, resource sharing, exit and parent wait through object lifetimes.

14 min readChapter 16 of 168Beginner → intermediate
In this chapter

Before you start#

Read exec and binary loading, system-call entry, and files, processes and permissions. The source path is upstream v6.12. The optional exercise creates exactly one child of its own, waits for it and removes its temporary file; it does not signal unrelated processes, create a process tree or change host settings. A source-only state table is supplied.

Learning goals#

Describe the task and resource objects involved in process creation; distinguish PID, TID and thread-group ID; trace selected copy_process success and failure paths; explain copy-on-write and shared open-file state; and follow exit, zombie state and wait without treating a numeric PID as an object reference.

A task and the objects it references#

Linux represents a schedulable task with struct task_struct. A conventional process can contain one or more threads, each represented by a task. Relevant references in the task connect it to an address space (mm for userspace tasks), open descriptor table (files), filesystem context such as root and current directory (fs), signal-handling structures (sighand and signal), namespace state (nsproxy) and credentials. The precise fields, configuration and relationships are visible in v6.12 include/linux/sched.h; not every task has every userspace resource. Kernel threads, for example, differ from ordinary userspace tasks.

These are separate objects because they can have different sharing and lifetime rules. A task pointer does not imply exclusive ownership of its mm or file table, and a lock that protects one field does not necessarily protect another object. Some references are counted; some relationships require RCU or locks. thread_info provides low-level thread-related state whose placement and use depend on architecture and configuration; do not assume its layout from a different release or CPU. The correct question is which reference, lock and lifetime rule applies to the specific field and operation.

A TID identifies an individual task as seen in a PID namespace. A thread-group ID, or TGID, is conventionally the process ID returned by getpid; threads in the group share that group identity but have individual TIDs. A number is interpreted within a namespace and can be reused after the associated identity is released. A struct pid is a kernel object that can represent identity across namespace levels, not simply an integer. Holding a task reference can keep the task object alive even after it has exited, while a bare number found in a log supplies no such lifetime guarantee. See v6.12 include/linux/pid.h.

Source walkthrough#

Construction, execution and reaping
Construction, execution and reaping1. Parent to kernel_clone: request fork or clone. 2. kernel_clone to kernel_clone: copy_process or unwind acquired resources. 3. kernel_clone to Child task: publish and wake a successful child. 4. Child task to Child task: run, release resources and exit. 5. Parent to Wait: wait for an eligible child. 6. Wait to Parent (return): return status and reap when applicable.Parentkernel_cloneChild taskWaitrequest fork orclonecopy_process orunwind acquiredresourcespublish and wake asuccessful childrun, releaseresources andexitwait for an eligible childreturn status and reap when applicable
  1. Parentkernel_clone

    request fork or clone

  2. kernel_clonekernel_clone

    copy_process or unwind acquired resources

  3. kernel_cloneChild task

    publish and wake a successful child

  4. Child taskChild task

    run, release resources and exit

  5. ParentWait

    wait for an eligible child

  6. WaitParent

    Return. return status and reap when applicable

Time runs downward. Dashed arrows show returns.

Read the text version
  1. Parent → kernel_clone: request fork or clone
  2. kernel_clone → kernel_clone: copy_process or unwind acquired resources
  3. kernel_clone → Child task: publish and wake a successful child
  4. Child task → Child task: run, release resources and exit
  5. Parent → Wait: wait for an eligible child
  6. Wait → Parent (return): return status and reap when applicable
Source map. A simplified conditional task lifecycle, not a captured process trace; sharing and wait behavior depend on flags and policy.

fork, clone and clone3 interfaces construct arguments for common machinery, subject to architecture and configuration. In v6.12 kernel/fork.c, kernel_clone includes this excerpt (SPDX GPL-2.0-only; tracing and other statements omitted):

c
p = copy_process(NULL, trace, NUMA_NO_NODE, args);
c
if (IS_ERR(p))
	return PTR_ERR(p);

copy_process constructs a task and returns either a task pointer or an error pointer. The caller tests the error before treating it as a task; failure does not produce a runnable child. Successful construction is not the same as the child already executing: later kernel_clone obtains a PID reference and calls wake_up_new_task. The source comments warn that the task pointer can become invalid after wakeup if the child exits quickly. A caller requiring a longer lifetime must use the correct reference protocol; scheduling the child introduces a race with its exit.

In kernel/fork.c, v6.12, SPDX GPL-2.0-only (lines omitted):

c
	/*
	 * Do this prior waking up the new thread - the thread pointer
	 * might get invalid after that point, if the thread exits quickly.
	 */
	trace_sched_process_fork(current, p);
/* ... */
	wake_up_new_task(p);

The comment concerns the child pointer after wakeup. The get_task_pid call in an omitted line takes a reference on the struct pid, but the excerpt does not show that every later use of p is protected.

Inside copy_process, clone flags are checked before resource setup. Some combinations are invalid: for example, thread-group and signal-handler sharing have dependencies. The exact rules are in the source, not a free-form menu of flags. The function creates or shares resources according to those flags and configuration. This excerpt from the same file shows one bounded failure edge (SPDX GPL-2.0-only; adjacent setup omitted):

c
retval = copy_fs(clone_flags, p);
if (retval)
	goto bad_fork_cleanup_files;

copy_files has already succeeded at this point. If copy_fs fails, the jump selects a cleanup route for previously acquired resources, including the file state, rather than publishing a partly constructed runnable child. Later steps handle signal structures, memory, namespaces, architecture-specific thread state and PID allocation, with their own error labels. The cleanup chain eventually returns an error pointer. It is incorrect to assume every failure has the same set of acquired objects; follow the label reached at the actual failure point.

After the relevant checks and setup, the task is linked into process and PID structures under appropriate synchronization before it can run. This publication and the later wakeup are distinct events. The task list and PID relationships can be read under specific locks or RCU protocols; a lockless arbitrary traversal is not justified by the existence of a task_struct pointer. kernel_clone obtains a struct pid reference before wakeup and eventually drops it with put_pid; the returned numeric ID is interpreted in the caller's namespace.

copy_process resource steps
  1. copy_files

    Share the descriptor table under CLONE_FILES; otherwise duplicate it.

  2. copy_fs

    Share fs_struct under CLONE_FS; otherwise copy the root and working-directory state.

  3. copy_sighand, copy_signal

    Share or create the signal-handler and signal structures.

  4. copy_mm

    CLONE_VM takes a reference to the parent mm; otherwise dup_mm builds a new one.

  5. wake_up_new_task

    Make the child runnable only after the PID reference and publication steps.

  6. bad_fork_cleanup_*

    Each failure releases only what was already acquired, then copy_process returns an error pointer.

Source map. Simplified source map of copy_process and kernel_clone in Linux v6.12 kernel/fork.c. Many flags and checks are omitted; this is not a captured fork.

Sharing, copying and copy-on-write#

A traditional fork creates a child process with a logically separate address space. Linux implements this with copy-on-write: parent and child initially share physical pages, while page-table mappings arrange for private changes to become separate when needed. The usual cost includes duplicating page tables and creating the child task, rather than eagerly copying every data page (fork(2)). It does not mean that all memory is eagerly copied, nor that every mapping is private: explicitly shared mappings remain governed by their own semantics. A write fault, allocation failure or special mapping can follow a different path. The full virtual-memory implementation is covered later.

Clone flags permit different sharing. CLONE_VM shares an address space; a write through an ordinary shared mapping is then visible under the applicable synchronization rules. CLONE_FILES shares the descriptor table itself, so a descriptor-table change by one task can affect another. Without CLONE_FILES, the tables can be separate while corresponding inherited descriptors still reference the same open file description, including its position (open(2)). CLONE_FS concerns filesystem context such as root and current directory, while signal and thread-group flags have their own dependencies. Consult v6.12 UAPI clone definitions and the kernel source for exact behavior.

Clone flags and shared task fields
Clone flags and shared task fieldsCLONE_VM: copy_mm takes a reference to the parent mm when set. → task->mm: Address-space pointer of the child task. (same mm_struct). CLONE_FILES: copy_files shares the files_struct when set. → task->files: Descriptor table pointer of the child task. (same descriptor table). CLONE_FS: copy_fs shares the fs_struct when set. → task->fs: Root and working-directory context of the child task. (same fs_struct)same mm_structsame descriptor tablesame fs_structCLONE_VMcopy_mm takes a reference tothe parent mm when set.CLONE_FILEScopy_files shares thefiles_struct when set.CLONE_FScopy_fs shares the fs_structwhen set.task->mmAddress-space pointer of thechild task.task->filesDescriptor table pointer ofthe child task.task->fsRoot and working-directorycontext of the child task.
CLONE_VMcopy_mm takes a reference to the parent mm when set.
task->mmAddress-space pointer of the child task.
CLONE_FILEScopy_files shares the files_struct when set.
task->filesDescriptor table pointer of the child task.
CLONE_FScopy_fs shares the fs_struct when set.
task->fsRoot and working-directory context of the child task.
Read the text version
  • CLONE_VM: copy_mm takes a reference to the parent mm when set. → task->mm: Address-space pointer of the child task. (same mm_struct)
  • CLONE_FILES: copy_files shares the files_struct when set. → task->files: Descriptor table pointer of the child task. (same descriptor table)
  • CLONE_FS: copy_fs shares the fs_struct when set. → task->fs: Root and working-directory context of the child task. (same fs_struct)
Source map. Source map of copy_mm, copy_files and copy_fs in Linux v6.12 kernel/fork.c. Only the shared case is drawn; unset flags copy or duplicate state instead.

vfork-style behavior is another conditional relationship. The parent is suspended while the child shares the relevant address-space state until the child execs or exits under the kernel's completion protocol. The child must obey the restrictive userspace API contract; arbitrary changes to shared memory or library state can cause undefined or dangerous behavior (vfork(2)). It is not simply a faster fork with no coupling. kernel_clone sets up a completion and waits on the CLONE_VFORK path after waking the child. A successful exec replaces the child's image; it does not imply that every ordinary clone uses vfork semantics.

Exit is not immediately reaping#

do_exit in v6.12 kernel/exit.c begins termination for the current task. Its work depends on whether the thread group is also ending and on configuration. Among other cleanup it marks exit state, handles signals and accounting, detaches the memory context, releases file and filesystem references, handles namespaces and task work, and notifies the appropriate parent or reaper. The following excerpt is from do_exit (SPDX GPL-2.0-only; intervening operations omitted):

c
exit_mm();
c
exit_files(tsk);
exit_fs(tsk);

These calls release or detach the exiting task's relationships at their respective points. They do not prove that every shared object is immediately freed: another task or reference may still use it. Later exit_notify participates in parent notification and reparenting. The source also uses RCU-related exit coordination; memory reclamation and observer safety can extend beyond the point where the task stops executing.

A terminated child can remain as a zombie until an eligible parent or reaper collects its exit status. The zombie no longer runs and has given up many execution resources, but enough identity and status state remains for wait semantics. waitpid or waitid reports status according to its options and can reap the child (wait(2)). A parent must interpret the status macros rather than treating the raw integer as a direct exit code. WNOHANG can return without an available event; signal interruption and errors need handling. Depending on SIGCHLD disposition and flags such as SA_NOCLDWAIT, ordinary zombie behavior can differ. Reparenting changes which reaper is responsible when a parent exits. These are conditional policies, not a guarantee that every terminated task stays a zombie until its original parent explicitly calls wait.

Exit leaves a zombie until wait
Exit leaves a zombie until wait1. Child task to do_exit: exit or fatal signal enters do_exit. 2. do_exit to do_exit: exit_mm, exit_files and exit_fs detach resources. 3. do_exit to Parent: exit_notify informs the parent; status stays for wait. 4. Parent to Parent: waitpid collects the zombie when eligible. 5. Parent to Child task (return): zombie reaped; other references may still delay freeing.Child taskdo_exitParentexit or fatal signalenters do_exitexit_mm,exit_files andexit_fs detachresourcesexit_notify informs theparent; status stays forwaitwaitpid collectsthe zombie wheneligiblezombie reaped; other references may still delayfreeing
  1. Child taskdo_exit

    exit or fatal signal enters do_exit

  2. do_exitdo_exit

    exit_mm, exit_files and exit_fs detach resources

  3. do_exitParent

    exit_notify informs the parent; status stays for wait

  4. ParentParent

    waitpid collects the zombie when eligible

  5. ParentChild task

    Return. zombie reaped; other references may still delay freeing

Time runs downward. Dashed arrows show returns.

Read the text version
  1. Child task → do_exit: exit or fatal signal enters do_exit
  2. do_exit → do_exit: exit_mm, exit_files and exit_fs detach resources
  3. do_exit → Parent: exit_notify informs the parent; status stays for wait
  4. Parent → Parent: waitpid collects the zombie when eligible
  5. Parent → Child task (return): zombie reaped; other references may still delay freeing
Source map. Teaching sequence from Linux v6.12 kernel/exit.c and wait handling, not a captured process trace. Exit, zombie status and reaping are separate steps.

The last thread of the global init task is special: the v6.12 exit source panics rather than continuing without the global init process. Do not test that behavior on the host. A PID namespace can have its own init and lifecycle; PID 1 seen inside a container is not automatically the host's global init. A PID number can be reused after reaping, while an independently held kernel reference can keep an object alive for a different period. Waiting for a child and holding a task reference answer different questions.

In kernel/exit.c, v6.12, SPDX GPL-2.0-only (surrounding code omitted):

c
	if (group_dead) {
		/*
		 * If the last thread of global init has exited, panic
		 * immediately to get a useable coredump.
		 */
		if (unlikely(is_global_init(tsk)))
			panic("Attempted to kill init! exitcode=0x%08x\n",
				tsk->signal->group_exit_code ?: (int)code);

The branch applies only when the exiting task is the global init and its thread group has ended. The excerpt does not show the other exit steps, and it does not tell you what a PID namespace init does; that is a separate case.

A worked parent-child state#

Start with a parent whose private integer value is 7 and whose descriptor 3 refers to an open regular file at offset 0. After a normal fork, the parent and child have logically separate address spaces and separate descriptor tables. Initially the file descriptors in both tables refer to the same open file description. The child sets its private value to 42, reads one byte a through descriptor 3 and exits with status 7. The parent's private value remains 7. After the parent waits for the child, a read through its own descriptor can obtain b because the inherited descriptors share the open-file position, now 1. This example assumes no other actor reads or changes that open description.

The timeline matters: before the child exits it may race with the parent, so reading from the parent immediately after fork without synchronization could return a or b in an order determined by scheduling. Waiting first establishes that the child's read and exit have completed for this example. The wait collects the child status; it is not itself the operation that advanced the file position. The COW relationship concerns memory, while the open-file reference concerns a kernel file object. Treating both as simply “everything is copied” loses the important difference.

For a failure case, if copy_fs returns an error after file state has been prepared, the kernel uses the cleanup route described above and returns an error rather than a usable child. In userspace, fork returns -1 and sets errno; a correct program must not call waitpid on an uninitialized child ID. Resource limits, memory pressure or policy can cause failure. If a child did start and exits before the parent calls wait, the parent still needs the appropriate wait or configured no-zombie behavior; the child being fast does not make the wait unnecessary.

Practice#

Source-only route: draw the parent-child table from the worked state: before fork, after fork, after the child's read, and after wait. Include integer values, descriptor tables, the shared open-file offset, child execution state and exit status. Add the copy_fs failure branch and mark which resources need cleanup. This is a reasoned example rather than a measurement.

Bounded host route: run this synthetic program as your ordinary user. It creates one child, uses a private temporary file, waits for that child, closes the descriptor and unlinks the file. It sends no signals. The child's work is finite; scheduling can still be delayed by the host.

sh
lab_dir=$(mktemp -d) || exit 1
cd "$lab_dir" || exit 1
cat > child.c <<'C'
#define _POSIX_C_SOURCE 200809L
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
int main(void) {
    char path[] = "/tmp/fork-observe-XXXXXX";
    int fd = mkstemp(path);
    if (fd < 0) { perror("mkstemp"); return 1; }
    if (unlink(path) < 0) { perror("unlink"); close(fd); return 1; }
    if (write(fd, "abc", 3) != 3 || lseek(fd, 0, SEEK_SET) < 0) {
        perror("prepare"); close(fd); return 1;
    }
    int value = 7;
    pid_t pid = fork();
    if (pid < 0) { perror("fork"); close(fd); return 1; }
    if (pid == 0) {
        char c = 0;
        value = 42;
        ssize_t n = read(fd, &c, 1);
        _exit(n == 1 && c == 'a' && value == 42 ? 7 : 8);
    }
    int status;
    pid_t waited;
    do { waited = waitpid(pid, &status, 0); } while (waited < 0 && errno == EINTR);
    if (waited < 0) { perror("waitpid"); close(fd); return 1; }
    char next = 0;
    ssize_t n = read(fd, &next, 1);
    printf("parent value=%d next=%c child-exit=%d\n", value,
           n == 1 ? next : '?', WIFEXITED(status) ? WEXITSTATUS(status) : -1);
    if (close(fd) < 0) { perror("close"); return 1; }
    return 0;
}
C
cc -std=c11 -Wall -Wextra -Werror -o child child.c || exit 1
./child

Expect parent value=7 next=b child-exit=7 on an ordinary Linux system. The child changes its own memory and advances the shared open-file position before the parent reads. If fork or waitpid fails, record the actual error; the program closes its descriptor and does not invent a child result. If the output differs, check compiler diagnostics, filesystem behavior and the actual executable rather than assuming the expected line was printed. This host observation is not evidence that the host ran upstream v6.12.

For a counterexample, reason about removing the waitpid synchronization: either task could read first, so the parent cannot assume it sees b. Do not repeatedly fork to try to force a particular schedule. Keep the output and state table; clean up only the files you created:

sh
rm child child.c
cd /tmp
rmdir "$lab_dir"

Knowledge check#

  1. Why can parent and child have separate descriptor tables but still share a file offset?
  2. What does the copy_fs failure branch release, and why is there no runnable child on that path?
  3. Why can kernel_clone not assume its raw child task pointer remains valid after wakeup?
  4. What distinguishes a zombie from a running child and from a reaped child?
  5. How do CLONE_VM, CLONE_FILES and vfork-style waiting change different relationships?

Answers#

  1. Inherited descriptors in separate tables can reference the same open file description. Its file position belongs to that shared object, not to the descriptor integer alone.
  2. The failure jumps to cleanup for resources already acquired, including file state. Construction returns an error pointer to kernel_clone, which returns the error before its normal wakeup path.
  3. Once runnable, the child can execute and exit quickly. The source obtains the PID reference it needs before wakeup; longer task-pointer use requires the appropriate lifetime protocol.
  4. A zombie has terminated and retains status/identity needed for wait; it is not running. Reaping collects the status under the applicable rules and allows further release, although other references may still affect object lifetime.
  5. CLONE_VM shares an address space, CLONE_FILES shares a descriptor table, and vfork-style operation additionally coordinates the parent waiting for the child to exec or exit. Flag validity and exact details are constrained by the kernel and API.

Checkpoint#

Deliver the state table and either the source-only analysis or the one-child program output. Pass when you explain why memory diverges while the file position is shared, identify a real copy_process cleanup edge, and distinguish exit from reaping and numeric PID reuse. If uncertain, retrace copy_files, copy_fs, wake_up_new_task and do_exit, then repeat the bounded exercise. This checkpoint is not certification.

Sources#

A little further beneath the surface.

Check your understanding with the checkpoint. Marking a chapter as read saves a bookmark, not a proficiency assessment.