Before you start#
Read pipes and Unix sockets and tasks, fork, exit, and wait. Source examples are Linux v6.12; signal-frame details below are specifically x86, not a universal architecture ABI. The lab signals only a child it just created and always waits for it.
Learning goals#
Distinguish process-directed and thread-directed signals, pending state and handler execution, standard and real-time queues, numeric PIDs and pidfds, and process groups, sessions and child reaping.
Source walkthrough#
- Generate
Thread-directed or process-directed pending state is selected.
- Pending
Mask and disposition affect eligibility; standard signals can coalesce.
- Select and deliver
get_signal and architecture code prepare an eligible action.
- Child lifecycle
SIGCHLD reports a state change; wait collects status.
Linux schedules tasks. Threads in a thread group share some process-level state, including a signal_struct, while each task has its own pending signals and blocked mask. A sighand_struct holds signal dispositions and the siglock that protects relevant signal state. The disposition says whether a signal is ignored, uses its default action, or invokes a handler; a mask says which signals a particular thread blocks. Pending does not mean a handler is already running. Some signals, notably SIGKILL and SIGSTOP, cannot be caught or blocked. See include/linux/sched/signal.h and kernel/signal.c.
A process-directed signal can be selected for an eligible thread in the group; a thread-directed signal is targeted at a particular task. A synchronous fault such as a particular thread's invalid memory access is associated with the faulting execution context, whereas an asynchronous kill request can target a process or group under its API. Do not assume every signal of the same number has the same origin or target. Permissions, PID namespaces, credentials, signal disposition and process state affect whether a send succeeds and what follows.
In __send_signal_locked, GPL-2.0-only, the source contains:
pending = (type != PIDTYPE_PID) ? &t->signal->shared_pending : &t->pending;
For the PIDTYPE_PID case the selected pending set is the task's; other cases in this function use the thread group's shared pending set. The caller must hold t->sighand->siglock, asserted at the start of the function. After checking whether the signal should be prepared, the code tests legacy_queue for standard-signal coalescing:
if (legacy_queue(pending, sig))
goto ret;
If a suitable legacy signal is already pending in that pending set, the code can return without adding another queued instance. The signal may remain pending until an eligible thread unblocks it; it is not a counter of sends. Real-time signals have queueing and ordering rules, limits and allocation failures that differ from standard signals. The source comments distinguish sends whose interface permits EAGAIN from cases where at least one delivery is retained under pressure; do not promise unlimited real-time queue capacity.
Delivery is a later transition#
get_signal runs when the kernel is preparing to deliver a signal to the current task. It checks pending state and takes sighand->siglock while selecting and processing signal actions. It can handle default stop, termination, ignore, tracing and other paths instead of calling a userspace handler. Sending and delivery can race with changes to masks or dispositions; a successful send does not mean a handler has completed. SIGKILL and SIGSTOP have special behavior and cannot be handled like a user-defined signal.
On x86, arch/x86/kernel/signal.c handles architecture-specific userspace register state. After a signal is selected for a handler, setup_rt_frame builds the appropriate frame for the configured x86 ABI; register state is arranged for the handler, and a signal-return path restores saved context. The exact frame, register calling convention and return trampoline depend on architecture and ABI; arm64 uses its own code. A bad signal frame can cause another fault. This is not an ordinary C function call from the sender to the receiver.
The x86 entry to this decision is visible in arch_do_signal_or_restart (Linux v6.12, arch/x86/kernel/signal.c, GPL-2.0; surrounding code omitted):
if (get_signal(&ksig)) {
/* Whee! Actually deliver the signal. */
handle_signal(&ksig, regs);
return;
}
When generic selection returns a signal to deliver, x86 calls its handler setup and returns from this branch; otherwise the function can examine syscall restart state. Generic pending selection and x86 register-frame construction are distinct responsibilities. The selected task and handler state remain subject to the actual code's lock and lifetime contracts.
A blocked standard signal gives a concrete state example. Let a child block SIGUSR1 and tell its parent it is ready. The parent sends SIGUSR1 twice before allowing the child to continue. Both sends can return successfully, but the pending set represents one standard signal. When the child consumes it with sigwait, there is no second queued instance from those two sends. If the signal were unblocked and handled between sends, two handler executions could occur; coalescing is not a rule that all sends throughout a process lifetime collapse into one. Real-time signal queueing needs a separate example and resource-limit analysis.
A blocking syscall can be interrupted by a caught signal. Depending on the operation, handler flags such as SA_RESTART, timeout semantics and where interruption occurred, the call may restart or return EINTR. Application code must follow the particular interface and not blindly repeat non-idempotent operations. A signal handler runs asynchronously relative to most application code. Only async-signal-safe operations are safe under the relevant contract; taking a lock already held by interrupted code or calling an unsafe allocator can deadlock or corrupt state. A handler often records a minimal event and lets normal control flow do the complex work.
- kill() sendersend_signal_locked
kill path, with siglock held
- send_signal_lockedpending set
__send_signal_locked: legacy_queue check, then queue
- get_signal (target)pending set
get_signal: dequeue_signal from pending
- get_signal (target)x86 handle_signal
signal selected: handle_signal
- x86 handle_signalget_signal (target)
Return. setup_rt_frame builds frame; return to user handler
Time runs downward. Dashed arrows show returns.
Read the text version
- kill() sender → send_signal_locked: kill path, with siglock held
- send_signal_locked → pending set: __send_signal_locked: legacy_queue check, then queue
- get_signal (target) → pending set: get_signal: dequeue_signal from pending
- get_signal (target) → x86 handle_signal: signal selected: handle_signal
- x86 handle_signal → get_signal (target) (return): setup_rt_frame builds frame; return to user handler
PID identity and authority#
A numeric PID is a name in a PID namespace and can be reused after the old process is reaped. A program that records PID 123, waits, then sends a signal to PID 123 might target a different process if the original exited and the number was reused. A pidfd is a file descriptor referring to a process identity under the pidfd API, so it avoids selecting a new process merely because its old numeric PID was reused. It does not keep the target alive indefinitely or guarantee that an operation will succeed after exit.
pidfd_send_signal resolves a pidfd and applies its signal-sending rules; permissions, namespace visibility, flags and target state still matter. Possessing a pidfd is not blanket authority over the process. pidfd_getfd has separate permission checks and can fail; it duplicates a target descriptor under its own contract rather than making all target files automatically accessible. A pollable pidfd can indicate exit under the applicable API, while waiting and reaping remain separate concerns. Consult kernel/pid.c and the relevant syscall implementation rather than treating a pidfd as an immortal process object.
Sessions, groups, and children#
A process group groups processes for job control and group-directed signals. A session groups process groups and can have a controlling terminal. The terminal tracks a foreground process group; terminal-generated signals such as interrupt normally affect the foreground job under terminal settings. Background terminal reads or writes can be stopped or otherwise handled according to job-control rules and settings. A shell establishes groups, changes the foreground group, waits for state changes, and restores terminal control. A process group is not the same as a thread group, and a PID is not necessarily a process-group ID chosen by the program.
When a child exits, its parent can receive SIGCHLD subject to disposition and flags. The exited child can remain a zombie carrying status until the parent waits, with special cases for ignored SIGCHLD and SA_NOCLDWAIT. wait or a related interface collects the status and releases the waitable child state. A received SIGCHLD is not a one-to-one counter of exited children: it is a standard signal and can coalesce. A parent should loop over available child state using appropriate wait options rather than assuming one signal means exactly one child. A zombie is no longer executing, but still consumes kernel bookkeeping until reaped.
Pending state, masks, and ordering#
A thread can block a signal while another thread in the same group leaves it unblocked. A process-directed signal in the shared pending set can be selected for an eligible thread; blocking it in one thread does not necessarily delay delivery to the entire group. Programs that dedicate a thread to sigwait usually arrange masks consistently across the participating threads before those threads are created. A thread-directed synchronous fault has different targeting requirements from a process-directed notification. In either case, ignoring a signal, blocking it, and consuming it with a synchronous wait are distinct choices.
Standard-signal coalescing also limits the information a notification can convey. If a parent needs to know which of several children exited, it should query child state rather than encode an event count in SIGCHLD. Real-time signals can queue individual instances with associated values under their API, but queue resources are bounded and sending can fail. A program must handle that failure and should not silently replace a reliable message queue with unbounded assumptions about signals. Signal ordering across different signal types, threads and concurrent senders must be taken from the applicable API rather than inferred from a simple single-threaded experiment.
Handler interruption and restart#
A handler can interrupt code while it holds an internal library lock. Calling a function that takes the same lock from the handler can deadlock even though the same call is safe in ordinary sequential code. A handler can set an appropriate sig_atomic_t flag or use an async-signal-safe notification operation, but still needs a design for a full pipe, repeated signals and shutdown. The receiver should do complex logging, allocation and state changes in normal control flow. A signal handler that calls non-safe functions may appear to work in light testing and still fail under an unlucky interruption.
SA_RESTART is not a universal guarantee that every blocked syscall silently resumes. Which calls restart, how timeouts are accounted, and whether a partial transfer is returned depend on the syscall and signal context. If a read already returned some bytes, restarting the entire application-level operation without tracking progress can duplicate work. For a timeout, repeatedly starting a fresh full-duration wait can exceed the intended deadline; calculate remaining time against an appropriate clock. These are interface-level decisions, separate from the architecture-specific work of building and restoring a signal frame.
A concrete job-control sequence#
Imagine a shell launches a two-process pipeline in a new process group and gives that group the controlling terminal's foreground role. A terminal interrupt can send SIGINT to that foreground group; each process's mask and disposition still affect its response. The shell waits for child state and later reclaims the foreground terminal for itself. If a background process attempts a terminal read under normal job-control settings, the terminal can generate a stop signal instead of supplying input. An orphaned group and a session without a controlling terminal have different behavior; do not extrapolate the interactive-shell case to daemons.
A process exiting and a parent observing SIGCHLD are not the same event as the parent reaping status. The process can be a zombie between exit and wait. Reusing its numeric PID becomes possible only after the relevant lifecycle permits it, so a delayed PID-based operation can be dangerous. A pidfd narrows the identity problem, but permission checks and the target's exit still affect each action. A process group ID is similarly a namespace identifier, not a proof of permission to signal every member. The lab confines its kill calls to the child PID obtained directly from fork and waits for that child before returning.
Practice#
Source-only route:
rg -n -A42 'static int __send_signal_locked' "$KERNEL_SRC/kernel/signal.c"
rg -n -A28 'bool get_signal\(' "$KERNEL_SRC/kernel/signal.c"
rg -n -A24 'setup_rt_frame\(' "$KERNEL_SRC/arch/x86/kernel/signal.c"
Confirm the checkout is v6.12. Mark the asserted signal lock, shared versus per-task pending selection and standard-signal test. Then find x86's frame setup and explain why it is architecture-specific. No signal is sent in this source-only route.
On Linux with Python 3 supporting fork, pthread_sigmask and sigwait, run this own-child test:
python3 - <<'PY'
import os, signal
ready_r, ready_w = os.pipe(); go_r, go_w = os.pipe()
pid = os.fork()
if pid == 0:
os.close(ready_r); os.close(go_w)
signal.pthread_sigmask(signal.SIG_BLOCK, {signal.SIGUSR1})
os.write(ready_w, b'R'); os.close(ready_w)
go = os.read(go_r, 1); os.close(go_r)
if go != b'G': os._exit(1)
got = signal.sigwait({signal.SIGUSR1})
print('received:', got == signal.SIGUSR1, 'still pending:', signal.SIGUSR1 in signal.sigpending(), flush=True)
os._exit(0)
os.close(ready_w); os.close(go_r)
try:
assert os.read(ready_r, 1) == b'R'
os.kill(pid, signal.SIGUSR1); os.kill(pid, signal.SIGUSR1)
os.write(go_w, b'G')
finally:
os.close(ready_r); os.close(go_w)
_, status = os.waitpid(pid, 0)
print('child exit:', os.waitstatus_to_exitcode(status))
PY
Expected received: True still pending: False and child exit: 0. The handshake ensures both sends precede the child's wait while the signal is blocked. The parent signals only the PID returned by its own fork, and reaps it. If a required API is absent, report that and use the source route; do not signal a guessed PID. If the program is interrupted, terminate only its own child and wait for it. This demonstrates one standard-signal case, not real-time queue capacity or pidfd authorization.
Knowledge check#
- What is the difference between the shared pending set and a task's pending set in the quoted function?
- Why can two successful standard-signal sends produce one pending instance?
- What does a pidfd protect against, and what authority does it not automatically confer?
- Why is a signal handler not an ordinary synchronous call from the sender?
- Why should a parent loop over waitable child state rather than count SIGCHLD deliveries?
Answers#
- The code uses the task's pending set for
PIDTYPE_PIDand the thread-group shared pending set for other types in that branch; selection is protected by the signal lock. - A standard signal already pending in the relevant set can be coalesced by the
legacy_queuecheck. - It refers to a process identity without relying on a later reuse-prone numeric PID lookup; operations still have their own permissions and can fail when the target exits.
- Delivery happens later in the target's execution; the kernel sets up an architecture-specific frame and the handler interrupts normal control flow.
- Standard SIGCHLD signals can coalesce, and multiple children can change state before the parent handles one notification.
Checkpoint#
Save the signal-state timeline and child output. Pass when it distinguishes generation, pending, selection and handler execution, explains pidfd identity versus permission, and includes the child's wait/reap. Re-read the locked send path and rerun the child test if necessary. This is practice, not certification.
Sources#
- signal(7), Linux man-pages — dispositions, masks, pending sets, coalescing, real-time queueing, and delivery.
- pidfd_send_signal(2), Linux man-pages — pidfd identity, namespace constraints, permission errors, and exit behavior.
- credentials(7), Linux man-pages and setpgid(2), Linux man-pages — sessions, foreground jobs, terminal signals, and background terminal access.
- Signal generation and delivery, Linux v6.12.
- Signal structures.
- x86 signal frame and delivery.
- PID and pidfd implementation.