SystemVerilog · Module 14
The process Class
SystemVerilog process class — the precision counterpart to disable fork. Get a handle via process::self(), then kill / suspend / resume / await an exact thread by reference. Five states (RUNNING, WAITING, SUSPENDED, KILLED, FINISHED), six methods, and the canonical agent-with-handle-control pattern.
Module 14 · Page 14.5
disable fork is a blunt instrument — it kills every child thread in the calling process's scope. The process class is the precision tool. It gives the testbench a handle to any specific running thread: kill it individually, pause it, resume it, or wait for it to finish — targeting exactly the thread you mean, leaving every other thread untouched. This page walks the five process states, the six methods, the process::self() registration discipline, and the agent / pool patterns that production verification environments use to manage hundreds of background threads at runtime.
1. Engineering Problem — Why the process Class Exists
14.4 gave the testbench disable fork and wait fork — both operate on a scope (the calling process's children) and both treat every child uniformly. That works for the timeout idiom (kill the loser) and the N-thread barrier (wait for them all). It does not work when:
- One specific agent of N must be stopped. A 4-channel testbench finds channel 2 misbehaving and wants to disable just that channel's driver.
disable forkwould kill all four;disable <block_name>requires the block be named at compile time, which doesn't scale to runtime-spawned thread pools. - A driver needs to be temporarily paused, not killed. During DUT reset or DFT scan mode, the driver should freeze (state preserved) and resume after —
disableis one-way; there is nore-enable fork. - A test needs to wait for one specific initialiser thread out of many.
wait forkwaits for all in-scope children; the test wants to wait for thread 7 specifically while monitors keep running. - A health monitor needs to poll the state of every running agent. "Is the AXI driver still alive?" cannot be answered by a fork-statement scope — it requires a handle to the specific thread.
The process class is the IEEE 1800 answer: a built-in class that gives every running thread an addressable handle. Once captured (via process::self() from inside the thread), the handle lets the rest of the testbench act on that exact thread.
2. Mental Model — A Thread Has a Handle
The picture every engineer carries:
Every running thread has exactly one
processobject — like an OS thread has a thread ID. The thread captures its own handle withprocess::self(), then publishes the handle so other code can act on it. Without a handle, a thread is anonymous (you can onlydisable forkit as part of a scope); with a handle, the thread is addressable (you cankill(),suspend(),resume(), orawait()it individually).
Three invariants this picture preserves:
process::self()is the only constructor. You cannotnew()aprocess— the class has no user-callable constructor. The only way to obtain a handle is to callprocess::self()from inside the thread whose handle you want.- Five states cover every lifecycle moment.
RUNNING(actively executing) /WAITING(blocked on#,@, or a blocking call) /SUSPENDED(paused bysuspend()) /KILLED(terminated bykill()) /FINISHED(ran to completion).status()reads the state; six methods drive the transitions. - Handles outlive the thread. After
kill()or natural completion, the handle remains valid —status()returnsKILLEDorFINISHED. You cannotresume()a dead thread, but you can still query whether it died.
3. Visual Explanation — The Five-State Lifecycle
The process::state enum has five members. Every running thread is in exactly one of them at any moment; methods drive the transitions.
| State | Meaning | Entered by | Exited by |
|---|---|---|---|
RUNNING | Actively executing in the scheduler | spawn; resume(); unblocking | natural completion → FINISHED; kill() → KILLED; blocking call → WAITING; suspend() → SUSPENDED |
WAITING | Blocked on #delay, @event, mailbox.get(), semaphore.get(), etc. | blocking call from RUNNING | unblock → RUNNING; kill() → KILLED; suspend() → SUSPENDED |
SUSPENDED | Explicitly paused by suspend() — state preserved, not blocked | suspend() from RUNNING/WAITING | resume() → back to prior state; kill() → KILLED |
KILLED | Terminated by kill() — handle still valid, thread dead | kill() from any non-terminal state | (terminal — no exits) |
FINISHED | Ran to completion — all statements executed | natural end of thread body | (terminal — no exits) |
Two states are terminal (KILLED, FINISHED); three are transient (RUNNING, WAITING, SUSPENDED). The handle persists after the thread reaches a terminal state — status() continues to work, but the thread's local variables and execution context are gone.
4. Syntax & Semantics — process::self() and the Six Methods
4.1 Getting a handle — process::self()
// ── Pattern: thread captures its own handle as the FIRST statement
process driver_proc; // shared variable for other threads to use
initial fork
// ── The driver thread registers itself ────────────────────
begin : driver_thread
driver_proc = process::self(); // FIRST line — before any delay or wait
forever drive_next_txn();
end
// ── The controller can now act on the driver by handle ────
begin : controller
#1000;
if (driver_proc != null)
driver_proc.kill(); // kills ONLY the driver, nothing else
end
join_noneThe "register your handle as the first statement" discipline is non-negotiable — see DebugLab #1. If the thread does any delay or wait before calling process::self(), the controller may try to use a null handle and crash.
4.2 The complete method set
| Method | Called by | What it does | Blocks? |
|---|---|---|---|
process::self() | the thread itself | Returns the calling thread's handle | No |
p.status() | any thread | Returns the process::state enum value | No |
p.kill() | any thread (or p itself) | Terminates p immediately. State → KILLED. | No |
p.await() | any thread except p | Blocks the caller until p reaches FINISHED or KILLED | Yes |
p.suspend() | any thread (or p itself) | Pauses p. State → SUSPENDED. State preserved. | Yes, if called on self |
p.resume() | any thread except p | Resumes a SUSPENDED p. State → RUNNING or WAITING. | No |
// ── process::self() — get handle from inside the thread ───────
process h;
initial begin
h = process::self(); // h refers to THIS initial block's process
end
// ── p.status() — non-blocking read of current state ───────────
$display("%s", h.status().name()); // "RUNNING", "WAITING", etc.
// ── p.kill() — terminate the process immediately ──────────────
h.kill();
// state → KILLED; the rest of h's body is never executed
// the caller is NOT killed — only h
// ── p.await() — wait for a specific process to end ────────────
fork
begin
h = process::self();
repeat(50) @(posedge clk);
$display("Thread done");
end
join_none
h.await(); // caller blocks here until h reaches FINISHED or KILLED
$display("h has finished — safe to proceed");
// ── p.suspend() — pause the thread at its current point ───────
h.suspend(); // h is now frozen — it will not execute until resumed
// ── p.resume() — restart a suspended thread ───────────────────
h.resume(); // h resumes from exactly where it was paused4.3 Reading state
case (p.status())
process::RUNNING : $display("Thread is running");
process::WAITING : $display("Thread is blocked");
process::SUSPENDED : $display("Thread is suspended");
process::KILLED : $display("Thread was killed");
process::FINISHED : $display("Thread completed");
endcase
// ── Convenience predicate ─────────────────────────────────────
function bit is_alive(process p);
return (p != null &&
p.status() != process::KILLED &&
p.status() != process::FINISHED);
endfunction5. Simulation View — State Transitions in Detail
The simulator drives state transitions in response to scheduler events and explicit method calls:
- Spawn →
RUNNINGwhen the spawningforkschedules the thread for the current Active region. RUNNING→WAITINGon the first blocking call (#,@event,mailbox.get(),semaphore.get(),wait,wait fork).WAITING→RUNNINGwhen the blocking condition resolves (event fires, delay expires, mailbox has an item, semaphore has a key).- Any non-terminal →
SUSPENDEDonp.suspend(). The thread is removed from the active scheduler but its program counter, local variables, and pending events are preserved.suspend()called on self blocks the caller until someone else callsp.resume(). SUSPENDED→RUNNINGorWAITINGonp.resume(). The thread re-enters whichever state it was in beforesuspend().- Any non-terminal →
KILLEDonp.kill(). State is destroyed; the thread cannot be restarted. RUNNING→FINISHEDwhen the thread's body reaches its natural end.
p.await() blocks the caller until p's state reaches a terminal value (KILLED or FINISHED). If p is already in a terminal state when await() is called, the caller returns immediately. Never call p.await() on p itself — the thread cannot finish while it's blocking on itself, producing a deterministic deadlock.
process::self() itself does not change state — it's a pure read of the current process identity. Safe to call multiple times; always returns the same handle within a thread.
6. Waveform — suspend() / resume() Across Reset
The waveform below shows the canonical reset-isolation pattern: a driver thread runs concurrently with a reset manager. When rst_n deasserts at cycle 3, the manager calls drv_proc.suspend(); the driver freezes mid-cycle. When rst_n reasserts at cycle 7, the manager calls drv_proc.resume(); the driver continues from exactly where it left off. The monitor thread runs uninterrupted throughout — suspend() targeted only the driver's handle.
Figure — process.suspend() / resume() pause a driver across reset; monitor untouched
12 cyclesThis is impossible with disable fork — disable cannot pause, only terminate. The process handle is the only mechanism for the pause-and-resume pattern.
7. Synthesis — Not Applicable
The process class is a procedural simulation construct. It has no hardware footprint and no place in synthesisable RTL — synthesis tools reject it outright. This section is intentionally omitted; the topic does not warrant it.
The corresponding hardware idiom for "pause this activity then resume it" is a registered enable signal (always_ff @(posedge clk) if (en) ...). That is RTL semantics, not a procedural primitive.
8. Verification View — The Canonical Patterns
8.1 The per-agent process-control class
The standard pattern for production verification: every agent captures its own thread handles in start() and exposes pause_drive(), resume_drive(), stop(), and is_alive() on those handles. The agent's lifecycle is fully addressable, and operations on one agent don't touch any other.
class ApbAgent;
process drv_proc;
process mon_proc;
string name;
function new(string n); name = n; endfunction
task start();
fork
begin
drv_proc = process::self(); // register FIRST
$display("[%s] Driver started", name);
forever drive_next_txn();
end
begin
mon_proc = process::self();
$display("[%s] Monitor started", name);
forever sample_response();
end
join_none
endtask
// Pause stimulus without touching the monitor
task pause_drive();
if (drv_proc != null) drv_proc.suspend();
endtask
task resume_drive();
if (drv_proc != null && drv_proc.status() == process::SUSPENDED)
drv_proc.resume();
endtask
task stop();
if (drv_proc != null) drv_proc.kill();
if (mon_proc != null) mon_proc.kill();
endtask
function bit is_alive();
return (drv_proc != null &&
drv_proc.status() != process::KILLED &&
drv_proc.status() != process::FINISHED);
endfunction
endclass
// ── Usage ─────────────────────────────────────────────────────
ApbAgent agent = new("APB_0");
agent.start();
// Pause driver during reset, keep monitor alive
@(negedge rst_n); agent.pause_drive();
@(posedge rst_n); agent.resume_drive();
// Clean stop at end of test
agent.stop();Each agent owns its handles; methods are surgical. Compare to the disable <block_name> agent in 14.4 §8.3 — handles support suspend()/resume() and status() queries that named-block disable does not.
8.2 The dynamic process pool
When the number of threads is determined at runtime, a process array or queue lets the testbench inspect each thread individually — kill only the ones still waiting, await specific completions, prune finished handles.
process pool[$];
task automatic spawn_worker(int id);
fork
begin
automatic process me = process::self();
pool.push_back(me); // register in the pool
do_work(id);
// thread exits naturally — status → FINISHED
end
join_none
endtask
// ── Spawn 8 workers ───────────────────────────────────────────
for (int i = 0; i < 8; i++) begin
automatic int id = i;
spawn_worker(id);
end
// ── Print status of every thread in the pool ──────────────────
foreach (pool[i])
$display(" Worker[%0d] state = %s", i, pool[i].status().name());
// ── Kill only threads that are still WAITING (not yet running) ─
foreach (pool[i]) begin
if (pool[i].status() == process::WAITING) begin
pool[i].kill();
$display(" Worker[%0d] killed (was WAITING)", i);
end
end
// ── Purge finished/killed handles from the pool ───────────────
process live[$];
foreach (pool[i]) begin
if (pool[i].status() == process::RUNNING ||
pool[i].status() == process::WAITING ||
pool[i].status() == process::SUSPENDED)
live.push_back(pool[i]);
end
pool = live;disable fork cannot do any of this — it terminates by scope, not by selection. The process pool is what makes runtime-policy decisions ("kill only the slow ones") expressible.
8.3 The health-monitor pattern
Background thread that polls every agent's is_alive() status every N cycles and $errors if any agent unexpectedly dies.
ApbAgent agents[4];
task automatic health_monitor();
forever begin
repeat(100) @(posedge clk); // check every 100 cycles
foreach (agents[i]) begin
if (!agents[i].is_alive()) begin
$error("[HEALTH] Agent %0d unexpectedly dead at %0t!", i, $time);
end
end
end
endtask
initial fork
health_monitor();
join_noneThis catches "agent silently died mid-test" failures that would otherwise only surface as "scoreboard never sees the expected transactions" much later.
8.4 await() for selective synchronisation
Waiting for one specific thread without waiting for all in-scope threads:
process initialiser_proc;
initial fork
forever monitor_apb(); // background — should NOT be waited on
begin
initialiser_proc = process::self();
load_firmware();
configure_dut();
$display("[INIT] Done");
end
join_none
// Main test waits for the initialiser specifically — monitor keeps running
repeat(2) @(posedge clk); // give it time to register the handle
initialiser_proc.await(); // wait ONLY for the initialiser
$display("[MAIN] Init complete — starting test");
run_test();wait fork here would block forever because the forever monitor_apb() is also in scope. await() targets exactly one thread — monitors are unaffected.
9. Industry Usage — Where the process Class Lands in Real Verification
- UVM
uvm_sequence::kill()— internally captures the sequence body's process handle when the sequence starts, thenkill()s that handle when the user callsseq.kill(). The mechanism is exactly theprocess::self()+ handle-stored-as-class-property pattern. - UVM
uvm_objectiondrain mechanics — the phase machinery captures process handles for every component'srun_phase()body; phase end uses these handles to detect when each component has reached a terminal state. The progress-callback API exposes this throughphase.phase_done.set_drain_time()and the underlying objection counts. - UVM
uvm_sequencer::start_phase_sequence()— uses a process handle to manage the per-phase default sequence; kills the handle when the phase ends so the sequence doesn't outlive its phase. - VIP per-channel suspend/resume — every protocol VIP (APB / AHB / AXI / DDR / PCIe) exposes a
pause()/resume()API on its driver agents for reset-isolation tests. The implementation is exactly §8.1's pattern:process::self()captured instart(),drv_proc.suspend()/drv_proc.resume()in the API. - Power-aware verification (UPF / CPF) — when a power domain enters retention or shutdown mode, the VIP for that domain must pause its drivers (state preserved, will resume at power-up).
disablecannot express this;process.suspend()is the only mechanism. - DFT scan mode tests — switching between functional and scan mode requires pausing every functional-mode agent while scan runs. The standard pattern uses a process pool of all agents' handles, iterates with
suspend()at scan-mode entry andresume()at scan-mode exit. - Health-check daemons in long regressions — the §8.3 pattern wrapped into a reusable
UvmHealthMonitorextension that runs across the entire UVM phase tree, catching silent agent death the moment it happens.
10. Design Review Notes — What a Senior Will Flag
| Pattern in the diff | What review will say |
|---|---|
process p; #10; p = process::self(); | "Race — the controller may try to use p before this thread registers. Call process::self() as the first statement of the thread, before any delay, event wait, or blocking call." |
me.await(); where me = process::self() | "Deadlock — you're waiting for yourself to finish. me cannot reach FINISHED while await() is blocking it. await() is for waiting on other threads." |
if (p.status() == process::RUNNING) p.resume(); | "Wrong predicate — resume() is for SUSPENDED processes only. resume() on a RUNNING process is undefined behaviour. Check status() == SUSPENDED." |
p.kill(); p.kill(); (double kill) | "Tool-dependent — some simulators no-op, others raise an error. Guard with if (p.status() != KILLED && p.status() != FINISHED) p.kill();." |
process driver_proc; declared globally but registered inside a class method | "Document the lifecycle. Who clears driver_proc when the agent is destroyed? If a new agent reuses the variable, the old handle is leaked and the new value is stale." |
Anonymous fork block with no process::self() capture and a comment "we may need to kill this later" | "Capture the handle now. Adding it after the fact requires re-running the spawn. Cost: one line. Benefit: future-you can kill it precisely." |
Class with process properties but no null check in methods using them | "if (drv_proc != null) before every method call. If start() was never called or the agent was constructed without spawning, the handle is null and the method call crashes." |
disable fork; in a context where one specific thread should die | "Use p.kill() instead — surgical. disable fork kills all children of the calling process; you'll silently take out monitors and clocks." |
wait fork; when waiting for a specific thread | "Use p.await() instead — targets exactly the thread you mean. wait fork blocks on every in-scope thread; one forever and you deadlock." |
process pool[$] that grows monotonically without pruning | "Pool leaks finished handles. Add a periodic prune (§8.2) — keep only RUNNING/WAITING/SUSPENDED entries. After 10,000 spawns the pool has 10,000 dead handles all reporting FINISHED on every iteration." |
The single highest-value rule: always register process::self() as the first statement of the thread, and always null-check before calling methods on a stored handle. Those two disciplines prevent almost every process-class bug.
11. Debugging Guide — Real Failures, Real Fixes
Controller crashes with 'null handle' before doing anything
SELF-CALLED-TOO-LATEprocess p;
initial fork
begin
#10; // BUG: thread runs for 10 before registering
p = process::self();
end
join_none
#5; // controller runs at t=5
p.kill(); // BUG: p is still null — crashp.kill() line. The thread it was supposed to kill hadn't even started yet. Reproduces 100% of the time at the same seed.#10 before calling process::self(). The controller dereferences p at t=5, before the thread has registered its handle. p is still null.process::self() must be the first statement of the thread, before any delay, event wait, or blocking call. Then the handle is set the moment the thread is spawned, and any controller can dereference it safely (with a null check for robustness).Init task hangs forever; await() never returns
AWAIT-SELF-DEADLOCKtask automatic init_with_await();
process me = process::self();
do_init_work();
me.await(); // BUG: waiting for myself to finish
// Unreachable — me can't FINISH while await() is blocking it
endtaskinit_with_await() runs do_init_work() then hangs. Watchdog eventually fires. Log shows the task entered but never exited.p.await() blocks the caller until p reaches FINISHED or KILLED. When the caller is p, the caller is blocked on itself — it can never reach FINISHED because the body that would reach endtask is the one blocked.await() is for waiting on other threads. Remove the call (the task naturally finishes when its body ends) or move the wait to a different process that wants to know when this one is done.resume() silently does nothing; driver stays paused
RESUME-WRONG-STATEtask resume_driver();
if (drv_proc != null) drv_proc.resume(); // no status guard
endtask
// Caller flow:
drv_proc.suspend();
resume_driver();
resume_driver(); // BUG: second call resumes a non-SUSPENDED process
// Driver may still be paused — behaviour tool-dependentdrv_proc.status() = SUSPENDED even though resume() was called twice.resume() on a non-SUSPENDED process is undefined behaviour per IEEE 1800. Some simulators silently no-op; others raise an error. The double-resume here was meant to be idempotent but is actually a contract violation.resume() with a status check: if (drv_proc.status() == process::SUSPENDED) drv_proc.resume();. The same discipline applies to suspend() (don't suspend KILLED/FINISHED) and kill() (don't kill KILLED/FINISHED).Health monitor reports 'agent dead' even after agent.start()
HANDLE-NEVER-CAPTUREDclass ApbAgent;
process drv_proc;
task start();
fork
begin
// BUG: forgot process::self()
forever drive_next_txn();
end
join_none
endtask
function bit is_alive();
return (drv_proc != null &&
drv_proc.status() != process::KILLED &&
drv_proc.status() != process::FINISHED);
endfunction
endclass
// Health monitor:
if (!agent.is_alive()) $error("Agent dead!"); // fires immediately[HEALTH] Agent 0 dead at t=100 even though the agent's driver is clearly running and producing log output. Test passes the actual functional checks but the health monitor spams errors.drv_proc was declared but never assigned — process::self() was forgotten inside the spawned thread. The handle stays null. is_alive() returns false (the null check is the first conjunct). The driver is actually running, but the agent has no handle to it.drv_proc = process::self(); as the first statement of the forked block, before the forever. Lint rules at every shop flag process declarations that are never assigned via self().Process pool grows without bound; performance degrades over time
POOL-NEVER-PRUNEDprocess pool[$];
task automatic spawn_worker(int id);
fork
begin
automatic process me = process::self();
pool.push_back(me);
do_work(id);
end
join_none
endtask
// 10,000 spawns over the test...
foreach (pool[i]) check_status(pool[i]); // O(N) every iteration!check_status iteration time growing linearly. After 1 hour, simulator is spending 50% of CPU walking the pool. Functional behaviour correct but throughput unacceptable.FINISHED. Every iteration over the pool is O(N), and N grows linearly with simulation time.RUNNING/WAITING/SUSPENDED) to a new queue, reassign. Run the prune every M spawns or every K cycles. Pool size stabilises at the steady-state count of live threads.12. Interview Insights — What Interviewers Actually Probe
process is a built-in SystemVerilog class. Every running thread has exactly one associated process object. You cannot construct one with new() — the only way to obtain a handle is to call process::self() from inside the thread whose handle you want. The thread typically captures its own handle as the first statement of its body and stores it in a shared variable so other threads can act on it.
13. Exercises
1. Design — reset-isolated driver (Foundation)
Build an ApbAgent class that captures its driver thread's process handle via process::self(), and exposes pause_during_reset() and resume_after_reset() methods. Confirm the monitor thread is untouched by the pause/resume cycle.
2. Debug — the silent agent (Intermediate)
A teammate's ApbAgent reports is_alive() == false from the health monitor, but the driver is clearly producing log output. The agent class has process drv_proc; declared and the driver thread runs forever drive_next_txn();. Identify the missing line and write the fix.
3. Code review — the resume bug (Intermediate)
A teammate's resume() method is if (drv_proc != null) drv_proc.resume();. The driver gets stuck after a sequence of operations. Identify why this implementation is unsafe and propose the canonical fix with a status guard.
4. Trade-off — process pool vs disable fork (Advanced)
A teammate argues: "process pools are overkill — disable fork is simpler and shorter." Argue the case against the blanket rule. Construct a real verification scenario (multi-agent regression with per-agent failure injection) where the process pool is required and disable fork cannot express the needed semantics.
14. Summary
The process class is the precision counterpart to disable fork. Every running thread has one process object; the thread captures its handle via process::self() (always the first statement of the body); other code uses the handle to kill() / suspend() / resume() / await() that exact thread. Five states track the lifecycle (RUNNING, WAITING, SUSPENDED, KILLED, FINISHED); six methods drive the transitions.
Defaults to memorise. process::self() is always the first line. Always null-check before dereferencing a stored handle. Use p.kill() for surgical termination, disable fork for scope-wide cleanup. suspend()/resume() is the only way to pause-and-restart a thread with state preserved. Never await() on self — instant deadlock. Periodically prune long-lived process pools to avoid O(N) iteration growth.
Module 14 complete. The five lessons together cover every fork-lifecycle primitive SystemVerilog provides:
- 14.1 fork-join — spawn parallel threads and block until all complete. Loop-variable capture is the canonical first-fork bug.
- 14.2 fork-join_any — block until any one thread completes. The timeout idiom. Always pair with
disable fork. - 14.3 fork-join_none — don't block at all. The basis for daemons and variable-N spawning. Pair with
wait forkfor finite-thread barriers. - 14.4 disable fork & wait fork — scope-wide kill and barrier. Always wrap in a
task automaticto isolate scope. - 14.5 process Class — handle-based precision. The only way to target one specific thread, pause/resume, or query status.
Pick by intent: disable fork for scope; disable <block_name> for named-thread surgical kill; p.kill() for runtime-targeted surgical kill. The same trichotomy applies to waiting — wait fork for scope, await(named_block.triggered) for named blocks, p.await() for handles.
Next module: 15 — Program Blocks (begins with 15.1 Program Block vs Module — Race-Free Simulation).