Skip to content
academia.sh

Lesson 01 / 22

Listing and Searching Processes

On a synthetic server of twenty-four processes, 14 are actually heavy: once the sampling interval rises to 15 seconds the tool misses 3 of them, and at 30 and 60 seconds it misses 5; a single glance gives 5–7 false diagnoses, the lifetime average gives 7, and selecting by name touches 9 processes wrongly in the best case.

Contents

The Shell Programming course turned a script into a tool: it gained an argument interface, stopped on errors, cleaned up after itself, and was handed to a scheduler. Writing a script is making something happen. System administration begins with a different task — first, seeing what is happening. And seeing has its own errors.

This course counts those errors. The Operating System Concepts course measured the process, the scheduler, and context switching as mechanism; what was measured there was the cost of the abstraction. Here the mechanism is not rebuilt: what is measured is what the operator can see of that mechanism.

The Course’s Measure

In every lesson, three numbers stand side by side. The first is the oracle: the system’s real state. Because we generate the fiction ourselves, we know which process is actually heavy. The second is the tool’s output: what the tool shows. The third is the false diagnosis count: how many times the conclusion drawn from the output contradicts the oracle.

The rule itself is one sentence: a command’s value is not the number of lines it shows, but how many times the diagnosis drawn from those lines is wrong. An output whose diagnosis is not tested is counted as unmeasured.

  • PM1. The observation window is 600 seconds. All durations are seconds in the model; no real time is measured.
  • PM2. The synthetic server has 24 processes. Each process’s real CPU usage is not a single number but a time series.
  • PM3. There are three patterns: flat (continuously low), spike (a five-second jump once every sixty seconds), heavy (continuously high).
  • PM4. A process is counted as heavy if its usage exceeds 50 even once during the window. The oracle applies this definition by looking at the full 600 seconds; the tool looks only at the instants it observes.
  • PM5. The monitoring tool looks once every interval seconds and keeps the highest value it saw per process. It knows nothing of what happened between glances.
  • PM6. The false diagnosis count is the sum of missed heavy processes and light processes mistaken for heavy.
  • PM7. The second seed is 20260219. If the resulting diagnosis counts do not stay in the same order of magnitude, the result depends on the fiction, and is written as such.

Reading the Process Table

The command that gives the process list is ps; the command that gives a continuously refreshed view is top or one of its interactive derivatives. ps is a snapshot; the list it gives is the process table at the instant the command ran. The transcript below is illustrative, produced for this page, and has not been run:

$ ps -eo pid,user,ni,pcpu,pmem,etime,comm --sort=-pcpu
    PID USER        NI %CPU %MEM   ELAPSED COMMAND
   1128 app          0 71.2  3.1  00:10:00 queue-worker
   1043 monitor      5 68.4  1.2  00:10:00 metric-collector
   1014 app         -3 64.9  2.8  00:10:00 report-generator
   1092 root         0  6.1  0.4  00:10:00 backup-job
   1071 backup      10  3.0  0.2  00:10:00 cache-cleaner

Most columns explain themselves; two do not. NI is the priority value that will be measured in the fourth lesson. %CPU is the most misleading column: ps divides this value by the time elapsed since the process’s birth, that is, it is a lifetime average. A process that has run for ten hours appears low in this column even if it consumed the core by itself for the last five minutes.

top computes the same column differently: it represents the short interval between two refreshes. When the two commands are run on the same machine at the same time, they give different numbers for the same process, and both are correct; they are measuring different time spans. This distinction is exactly the source of what this lesson measures.

The Synthetic Server

The measurement rests not on a real command but on the course’s common definition. The output of a command run on a real server cannot be reproduced: the machine differs, the load differs, the moment differs. Because we generate the synthetic server ourselves, we know the oracle.

"""M03/K03 common definition: a synthetic server, the text the tools produce, and the
diagnosis drawn from that text compared against the ORACLE. No real command is ever called.
"""
SEED = 20260218
SECOND_SEED = 20260219
PERIOD = 600                      # observation window: 600 seconds


def generator(seed):
    d = seed

    def next_value(n):
        nonlocal d
        d = (d * 1103515245 + 12345) % 2147483648
        return d % n
    return next_value


USER = ("root", "app", "backup", "monitor")
COMMAND = ("data-receiver", "report-generator", "backup-job", "metric-collector",
           "queue-worker", "cache-cleaner")


def processes(seed=SEED, count=24):
    """Each process's real CPU usage is a TIME SERIES, not a single number."""
    r = generator(seed)
    result = []
    for i in range(count):
        kind = r(10)
        pattern = "flat" if kind < 5 else ("spike" if kind < 8 else "heavy")
        result.append({"pid": 1000 + i * 7 + r(5),
                       "user": USER[r(len(USER))],
                       "command": COMMAND[r(len(COMMAND))], "pattern": pattern,
                       "priority": r(11) - 5, "memory": 20 + r(400),
                       "ignores_signal": r(10) < 2})
    return result


def usage(process, second):
    """Real CPU usage, second by second. The oracle knows this; the tool does not."""
    p = process["pattern"]
    if p == "flat":
        return 3 + (process["pid"] + second) % 4
    if p == "heavy":
        return 60 + (process["pid"] + second) % 25
    phase = (second + process["pid"]) % 60          # spike: a 5-second jump every 60 seconds
    return 88 + phase if phase < 5 else 2 + phase % 3


S = processes()
print("synthetic server:", len(S), "processes |",
      {d: sum(1 for s in S if s["pattern"] == d) for d in ("flat", "spike", "heavy")})
print("processes actually crossing the threshold:",
      sum(1 for s in S if max(usage(s, t) for t in range(PERIOD)) >= 50))
synthetic server: 24 processes | {'flat': 10, 'spike': 7, 'heavy': 7}
processes actually crossing the threshold: 14

The oracle is 14. Seven processes are continuously heavy, and seven spike once every sixty seconds, comfortably exceeding the threshold when they do. Of the 24 processes in total, 14 cross the threshold somewhere in the window. Every number from here on is read against this 14.

The Sampling Interval Determines the Diagnosis

The monitoring tool has one setting: how often it looks. Enlarging the interval shrinks the output and reduces the load. What this does to the diagnosis can be measured.

# On top of the first block: S, usage, PERIOD, SECOND_SEED, processes come from there.
def sample(processes_, interval, window=PERIOD):
    """A monitoring tool that looks once every `interval` seconds. Returns: highest seen per process."""
    seen = {s["pid"]: 0 for s in processes_}
    glances = 0
    for t in range(0, window, interval):
        glances += 1
        for s in processes_:
            seen[s["pid"]] = max(seen[s["pid"]], usage(s, t))
    return {"glances": glances, "seen": seen}


def true_peak(processes_, window=PERIOD):
    return {s["pid"]: max(usage(s, t) for t in range(window)) for s in processes_}


def diagnose_heavy(measurement, threshold=50):
    """The diagnosis drawn from the output: which processes are heavy."""
    return {p for p, v in measurement.items() if v >= threshold}


INTERVALS = (1, 5, 15, 30, 60)


def sweep(processes_):
    truth = diagnose_heavy(true_peak(processes_))
    rows = []
    for a in INTERVALS:
        o = sample(processes_, a)
        t = diagnose_heavy(o["seen"])
        rows.append((a, o["glances"], len(t), len(truth - t),
                     len(truth - t) + len(t - truth)))
    return len(truth), rows


oracle, rows = sweep(S)
print("interval  glances  lines produced  seen heavy  missed  false diagnosis")
for a, glances, seen, missed, wrong in rows:
    print(f"{a:8d}  {glances:7d}  {glances * len(S):14d}  {seen:10d}"
          f"  {missed:6d}  {wrong:15d}")
print("oracle (actually heavy):", oracle, "| total processes:", len(S))
print()
oracle2, rows2 = sweep(processes(SECOND_SEED))
print("second seed", SECOND_SEED, "- oracle:", oracle2)
print("  false diagnosis:", {a: y for a, _, _, _, y in rows2})
print("  first seed      :", {a: y for a, _, _, _, y in rows})
interval  glances  lines produced  seen heavy  missed  false diagnosis
       1      600           14400          14       0                0
       5      120            2880          14       0                0
      15       40             960          11       3                3
      30       20             480           9       5                5
      60       10             240           9       5                5
oracle (actually heavy): 14 | total processes: 24

second seed 20260219 - oracle: 12
  false diagnosis: {1: 0, 5: 0, 15: 5, 30: 6, 60: 7}
  first seed      : {1: 0, 5: 0, 15: 3, 30: 5, 60: 5}

A tool looking once every one or five seconds sees all 14 of the 14 heavy processes: zero false diagnoses. Once the interval rises to 15 seconds, 3 are missed; at 30 and 60 seconds, 5 are missed. The ones missed are always the same class — those that rise once every sixty seconds, for five seconds. A tool that looks once every thirty seconds is not obligated to catch a five-second spike; most of the time it does not.

The tool is not lying. At the moment it looked, that process really was light. What is wrong is not the output but the diagnosis drawn from it: “it is not on the list, so it is not a problem.”

The second column carries the second claim. At interval 1, the tool produces 14,400 lines; at interval 60, 240. As the line count rises sixtyfold, the false diagnosis count drops from 5 to 0 — but the relationship between these two numbers is not linear: 2,880 lines give a diagnosis just as good as 14,400 lines. Every line added past five seconds adds nothing to the diagnosis.

With the second seed, the oracle drops to 12 and the false diagnoses come out as 5, 6, 7. The numbers are not the same; the direction and order of magnitude are — starting from zero, growing within a single-digit band as the interval grows. The result that stands is this: enlarging the interval produces missed processes. How many will be missed depends on the fiction.

Single Glance vs. Lifetime Average

The sampling interval is a problem of many glances. What the operator does most often, however, is a single glance: running ps once. The cost of this, and of ps’s own column average, is measured separately.

# On top of the previous blocks: S, usage, PERIOD, and diagnose_heavy come from there.
TRUTH = diagnose_heavy({s["pid"]: max(usage(s, t) for t in range(PERIOD))
                        for s in S})


def glance(processes_, t):
    """The value read on a single glance: what it was using at that instant."""
    return {s["pid"]: usage(s, t) for s in processes_}


def lifetime_average(processes_, window=PERIOD):
    """The process's average since its birth. Dilutes the spike."""
    return {s["pid"]: sum(usage(s, t) for t in range(window)) // window
            for s in processes_}


readings = [(f"glance t={t:<3d}       ", glance(S, t)) for t in (0, 3, 30, 300)]
readings.append(("lifetime average  ", lifetime_average(S)))
print("reading form         seen heavy  missed  false diagnosis")
for name, measurement in readings:
    d = diagnose_heavy(measurement)
    print(f"  {name}{len(d):10d}  {len(TRUTH - d):9d}"
          f"  {len(d - TRUTH) + len(TRUTH - d):11d}")
print("oracle:", len(TRUTH), "heavy processes |", len(S), "processes total")
reading form         seen heavy  missed  false diagnosis
  glance t=0                  9          5            5
  glance t=3                  8          6            6
  glance t=30                 7          7            7
  glance t=300                9          5            5
  lifetime average           7          7            7
oracle: 14 heavy processes | 24 processes total

A single glance gives between 5 and 7 false diagnoses, depending on the moment it looks. Same machine, same 600 seconds, same command: the answer depends on which second it ran. This is the measure of the sentence “I ran ps to see the problem, and there was nothing there.”

The real result is in the last row. The lifetime average is as wrong as the worst single glance: 7 false diagnoses. Averaging does not erase the spike, it dilutes it — a five-second spike, divided over 600 seconds, drops far below the threshold. Looking at a longer window does not give a better diagnosis here; which measure is taken comes before how much data was collected.

The Cost of Searching by Name

There are two ways to search for a process. Filtering the list as text — narrowing ps output with grep — also catches the searching process itself, and brings back unrelated lines when the pattern is written wrong. The second way is pgrep and its counterpart pkill, which query the process table directly:

$ pgrep -a -u app queue-worker
1128 /opt/queue/queue-worker --shard 3
1135 /opt/queue/queue-worker --shard 4

This transcript, too, is illustrative and has not been run. pgrep is more precise than text filtering: it does not list itself, and it can be narrowed by user and session. But what is precise is the match, not the diagnosis. Selecting by name silently accepts the assumption “the processes carrying this name are the problem.”

# On top of the previous blocks: S and TRUTH come from there.
name_table = {}
for s in S:
    name_table.setdefault(s["command"], [0, 0])
    name_table[s["command"]][0] += 1
    name_table[s["command"]][1] += 1 if s["pid"] in TRUTH else 0

print("command name            matched  actually heavy  wrong touches by name")
for k in sorted(name_table):
    total, heavy = name_table[k]
    print(f"  {k:22s} {total:6d}  {heavy:14d}  {min(heavy, total - heavy):21d}")
print("total wrong touches (best case):",
      sum(min(a, t - a) for t, a in name_table.values()))
print("how many heavy processes a single name catches at most:",
      max(a for _, a in name_table.values()), "/", len(TRUTH))
command name            matched  actually heavy  wrong touches by name
  backup-job                  5               3                      2
  cache-cleaner               2               1                      1
  data-receiver               1               1                      0
  metric-collector            5               2                      2
  queue-worker                6               3                      3
  report-generator            5               4                      1
total wrong touches (best case): 9
how many heavy processes a single name catches at most: 4 / 14

No command name coincides exactly with the heavy processes. Of the six processes carrying the name queue-worker, three are heavy and three are not; an operator selecting by name either sweeps in the three innocent processes too or leaves out the three heavy ones. Even in the best case, 9 processes are touched wrongly, and a single name catches at most 4 of the heavy processes — the oracle is 14.

The reason for this is not the fiction but the structure itself: a command name is not an identity, it is a label. The same program runs as multiple instances, and the instances’ loads are independent of each other. Selecting by name is fast, and this speed turns into a cost when sending signals, in the third lesson of this topic.

Summary

  • The course’s measure is three numbers: the oracle, the tool’s output, and the false diagnosis count. A command’s value is not the number of lines it shows, but how many times the diagnosis drawn from those lines is wrong.
  • 14 of the 24 processes on the synthetic server are actually heavy; this is the oracle, and every number in the lesson is read against it.
  • When the sampling interval rises to 15 seconds, the tool misses 3 heavy processes; at 30 and 60 seconds, 5. The ones missed are those that spike once every sixty seconds; the tool is correct at the instant it looks.
  • At a 1-second interval, 14,400 lines are produced; at 60 seconds, 240. But 2,880 lines give a diagnosis just as good as 14,400. Past a certain point, more lines add nothing to the diagnosis.
  • A single glance gives between 5 and 7 false diagnoses depending on the moment it looks; the lifetime average taken since the process’s birth is, with 7 false diagnoses, as bad as the worst single glance.
  • Searching by name touches 9 processes wrongly in the best case, and a single name catches at most 4 of the heavy processes; a command name is not an identity but a label.

Next Step

This lesson looked at processes from outside: list, column, search. Processes also have a bond with the operator — which terminal they are attached to, which session they belong to, what happens when the session closes. The next lesson measures this bond: which processes survive when a script is backgrounded and the session is closed may not overlap at all with what jobs output shows.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close