8.1 Processes, Tasks, and Context
The scheduler assigns runnable threads to available CPUs. One logical CPU switches between execution flows, while multiple CPUs can run them in parallel. Scheduling policy, blocking, and preemption determine when a thread yields the CPU; a fixed time slice is not universal.
A system call allows the current thread to enter the kernel, but it doesn't explain what state constitutes "current." This lesson first separates programs, processes, and threads. The next one will trace the lifecycle of a process along Unix's
fork,exec, andwait.
Executable Files Live on Disk, Processes Exist in the Kernel
The disk contains one worker file, yet the process list can show dozens of PIDs for it. The file is a static program image containing machine instructions, read-only data, loading information, and symbols. A process is the kernel resource and protection domain for one execution.
The same executable can be loaded into multiple processes; a single process can also switch to a different program image during its lifetime via exec. Dynamic linking, JIT compilation, and plugins make the idea that "a process corresponds to exactly one program file" seem overly simplistic.
Processes typically possess or associate with:
- a virtual address space and its mappings;
- a file descriptor table, current working directory, and umask;
- credentials, resource limits, and namespace/cgroup membership;
- signal disposition, timer, and IPC state;
- one or more schedulable threads;
- a PID, parent/child relationships, process group, and session associations.
The CPU does not load all of these objects into registers at once. Instead, they are referenced through kernel data structures and accessed on-demand during system calls, scheduling, and resource cleanup.
Threads Are Usually the CPU Scheduling Units
A thread has its own instruction pointer, general-purpose registers, stack, signal mask, and scheduling state. Each thread uses its own stack, but that stack lives in the shared address space and is not protected from other threads merely by belonging to one thread. Threads within the same process typically share address space, file descriptor table, and signal disposition.
The Linux kernel schedules tasks as its fundamental unit. Each thread has its own task ID (commonly referred to as TID from user space); members of the same thread group share a thread-group ID, which users typically treat as the process PID. The TID of the main thread is equal to its TGID.
"The Linux PCB is just a fixed-field structure like task_struct" serves only as a beginner-level analogy. task_struct varies with kernel version and configuration and is linked via pointers to objects such as mm_struct, files_struct, credentials, and signal state. Some resources are shared across threads, while others are unique to each task; there is no stable teaching structure that can fully replicate this complexity.
A model can be visualized as:
process / thread group
├─ shared mm (virtual address space)
├─ shared files table
├─ shared signal dispositions
├─ thread A: registers + stack + scheduling state + mask
└─ thread B: registers + stack + scheduling state + maskThis is also why processes and threads cannot be distinguished simply by their "weight." The fundamental difference lies in their shared resource boundaries.
Address Space and File Descriptor Are Two Different Kinds of References
A process maps virtual addresses to memory objects via its page table and maps small integers (file descriptors) to open file descriptions via its file descriptor table.
fd 3 in process A --+
+--> open file description
fd 7 in process B --+ (offset, status flags)
|
+--> inode / pipe / socket ...After fork creates a child, entries in their file descriptor tables often point to the same open file description. As a result, they share the file offset and certain file status flags. Their separate descriptor tables refer to the same open-file state. Closing the child’s fd does not close the parent’s entry. Descriptor flags such as FD_CLOEXEC belong to each entry, unlike shared file status flags.
dup also creates new descriptors that point to the same open file description. Simply comparing the integer values of two file descriptors does not reveal whether they share the underlying state.
Abstract State Machines and Linux State Names Should Not Be Mixed
The textbook often uses a four-state model:
ready --dispatch--> running --block--> waiting
^ | |
+----preempt---------+------wake-------+
running --exit--> terminatedAfter process creation, it enters the ready set; the scheduler selects one task to execute. When waiting for I/O, a mutex, a timer, or a page, the task transitions into waiting state and becomes runnable again upon completion of the event. Once terminated, it no longer participates in scheduling.
This model helps with conceptual understanding, but Linux's state fields do not map one-to-one to these states:
TASK_RUNNINGcovers both "currently executing on a CPU" and "waiting in the run queue";- interruptible sleep can be woken by a signal, while uninterruptible sleep typically waits for specific kernel conditions;
- stopped/traced states are used for job control and debugging;
- zombie processes have already exited and are not blocked tasks waiting for CPU time.
The claim that "terminated state equals zombie" is inaccurate. After a process exits, most of its runtime resources are released. The kernel retains the exit status and a minimal amount of identity and statistics for the parent process to reclaim. Normally wait collects that record; configurations such as explicitly ignoring SIGCHLD or setting SA_NOCLDWAIT may instead arrange automatic reaping.
A Context Switch Preserves Resumable State
When the scheduler switches from task A to task B, it must ensure that when A is resumed, it continues execution exactly where it was paused. Typical operations include:
- Saving the necessary register context at the kernel entry or preemption point;
- Updating A’s scheduling and accounting state;
- Selecting task B and switching to B’s kernel stack along with its saved context;
- If B operates in a different address space, switching page table contexts and handling TLB tag or flush rules;
- Resuming execution of B, ultimately returning to its user or kernel execution point.
This does not involve copying the entire address space, all cache lines, or the full PCB. Page table switching merely changes the current translation context; physical pages remain in place. ASID/PCID allows TLB entries for different address spaces to coexist.
Threads within the same process share mm. When switching between them, page table changes are often unnecessary, though register, stack, and scheduler state must still be preserved. Conversely, switching between different processes can be surprisingly cheap due to PCID, warm cache, and working set characteristics. The cost cannot be reduced to a fixed multiplier based solely on "process-heavy, thread-light."
The Indirect Costs of Context Switches Are Often Harder to Measure Than the Entry Instruction
Direct costs include scheduler code, register save/restore, clock accounting, and potential address-space switches. Indirect costs arise from:
- The instruction and data working set of a new task evicting cached content from the previous task;
- The branch predictor and prefetch state becoming outdated;
- Changes in TLB reach, NUMA locality, or CPU migration;
- Increased lock contention and latency from the run queue.
perf stat -e context-switches records OS event counts and does not directly indicate how many cache misses are caused by each context switch. Microbenchmarks using pipe ping-pong to measure performance also include overhead from system calls, wake-up operations, scheduler queue management, and synchronization primitives.
When comparing two versions, it's essential to monitor wall time, CPU time, voluntary/involuntary context switches, migration events, and cache/TLB counters, while keeping CPU affinity and system load constant. A simple reduction in context-switch count does not automatically imply improved application performance.
Voluntary vs. Involuntary: Only the Trigger Source Differs
A task entering a waiting state voluntarily (such as by blocking on read, futex, or nanosleep) is typically classified as a voluntary context switch. Conversely, when a task is preempted by a time slice expiration, a higher priority, or another runnable task, it's considered an involuntary context switch.
"Voluntary" does not mean that the application code explicitly calls sched_yield. Even kernel-level events like page faults, which involve waiting for I/O, can cause a task to enter sleep. Nonblocking I/O may prevent the current operation from blocking, but it can still result in the event loop sleeping at locations like epoll_wait, meaning context switches do not disappear.
sched_yield is not a universal solution for concurrent waiting. Its behavior depends on the scheduler policy and the presence of other runnable tasks. Proper synchronization should instead use mutexes, condition variables, futex-backed primitives, or event-driven mechanisms.
CPU Affinity and Migration
The scheduler can move runnable threads from one CPU to another to balance the workload. After migration, the target CPU's private cache might not contain the thread's working set, and NUMA memory access could become farther away.
Pinning threads can reduce experimental noise or meet real-time or NUMA design requirements, but it limits the scheduler's ability to balance loads and can create hotspots. Production services typically use topology-aware thread placement instead of permanently pinning all threads.
Linux supports taskset or sched_setaffinity for experimentation, but both container cpuset/cgroup settings and the host scheduler still influence which CPUs are available. What is recorded is the actual allowed CPU mask, not the machine's nominal core count.
Use /proc to Observe the Current Task, Rather Than Speculating on Structs
Linux's /proc/self/status and /proc/self/stat expose the state of tasks after kernel processing. Save this program as task_status.c; it selects exact field names for identifiers and context-switch counts. Compile with cc -std=c17 -Wall -Wextra -Werror task_status.c -o task_status and run it:
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(void) {
FILE *status = fopen("/proc/self/status", "r");
if (status == NULL) {
perror("fopen /proc/self/status");
return 1;
}
const char *keys[] = {
"Name:", "State:", "Tgid:", "Pid:", "PPid:", "Threads:",
"voluntary_ctxt_switches:", "nonvoluntary_ctxt_switches:"
};
int result = 0;
char *line = NULL;
size_t capacity = 0;
while (getline(&line, &capacity, status) >= 0) {
for (size_t index = 0; index < sizeof keys / sizeof keys[0]; index++) {
if (strncmp(line, keys[index], strlen(keys[index])) == 0) {
if (fputs(line, stdout) == EOF) {
perror("write status");
result = 1;
}
break;
}
}
if (result != 0) break;
}
if (result == 0 && !feof(status)) {
perror("getline");
result = 1;
}
free(line);
if (fclose(status) != 0) {
perror("fclose");
result = 1;
}
if (fflush(stdout) == EOF) {
perror("fflush");
result = 1;
}
return result;
}The colon participates in matching, so Pid: does not also select PidSomething:. Procfs is Linux-specific and can gain fields over time. The example selects named fields without depending on line order or fixed line numbers.
You can also check this in a shell:
ps -o pid,ppid,stat,nlwp,comm -p "$$"
grep -E '^(Name|State|Pid|PPid|Threads|voluntary|nonvoluntary)' /proc/self/statusThe shell's /proc/self points to the process executing grep, which may not be the shell itself. Such details serve as a reminder: observation tools themselves create and schedule tasks.
Process Identity Does Not Determine Permissions
Process IDs (PIDs) can be reused. If you first read a PID and then later send a signal or check /proc/PID, the target process might have already terminated and been replaced by a new one. Modern Linux introduces pidfd to provide stable references for certain lifecycle operations, reducing reuse races after the reference is acquired. Checking a numeric PID long before calling pidfd_open still leaves a pre-acquisition race; obtain the reference at creation or verify that it identifies the intended target.
Credentials are not just two numbers, real UID and effective UID. They also include saved UID, filesystem ID, groups, capabilities, namespaces, and LSM (Linux Security Module) policies. Each of kill(pid, signal), ptrace, and open("/proc/...") must undergo its own independent permission checks.
Thus, the analogy "the PCB is the process identity card" should be abandoned here. Identity, resource containers, and scheduling entities are interlinked in implementation, but no single field determines the full picture.
The Program Counter Can Point into the Kernel
A task might run in user mode, or it might represent the thread currently executing a system call, a fault handler, or an interrupt return path in kernel mode. When the thread is scheduled out, the saved recovery point may reside on the kernel stack as a kernel continuation, and the thread will later return to user space.
Hardware interrupts might temporarily borrow the current thread's stack and context, while softirqs or workqueues could be processed in different kernel execution environments. Viewing the CPU world as simply "either a user process or an exception handler" is still too coarse; kernel threads and deferred work are also subject to scheduling.
Hands-on Breaking Down Context
- Distinguish executable, process, thread, and Linux task, and list one unique attribute for each.
- Explain why parent and child processes can share open file descriptions but maintain separate file descriptor tables.
- Draw diagrams showing thread switching within the same process versus process switching between processes, and label which one typically requires a switch of
mm. - Use
getrusageor/proc/self/statusto compare voluntary and involuntary context switches between sleep-heavy and CPU-heavy programs. - Describe how PID reuse can introduce race conditions in a "check-before-signal" pattern, and explain the scope of resolution provided by using pidfd.
- Identify the differences between the textbook's four-state model and Linux's
TASK_RUNNING.
Next Step: Copy the Resource View, Then Replace the Program Image
After understanding context, fork is no longer a mysterious copying operation; it creates a new task along with resource references, establishing a new address space semantics. exec, in contrast, replaces the program image of the calling process. The next lesson will place fork, exec, wait, and zombie on the same executable timeline.