⚡ Zig Guide LiveUnofficialbut fully verified
✓ Zig 0.17.0-dev.1503+1f1bee62eWhat's newOn an older Zig?

Signals

A signal is the operating system calling a function in your program at a moment you did not choose. Not a queue you poll and not a message you receive: your thread is stopped between two instructions, the handler runs on that thread’s stack, and then the instruction that was next runs as though nothing happened.

Built and run natively by CI. There are no signals in a WASI sandbox, because there is no kernel to deliver them.
const std = @import("std");
const posix = std.posix;

/// The only thing a handler here does. `std.atomic.Value` because the handler
/// runs between two arbitrary instructions of the code below, and a plain
/// `bool` written there is a data race with whatever that code was doing.
var caught: std.atomic.Value(u32) = .init(0);

fn onUsr1(sig: posix.SIG) callconv(.c) void {
    _ = sig;
    caught.store(1, .seq_cst);
}

pub fn main(init: std.process.Init) !void {
    var buf: [1024]u8 = undefined;
    var stdout_writer = std.Io.File.stdout().writerStreaming(init.io, &buf);
    const out = &stdout_writer.interface;

    // `sigaction` returns nothing. It used to return an error union, and the
    // handler used to take a `c_int`; both changed, so a handler copied from
    // an older tutorial does not compile rather than misbehaving.
    var action: posix.Sigaction = .{
        .handler = .{ .handler = onUsr1 },
        .mask = posix.sigemptyset(),
        .flags = 0,
    };
    posix.sigaction(.USR1, &action, null);

    try out.print("caught before: {d}\n", .{caught.load(.seq_cst)});
    try posix.raise(.USR1);
    try out.print("caught after:  {d}\n\n", .{caught.load(.seq_cst)});

    // Blocking does not discard a signal, it defers it. USR1 raised while
    // blocked stays pending, and is delivered the moment it is unblocked.
    caught.store(0, .seq_cst);
    var mask = posix.sigemptyset();
    posix.sigaddset(&mask, .USR1);
    posix.sigprocmask(posix.SIG.BLOCK, &mask, null);

    try posix.raise(.USR1);
    try out.print("while blocked: {d}\n", .{caught.load(.seq_cst)});
    try out.flush();

    posix.sigprocmask(posix.SIG.UNBLOCK, &mask, null);
    try out.print("once unblocked: {d}\n\n", .{caught.load(.seq_cst)});

    // Handing the signal back to the kernel's default disposition. For USR1
    // that default is "terminate the process", which is why an unhandled one
    // kills a program that never asked for it.
    var default: posix.Sigaction = .{
        .handler = .{ .handler = posix.SIG.DFL },
        .mask = posix.sigemptyset(),
        .flags = 0,
    };
    posix.sigaction(.USR1, &default, null);
    try out.writeAll("USR1 restored to its default disposition\n");

    try out.flush();
}

Two things changed, and both are compile errors

var action: std.posix.Sigaction = .{
    .handler = .{ .handler = onUsr1 },
    .mask = std.posix.sigemptyset(),
    .flags = 0,
};
std.posix.sigaction(.USR1, &action, null);

sigaction returns void. It used to return an error union, so try in front of it is now an error about a non-error value. And a handler takes a std.posix.SIG rather than a c_int:

fn onUsr1(sig: std.posix.SIG) callconv(.c) void

Every handler written before this change has the signature fn (c_int) callconv(.C) void and does not compile. That is the good outcome. An enum parameter also means switch over the signal in a shared handler is checked, and .USR1 cannot be confused with the integer 10 on one platform and 30 on another.

Set a flag. Do nothing else.

var caught: std.atomic.Value(u32) = .init(0);

fn onUsr1(_: std.posix.SIG) callconv(.c) void {
    caught.store(1, .seq_cst);
}

The handler interrupted arbitrary code. If that code was inside malloc, holding the allocator’s lock, and your handler allocates, it waits for a lock held by the thread it is running on. That is a deadlock with one thread in it, and it happens perhaps once in ten thousand runs, in production, under load.

The same applies to anything that takes a lock or allocates, which is most of what you would want to do. std.debug.print locks standard error. A logger allocates. A hash map insert may allocate. The set of calls that are safe here is small and does not include the ones you reach for while debugging why the handler is not working.

So the handler stores to an atomic and returns. Everything real happens in the main loop, which reads the flag when it is between two units of work and can afford to think. std.atomic.Value rather than a plain bool because the handler runs between two instructions of code that may be reading the same variable, and a torn or reordered read there is a real bug on a weakly ordered machine.

Blocking defers, it does not discard

std.posix.sigprocmask(std.posix.SIG.BLOCK, &mask, null);
try std.posix.raise(.USR1);   // pending, not delivered
std.posix.sigprocmask(std.posix.SIG.UNBLOCK, &mask, null);
// handler runs here

A blocked signal is held pending until it is unblocked, and then delivered. This is how a program protects a section that must not be interrupted without losing the notification. It is also the mechanism behind sigwait and signalfd, which block a signal permanently and then read it as data on a descriptor, turning an interruption into something a normal event loop can select over. That is what most servers actually do.

Pending is a bit, not a count. Ten SIGUSR1s raised while blocked deliver once. A handler that increments a counter is counting deliveries, not signals, and a program that needs to know how many things happened has to count them somewhere else.

Defaults you inherit

Every signal has a disposition before your program touches it, and for most of them it is to terminate the process. SIGUSR1 is the snippet’s example: named “user defined” and fatal by default, so a program that never installs a handler dies when one arrives.

Three worth remembering:

SignalDefaultSent when
SIGINTterminatesomeone pressed Ctrl-C
SIGTERMterminatesomething asked you to stop politely
SIGPIPEterminateyou wrote to a pipe nobody is reading

SIGINT and SIGTERM are the two a long-running program should handle, and handling them means setting a flag that the main loop turns into a clean shutdown. SIGKILL and SIGSTOP cannot be handled or blocked at all, which is the guarantee that lets an operator stop a process that has stopped listening.