Published on

Understanding Signals in Linux: From Ctrl+C to Process Communication

Authors
Linux Signals

If you’ve ever used the terminal and pressed Ctrl+C, you probably thought you were “killing” the program.
But the truth is, you were sending a signal a simple notification that the process can react to.

Signals are a lightweight but powerful way for processes to communicate in Unix and Linux systems. They don’t transfer data, but they let processes know that something important has happened.

In this article, we’ll dive into:

  • What signals are and how they work
  • The most common signals you’ll encounter
  • How processes can handle signals with C programs
  • Practical ways signals are used in real systems

What Are Signals?

A signal is a software interrupt sent to a process. It’s the operating system’s way of telling a program that an event occurred.

Think of it like a traffic horn. You don’t explain in words what to do the horn is just a quick notification. The other driver then decides how to react.

In the same way:

  • The OS sends a signal.
  • The process either reacts, ignores, or terminates depending on the signal.

Common Signals You’ll See

Linux defines many signals, but here are the ones you’ll run into most often:

SignalNumberSent ByDefault Action
SIGINT2Ctrl+C in the terminalTerminates the process
SIGTERM15kill <pid>Terminates the process (can be caught/handled)
SIGKILL9kill -9 <pid>Forcefully kills process (cannot be caught or ignored)
SIGHUP1Terminal closed / hangupTerminates process or reloads config
SIGSTOP19kill -STOP <pid> or Ctrl+ZPauses the process
SIGCONT18kill -CONT <pid> or fgResumes a paused process

Notice the difference between SIGTERM and SIGKILL:

  • SIGTERM is a polite request: “Please shut down.”
  • SIGKILL is the hammer: “Shut down now, no questions.”

Trying Signals from the Terminal

Let’s say you start a long-running program, like sleep 1000:

sleep 1000

Now open another terminal and find its process ID (PID):

ps aux | grep "[s]leep 1000"

You’ll see a line like this:

user   12345  0.0  0.0   1234   432 ?  S   12:00   0:00 sleep 1000

Here, 12345 is the PID. You can send signals to it:

# Ask it to stop nicely (default is SIGTERM)
kill 12345

# Forcefully kill it (cannot be caught or ignored)
kill -9 12345   # SIGKILL

# Pause (stop) the process
kill -STOP 12345  # SIGSTOP

# Resume a stopped process
kill -CONT 12345  # SIGCONT

Tip: kill -l lists all signal names and numbers on your system.


Handling Signals in C

Programs don’t have to accept the default action. They can catch certain signals and run cleanup code before exiting.

Here’s a minimal C program that catches SIGINT (Ctrl+C) and exits cleanly:

#include <signal.h>
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>

volatile sig_atomic_t stop = 0;

void on_sigint(int sig) {
    (void)sig; // unused
    write(STDOUT_FILENO, "\nReceived SIGINT. Cleaning up...\n", 34);
    stop = 1;
}

int main(void) {
    signal(SIGINT, on_sigint); // register handler

    while (!stop) {
        write(STDOUT_FILENO, ".", 1);
        sleep(1);
    }

    // Do any final cleanup here
    write(STDOUT_FILENO, "\nDone.\n", 7);
    return 0;
}

Compile and run:

gcc signals_basic.c -o signals_basic
./signals_basic

Press Ctrl+C. Instead of an abrupt stop, the program prints a message and exits cleanly.


SIGTERM vs SIGKILL

  • SIGTERM (15): A polite request to stop. Programs can catch this, finish work, and exit.
  • SIGKILL (9): The operating system immediately ends the process. Programs cannot catch or ignore it.

Graceful services (databases, web servers, background workers) should handle SIGTERM to close files, flush buffers, and release locks.

Example handler for SIGTERM using the safer sigaction API:

#define _POSIX_C_SOURCE 200809L
#include <signal.h>
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>

volatile sig_atomic_t shutting_down = 0;

static void term_handler(int sig) {
    (void)sig;
    shutting_down = 1;
}

int main(void) {
    struct sigaction sa = {0};
    sa.sa_handler = term_handler;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = SA_RESTART; // restart some interrupted syscalls
    sigaction(SIGTERM, &sa, NULL);

    puts("Service running. Send SIGTERM to stop...");
    while (!shutting_down) {
        // do work
        sleep(1);
    }
    puts("Shutting down gracefully: closing resources...");
    // close files, flush, free memory, etc.
    return 0;
}

Send a graceful stop from another terminal:

kill -TERM <pid>

Handling Multiple Signals

You can handle several signals by registering handlers for each:

struct sigaction sa_int = {0}, sa_term = {0};
sa_int.sa_handler = on_sigint;
sa_term.sa_handler = term_handler;
sigaction(SIGINT, &sa_int, NULL);
sigaction(SIGTERM, &sa_term, NULL);

For short, predictable work in handlers, prefer async-signal-safe functions (like write) instead of printf.


Temporarily Blocking Signals (Signal Masks)

Sometimes you need to protect a critical section from interruption:

sigset_t block, prev;
sigemptyset(&block);
sigaddset(&block, SIGINT);

// Block SIGINT
sigprocmask(SIG_BLOCK, &block, &prev);
// critical section
// ...
// Restore previous mask
sigprocmask(SIG_SETMASK, &prev, NULL);

Reloading Configuration with SIGHUP

Many daemons use SIGHUP to mean “reload configuration.” Here’s a tiny example:

static volatile sig_atomic_t reload = 0;

static void hup_handler(int sig) {
    (void)sig;
    reload = 1;
}

int main(void) {
    struct sigaction sa = {0};
    sa.sa_handler = hup_handler;
    sigaction(SIGHUP, &sa, NULL);

    for (;;) {
        if (reload) {
            puts("Reloading configuration...");
            reload = 0;
            // reread config file here
        }
        sleep(1);
    }
}

Trigger a reload from another terminal:

kill -HUP <pid>

Bash: Handling Signals with trap

You can also handle signals in shell scripts using trap:

#!/usr/bin/env bash
set -euo pipefail

cleanup() {
  echo "Cleaning up..."
}

trap cleanup SIGINT SIGTERM

echo "Working... press Ctrl+C to stop"
sleep 1000

Run it and press Ctrl+C the cleanup function runs before the script exits.


Debugging: See Signals in Action

A few handy tools and commands:

# Show signals received by a process
strace -e trace=signal -p <pid>

# Send a user-defined signal (for custom actions)
kill -USR1 <pid>

# Send a signal to processes by name
pkill -TERM myservice
killall -HUP nginx

Note: SIGUSR1 and SIGUSR2 are great for custom, app-specific actions (e.g., rotate logs).


Signals in Containers and Services

  • Docker sends SIGTERM to PID 1 in the container on docker stop, then SIGKILL if it doesn’t exit within the timeout.
  • systemd services receive SIGTERM on systemctl stop <service>. Implement handlers so your service shuts down cleanly.

Quick Reference (Cheat Sheet)

NameNumber*Default ActionTypical Use
SIGHUP1Terminate (often reload)Reload config for daemons
SIGINT2TerminateCtrl+C from terminal
SIGTERM15TerminateGraceful shutdown request
SIGKILL9KillImmediate stop (cannot handle)
SIGSTOP19StopPause execution (cannot handle)
SIGCONT18ContinueResume a stopped process
SIGUSR1/210/12TerminateApp-defined actions

*Signal numbers can vary across platforms; prefer names like -TERM, -KILL.


Key Takeaways

  • Ctrl+C sends SIGINT it’s a signal, not magic.
  • Use SIGTERM for graceful stops; reserve SIGKILL for last resort.
  • Handle signals with sigaction for safer, more predictable behavior.
  • trap in Bash and tools like strace help you test and debug.
  • In production, always clean up: close files, flush data, and release resources before exiting.

Practice: Your Turn

  1. Run a long task (sleep 1000). Use kill, kill -STOP, and kill -CONT to control it.
  2. Compile the C samples and send SIGINT, SIGTERM, and SIGHUP to see different behaviors.
  3. Add a SIGUSR1 handler to toggle verbose logging at runtime.