SystemVerilog fork join, join_any & join_none Guide
A testbench rarely does one thing at a time. While the driver pushes a packet in, a monitor watches the output, a scoreboard checks results, and a watchdog counts down waiting for a response. Verilog’s plain begin...end runs top to bottom, so it cannot express “do these at the same time”. That is exactly the gap the SystemVerilog fork join construct fills: it spawns several statements as independent, concurrent processes. This guide walks through the three ways a fork can finish (join, join_any, join_none) and the two companion keywords every verification engineer needs, wait fork and disable fork, with examples you can compile and run yourself.
The three join flavours at a glance
Every fork block starts its child processes immediately. The keyword that closes the block decides when the parent process is allowed to move past it.
| Keyword | Parent resumes when… | Typical use |
|---|---|---|
join | all child processes have finished | run tasks in parallel, then sync up |
join_any | any one child finishes | race, first-response-wins, timeouts |
join_none | immediately (children keep running) | fire-and-forget background threads |
fork…join: wait for everyone
With a plain join, the parent blocks until the slowest child is done. Below, three processes are launched together at time 0 with different delays. Notice the children finish in delay order (10, 20, 30 ns), and the line after join only prints once the 30 ns branch completes. That is why the total elapsed time is 30 ns, not 10+20+30 = 60 ns: they truly overlap.
module tb;
initial begin
$display("[%0t] main: entering fork-join", $time);
fork
begin : P1
#10 $display("[%0t] P1 done (10ns job)", $time);
end
begin : P2
#30 $display("[%0t] P2 done (30ns job)", $time);
end
begin : P3
#20 $display("[%0t] P3 done (20ns job)", $time);
end
join
$display("[%0t] main: all three finished, continuing", $time);
#5 $display("[%0t] main: done", $time);
$finish;
end
endmodule[0] main: entering fork-join [10] P1 done (10ns job) [20] P3 done (20ns job) [30] P2 done (30ns job) [30] main: all three finished, continuing [35] main: done /var/folders/l0/gs5hnkm56g54bmxg32r6nnwr0000gn/T/tmpzujyceq4/fork_join.sv:17: $finish called at 35 (1s)
Each branch is wrapped in a named begin...end (P1, P2, P3). Naming is optional but strongly recommended: simulators use these labels in waveform and process views, which makes debugging a crowded fork far easier.
fork…join_any: wait for the first
join_any unblocks the parent the moment one child completes. The other children do not die automatically, they keep running in the background. This is the pattern behind “send a request to two servers, use whichever answers first”. Here SERVER_B (15 ns) wins, so the parent continues at 15 ns while SERVER_A is still pending.
module tb;
initial begin
$display("[%0t] firing two redundant requests, keeping the fastest", $time);
fork
begin : SERVER_A
#40 $display("[%0t] SERVER_A replied", $time);
end
begin : SERVER_B
#15 $display("[%0t] SERVER_B replied", $time);
end
join_any
$display("[%0t] first reply is in, abandoning the slower one", $time);
disable fork;
$display("[%0t] cleaned up, moving on", $time);
#50 $display("[%0t] end of test (SERVER_A never printed)", $time);
$finish;
end
endmodule[0] firing two redundant requests, keeping the fastest [15] SERVER_B replied [15] first reply is in, abandoning the slower one [15] cleaned up, moving on [65] end of test (SERVER_A never printed) /var/folders/l0/gs5hnkm56g54bmxg32r6nnwr0000gn/T/tmphrdwxv5a/join_any.sv:16: $finish called at 65 (1s)
The crucial line is disable fork; right after join_any. Without it, SERVER_A would still fire at 40 ns. Because we called disable fork, the losing branch is terminated and its message never prints, as the log confirms.
fork…join_none: spawn and keep going
join_none is fire-and-forget: the parent does not wait at all. The child processes are scheduled, but the statement after the fork executes in the same time step. This is how you launch persistent background threads, a monitor, a clock-checker, a coverage sampler, and immediately get on with stimulus.
module tb;
initial begin
$display("[%0t] spawning 3 background workers with join_none", $time);
fork
#10 $display("[%0t] worker 1 finished (10ns)", $time);
join_none
fork
#30 $display("[%0t] worker 2 finished (30ns)", $time);
join_none
fork
#20 $display("[%0t] worker 3 finished (20ns)", $time);
join_none
$display("[%0t] all workers launched; main keeps running immediately", $time);
#5 $display("[%0t] main did a quick 5ns chore while workers run", $time);
wait fork;
$display("[%0t] wait fork returned: every worker is done", $time);
$finish;
end
endmodule[0] spawning 3 background workers with join_none [0] all workers launched; main keeps running immediately [5] main did a quick 5ns chore while workers run [10] worker 1 finished (10ns) [20] worker 3 finished (20ns) [30] worker 2 finished (30ns) [30] wait fork returned: every worker is done /var/folders/l0/gs5hnkm56g54bmxg32r6nnwr0000gn/T/tmpi_y7l8zc/join_none.sv:17: $finish called at 30 (1s)
Look at the log: “all workers launched” and the 5 ns chore both print before any worker, proving the parent never blocked. Then wait fork; holds the parent until every process it spawned has drained, so the final message lands at 30 ns.
wait fork vs disable fork
These two keywords manage the processes a fork left running, and verification code leans on them constantly:
wait fork;blocks the current process until all its descendant processes (spawned by any earlierfork, usuallyjoin_noneorjoin_any) have completed. It is the clean way to say “let my background threads finish before I end the test”.disable fork;immediately kills all descendant processes of the current process. It is how you cancel the losers after a race, or tear down background monitors at end-of-test.
A classic real use is a timeout guard. Race the real job against a timer with join_any, then disable fork to kill whichever branch lost. In case 1 the 20 ns job beats the 50 ns timer; in case 2 the 80 ns job is too slow, so the timer fires first and the job branch is killed before it can print.
module tb;
initial begin
// Case 1: the job (20ns) beats the 50ns timeout.
$display("[%0t] case 1: 20ns job under a 50ns timeout", $time);
fork
begin : WORK_1
#20 $display("[%0t] job finished", $time);
end
begin : TIMEOUT_1
#50 $display("[%0t] TIMEOUT fired", $time);
end
join_any
disable fork; // kill whichever branch lost the race
// Case 2: the job (80ns) is too slow; the 50ns timeout wins.
$display("[%0t] case 2: 80ns job under a 50ns timeout", $time);
fork
begin : WORK_2
#80 $display("[%0t] job finished", $time);
end
begin : TIMEOUT_2
#50 $display("[%0t] TIMEOUT fired", $time);
end
join_any
disable fork;
$display("[%0t] done", $time);
$finish;
end
endmodule[0] case 1: 20ns job under a 50ns timeout [20] job finished [20] case 2: 80ns job under a 50ns timeout [70] TIMEOUT fired [70] done /var/folders/l0/gs5hnkm56g54bmxg32r6nnwr0000gn/T/tmposwlqzxh/timeout.sv:28: $finish called at 70 (1s)
Why these examples need timing
Every example uses # delays, and that is not incidental. Concurrency only becomes observable because the processes take time: the #10, #30 delays are what make one branch finish before another and let you watch the scheduler interleave them. A fork whose branches had zero delay would complete in the same instant, and join versus join_any would look identical. When you compile these, enable timing (standard event simulators do this by default; with Verilator you need its timing mode). The $time stamps in each log are your proof of what actually ran in parallel.
Common pitfalls
- Capturing a loop variable. If you spawn processes inside a
forloop withjoin_none, every child shares the same loop index unless you copy it into anautomaticvariable inside the loop body. Forget that, and all children see the final value. This is the single most common fork bug for beginners. - Forgetting to reap background threads. A
join_nonethread that is still alive when the test ends can either hang the simulation or hide a real failure. Pair every long-livedfork...join_nonewith a matchingwait forkor an explicitdisable fork. - disable fork is broad. It kills all descendants of the current process, not just the most recent
fork. If you need to cancel only one process, wrap thatforkinside its own named block or anautomatictask so thedisable forkinside it is scoped to just those children. - join_any leaves losers running. As the second example showed,
join_anyon its own does not stop the other branches. Nearly always you wantdisable forkimmediately after it.
Master these five keywords, fork, join, join_any, join_none, plus wait fork and disable fork, and you can model any concurrency a testbench demands: parallel drivers, timeout checkers, background monitors, and first-response races.
Quick check
After fork...join, when does the parent process continue?
Only after every child process inside the fork has finished. The parent waits for the slowest branch, which is why the first example takes 30 ns (the longest delay), not the sum of all delays.
What is the difference between join_any and join_none?
join_any blocks the parent until the first child finishes, then resumes. join_none does not block at all, the parent continues in the same time step while all children run in the background.
Why is disable fork usually written right after join_any?
Because join_any only waits for the first child, the remaining children keep running. disable fork terminates those leftover processes so the losers of the race (for example the unused server or the timeout branch) do not keep executing.
You spawn N processes in a for loop with join_none and each prints the loop index, but they all print the same number. Why?
The children capture the loop variable by reference and all read its final value. Copy the index into an automatic variable declared inside the loop body so each process gets its own private snapshot.
Share your question in comments or talk to our mentor team for batch guidance.
Compile and simulate this in the browser — no install. Edit the code and re-run instantly.
Learn Verilog with mentors, labs and placement support
This free series is maintained by the ChipXpert training team in Hyderabad & Bengaluru. Go deeper with instructor-led courses on real EDA tools.
Explore VLSI Courses