SystemVerilog · Module 14
disable fork & wait fork
SystemVerilog disable fork and wait fork — the lifecycle controls for background threads. disable fork kills; wait fork waits; both operate on a process scope, not on a specific thread. The scope-isolation discipline (wrap forks in tasks) is what separates engineers who use these correctly from those who silently kill monitors.
Module 14 · Page 14.4
disable fork kills threads. wait fork collects them. Together they give precise control over the lifecycle of every background process the testbench spawns. But both operate on a scope, not on a specific thread — understanding that scope is what separates engineers who use these correctly from those who silently kill monitors they wanted to keep alive. This page walks the scope semantics, the surgical disable <block_name> alternative, the deadlock failure mode of wait fork with forever threads, and the task-isolation discipline that makes both safe.
1. Engineering Problem — Why disable fork and wait fork Exist
The three fork-join* variants (14.1, 14.2, 14.3) give the testbench three ways to spawn concurrent threads. None of them give the testbench a way to manage the threads' lifecycle after spawning. Two needs remain unmet:
- Kill threads on demand. After
fork-join_anyunblocks the parent because the work succeeded, the watchdog thread is still counting and will fire its$fatallater — a false timeout (see 14.2 §10 bug #1). The parent needs a way to terminate the loser immediately. - Wait for an unknown number of background threads. After spawning N threads via
fork-join_nonein a loop, the parent needs a barrier that blocks until all N complete — butfork-joincannot express "wait for N threads where N is a runtime value" (see 14.3 §4.3).
disable fork and wait fork are the IEEE 1800 answers. Both target the child threads of the calling process — every thread the current process has spawned via any fork statement that has not yet terminated. The scope is per-calling-process, not per-fork-statement.
2. Mental Model — Process-Tree Operations
The picture every engineer carries:
A SystemVerilog process is a node in a tree. Every
forkstatement the process executes adds child nodes — the spawned threads — under it.disable forkis a prune-this-process's-children operation: it cuts the entire subtree below the current process.wait forkis a wait-for-all-my-children-to-finish barrier: it blocks until the subtree below the current process collapses to zero leaves.
Three invariants this picture preserves:
- Scope is the calling process. Neither
disable forknorwait forksays "this fork" — they say "every child of the process executing me." If the same process spawned five separate fork blocks, all five sets of threads are in scope. - Tasks reset the scope. Each task call creates a fresh process. Forks inside the task spawn threads under the task's process, not the caller's.
disable forkinside the task kills only the task-call's threads — not the caller's threads. This is the cornerstone discipline of safe lifecycle control. disable forkandwait forkare scope-symmetric. They affect exactly the same set of threads — kill versus wait.wait forkafterdisable forkis a no-op (the threads are already gone);disable forkafterwait forkis a no-op (everything already terminated).
3. Visual Explanation — The disable fork Scope
The figure shows the exact scenario that bites every junior. Three threads spawned in the same initial block: a work thread, a watchdog timer, and a forever monitor. After fork-join_any unblocks on the first completion, a disable fork issued from the surrounding initial block kills all three — including the monitor that was supposed to live forever. The fix (§8) is to isolate the work + watchdog pair inside a task automatic; the monitor stays in the outer scope.
4. Syntax & Semantics — disable, wait fork
4.1 disable fork — kill all child threads
// ── Syntax ────────────────────────────────────────────────────
disable fork; // kills ALL child threads of the current process
// ── Basic example: the canonical timeout idiom ────────────────
bit work_done = 0;
fork
begin : work_thread
do_long_operation();
work_done = 1;
end
begin : timeout_thread
repeat (1000) @(posedge clk);
end
join_any
disable fork; // kills whichever thread did NOT finish first
if (work_done) $display("[TB] Done in time");
else $fatal(1, "[TB] TIMEOUT");
// ── After fork-join_none: kill all background threads ─────────
fork
forever monitor_apb();
forever monitor_axi();
join_none
run_stimulus();
// ... test body ...
disable fork; // kills both monitors when test is complete
$finish;4.2 disable <block_name> — surgical single-thread kill
When disable fork's shotgun semantics are wrong, name the specific thread and kill it individually.
// ── Name the blocks you want to be able to kill ───────────────
fork
begin : monitor_apb // named block
forever sample_apb();
end
begin : monitor_axi // named block
forever sample_axi();
end
begin : stimulus
send_test_traffic(); // finite
end
join_any
// Kill ONLY the stimulus thread — keep both monitors alive
disable stimulus;
// OR: kill only one monitor while keeping the other
disable monitor_axi;
// ── disable can target any named block, not just fork threads ─
begin : outer_loop
for (int i = 0; i < 100; i++) begin
process_item(i);
if (early_exit_condition)
disable outer_loop; // jumps out of the named block immediately
end
end
// Execution continues here after disable outer_loopdisable <name> is the right tool when an agent wants to stop its threads without nuking the testbench-wide monitor pool — see §8.3.
4.3 wait fork — barrier for child threads
wait fork is the inverse of disable fork: instead of killing threads, it blocks the current process until every child thread completes. The natural companion to fork-join_none loops.
// ── Syntax ────────────────────────────────────────────────────
wait fork; // blocks until ALL child threads of current process complete
// ── Standard use: N-thread spawn then barrier ─────────────────
for (int i = 0; i < 8; i++) begin
automatic int ch = i;
fork
drive_channel(ch);
join_none
end
wait fork; // blocks here until all 8 channel threads are done
$display("[TB] All 8 channels complete");
// ── Timeout-protected wait fork ───────────────────────────────
fork
wait fork; // wait for N threads
#1_000_000 $fatal(1, "Timeout waiting for channels");
join_any
disable fork; // cancel whichever didn't win
// ── wait fork with zero background threads: returns immediately
wait fork; // if no child threads are running, returns instantly5. Simulation View — Scope Semantics in Detail
Every SV process has a notion of "my children." When a process executes fork ... join*, every spawned thread becomes a child of that process. The relationship is recorded in the simulator's process tree.
disable forkwalks the children list, immediately terminates every still-running child, and removes them from the tree. The calling process is unaffected — only its children. Returns instantly (no time advance).disable <block_name>walks the named-block index and terminates exactly the one block named. Returns instantly.wait forkwalks the children list and blocks the calling process until the children list is empty. As children terminate, they are removed from the list; when the count reaches zero, the wait unblocks.- A task call is a process boundary. When task
Tis called, a new process is created for the call. Forks insideTspawn children under T's process — not the caller's.disable fork/wait forkinsideToperate on T's children only.
The "task as scope boundary" rule is the entire reason the timeout idiom requires a task wrapper: it isolates the disable fork so it doesn't nuke threads the caller spawned elsewhere.
6. Waveform — disable fork Cleanly Killing the Watchdog
The waveform below shows the canonical timeout pattern: a work thread races a watchdog. Work finishes at cycle 4; join_any unblocks the parent; disable fork immediately kills the still-running watchdog at cycle 4 (its 10-cycle timer never reaches its $fatal). The parent proceeds normally.
Figure — canonical timeout idiom: work wins, disable fork kills the watchdog
12 cyclesThe monitor row stays RUN throughout because the work + watchdog fork is inside a task automatic — the disable fork inside the task is scoped to the task's children, not the outer initial's monitor. Without that task wrapper, the monitor row would flip to KILL at cycle 4 too — the bug §11 #1 catches.
7. Synthesis — Not Applicable
disable fork and wait fork are procedural simulation constructs. They have no hardware footprint and no place in synthesisable RTL — synthesis tools reject them outright. This section is intentionally omitted; the topic does not warrant it.
The hardware idiom for "stop this activity" is just deasserting an enable; for "wait for completion" it is sampling a done flag. Both are RTL semantics, not procedural primitives.
8. Verification View — The Canonical Patterns
8.1 The complete timeout idiom — always wrap in a task
The single most-used idiom in real testbenches. The task automatic wrapper is non-negotiable — it isolates disable fork's scope.
// ── Reusable timeout wrapper — every shop has one ─────────────
task automatic run_with_timeout(
input int max_cycles,
ref bit timed_out
);
timed_out = 0;
fork
begin : work
do_operation();
end
begin : timer
repeat(max_cycles) @(posedge clk);
timed_out = 1;
end
join_any
disable fork; // safe — scoped to this task call only
endtask
// ── Variant: wait on an event with a timeout ──────────────────
task automatic wait_for_event_or_timeout(
ref event done_ev,
input int max_cycles,
ref bit success
);
success = 0;
fork
begin
@done_ev;
success = 1;
end
begin
repeat(max_cycles) @(posedge clk);
end
join_any
disable fork;
endtask
// ── Usage ─────────────────────────────────────────────────────
event transfer_done;
bit ok;
wait_for_event_or_timeout(transfer_done, 500, ok);
if (!ok) $error("DMA transfer did not complete within 500 cycles");Without the task automatic wrapper, the disable fork would kill every in-scope thread the caller spawned earlier — including monitors, clock generators, and coverage collectors.
8.2 The N-thread barrier — fork-join_none loop in a task with wait fork
// ── Spawn N channels, wait for all (N is a runtime value) ──────
task automatic run_all_channels(int n_ch);
for (int i = 0; i < n_ch; i++) begin
automatic int ch = i;
fork drive_channel(ch); join_none
end
wait fork; // safe — scoped to this task's children only
// Monitors spawned by the caller are NOT in scope.
endtask
initial begin
// Launch persistent monitors (outer scope — NOT inside the task)
fork
forever monitor_apb();
forever monitor_axi();
join_none
// The N-thread work runs inside a task; the task's wait fork
// waits ONLY for its own children, not for the monitors.
run_all_channels(8);
$display("[TB] All 8 channels complete");
endThis is the canonical structure: persistent infrastructure in the outer scope, lifecycle-controlled work in a task. The task wrapper is what makes the patterns from 14.2 and 14.3 compose safely.
8.3 Controlled agent shutdown — named-block disable
disable <block_name> is the right tool when an agent owns several threads and needs to stop them without affecting other agents.
class ApbAgent;
string name;
function new(string n); name = n; endfunction
task run();
fork
begin : drive_blk
$display("[%s] Driver started", name);
forever drive_next_txn();
end
begin : mon_blk
$display("[%s] Monitor started", name);
forever sample_response();
end
join_none
endtask
task stop_drive();
disable drive_blk; // kill only this agent's driver
$display("[%s] Driver stopped", name);
endtask
task stop_all();
disable drive_blk;
disable mon_blk; // kill exactly this agent's two threads
endtask
endclass
// ── Coordinated multi-agent shutdown ──────────────────────────
ApbAgent agents[4];
initial begin
foreach (agents[i]) agents[i] = new($sformatf("APB_%0d", i));
foreach (agents[i]) begin
automatic int idx = i;
fork agents[idx].run(); join_none
end
run_test_stimulus();
repeat(50) @(posedge clk); // drain
foreach (agents[i]) agents[i].stop_all(); // each agent kills only its own threads
$finish;
endNote disable drive_blk targets the named block within the agent's run() call. If two ApbAgent instances each have a drive_blk block, disable drive_blk issued from agent A's stop_all() kills only A's drive_blk — not B's. Named-block disable respects the surrounding process tree.
8.4 The nested-task pattern — universal scope isolation
The safest testbench-level structure: every fork that needs disable fork or wait fork lives inside its own task. Each task call is a process boundary; lifecycle controls inside the task affect only that task call's children.
// ── Task 1: stimulus with an internal timeout ─────────────────
task automatic timed_stimulus(int cycles);
fork
send_packets(100);
repeat(cycles) @(posedge clk);
join_any
disable fork; // SAFE: only kills this task's children
endtask
// ── Task 2: parallel N-channel spawn ──────────────────────────
task automatic run_all_channels(int n);
for (int i = 0; i < n; i++) begin
automatic int ch = i;
fork drive_channel(ch); join_none
end
wait fork; // SAFE: only waits for this task's N children
endtask
// ── Top-level initial block: clean separation ─────────────────
initial begin
// Persistent monitors live in the outer scope — NOT in any task
fork
forever monitor_apb();
forever monitor_axi();
join_none
// Lifecycle-controlled work runs in tasks
timed_stimulus(500); // disable fork inside — monitors survive
run_all_channels(8); // wait fork inside — monitors not waited on
repeat (50) @(posedge clk); // drain
$finish; // monitors killed by $finish — intended
endThis pattern is the right answer to "how do I structure a testbench that has persistent monitors AND timeouts AND variable-N parallel work?"
9. Industry Usage — Where disable / wait fork Land in Real Verification
- UVM
phase.drop_objectionshutdown — the canonical UVM way to end a test phase. Internally,uvm_phaseusesdisable forkto terminate the run-phase tasks of every component when the last objection drops. Understanding the raw mechanism makes UVM's "test ends but my monitor's last sample is lost" failures debuggable. - UVM
uvm_objection::set_drain_time()— the drain-window pattern from 14.3 §8.4 is exactly the same idea: give background threads a window before they're killed. The drain time becomes arepeat(N) @(posedge clk);before the implicitdisable forkat phase end. - UVM
uvm_sequence::kill()— kills the currently-running sequence body viadisableon a named block inside the sequencer. - Reusable verification IP timeout wrappers — every shop has a
run_with_timeout(task, cycles)macro / task built onfork-join_any + disable forkinside a task wrapper. The pattern is in every protocol VIP, every reusable scoreboard helper, every regression infrastructure module. - Reset-injection sequences — randomised reset injectors run as background
fork-join_nonethreads. When the test ends, adisable reset_injector;cleanly stops the injector without nuking other monitors. The named-block discipline matters. - Multi-clock domain testbenches — each clock generator runs in its own
foreverthread, named individually so a CDC test candisable fast_clk;to stop one domain without affecting another. - Coverage drain windows — the
wait forkpattern at end of test, scoped to a task wrapping the parallel sample-points spawned per channel, ensures every coverage point gets its final sample before the next phase begins.
10. Design Review Notes — What a Senior Will Flag
| Pattern in the diff | What review will say |
|---|---|
disable fork at top level of an initial block where monitors were also spawned | "Scope bug — disable fork kills the monitors too. Move the timeout into a task automatic wrapper so the disable is scoped to the task's children, not the initial block's." |
wait fork; at top level of an initial block where a forever monitor was spawned | "Deadlock — wait fork blocks until every child completes, including the forever. Same fix: move the N-thread spawn into a task automatic." |
disable fork after fork-join_any without a task automatic wrapper | "Either confirm there are no other in-scope spawns from this process, or wrap in a task. Default to the task wrapper — it costs one line and prevents an entire class of regressions." |
| Anonymous fork branches when the comment says "we'll disable this later" | "Name the branches — disable <name> requires a name. Anonymous can only be killed by disable fork, which is nuclear. Naming is free; not naming is a debt." |
disable fork; followed by wait fork; | "Redundant — disable fork already terminated everything, so wait fork has nothing to wait on. Remove one; if the author wanted 'kill and wait for them to actually stop,' note that disable fork is already synchronous." |
task automatic foo(); fork ... disable fork; endtask with no comment explaining the scope rationale | "Add a one-line comment: // disable fork scoped to this task call; outer monitors safe. The scope choice is not obvious; document it." |
Class agent with task stop(); /* empty */ endtask and no disable of named blocks | "stop() does nothing — the spawned threads outlive the call. Name the agent's blocks and disable <name> each in stop(). See 14.3 §9." |
Nested forks inside a task with disable fork at the outer level | "disable fork recurses into nested forks within the same process — confirm this is the intent. If you want to kill only the outer fork's direct children, name them and disable <name> selectively." |
wait fork; immediately after $finish is reached on the foreground path | "Dead code — $finish kills everything before wait fork runs. Remove." |
The single highest-value rule: every disable fork and every wait fork belongs inside a task automatic. The task wrapper is the scope boundary that makes both safe to compose with persistent monitors. Code-review's default question is: "is this disable / wait fork inside a task?" — and if not, "why not?".
11. Debugging Guide — Real Failures, Real Fixes
Test passes then immediately stops monitoring; coverage shows zero samples after t=N
DISABLE-FORK-KILLED-MONITORinitial begin
fork forever monitor_apb(); join_none
fork forever monitor_axi(); join_none
fork
do_work();
repeat(100) @(posedge clk);
join_any
disable fork; // BUG: kills BOTH monitors too
repeat(50) @(posedge clk);
$finish;
enddo_work() finished, then zero transactions for the next 50 cycles of drain. Scoreboard reports "0 transactions to check" for those final cycles.disable fork killed all child threads of the current process — including both forever monitors spawned earlier. The drain window had nothing watching the bus; the final transactions went uncaptured.task automatic: task automatic timed_work(int cycles); fork do_work(); repeat(cycles) @(posedge clk); join_any disable fork; endtask. The disable is now scoped to the task call's children — the outer-scope monitors are untouched.Test hangs at end after the N-channel loop; watchdog eventually fires
WAIT-FORK-FOREVER-MONITORinitial begin
fork forever monitor(); join_none
for (int i = 0; i < 8; i++) begin
automatic int ch = i;
fork drive_channel(ch); join_none
end
wait fork; // BUG: includes the forever monitor
$finish;
endwait fork; line.wait fork blocks until every child of the current process completes. The forever monitor() is a child of this initial block and never completes — wait fork blocks indefinitely.task automatic run_channels(int n); for ...; wait fork; endtask. The wait fork inside the task waits only for the task call's 8 children; the outer-scope monitor is not in its scope.agent.stop() returns but threads keep running for many more cycles
STOP-WITHOUT-DISABLEclass ApbAgent;
task run();
fork
forever drive_next();
forever sample_response();
join_none
endtask
task stop();
// empty — author thought "running = 0" would suffice
endtask
endclass
ApbAgent a = new();
initial begin
fork a.run(); join_none
do_test();
a.stop(); // returns immediately; threads keep running
$finish;
enda.stop() the log keeps printing [AGENT] drive_next messages for tens of cycles. Subsequent tests collide with the still-active driver.stop() did nothing — the threads were anonymous and stop() had no way to kill them. The threads outlived the stop() call until $finish finally took them out.begin : drive_blk forever ... end) and disable drive_blk; / disable mon_blk; inside stop(). The named-block disable terminates exactly the agent's two threads. Pattern documented in §8.3.Cross-simulator timeout behaviour: VCS clean, Questa fails
UNDEFINED-DISABLE-SCOPEfork
begin
inner_task(); // inner_task spawns its own fork-join_none
disable fork; // ambiguous: includes inner_task's threads?
end
join_nonedisable fork recurses into nested forks within the same process — but whether inner_task's threads are in the same process depends on whether inner_task was declared task automatic (separate process) or plain task (potentially still in the caller's process for some constructs). Different simulators interpret edge cases differently.task automatic. Then the inner task is unambiguously a separate process; its forks have their own scope; the outer disable fork does not reach them. The disable-fork scope rules become deterministic.Reset injector won't stop at end of test
UNREACHABLE-DISABLEinitial begin
fork
begin : reset_injector
forever begin
wait_random_interval();
pulse_reset();
end
end
join_none
run_test();
$finish; // BUG: never reaches a disable for the injector
end
// $finish kills the injector — but does it kill the in-flight pulse_reset()?$finish boundary and immediately sees stale reset state — the previous test's reset injector was mid-pulse when $finish killed it; some signals were left in an intermediate state.$finish is a guillotine, not a clean shutdown. The reset injector's pulse_reset() was mid-execution when $finish ran; signals it had asserted weren't deasserted; the next test inherits the corrupted state.$finish, call disable reset_injector; to terminate the injector at a known point. Better: add a drain step inside the injector that respects a running flag and exits cleanly: forever begin if (!running) break; wait_random_interval(); pulse_reset(); end. Combined with disable reset_injector for the hard stop, the test cleans up deterministically.12. Interview Insights — What Interviewers Actually Probe
disable fork kills all threads that are children of the calling process — every thread spawned by any fork statement executed by the current process that has not yet completed. The scope is per-calling-process, not per-fork-statement: if the same initial block spawned five separate fork blocks, all five sets of threads are in scope. To limit scope, wrap the fork in a task automatic; the disable inside the task affects only the task call's children.
13. Exercises
1. Design — reusable timeout task (Foundation)
Write a task automatic run_with_event_timeout(ref event done_ev, input int max_cycles, ref bit timed_out) that races @done_ev against repeat(max_cycles) @(posedge clk), sets timed_out correctly, and uses disable fork safely. Confirm the implementation doesn't kill any threads spawned by the caller.
2. Debug — the disappearing monitor (Intermediate)
A teammate's test passes, but coverage shows zero APB transactions for the last 100 cycles of the drain window. The testbench has fork forever monitor_apb(); join_none in the initial block, followed by a fork do_work(); repeat(500) @(posedge clk); join_any disable fork;. Identify the bug; write the one-line architectural change that fixes it.
3. Code review — the silent agent (Intermediate)
An ApbAgent class has task run(); fork forever drive(); forever monitor(); join_none endtask and task stop(); /* empty */ endtask. Identify the bugs in stop(), propose the canonical fix using named blocks + disable, and explain why disable fork inside stop() would NOT be a correct alternative.
4. Trade-off — disable fork vs disable <block_name> (Advanced)
A junior teammate argues: "always use disable fork — it's simpler and shorter. The task wrapper handles scope." Argue the case against the blanket rule. Construct a real-world scenario (multi-agent testbench) where the agent must stop its own threads without affecting other agents' threads in the same task call — and show why disable <block_name> is required there.
14. Summary
disable fork kills all child threads of the calling process. wait fork blocks until they all finish. Both operate on the process scope, not on a specific fork statement — and the calling process accumulates children from every fork it ever executed. The discipline that makes both safe is wrapping every lifecycle-controlled fork inside a task automatic: the task creates a new process boundary, and disable fork / wait fork inside the task scope only the task call's children.
Defaults to memorise. fork-join_any + disable fork is the timeout idiom — always inside a task. fork-join_none loop + wait fork is the variable-N barrier — always inside a task. disable <block_name> is surgical: name long-lived threads so you can stop them individually without nuking the whole scope. Never call wait fork when any child is a forever loop. Persistent monitors live in the outer scope, never inside the task that runs the lifecycle-controlled work.
This completes the fork-lifecycle controls. With the three fork-join* variants (14.1, 14.2, 14.3) and disable / wait fork, you have every primitive Module 14 covers except the explicit process class — which exposes thread state and gives even finer-grained control.
Next up: 14.5 — The process Class — the SystemVerilog mechanism for capturing a thread handle, querying its state (FINISHED / RUNNING / WAITING / SUSPENDED / KILLED), and suspending / resuming / killing individual threads via the handle. The final tool in the fork-lifecycle toolbox.