SystemVerilog supports parallel, concurrent threads through the fork...join construct. Multiple procedural blocks can be spawned off at the same time using fork and join, and different variations of the construct (covered later in this chapter) let the parent thread continue at different points depending on when the spawned threads finish. This article covers the plain fork...join form, which waits for every spawned thread to complete.
7 min read | Intermediate Level
What You'll Learn
- The basic
fork...joinsyntax, and what counts as a single spawned thread - How long the parent thread stays suspended at a
fork...join - How nested
fork...joinblocks resolve their timing - Why a task called concurrently from multiple threads needs to be declared
automatic
Syntax
fork
// Thread 1
// Thread 2
// ...
// Thread 3
join
A Basic fork...join Example
In the example below, three threads are forked using fork...join. The main thread stays suspended until every thread spawned by the fork completes. Any block of code within begin and end counts as a single thread — here, that's Thread2:
module tb;
initial begin
$display ("[%0t] Main Thread: Fork join going to start", $time);
fork
// Thread 1
#30 $display ("[%0t] Thread1 finished", $time);
// Thread 2
begin
#5 $display ("[%0t] Thread2 ...", $time);
#10 $display ("[%0t] Thread2 finished", $time);
end
// Thread 3
#20 $display ("[%0t] Thread3 finished", $time);
join
$display ("[%0t] Main Thread: Fork join has finished", $time);
end
endmodule
Output
ncsim> run [0] Main Thread: Fork join going to start [5] Thread2 ... [15] Thread2 finished [20] Thread3 finished [30] Thread1 finished [30] Main Thread: Fork join has finished ncsim: *W,RNQUIE: Simulation is complete.
All three threads are forked at time 0ns. Thread2 is a block of procedural code that finishes only after executing every statement inside its begin/end, so it completes at 5ns + 10ns = 15ns — the first thread to finish. Thread3 finishes at 20ns, and Thread1 takes the longest, finishing at 30ns. Since plain fork...join waits for every thread, the main thread doesn't resume until 30ns — whichever thread finishes last.
Quick Check
Q: If Thread1's delay above were changed from #30 to #8, at what time would the main thread resume after the join?
Show Answer
20ns. fork...join always waits for the slowest thread to finish — with Thread1 now finishing at 8ns, Thread2 at 15ns, and Thread3 at 20ns, the latest of the three is Thread3 at 20ns.
Where Is fork...join Used?
fork...join is a natural fit for synchronization points in a testbench — spinning up several independent stimulus generators or agents in parallel, then waiting for every one of them to finish before moving on to checking results or starting the next test phase.
Nested fork...join
A fork...join can be nested inside another fork...join.
Example #1
module tb;
initial begin
$display ("[%0t] Main Thread: Fork join going to start", $time);
fork
fork
print (20, "Thread1_0");
print (30, "Thread1_1");
join
print (10, "Thread2");
join
$display ("[%0t] Main Thread: Fork join has finished", $time);
end
// Note that this task has to be automatic
task automatic print (int _time, string t_name);
#(_time) $display ("[%0t] %s", $time, t_name);
endtask
endmodule
Output
ncsim> run [0] Main Thread: Fork join going to start [10] Thread2 [20] Thread1_0 [30] Thread1_1 [30] Main Thread: Fork join has finished ncsim: *W,RNQUIE: Simulation is complete.
The outer fork...join spawns two things: the inner fork...join block itself (which spawns Thread1_0 and Thread1_1 concurrently, finishing once both complete at 30ns) and Thread2 directly (finishing at 10ns). The outer join treats the entire inner block as one of its own threads, so it waits for the later of the two: the inner block's 30ns, versus Thread2's 10ns — landing on 30ns.
print is called concurrently from three separate threads in this example, so it must be declared automatic. Without it, every concurrent call would share a single copy of _time and t_name, and one call could silently overwrite another's arguments mid-execution — automatic gives each call its own independent copy of the task's local variables and arguments.
Example #2
module tb;
initial begin
$display ("[%0t] Main Thread: Fork join going to start", $time);
fork
fork // Thread 1
#50 $display ("[%0t] Thread1_0 ...", $time);
#70 $display ("[%0t] Thread1_1 ...", $time);
begin
#10 $display ("[%0t] Thread1_2 ...", $time);
#100 $display ("[%0t] Thread1_2 finished", $time);
end
join
// Thread 2
begin
#5 $display ("[%0t] Thread2 ...", $time);
#10 $display ("[%0t] Thread2 finished", $time);
end
// Thread 3
#20 $display ("[%0t] Thread3 finished", $time);
join
$display ("[%0t] Main Thread: Fork join has finished", $time);
end
endmodule
The main thread stays suspended until every nested fork is over, at 110ns:
Output
ncsim> run [0] Main Thread: Fork join going to start [5] Thread2 ... [10] Thread1_2 ... [15] Thread2 finished [20] Thread3 finished [50] Thread1_0 ... [70] Thread1_1 ... [110] Thread1_2 finished [110] Main Thread: Fork join has finished ncsim: *W,RNQUIE: Simulation is complete.
The inner fork (labeled Thread 1) spawns three threads of its own: one finishing at 50ns, one at 70ns, and a begin/end block finishing at 10ns + 100ns = 110ns. The inner join waits for the slowest of those three — 110ns. The outer join then waits for the slowest of its three threads: the inner block (110ns), Thread2 (15ns), and Thread3 (20ns) — landing on 110ns overall.
Common Beginner Mistakes
Mistake #1: Assuming fork...join lets the main thread continue immediately
Plain fork...join does the opposite — it blocks the parent thread until every spawned thread finishes. Why? If you need the parent to continue as soon as just one thread finishes, or not wait at all, those are different constructs (fork...join_any and fork...join_none, covered later in this chapter) — plain join is the one that waits for everything.
Mistake #2: Forgetting automatic on a task called from multiple concurrent threads
As the note above the print task shows, a task called concurrently by more than one thread needs to be declared automatic. Why? Without it, all concurrent calls share one copy of the task's local variables and arguments, and one thread's call can silently corrupt another's in-progress execution — a bug that's easy to miss since it depends on the exact timing of concurrent calls.
Mistake #3: Assuming a nested fork...join finishes independently of the outer one
The outer join treats an entire nested fork...join block as just one of its own threads. Why? The outer thread doesn't resume until the inner block itself has finished, which means the outer join's total wait time is determined by whichever thread — inner or outer — actually finishes last, not by the outer threads alone.
Practice Exercise
Challenge: Predict the Finish Time
A fork...join spawns three threads: one with a #15 delay, one with a #40 delay, and a begin/end block that waits #5 and then #25 more before finishing. At what simulation time does the main thread resume after the join?
Hint
Work out each thread's own finish time first, then take the latest one — fork...join always waits for the slowest thread.
Solution
40ns. The three threads finish at 15ns, 40ns, and 5ns + 25ns = 30ns respectively. The latest of the three is 40ns, so that's when the main thread resumes.
Summary
In this tutorial, you learned:
fork...joinspawns multiple threads concurrently, and the parent thread waits until every one of them finishes- A
begin/endblock inside aforkcounts as a single thread, finishing only once every statement inside it has executed - A
fork...joincan be nested inside another; the outerjointreats the entire inner block as one of its own threads - The total wait time for any
fork...joinis always determined by its slowest thread - A task called concurrently from multiple threads must be declared
automatic, or concurrent calls will share and potentially corrupt the same local variables