Thread Pools, Affinity, and Real-Time Scheduling
By default, Copper runs your task graph on one thread: the global loop executes
source -> processing -> sink back-to-back every cycle. Two features step outside that
single thread – background: true tasks and the parallel-rt runtime – and both need
worker threads to run on. This chapter is about controlling those workers: how many there
are, which CPU cores they run on, and what scheduling priority the OS gives them.
On a laptop the defaults are fine. On a real robot, where a latency-critical control loop shares a machine with heavy background work (logging, vision, networking), you often want to isolate the critical work onto dedicated cores and give it real-time priority so the OS never lets background work preempt it. That is what thread pools, CPU affinity, and real-time scheduling are for.
Note: This is a
std-only, host-side feature. Onno_std/ embedded targets there are no threads to schedule and this whole chapter does not apply.
Declaring thread pools
Thread pools are declared in the runtime section of copperconfig.ron, next to
rate_target_hz:
runtime: (
thread_pools: [
( id: "rt", threads: 4, affinity: [2, 3, 4, 5], policy: Fifo(priority: 80) ),
( id: "background", threads: 2, affinity: [0, 1] ),
( id: "vision", threads: 2, policy: Nice(10), on_error: Strict ),
],
),
Each pool has:
id– a unique name. Tasks and the runtime refer to pools by this name. Two names are reserved (see below):backgroundandrt.threads– how many worker threads the pool runs.affinity(optional) – the CPU cores the workers may run on.policy(optional) – the OS scheduling policy and priority. Defaults toFair.on_error(optional) – what to do if the affinity or policy cannot be applied. Defaults toWarn.
Scheduling policies
The policy controls how the OS scheduler treats the pool’s worker threads. On Linux these
map directly onto the POSIX scheduling policies.
| Policy | What it is | When to use it |
|---|---|---|
Fair | Normal fair time-sharing (SCHED_OTHER / CFS). The OS shares the CPU across threads and nothing starves. | The default. Everything that is not latency-critical. |
Nice(n) | Fair scheduler with a niceness bias, n in -20..=19 (lower is more favorable). A soft hint, not a guarantee. | Bias a pool below or above normal work without leaving the fair scheduler – e.g. Nice(10) for heavy work that should yield to the control loop. |
Fifo(priority: p) | SCHED_FIFO hard real-time, p in 1..=99. Runs ahead of every fair thread and is not time-sliced – it runs until it blocks or a higher-priority thread preempts it. | The latency-critical pipeline. Pin it with affinity so a busy worker cannot starve other work on the same core. |
RoundRobin(priority: p) | SCHED_RR, same as Fifo except threads at the same priority are time-sliced in turn. | Several real-time workers that share a priority and should interleave fairly. |
Rule of thumb: reach for
Nicefirst. Real-time policies (Fifo/RoundRobin) are powerful but easy to misuse – a busyFifoworker with noaffinitycan starve the rest of the system. Use them for genuinely time-critical work, and pin them.
Real-time policies are Linux-only and usually require the CAP_SYS_NICE capability (run
as root, grant the capability, or raise the limit in /etc/security/limits.conf). Negative
niceness (Nice(-1) and below) needs the same capability – without it the request falls
back per the pool’s on_error. On non-Linux hosts only Fair is applied directly; Nice,
Fifo, and RoundRobin all fail according to the pool’s on_error. CPU affinity is
cross-platform regardless.
CPU affinity
affinity is a list of logical CPU cores the pool’s workers are allowed to run on. When
set, workers are pinned Spread: worker i is pinned to affinity[i % affinity.len()].
So a pool with threads: 4 and affinity: [2, 3, 4, 5] pins one worker to each of
cores 2-5:
worker 0 -> core 2
worker 1 -> core 3
worker 2 -> core 4
worker 3 -> core 5
The point of affinity is isolation. If your real-time pool owns cores 2-5 and your
background pool is confined to cores 0-1, the OS will not schedule background work onto the
cores running your control loop. Combined with a Fifo policy, this is how you keep jitter
out of the critical path.
If you omit affinity, the OS is free to run the workers on any core.
Choosing cores
Copper does not auto-detect kernel work or CPU topology – the affinity list is taken at
face value. Two things on Linux are worth checking before you pick cores for a
latency-critical pool:
- Where the kernel handles IRQs. Hardware interrupts (NIC, NVMe, USB, timers) run on
whichever CPUs the kernel binds them to, and a busy IRQ adds jitter to that core.
cat /proc/interruptsshows per-CPU interrupt counts;cat /proc/irq/<n>/smp_affinity_listshows the affinity of a specific IRQ. By default most distros either pin IRQs to CPU 0 or letirqbalancespread them; either way, the conservative move is to keep your real-timeaffinityoff the cores that show large counts in/proc/interrupts. For full isolation, the standard tool is the kernel command line (isolcpus=,nohz_full=,rcu_nocbs=), which removes the listed cores from the general scheduler entirely. - Hyperthread siblings. Two logical CPUs that share one physical core compete for the
same execution units, so pinning a real-time pool across a sibling pair gives almost no
isolation from background work landing on the other sibling.
lscpu -eshows theCORE/CPUmapping;/sys/devices/system/cpu/cpuN/topology/thread_siblings_listgives the pair directly. The conservative move is to pick one logical CPU per physical core for thertpool and leave the siblings tobackgroundor to the OS.
The explicit list is intentionally the lowest-level knob – a list of logical CPU ids the
OS understands – and there are no higher-level selectors (BigCores, NumaNode, “avoid
IRQs”, “avoid siblings”) yet. Future versions can layer those on top without changing the
list form, so the recommendation today is to inspect your target machine with the tools
above and write the list explicitly.
Reserved pools: background and rt
Two pool names have special meaning.
background
When a source or task is marked background: true, it runs on the pool named background.
If you do not declare one, Copper creates a default background pool for you: 2 worker
threads, no affinity, Fair scheduling. Declare it explicitly when you want to size it or
pin it:
runtime: (
thread_pools: [
( id: "background", threads: 2, affinity: [0, 1] ),
],
),
A task can also opt into a different pool with the object form of background:
(
id: "detector",
type: "vision::Detector",
background: (pool: "vision"),
),
This runs detector on the vision pool instead of background – useful when you want a
class of heavy tasks scheduled separately from the rest of your background work. The pool
name is resolved at build time: referencing a pool that isn’t declared in
runtime.thread_pools, or pointing a background task at the reserved rt pool, is a
compile-time error.
See The Task Graph for the background field, and
Performance Basics for when backgrounding a stage is the right
call.
rt
The rt in parallel-rt stands for runtime – the generated execution engine that
runs the task graph – not “real-time”. The pool named rt is the one the runtime looks
for to drive the parallel-rt engine: it spawns one worker per
generated process stage, and the rt pool lets you pin and prioritize those workers
exactly like any other pool:
runtime: (
thread_pools: [
( id: "rt", threads: 4, affinity: [2, 3, 4, 5], policy: Fifo(priority: 80) ),
],
),
The rt pool is consumed directly by the engine: only its affinity and policy are
applied to the stage workers (Spread across the stage index). parallel-rt spawns one
worker per generated process stage, so the threads: field has no effect on the rt pool
– size affinity to match the stage count if you want exact one-core-per-stage pinning.
Unlike other pools, you do not bind tasks to it and you cannot use it as a background
target.
When it cannot be applied: on_error
Setting CPU affinity or a real-time priority can fail. The common cases are platform-
specific: on Linux, a Fifo / RoundRobin priority (or Nice below 0) without
CAP_SYS_NICE; on Windows, any non-Fair policy at all, since the POSIX real-time
policies are not implemented there; and on either OS, an affinity entry that names a
core the machine does not have. on_error chooses what happens:
Warn(default) – log a warning and fall back to default scheduling. The pool still runs; it just does not get the requested affinity/priority. This keeps unprivileged dev/laptop runs working out of the box.Strict– fail at startup. Use this on a deployed real-time robot, where running the control loop at default priority is a fault you want to catch loudly rather than discover as jitter in the field.
( id: "control", threads: 2, policy: Fifo(priority: 90), on_error: Strict ),
The rt-scheduling feature
Affinity and scheduling are gated behind the rt-scheduling Cargo feature on cu29,
forwarded from your crate the same way as the other runtime features:
[features]
rt-scheduling = ["cu29/rt-scheduling"]
cargo run --features rt-scheduling
With the feature off, runtime.thread_pools still works – pools are built with the
requested thread counts – but affinity and policy are ignored and a warning is emitted.
This lets you keep one config that runs unprivileged in development and pins to real-time on
the target by flipping a feature flag. CPU affinity is cross-platform; the real-time
policies are Linux-only.
Determinism and replay
Pool configuration is performance-only. Affinity and policy change when and where work
runs, never what each task computes or in what order results commit to a copperlist. By
design, swapping in or out an rt pool produces a bit-identical normalized CopperList
stream in both sync and parallel-rt modes, and the runtime matrix integration tests guard
this as a regression.
The practical consequence is that an offline replay or resim run on a different machine
keeps producing the same output as the live capture, even when the pool config cannot be
honored: with the default on_error: Warn the runtime logs and falls back to default
scheduling, and the stream is unchanged. If you need to force neutralization (for
example, to suppress warnings on a CI host that cannot pin, or to pick cores at runtime),
mutate config.runtime.thread_pools before building the app.
Good fit / Bad fit
Good fit:
core 0-1: background pool (logging, telemetry) Nice / Fair
core 2-5: rt pool (control pipeline) Fifo, pinned
Why:
- the critical pipeline owns dedicated cores at real-time priority
- everything else is confined elsewhere and yields to it
Bad fit:
( id: "everything", threads: 8, policy: Fifo(priority: 99) )
Why:
- a single high-priority
Fifopool with no affinity, sized to every core, can starve the OS itself - real-time priority is for isolating specific work, not for “make it all fast”
Summary
| Field | Purpose | Default |
|---|---|---|
id | Pool name; background and rt are reserved | – (required) |
threads | Number of worker threads | – (required) |
affinity | CPU cores the workers are pinned to (Spread) | unpinned |
policy | OS scheduling policy/priority | Fair |
on_error | Behavior when affinity/policy cannot be applied | Warn |
For complete working examples, see
examples/cu_background_task
(both background forms) and
examples/cu_runtime_matrix
(rt and background pools).