Repository navigation
Theory Background Sampler and Demand
A frame has a budget of a hundred milliseconds at the default ten frames a second. Some of what isotop measures
costs far more than that: listing every descriptor of every process, dumping the kernel's socket tables over
netlink, reading a cgroup tree, asking a GPU driver. So the sampler is split in two. The frame loop reads the
cheap, essential things (stat and io per process) every --sample-ms; a second thread, isotop-sampler, reads
the slow ones every couple of seconds and leaves them in a mailbox; and a join on process
Identity glues the two together so that a measurement never lands on the wrong
process. On top of that, a demand mechanism lets the view on screen say which optional sources it wants, so a
source nobody is looking at costs nothing. This page is how those three pieces work and what they guarantee.
In this project. The isotop-sampler thread is in the repository's first commit (2026-10-07): from the start the slow scans ran off the frame loop on a fixed period. What the thread read was not a question until 2026-10-11, when the architecture batch that added the view registry (#120) also added source demand, so that a view says which optional sources it draws. The typed Source set and "unknown is not zero" had come earlier the same day in #113.
The idea. Demand-driven reading is an old pattern with two well-known names.
-
Lazy evaluation. Peter Henderson and James H. Morris, "A lazy evaluator" (POPL 1976), and Daniel P. Friedman and David S. Wise, "CONS should not evaluate its arguments" (1976), proposed computing a value only when something asks for it. (Venues from memory; not re-checked for this page.)
Demand::read_ifis the same rule at the scale of a system call: the closure runs only if the source is in the set. -
Pay only for what you use. Bjarne Stroustrup's zero-overhead principle, "what you don't use, you don't pay for", is attributed to his 1994 The Design and Evolution of C++. In a 2024 paper to the C++ committee (P3406R0) he writes that the principle was conceived as applying to run time only. isotop's version is the run-time one, applied to the operating system rather than to the language: a source nobody draws leaves no trace in
sourcesormissing, and no file is opened.
-
Cost. On Linux the slow sources are
/proc/net,sock_diag,/proc/<pid>/fdwith astatxper descriptor,/proc/locks, cgroup files and NVML; on macOS sockets and open files, oneproc_pidfdinfoper descriptor. Anything slower than a/proc/<pid>/statread belongs off the frame loop (AGENTS.md's rule, and the reason this thread exists). - Staleness is the price. A slow source is read at one instant and merged into samples taken later, so it is always somewhat old. The design bounds the age, and makes the age visible rather than hiding it.
- Correctness is the hard part. The scan reads pids; the frame loop tracks identities. Between the two, a process can exit and another take its pid. A naive join by pid would attribute the dead process's sockets, files and GPU memory to the newcomer.
Background::spawn starts a thread named isotop-sampler. It calls a setup closure once (loading NVML, opening
the GeoIP database lazily, creating the file scan), then loops:
- run one cycle (a closure returning an
Extrasvalue); - stamp it
finished = now; - lock the shared slot and replace it whole (
*slot = measured); - sleep
BACKGROUND_PERIOD, 2 seconds.
It exits when it is the only holder of the mailbox (Arc::strong_count(&writer) == 1, which is how a dropped
Collector stops it) or when the lock is poisoned. Two consequences follow from the loop shape:
- The gap between cycle starts is the cycle's own duration plus 2 s, not 2 s: the sleep follows the work.
- The lock is taken only after a cycle has finished, never during it. A scan that hangs (a stale network mount) can
therefore never block the frame loop on the mutex; the frame loop just keeps merging the last complete
Extras. Because the slot is replaced whole and never written in parts, even a poisoned lock still holds the last complete cycle.
The first cycle starts only after the first sample has published what to scan (the cgroup paths and the identities), so it has something to join against.
Each cycle reads, in this order, each scan timed into latency under the Source it reads:
- the socket scan (
platform::network), which includes the Unix sockets' unread bytes; - the locations lookup (demanded:
Source::Locations); - GPU memory, cgroup accounting for the paths the latest sample wanted, the bounded open-file scan and the lock table;
- the memory reads (
MemoryReads): the out-of-memory ranking of every pid the cycle scanned (Source::Oom) andsmaps_rollupfor theROLLUP_PROCESSES(64) largest processes the frame thread handed over inrollup_pids(Source::MemoryDetail), under a 50 ms budget (ROLLUP_BUDGET) that resumes where it stopped, the unreached processes keeping their last reading (Kept); - the kernel counters (
KernelScan: interrupts, softirqs, IRQ affinity, OOM kills), each read only if demanded; - the threads (
Source::Threads):read_threadswalks the processes busiest first, at mostTHREADS_PER_PASS(8192) threads andTHREADS_PER_PROCESS(1024) per process; - the pipes (
Source::Pipes), joined into reader and writer links bypair_pipes.
It also derives per-connection byte rates (remotes), and loopback traffic between process pairs (traffic), from the change in each socket's cumulative counter between cycles, the same counters-not-gauges pattern the frame loop uses. Every scan that keeps a table between cycles (open files, rollups, pipes) keeps it together with the identity it was read for.
The frame loop publishes, after every sample, a map pid → Identity of the processes it just read. At the start of
each cycle the background thread copies that map before any scan reads:
let identities = identities.lock().map(|map| map.clone()).unwrap_or_default();Every pid a scan then finds is either the process the map names or one that took the pid since. The cycle returns
that map inside its Extras. merge (in model.rs) joins a sample with an Extras under one rule: a pid's data
counts for a process only if extras.identities[pid] == process.id.
sequenceDiagram
autonumber
participant F as frame loop (Collector::sample)
participant M as Extras mailbox (Mutex)
participant B as isotop-sampler
participant K as kernel (/proc, netlink, sysfs)
F->>F: sample k: read stat+io, build processes
F->>F: publish identities (pid to Identity), wanted cgroups
B->>B: copy identities (the identities this cycle is about)
B->>K: scan sockets, files, locks, cgroups, GPU
K-->>B: tables keyed by pid
B->>M: replace Extras {identities, scans, finished}
F->>M: sample k+1: lock, merge, unlock
Note over F,M: merge keeps pid data only where<br/>extras.identities[pid] == process.id
Concretely (the test background_data_is_not_inherited_by_a_reused_pid): a cycle scanned against pid 7 as the
process that started at tick 70. Before the next sample, that process dies and pid 7 is reissued to a process that
started at 71. The sample's pid 7 has Identity { 7, 71 }, which differs from the map's { 7, 70 }, so the
newcomer gets no GPU memory, its files and locks read Measured::Pending, and every link, outside connection,
loopback rate and remote connection that cycle attributed to pid 7 is dropped. Symmetrically, a lock holder or
link peer that has exited since is dropped (blocked_on is cleared if its holder is gone). The newcomer is simply
unmeasured until the next cycle.
Two details complete it. A process born since the cycle began is not in the map, so nothing joins to it either;
and the budget-bounded file scan, which reaches every pid only over several cycles, keeps its last table per pid
together with the identity it was read for (KeptFiles), dropping the entry when the pid names a different
process. The invariant, stated plainly: a background measurement is attributed to a process only if that exact
process was alive, and listed by the frame loop, when the scan began.
Extras holds the scans' results, the identities, the missing and sources sets of the cycle, the GeoIP source
name, the per-scan latency, and when the cycle finished. Collector::sample merges it with
snapshot.sampler = background.health(now), a SamplerHealth:
| field | meaning |
|---|---|
running |
the thread is alive and its lock is not poisoned |
last_cycle |
how long ago the last cycle finished; None before the first |
quiet |
time since the last cycle finished (or since the thread started, before the first) |
scan_latency |
how long each scan of the last cycle took, by Source
|
stalled() |
running and quiet > 10 · BACKGROUND_PERIOD, i.e. 20 s without finishing a cycle |
The status line prints (background sampler stopped: links, files and cgroups frozen) or
(background sampler stalled Ns) beside the process count. A stopped or stalled thread leaves the slow sources
frozen at their last values; it never makes the frame loop wait.
Age of a slow value. A scan reads at some instant inside a cycle, and reaches a sample after the cycle ends and the next sample merges it: up to the cycle duration, plus the 2 s sleep, plus one sample interval. Slow sources are a few seconds old, by construction; per-connection rates and loopback traffic are averages over the 2 s-plus gap between cycles, not instantaneous.
The registry row of each view (ViewInfo::sources, a Sources bit set) lists the optional sources that the
view draws. main turns the shown view's row into a Demand at startup and on every view change
(Collector::demand). There are three consumers, one per source, and they differ in where the check happens:
| optional source | declared by (the row's sources) |
read where | mechanism |
|---|---|---|---|
Source::Locations (GeoIP lookups of remote addresses) |
Globe | background cycle |
Reads::read through Demand::read_if
|
Source::Oom, Source::OomKills
|
Horizon (and OomKills for Murmuration) |
background cycle |
MemoryReads::read, KernelScan::scan
|
Source::Threads |
Murmuration, Arbor | background cycle | read_threads |
Source::Pipes |
Cortex | background cycle | Reads::read |
Source::Journal (the system log) |
Matrix | frame loop (interactive.rs) |
App::journal_wanted starts or stops the follower; its lines reach the view on Snapshot::journal (a journal::Delivery); see Journal
|
Source::LifeEvents (fork, exec, exit) |
Necropolis | Collector::sample |
Life::start opens the event sources and the isotop-events thread; see Life events
|
Sources in the enum that no shipped row declares today (MemoryDetail, Interrupts, Softirqs, IrqAffinity) are read the same way when a row does. Every other view declares no sources. The sockets (with the Unix queues), GPU, cgroups, open files and locks are not demand-gated: they run every cycle whichever view is shown. The mechanism is general (a new source is a Source variant plus a read_if).
The pieces:
-
Demand(Sources)isCopy;Demand::read_if(source, read)runsreadand returns its value only if the source is demanded, elseNonewithout running it. -
Readswraps one cycle's demand and records outcomes:Reads::read(source, read)callsread_if, and if the closure returnsSomethe source is active; if it returnsNone(no database, no permission) it is missing; a source not demanded is neither. - The demand is copied once at the start of a cycle (
Reads::new(demand)), so a view change takes effect at the next cycle and no source is half read. After switching to the Globe the first frames may show no locations yet; a view treats that as warming up, not as missing.
Two sets, with a three-way meaning per optional source:
| state | in sources
|
in missing
|
|---|---|---|
| nobody demanded it | no | no |
| demanded and read | yes | no |
| demanded and unreadable | no | yes |
missing also holds what the platform never has (Sampler::missing), the per-file pressure gaps, and what the
background pass found absent (GpuMemory, FileLocks). A view says "no X" only for what is in missing, using
status::missing_note; status::sources_note lists sources. Source::Journal is demand-only: the follower
bypasses Reads, so it never appears in sources or missing in a live run (a failed journalctl shows up as an
error line in the rain). While LifeEvents is demanded, Snapshot::sources also holds the delivering mechanisms
(ProcConnector, Taskstats, Kqueue, SampleDiff) and missing the detail none of them gives
(ForkEvents, ExitRecords). Source has 32 variants (Source::ALL), one bit each in a u64, so there is room for 32 more.
Switching away from Matrix does not stop the follower at once. JOURNAL_LINGER = 30 s: App::journal_wanted
records when the demand last contained Source::Journal and keeps the follower running while that was less than
30 s ago, so a glance at another view and back does not restart journalctl and lose the rain's backlog. Dropping
the Journal value stops the follower and joins its reader.
-
Start. First
Collector::samplepublishes identities and cgroups, then spawnsisotop-sampler. - Run. One cycle, then 2 s sleep, forever; merged on every sample.
-
View change.
Collector::demandis called from the main loop when the shown row's sources change; the background thread sees it at its next cycle; the event thread starts or stops withLifeEventsin the nextCollector::sample; the journal lingers. -
--demo. No background thread and no collector:Snapshot::sourcesis empty,sampleris a healthy stub, and the demo fills the fields each view needs itself (Model and rates). -
Stop. Dropping the
Collectordrops theBackground; the thread sees the mailbox unshared and ends. Dropping the life queue endsisotop-eventswithin 200 ms.
- A link, a file count or a GPU figure is a few seconds old and was true of the process it names.
-
Absent means "not measured yet" (
Measured::Pending) or "not readable" (Measured::Unreadable), and the two are different states, not zero. - A nonempty
missingis a statement about the machine or the permission, never about the process. - An optional source that no shown view draws leaves no trace: no
sources, nomissing, and no cost.
-
model.rs:Background,Extras,background,merge,merge_files,KeptFiles,Kept,MemoryReads,ROLLUP_PROCESSES,Demand,Reads,SamplerHealth,BACKGROUND_PERIOD,STALLED_PERIODS,Collector::demandandCollector::sample. -
model/kernel.rs:KernelScan.threads.rs:ThreadScan,targets.journal.rs:Delivery. -
platform/mod.rs:Source,Sources. -
app/feed.rs:App::journal_wanted,JOURNAL_LINGER;app/status.rs:sampler_text;interactive.rs: the demand update after view changes. -
views.rs: thesourcesfield ofViewInfo. -
status.rs:missing_note,sources_note.
- The rule this implements is stated in
AGENTS.md("Sampling stays off the frame loop") anddocs/VIEW_CONVENTIONS.md(source demand). - For the concurrency pattern, Rust's
std::sync::{Arc, Mutex}andstd::thread::Builder; nothing here is more exotic than a single-slot mailbox with an atomic alive flag.
isotop — maximalism for its own sake. Source: tfpickard/isotop. This wiki is published from wiki/ in the repository; edit it there. This footer is decorative.
Philosophy · Gallery · Glossary
Theory
Overview
Sampling: Linux
Sampling: macOS
Background sampler
Model and rates
Life events
History
Journal
Scene and display list
Camera and projection
Rasterization
GPU and parity
Picking
Terminal graphics
Stable layout
Determinism
Simulation toolkit
Colour
Text safety
Mathematics
Fourier epicycles
Wave equation
Flocking
Spatial hashing
Stellar photometry
Lorentz and Schlegel
Reaction-diffusion
Spiking neurons
Schwarzschild
Views
City · Orbit · Ripple
Flow · Cores · Cells
Strata · Globe · Reef
Coop · Matrix
Ptolemy · Tesseract
Necropolis · Cortex
Horizon · Murmuration
Arbor · HR
Hive · Switchboard
Armorial · Lights
Planned
Mold · Chamber
Anthill · Plumbing
Morph · Ouija
Canyon · Die
Guides
Writing a view
Recipes
Headless renders
Contributing to the wiki