A signal is a software interrupt that is sent to a process to notify it of an event.

Let’s see what happens when we send signals to a simple Python script.

import signal
import time
import os

def handler(signum, frame):
    print(f"Received signal: {signum}")
    if signum == signal.SIGTERM:
        print("SIGTERM received. Cleaning up and exiting gracefully.")
        # Simulate cleanup
        time.sleep(2)
        print("Cleanup complete. Exiting.")
        exit(0)
    elif signum == signal.SIGCHLD:
        print("SIGCHLD received. A child process has terminated.")
        # In a real scenario, you'd reap the child process here
        # os.wait()
    else:
        print("Exiting due to unexpected signal.")
        exit(1)

def child_process():
    print(f"Child process started with PID: {os.getpid()}")
    time.sleep(5)
    print("Child process finishing.")

if __name__ == "__main__":
    print(f"Parent process PID: {os.getpid()}")

    # Register signal handlers
    signal.signal(signal.SIGTERM, handler)
    signal.signal(signal.SIGCHLD, handler)

    # Fork a child process
    pid = os.fork()

    if pid == 0:
        # This is the child process
        child_process()
    else:
        # This is the parent process
        print(f"Parent forked child with PID: {pid}")
        try:
            # Keep the parent alive to receive signals
            while True:
                time.sleep(1)
        except KeyboardInterrupt:
            print("Keyboard interrupt received. Exiting.")
            exit(0)

Save this as signal_demo.py and run it: python signal_demo.py.

You’ll see output like this:

Parent process PID: 12345
Parent forked child with PID: 12346
Child process started with PID: 12346

Now, let’s interact with it. Open another terminal and send signals:

  1. kill -SIGTERM 12345 (Replace 12345 with the parent PID)

    You’ll see the parent catch SIGTERM, print its cleanup messages, and exit. The child process will likely be terminated by the system because its parent is gone (unless it’s reparented to init).

    SIGTERM received. Cleaning up and exiting gracefully.
    Cleanup complete. Exiting.
    
  2. Run python signal_demo.py again. In the second terminal, this time, wait for the child to finish: kill -SIGCHLD 12345. This won’t do anything immediately because SIGCHLD is sent when a child terminates. The child will finish on its own in 5 seconds.

    Parent process PID: 12347
    Parent forked child with PID: 12348
    Child process started with PID: 12348
    Child process finishing.
    Received signal: 17  # SIGCHLD signal number
    SIGCHLD received. A child process has terminated.
    

    Note that the parent does not exit here because SIGCHLD is only informational. The handler function is designed to exit on SIGTERM, not SIGCHLD.

  3. Run python signal_demo.py again. Now, let’s try SIGKILL. Open another terminal and run: kill -SIGKILL 12345.

    Parent process PID: 12349
    Parent forked child with PID: 12350
    Child process started with PID: 12350
    

    Nothing happens in the script’s output. SIGKILL cannot be caught, blocked, or ignored. The process is terminated immediately by the kernel. The child process will also likely be terminated.

Signals are the primary way the operating system communicates with processes about events happening outside their normal execution flow. They are asynchronous and can interrupt a process at any time.

SIGTERM (signal number 15) is the polite way to ask a process to shut down. It’s a request, not a command. A process can choose to ignore it, or it can catch it to perform cleanup operations like saving state, closing files, or releasing resources before exiting. This is why our handler function explicitly checks for SIGTERM and initiates a graceful shutdown sequence.

SIGKILL (signal number 9) is the "you must die now" signal. It’s an uncatchable, unblockable, and unignorable signal sent directly by the kernel. When a process receives SIGKILL, it is immediately terminated without any opportunity to clean up. This is typically used as a last resort when a process is unresponsive or misbehaving and SIGTERM has failed.

SIGCHLD (signal number 17) is sent to a parent process whenever one of its child processes terminates, is stopped, or is resumed. It’s crucial for managing child processes. A parent process often registers a handler for SIGCHLD to know when its children are done so it can "reap" them (i.e., call wait() or waitpid() to collect their exit status and prevent them from becoming zombies). In our example, the parent receives the signal, but the handler just prints a message. A real application would need to call os.wait() within that handler to properly handle the child’s termination.

The core mechanism involves the signal.signal(signum, handler) function in Python, which maps a specific signal number (signum) to a Python function (handler). This handler function then receives the signal number and the current stack frame as arguments.

The most surprising thing about signals is how they fundamentally differ from normal function calls. A function call is a direct, synchronous transfer of control. A signal is an asynchronous event that can interrupt a process anywhere, even in the middle of a critical operation, forcing it to jump to a signal handler. This asynchronous nature is powerful but also a primary source of complexity, especially when dealing with shared state or critical sections.

When a signal is delivered, the kernel stops the process’s current execution path and pushes the signal handler onto the stack. Once the handler finishes, the process resumes execution from where it was interrupted, unless the handler itself caused the process to exit. This resumption behavior means signal handlers need to be carefully written to avoid introducing race conditions or leaving the system in an inconsistent state.

The one thing many developers miss about SIGCHLD is that receiving the signal doesn’t automatically reap the child. The parent must explicitly call wait() or waitpid() to collect the child’s exit status. If a parent process forks many children and doesn’t reap them, those children can accumulate as zombie processes, consuming minimal but non-zero process table entries until the parent eventually terminates.

After handling SIGTERM, SIGKILL, and SIGCHLD, the next logical step is understanding how to manage process groups and job control, especially with signals like SIGINT (Ctrl+C) and SIGTSTP (Ctrl+Z).

Want structured learning?

Take the full Linux & Systems Programming course →