Part 2 · OOP for Verification · Intermediate
fork...join, join_any, join_none
Blocking semantics of all three variants, the classic loop-variable capture bug, and spawning monitors.
Three variants, three blocking contracts
fork...join spawns each statement in the block as a concurrent child process. The closing keyword decides when the parent resumes: join waits for ALL children; join_any waits for the FIRST child to finish (the others keep running); join_none does not wait at all — and crucially, the children do not even start executing until the parent next blocks or finishes its timestep. That deferred start is a scheduling detail that matters for the loop bug below.
FORK VARIANT SEMANTICS (P = parent, A/B/C = children; A is shortest)
fork ... join fork ... join_any fork ... join_none
───────────── ───────────────── ──────────────────
P ──┬─► A ──┐ P ──┬─► A ──┐ P ──┬─ A (starts when
├─► B ────┐ ├─► B ──│───► ├─ B parent next
└─► C ──────┐ └─► C ──│───► └─ C blocks)
│ │ │
P resumes ◄─────┘ P resumes ◄─┘ P resumes IMMEDIATELY
(after A,B,C all end) (after FIRST end; (children run in
B, C still running) background)
Common pairings:
join → lockstep phases (drive + collect response together)
join_any → race a task against a timeout, then disable fork
join_none → spawn daemon monitors/scoreboards that run foreverThe classic loop-variable capture bug
Spawn one process per element of a loop and you hit the most famous concurrency bug in SystemVerilog. The child processes do not start until the parent blocks — by which time the loop has finished and the shared loop variable holds its final value . All children then read that same final value. The fix is to declare an automatic variable inside the fork (or inside the loop body) so each child captures its own private copy.
module fork_loop_bug;
initial begin
// BUG: all three children print i = 3
for (int i = 0; i < 3; i++) begin
fork
$display("BUGGY child sees i = %0d", i); // i shared with parent
join_none
end
#0; // parent blocks → children finally run; loop already ended, i == 3
// FIX: automatic copy captured per iteration
for (int i = 0; i < 3; i++) begin
fork
automatic int k = i; // evaluated NOW, one private k per child
$display("FIXED child sees k = %0d", k); // 0, 1, 2
join_none
end
#0;
end
endmoduleWhy the fix works: the automatic int k = i; declaration-with-initializer inside the fork block is evaluated at spawn time, in the parent's context, once per loop iteration — so each child gets a distinct k frozen at the right value, while the deferred child body reads only its own k. Note that in a class task all locals are automatic by default, but the loop variable of the for statement is still shared across the spawned children, so the explicit per-iteration copy is required there too.
Spawning monitors and racing timeouts
class env;
monitor mon[2];
scoreboard sb;
task run_all();
// Daemons: forever-loops that must NOT block the test flow
foreach (mon[i]) begin
automatic int idx = i; // capture fix, again
fork
mon[idx].run(); // forever loop inside
join_none
end
fork
sb.run(); // also a forever loop
join_none
// parent returns immediately; daemons run in background
endtask
task run_with_timeout(time limit);
fork
main_sequence(); // the real work
begin // the watchdog
#limit;
$fatal(1, "TIMEOUT after %0t", limit);
end
join_any // whichever finishes first wins
disable fork; // kill the loser (see next lesson)
endtask
endclassInterview angle
"What prints?" with a fork in a loop is a staple screening question — explain deferred child start plus shared variable.
"join_any returned — are the other threads dead?" — no, they keep running until disabled or completed.
"When do join_none children begin?" — when the parent next blocks (delay, event, end of timestep), not at the fork statement.
Key takeaways
join waits for all, join_any for the first, join_none for none — children outlive join_any/join_none.
join_none children start only when the parent blocks; this deferral is what enables the loop bug.
Fix loop capture with an automatic variable initialized inside the fork — one frozen copy per child.
join_any plus disable fork is the standard race-a-timeout pattern; join_none spawns daemons.
Common pitfalls
fork inside a loop without an automatic copy — every child sees the loop variable's final value.
Assuming join_any killed the slower children — they continue, often corrupting later test phases.
Forgetting that a forever-loop child makes plain join wait forever — daemons need join_none.
Spawning from a static (module) context where locals are static — declare automatic explicitly.