SystemVerilog · Module 14
fork-join_any
SystemVerilog fork-join_any — the variant where the parent resumes when any one thread finishes. The basis for the canonical timeout pattern, watchdog timers, first-responder races, and clock-plus-finite-init startup. The one rule juniors miss: surviving threads do not stop automatically — disable fork.
Module 14 · Page 14.2
fork-join_any is the variant of fork where the parent resumes as soon as the first thread finishes — the rest keep running in the background. This is the IPC tool for timeout patterns, watchdog timers, and first-responder races. The one critical rule juniors miss: the surviving threads do not stop just because the parent moved on. They must be explicitly killed with disable fork. The combination fork ... join_any + disable fork is the canonical timeout idiom in SystemVerilog.
1. Engineering Problem — Why fork-join_any Exists
Every real test needs at least three concurrency patterns that fork-join (the previous lesson) cannot express:
- A bounded operation. "Wait for the DUT to assert
done— but only for at most 100 cycles." If the DUT hangs, the test must fail fast with a clear timeout message, not hang silently until the regression watchdog kills the simulation hours later. - A first-responder race. "Watch for
ackanderrorsimultaneously — react to whichever arrives first." Withfork-joinyou would have to wait for both, then check which one's flag was set — wasting simulation time when the second signal may never come. - A startup sequence with a forever clock. "Generate the clock forever; in parallel, do the finite reset + init sequence; then proceed to the test."
fork-joinblocks the parent forever because the clock thread never ends.
fork-join_any is the IEEE 1800 answer: spawn all threads, but unblock the parent as soon as any one finishes. The remaining threads continue running unless explicitly killed.
2. Mental Model — Race to the Finish Line
The picture every engineer carries:
fork-join_anyis a race. Every thread sprints from theforkbar at the same instant. The parent waits at thejoin_anybar — but unlikejoin, the parent crosses the moment the first runner crosses. The other runners are still on the track; they keep going unless someone callsdisable forkto pull them off.
Three invariants this picture preserves:
- First past the post wins. The parent resumes in the Active region of the time slot when the first thread completes. "First" is by
$time; ties (multiple threads completing at the same time slot) are resolved by simulator scheduling and must not be relied on. - The losers keep running. Threads that have not finished at
join_anyare now detached — they continue executing until they naturally complete, hit a blocking statement, or are killed bydisable fork/disable <named_block>. disable forkis almost mandatory. Afork-join_anywithout a follow-updisable forkis usually a bug. The one important exception (§8.3) is when a surviving thread is intentionally infinite — a clock generator, a continuous monitor — that you want to keep running.
3. Visual Explanation — The join_any Bar
The figure captures the timeout pattern: a useful work thread races a fixed-cycle watchdog and an error-monitor. Whichever finishes first triggers join_any, the parent inspects which thread won (via a flag set inside each thread), and disable fork kills the rest. The JOIN_ANY variant is the warning-amber sibling of join and join_none precisely because of this gotcha-prone "losers keep running" behaviour.
4. Syntax & Semantics — fork, join_any, the Three-Variant Comparison
4.1 The basic form
// ── Syntax ────────────────────────────────────────────────────
fork
thread_1;
thread_2;
thread_3;
join_any
// parent resumes HERE after the FIRST thread completes
// thread_2 and thread_3 (if not finished) continue running in background
// ── Concrete example ──────────────────────────────────────────
$display("[MAIN] Starting 3 threads at t=%0t", $time);
fork
begin
#10;
$display("[A] finished at t=%0t (this one triggers join_any)", $time);
end
begin
#30;
$display("[B] finished at t=%0t (still ran after parent resumed)", $time);
end
begin
#20;
$display("[C] finished at t=%0t (still ran after parent resumed)", $time);
end
join_any
$display("[MAIN] Resumed at t=%0t — first thread done", $time);
// Output:
// [MAIN] Starting 3 threads at t=0
// [A] finished at t=10 ← triggers join_any
// [MAIN] Resumed at t=10 ← parent unblocks
// [C] finished at t=20 ← ran in background after parent resumed
// [B] finished at t=30 ← ran in background after parent resumed4.2 The three join variants — side-by-side
| Question | fork-join | fork-join_any | fork-join_none |
|---|---|---|---|
| Parent resumes when? | All threads done | First thread done | Immediately (no wait) |
| Remaining threads? | None — all finished | Keep running in background | All running in background |
| Works with forever loops? | No — parent never unblocks | Yes — finite thread triggers unblock | Yes — parent never waits |
Needs disable fork? | No — all threads already done | Usually yes — to kill remaining threads | Usually yes — all threads are background |
| Lesson | 14.1 — fork-join | this page (14.2) | 14.3 — fork-join_none |
5. Simulation View — When the Parent Unblocks, Where the Losers Go
At the fork keyword, the simulator spawns every statement inside the block as a separate process — identical to fork-join. The difference is what happens at join_any:
- Each spawned thread runs to completion in its own time. The first to complete records its completion in the simulator's scheduling tables.
- The parent is suspended at
join_anyuntil the first completion is observed. - The parent resumes in the Active region of the time slot the first thread completed in. Other threads that have not yet completed remain in the scheduler — they continue to be activated by their own delays, event controls, or external signals.
disable forkissued from the parent terminates every thread spawned by the parent (including ones spawned via nested forks).disable <named_block>terminates exactly the named thread.
5.1 Simultaneous completions — race-resolution discipline
If two or more threads complete at exactly the same simulation time step, join_any unblocks the parent at that time slot — but the order in which "first" is decided is not specified by IEEE 1800. Robust code does not depend on which thread the simulator picks; it inspects per-thread flags set inside each thread before exit.
// ── Two threads finish at the same time ───────────────────────
bit a_done = 0, b_done = 0;
fork
begin #100; a_done = 1; end // both finish at t=100
begin #100; b_done = 1; end
join_any
disable fork;
// ❌ WRONG: only checks one flag — assumes mutual exclusion
if (a_done) $display("A triggered");
else if (b_done) $display("B triggered");
// ✅ CORRECT: check all flags — any might be set, possibly both
if (a_done) $display("A was done");
if (b_done) $display("B was done");A more robust pattern uses a shared winner variable that records which thread crossed the line first by inspecting the variable's not-yet-written state.
string winner = "";
fork
begin
do_work();
if (winner == "") winner = "work"; // first one sets it
end
begin
repeat(1000) @(posedge clk);
if (winner == "") winner = "timeout"; // loses if work already set it
end
join_any
disable fork;
$display("[TB] First to complete: %s", winner);6. Waveform — Parent Unblocks at First Completion, Losers Run On
The waveform below shows the three-thread example from §4.1. Threads A, B, C all start at cycle 0. A finishes at cycle 2 → join_any unblocks the parent immediately. C and B keep running in the background — C finishes at cycle 4, B at cycle 6 — but the parent has already moved on and is running its post-fork work concurrently with them.
Figure — fork-join_any: parent resumes when A finishes; C and B keep running in background
12 cyclesThe amber marker at cycle 6 is the warning: B was still alive and writing to signals / variables / mailboxes the whole time the parent thought the fork was "over." If B's body contains a $fatal watchdog, the test fails three cycles after it actually passed.
7. Synthesis — Not Applicable
fork-join_any 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 "first-responder among N signals" is an OR-tree of assign first_done = a_done | b_done | c_done; — combinational logic that asserts when any input asserts. That is built into RTL semantics, not a procedural primitive.
8. Verification View — The Canonical Patterns
8.1 The timeout pattern — the most important use case
// ── Pattern: task must complete within 100 clock cycles ───────
bit task_done = 0;
fork
// ── Thread 1: the real work
begin : work
do_long_operation();
task_done = 1; // mark success BEFORE the thread exits
end
// ── Thread 2: the timeout watchdog
begin : watchdog
repeat (100) @(posedge clk);
end
join_any
disable fork; // kill whichever thread didn't win
// Check which one triggered join_any
if (task_done)
$display("[TB] Operation completed in time at %0t", $time);
else
$fatal(1, "[TB] TIMEOUT: operation did not complete within 100 cycles");Critical: the success flag (task_done) must be set inside the work thread before it exits, not after the join_any. By the time the parent reaches the post-fork code, the work thread is already terminated and any assignment to task_done outside it would race with disable fork.
8.2 The forgotten-disable-fork bug — wrong / right
fork
begin
do_work();
done = 1;
end
begin
repeat(100) @(posedge clk);
$fatal(1, "Timeout!");
end
join_any
// work finished first — done=1, parent
// resumes — BUT watchdog is STILL
// counting in the background!
// 100 cycles later: $fatal fires
// even though the test ALREADY PASSED.False timeout fatal — test aborts incorrectly with confusing log.
fork
begin : work
do_work();
done = 1;
end
begin : watchdog
repeat(100) @(posedge clk);
$fatal(1, "Timeout");
end
join_any
disable fork; // kills whichever
// thread is still runningIf work finished first: watchdog killed cleanly. If watchdog fired first: $fatal runs, work killed.
8.3 The legitimate exception — clock-plus-finite-startup
fork ... join_any paired with disable fork is the rule. The important exception is when one thread is intentionally infinite and should keep running after the parent unblocks — most commonly a clock generator or a continuous monitor.
// ── Clock runs forever; reset + init are finite ───────────────
// fork-join would block forever (clock never ends).
// fork-join_any unblocks when the FINITE thread (reset+init) finishes.
fork
// ── Thread 1: clock generator (forever — never terminates)
begin : clk_gen
clk = 0;
forever #5 clk = ~clk;
end
// ── Thread 2: reset + init sequence (finite)
begin : startup
rst_n = 0;
repeat(4) @(posedge clk);
rst_n = 1;
repeat(2) @(posedge clk);
init_dut();
$display("[TB] Startup complete at %0t", $time);
end
join_any
// Do NOT disable fork here — the clock generator must keep running!
// Only the 'startup' thread exits; the parent continues with the clock still active.
$display("[TB] Proceeding to test with clock running");
run_test();8.4 Global test watchdog
// ── Test body races against a global watchdog ─────────────────
initial begin
fork
// ── The actual test
begin : test_body
reset_and_init();
run_stimulus(500); // 500 transactions
check_results();
$display("[TB] TEST PASSED at %0t", $time);
end
// ── Global watchdog: 1 million cycles max
begin : watchdog
repeat (1_000_000) @(posedge clk);
$fatal(1, "[WDG] Test timed out after 1M cycles at %0t", $time);
end
join_any
disable fork; // kill watchdog if test passed, or kill test if watchdog fired
$finish;
end8.5 First-responder race
bit got_ack = 0, got_err = 0;
fork
begin // thread 1: wait for ACK
@(posedge clk iff ack);
got_ack = 1;
end
begin // thread 2: wait for ERROR
@(posedge clk iff error_flag);
got_err = 1;
end
join_any
disable fork;
if (got_ack) $display("[TB] ACK received — success");
else if (got_err) $error ("[TB] ERROR flag seen — protocol violation");
// ── Wait for any of N events ──────────────────────────────────
event ch_done [4];
int first_ch = -1;
fork
begin @ch_done[0]; if (first_ch == -1) first_ch = 0; end
begin @ch_done[1]; if (first_ch == -1) first_ch = 1; end
begin @ch_done[2]; if (first_ch == -1) first_ch = 2; end
begin @ch_done[3]; if (first_ch == -1) first_ch = 3; end
join_any
disable fork;
$display("[TB] Channel %0d completed first", first_ch);Note the if (first_ch == -1) guard inside each thread — protects against the simultaneous-completion race from §5.1 where two channels might finish in the same time slot and both try to write first_ch.
8.6 Phase completion with max-cycles cap
parameter MAX_CYCLES = 50_000;
bit stimulus_done = 0;
fork
begin
send_all_transactions();
stimulus_done = 1;
end
begin
repeat (MAX_CYCLES) @(posedge clk);
end
join_any
disable fork;
if (!stimulus_done)
$warning("[TB] Max cycles reached — %0d of %0d txns sent", sent_count, total_txns);
// Drain: let last transactions propagate before scoreboard check
repeat (100) @(posedge clk);
check_scoreboard();
$finish;9. Industry Usage — Where fork-join_any Lands in Real Verification
- UVM phase timeouts — every
uvm_testships with auvm_phase::set_timeout()mechanism that internally usesfork-join_anywith the user'srun_phase()racing against a deadline. The 100% standard idiom. - UVM
uvm_event::wait_trigger_data()+ timeout — common pattern: wait for an event, fall back to a timeout$fatalif it does not fire within a bound. - AXI / DDR / PCIe response-or-error races — every protocol VIP's monitor uses a
fork-join_anyto wait for either the expected response cycle or an out-of-spec response (parity error, retry, abort). - Regression-level test watchdogs — every test in every shop's regression has an outer
fork-join_anywith a global cycle cap; without it, hung simulations bring the LSF grid to its knees. - Reset bring-up tests — clock generator (forever) + finite reset / init sequence, exactly the §8.3 pattern. The most common
join_anyuse that legitimately omitsdisable fork. - OCV / SDF / scan-mode tests — multi-mode tests where one mode terminates the other in a race-to-completion pattern.
10. Design Review Notes — What a Senior Will Flag
| Pattern in the diff | What review will say |
|---|---|
fork ... join_any with no following disable fork and the threads are not intentionally infinite | "Surviving threads keep running. Whatever they do — fire a $fatal, write to a shared variable, push to a mailbox — will happen after the fork 'ended.' Add disable fork immediately after join_any, or document why the survivors must persist." |
fork ... join_any with $fatal inside the watchdog branch and no disable fork | "Classic false-watchdog-fatal bug. If the work thread wins, the watchdog keeps counting and fires $fatal later. Always pair join_any + disable fork when any thread has a $fatal / $error." |
Flag set after the join_any, not inside the thread | "Race — by the time the parent reaches the post-fork code, the thread may already be dead (killed by disable fork). Set the flag inside the thread before the thread's last statement." |
else if chain checking which thread won (without considering simultaneous completion) | "Two flags may both be set if threads complete in the same time slot. Use independent if checks, not mutually-exclusive else if." |
disable fork after a join_any that has a clock-generator branch | "You just killed the clock. Every clocked process in the design is now frozen. Either don't disable fork, or use named-block disable <work_block_only>." |
Anonymous fork branches in a join_any block | "Name the branches — debug logs show anonymous branches as process_N. Named blocks also enable selective disable <name> instead of the nuclear disable fork." |
fork ... join_any with one thread that contains @event (not wait(event.triggered)) | "If the trigger may fire at the same time the thread arms, the @ will miss it and the race becomes scheduling-dependent. Use wait(event.triggered) inside join_any branches for any one-shot event." |
wait fork; after a join_any block in the same parent | "wait fork blocks until every previously-spawned thread completes — including the survivors of the join_any. If you wanted that you should have used fork-join in the first place. Probably a leftover; remove." |
The single highest-value rule: fork-join_any + disable fork is one idiom, not two. Reviewing diffs for join_any without a following disable fork (with no intentional-infinite-thread comment) catches 80% of the bugs this construct produces.
11. Debugging Guide — Real Failures, Real Fixes
Test passes but $fatal fires 100 cycles later
FALSE-WATCHDOG-FATALfork
begin do_work(); done = 1; end
begin
repeat(100) @(posedge clk);
$fatal(1, "Timeout");
end
join_any
// no disable fork$fatal: "Timeout" fires from the watchdog. The test result is FAIL despite the test having succeeded. Regression report misclassifies the test.disable fork. When the work thread wins, the watchdog timer keeps counting in the background. After 100 cycles its $fatal runs, aborting the simulation. The "test passed" log line was correct — the fail is purely the orphaned watchdog.disable fork; immediately after join_any. This is the canonical timeout idiom: fork {work} {watchdog} join_any disable fork. Lint rules at every shop flag join_any without an immediately-following disable fork (with comment-allowed exceptions).Reset bring-up test hangs at t=0 forever
JOIN-WITH-FOREVERfork
begin
clk = 0;
forever #5 clk = ~clk;
end
begin
reset_and_init();
end
join // ← wrong: forever thread never ends$finish never executes. Looks like a DUT hang; it isn't — the test never reached the DUT.fork-join blocks the parent until every thread completes. The clock-generator branch is forever — it never completes. The parent waits indefinitely.join with join_any. The finite reset+init thread triggers join_any; the clock keeps running (intentional infinite); do not add disable fork. This is the canonical clock-plus-startup pattern.Watchdog kills the clock; every clocked assertion fails afterward
DISABLE-FORK-KILLED-CLOCKfork
begin clk = 0; forever #5 clk = ~clk; end
begin reset_and_init(); end
join_any
disable fork; // BUG: kills the clock!
run_test(); // every @(posedge clk) hangs@(posedge clk) blocks forever. Simulator timeout fires. Verdi shows the clock frozen at its last value after the startup sequence.disable fork after join_any killed every thread spawned by the parent — including the intentional-infinite clock generator. The clock signal stops toggling; all clocked processes freeze.disable fork. The clock generator is intended to keep running. If you do need to kill only the startup thread (you don't, since it already finished), use disable startup_block_name with the startup block named.First-responder code thinks neither thread won
FLAG-SET-AFTER-EXITbit a = 0, b = 0;
fork
begin wait_for_a(); end
begin wait_for_b(); end
join_any
disable fork;
a = 1; // BUG: set AFTER fork, on the parent
b = 0;
if (!a && !b) $error("neither?");"neither?" even when one of the wait conditions clearly fired. The flag assignments do not match which thread won.disable fork has already killed any pending thread state. The parent has no way to know which condition was actually observed.begin wait_for_a(); a = 1; end. The flag is now set in the thread that observed the condition, before the thread terminates, and survives disable fork.Two parallel channels both set winner; race-detect logs wrong winner
SIMULTANEOUS-COMPLETIONbit a_done = 0, b_done = 0;
fork
begin #100; a_done = 1; end
begin #100; b_done = 1; end
join_any
disable fork;
if (a_done) $display("A won");
else if (b_done) $display("B won");else if implies mutual exclusion, but both flags are actually set when the threads complete in the same time slot.$time, both reach their flag-setting statement before join_any resolves. Both a_done and b_done are 1. The else if chain only logs the first one checked — misleadingly suggesting only one fired.if statements, or (b) use a shared winner variable that is set inside the first thread to reach it, guarded by if (winner == ""). The latter gives a deterministic "first crosser" semantic when the simulator's pick is stable, and a single chosen winner when it isn't.12. Interview Insights — What Interviewers Actually Probe
fork-join_any spawns all threads simultaneously, identical to fork-join. The parent suspends at join_any and resumes in the Active region of the time slot when the first thread completes. The remaining threads continue running as background processes — they are not stopped, paused, or cancelled. They will execute until they naturally complete, hit a blocking statement, or are explicitly killed with disable fork or disable <named_block>.
13. Exercises
1. Design — bounded register write (Foundation)
Write a write_with_timeout(int addr, data, int max_cycles) task that drives a register write and returns success / fail based on whether ack asserts within max_cycles. Use fork-join_any + disable fork and set the success flag inside the work thread before it exits.
2. Debug — the false watchdog fatal (Intermediate)
A teammate's test consistently logs "test passed" then 50 cycles later prints $fatal: "TIMEOUT" and the regression marks the test as failed. The fork is fork begin do_work(); done=1; end begin repeat(50) @(posedge clk); $fatal(1, "TIMEOUT"); end join_any. Identify the bug and write the one-line fix.
3. Code review — the clock-killer (Intermediate)
A teammate's startup sequence is fork begin forever #5 clk = ~clk; end begin reset_and_init(); end join_any disable fork; followed by run_test(). The startup log looks correct, then every subsequent @(posedge clk) hangs. Identify the bug, predict the symptom in the simulator log, and write the fix.
4. Trade-off — fork-join_any + disable fork vs fork-join_none + wait fork (Advanced)
A teammate argues these two patterns are equivalent — both spawn N threads and don't wait for all of them. Argue the case against. Construct a scenario where fork-join_any + disable fork gives the correct semantics and fork-join_none + wait fork does not (or vice versa). Explain the underlying scheduler difference.
14. Summary
fork-join_any resumes the parent the moment the first thread completes; the rest keep running in the background. The single discipline you must internalise is fork ... join_any + disable fork as one idiom — without disable fork, surviving threads keep firing assertions, writing variables, and ticking watchdogs. The one important exception is when a surviving thread is intentionally infinite (clock generator); then omit disable fork deliberately.
Defaults to memorise. fork {work} {watchdog} join_any disable fork; is the canonical timeout idiom. Set success flags inside the work thread, before the thread's last statement — not on the parent after the fork. Check all per-thread flags with independent if checks, not else if chains. Name every branch you might want to debug or selectively disable.
Next up: 14.3 — fork-join_none — the variant where the parent does not wait at all. The basis for daemon threads, fire-and-continue stimulus, and the wait fork; deferred-join pattern.