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.

KeywordParent resumes when…Typical use
joinall child processes have finishedrun tasks in parallel, then sync up
join_anyany one child finishesrace, first-response-wins, timeouts
join_noneimmediately (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 earlier fork, usually join_none or join_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 for loop with join_none, every child shares the same loop index unless you copy it into an automatic variable 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_none thread that is still alive when the test ends can either hang the simulation or hide a real failure. Pair every long-lived fork...join_none with a matching wait fork or an explicit disable 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 that fork inside its own named block or an automatic task so the disable fork inside it is scoped to just those children.
  • join_any leaves losers running. As the second example showed, join_any on its own does not stop the other branches. Nearly always you want disable fork immediately 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.

Have questions about this topic?
Share your question in comments or talk to our mentor team for batch guidance.
▶ Try it in the ChipXpert Lab

Compile and simulate this in the browser — no install. Edit the code and re-run instantly.

Open in Verification Lab

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
Next batch starts 18th November 2026
Start your VLSI career with ChipXpert

Live online and classroom batches in Hyderabad & Bengaluru. Fill in your details and a counsellor will call you back.

  • Real EDA tools in your browser: industry-standard EDAReal EDA tools in your browser
  • Recorded sessions on the elearn portalRecorded sessions
  • Placement assistance: resume, mock interviewsPlacement assistance
  • Merit scholarship up to 60% · EMI optionsScholarship up to 60%
Hi! Ask me about courses, fees, batches or discounts. ×
BENGALURU
HYDERABAD