How it works
cpulimit does not change priorities or scheduling classes. It lets the target run for part of a short chunk and stops it for the rest, and adjusts the split after every chunk from the CPU time the target really used.
The control loop
One iteration, repeated for as long as there is a target:
- Update the process table and compute each process's CPU usage since the last cycle.
- Compute the slices:
compute_slices(limit, usage, previous rate, chunk)returns a work slice and a stop slice that add up to the chunk (default 100 ms). - Send
SIGCONTto everything, wait for the work slice, sendSIGSTOP(skipping processes below the-mthreshold), wait for the stop slice.
The controller
The controller is a pure function, compute_slices, with four invariants that are tested exhaustively on a grid and are the target of the bounded model checker:
0 ≤ work ≤ chunkandwork + stop = chunk;- the rate is in
[0, 1]and never NaN; - no floating-point exception for any finite input;
- where the older formula was well defined, the result agrees with it to 1 ns.
Accuracy is a property of this controller, shared with the original program: both under-deliver at low limits and overshoot near 50 % (measurements).
Process identity
A process is the pair (pid, start time). A new process that reuses a pid is a new entry, so a recycled pid is never limited by mistake (the original asserted and aborted). Each update stamps the entries it sees with a generation number and frees the rest, so the table is as large as the number of live processes and no larger. Every allocation is checked, and the unit tests fail the k-th allocation for every k to prove the table stays valid and leak-free.
Signals and shutdown
The handler for SIGINT, SIGTERM, SIGHUP and SIGQUIT only stores the signal number in a volatile sig_atomic_t. The four signals are blocked except inside the wait, which is a pselect() with the original mask, so a signal can neither be lost between checking the flag and sleeping nor delay shutdown by more than one wake-up. sleep() and nanosleep() alone cannot close that race.
SIGCONT before exit. The exception is outside the program's power: SIGKILL, an OOM kill or a crash during a stop slice leaves the target stopped (what to do).Timing uses CLOCK_MONOTONIC, so wall-clock steps cannot distort the duty cycle.
Process iterators
Everything that knows how an operating system lists processes lives in src/platform/<os>/iterator.c; nothing else includes platform code, and a new platform is one file plus a branch in src/platform/iterator.c. The iterator answers one question: which processes does this filter select, and how much CPU has each used?
| System | Source of the data | CPU time | Identity (start time) |
|---|---|---|---|
| Linux | /proc/<pid>/stat, parsed by a fuzzed, allocation-free parser | utime + stime clock ticks | ticks since boot |
| macOS | proc_listpids and proc_pidinfo | mach time, converted with the timebase (not nanoseconds on Apple silicon) | µs since the epoch |
| FreeBSD | kvm_getprocs (no group membership needed) | ki_runtime, µs | µs since the epoch |
| OpenBSD | sysctl kern.proc, no libkvm | statistics tick counters (p_uticks + p_sticks + p_iticks) | µs since the epoch |
| NetBSD | sysctl kern.proc2, shares the OpenBSD code | the same tick counters | µs since the epoch |
| Solaris, illumos | /proc/<pid>/psinfo | pr_time, ns | seconds |
On OpenBSD the obvious field, p_rtime, is wrong for this purpose: the kernel only adds to it when a process is switched out, so a process that holds the CPU for a whole scheduling quantum appears to use nothing and then twice its share. The iterator reads the statistics tick counters instead, which the kernel advances on every statistics clock tick.
Snapshots and the process tree
The Linux, macOS, FreeBSD, OpenBSD and NetBSD iterators copy the processes into a snapshot when they start and then yield from it, so the answer is consistent even while processes come and go. The selection of "the target and all its descendants" (-i) is one shared function, snapshot_select() in src/platform/snapshot.c: it sorts an index by parent pid and walks it breadth first, O(n log n), visiting each record once, so a pid that is its own parent cannot loop. Zombies and kernel threads are never reported: they cannot use CPU.
Further reading
The normative description, including the module rules and the lock protocol, is docs/ARCHITECTURE.md in the repository; a change that breaks a rule there has to change that document in the same commit.