cpulimit

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:

  1. Update the process table and compute each process's CPU usage since the last cycle.
  2. 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).
  3. Send SIGCONT to everything, wait for the work slice, send SIGSTOP (skipping processes below the -m threshold), wait for the stop slice.
chunk 1: SIGCONTSIGSTOP chunk 2chunk 3 measured usage above the limit, so each next run slice is shorter
Illustration of the idea, not measured data.

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:

  1. 0 ≤ work ≤ chunk and work + stop = chunk;
  2. the rate is in [0, 1] and never NaN;
  3. no floating-point exception for any finite input;
  4. 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.

Invariant: never strand a target. Whenever the control loop is left for any reason (target ended, signal, allocation failure, error), every process cpulimit may have stopped receives 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?

SystemSource of the dataCPU timeIdentity (start time)
Linux/proc/<pid>/stat, parsed by a fuzzed, allocation-free parserutime + stime clock ticksticks since boot
macOSproc_listpids and proc_pidinfomach time, converted with the timebase (not nanoseconds on Apple silicon)µs since the epoch
FreeBSDkvm_getprocs (no group membership needed)ki_runtime, µsµs since the epoch
OpenBSDsysctl kern.proc, no libkvmstatistics tick counters (p_uticks + p_sticks + p_iticks)µs since the epoch
NetBSDsysctl kern.proc2, shares the OpenBSD codethe same tick countersµs since the epoch
Solaris, illumos/proc/<pid>/psinfopr_time, nsseconds

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.