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#
- Parentkernel_clone
request fork or clone
- kernel_clonekernel_clone
copy_process or unwind acquired resources
- kernel_cloneChild task
publish and wake a successful child
- Child taskChild task
run, release resources and exit
- ParentWait
wait for an eligible child
- WaitParent
Return. return status and reap when applicable
Time runs downward. Dashed arrows show returns.
Read the text version
- Parent → kernel_clone: request fork or clone
- kernel_clone → kernel_clone: copy_process or unwind acquired resources
- kernel_clone → Child task: publish and wake a successful child
- Child task → Child task: run, release resources and exit
- Parent → Wait: wait for an eligible child
- Wait → Parent (return): return status and reap when applicable
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):
p = copy_process(NULL, trace, NUMA_NO_NODE, args);
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):
/*
* 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):
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_files
Share the descriptor table under CLONE_FILES; otherwise duplicate it.
- copy_fs
Share fs_struct under CLONE_FS; otherwise copy the root and working-directory state.
- copy_sighand, copy_signal
Share or create the signal-handler and signal structures.
- copy_mm
CLONE_VM takes a reference to the parent mm; otherwise dup_mm builds a new one.
- wake_up_new_task
Make the child runnable only after the PID reference and publication steps.
- bad_fork_cleanup_*
Each failure releases only what was already acquired, then copy_process returns an error pointer.
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.
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)
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):
exit_mm();
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.
- Child taskdo_exit
exit or fatal signal enters do_exit
- do_exitdo_exit
exit_mm, exit_files and exit_fs detach resources
- do_exitParent
exit_notify informs the parent; status stays for wait
- ParentParent
waitpid collects the zombie when eligible
- ParentChild task
Return. zombie reaped; other references may still delay freeing
Time runs downward. Dashed arrows show returns.
Read the text version
- Child task → do_exit: exit or fatal signal enters do_exit
- do_exit → do_exit: exit_mm, exit_files and exit_fs detach resources
- do_exit → Parent: exit_notify informs the parent; status stays for wait
- Parent → Parent: waitpid collects the zombie when eligible
- Parent → Child task (return): zombie reaped; other references may still delay freeing
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):
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.
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:
rm child child.c
cd /tmp
rmdir "$lab_dir"
Knowledge check#
- Why can parent and child have separate descriptor tables but still share a file offset?
- What does the
copy_fsfailure branch release, and why is there no runnable child on that path? - Why can
kernel_clonenot assume its raw child task pointer remains valid after wakeup? - What distinguishes a zombie from a running child and from a reaped child?
- How do
CLONE_VM,CLONE_FILESand vfork-style waiting change different relationships?
Answers#
- 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.
- 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. - 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.
- 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.
CLONE_VMshares an address space,CLONE_FILESshares 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#
- v6.12 kernel/fork.c: clone, construction, cleanup and wakeup.
- v6.12 kernel/exit.c: exit and wait.
- v6.12 task structure.
- v6.12 PID structures.
- v6.12 clone flags.
- Kernel process documentation.