fork…join, Process Threads & Fine-Grain Process Control
The three fork-join variants that give precise control over when the parent resumes, automatic variables inside fork loops, wait fork and disable fork for process lifecycle management, and the process built-in class for runtime process inspection and control.
🔂 fork…join Overview
The fork…join construct spawns one concurrent process per statement. Every statement inside the block runs simultaneously — each in its own thread. SystemVerilog extends Verilog-2001’s single join with two new variants that change when the parent process resumes.
join
Parent blocks until all spawned processes complete. The original Verilog-2001 behaviour.
join_any
Parent blocks until any one spawned process completes. The other processes keep running.
join_none
Parent continues immediately. All spawned processes run concurrently in the background. They don’t even start until the parent first hits a blocking statement.
📋 The Three Variants — Side by Side
| Variant | Parent resumes when… | Other processes after parent resumes | Use case |
|---|---|---|---|
| join | All spawned processes complete | None — all done | Tasks that must all finish before moving on |
| join_any | First spawned process completes | Still running (until killed or finished) | Race/competition — take result from first to finish |
| join_none | Immediately (no waiting) | Start running when parent hits blocking statement | Background workers, fire-and-forget, dynamic spawning |
⏱ Visual Timeline — How Each Variant Behaves
◼ join — Wait for All
Classic Verilog-2001 behaviour, unchanged in SV. The parent blocks until every statement inside the fork completes. Wrap multiple statements in a begin…end to make them a single sequential process.
// Two processes run concurrently; parent waits for both fork begin $display("First Block"); #20ns; end begin $display("Second Block"); @eventA; end join // Resumes when BOTH: 20ns elapsed AND eventA has triggered // Wrapping everything in begin...end makes ONE sequential process fork begin // ONE process — statements run sequentially inside statement1(); statement2(); end join // identical to just begin...end without fork // return inside fork is ILLEGAL — the task context is in another process task bad_task; fork #20; // return; // COMPILE ERROR join_none endtask
return statement in any branch of a fork block is a compile error. The forked code runs in a separate process from the enclosing task — returning from it would try to return from the spawned process’s context, not the task. Use flags and wait to coordinate completion instead.
▲ join_any — Wait for First
The parent blocks until any one of the spawned processes finishes. The remaining processes keep running concurrently. After resuming, the parent can interact with or kill the remaining workers.
// Classic use: race three tasks, take the first result task get_first(output int adr); fork wait_device(1, adr); // whichever of these three wait_device(7, adr); // finishes first wait_device(13, adr); // resumes the parent join_any disable fork; // clean up the two remaining workers endtask // join_any + wait for a timeout (whichever comes first) logic done; fork dut_task(); // does the real work begin #100; done=0; end // timeout sentinel join_any disable fork; if(!done) $error("Timeout!");
join_any with disable fork (or selectively kill processes via the process class) unless you deliberately want them to continue.
▶ join_none — Fire and Forget
The parent continues executing immediately without waiting. The spawned processes do not even begin running until the parent itself executes a blocking statement. This enables dynamic, background spawning of any number of concurrent tasks.
// Background monitors — launched from initial, never awaited initial begin fork monitor_bus(); // runs concurrently forever check_timing(); // runs concurrently forever join_none // parent continues immediately to run the test run_test(); end // Dynamic spawning in a loop — each iteration spawns a new process initial for(int j=1; j<=3; j++) fork automatic int k = j; // CRITICAL: capture j in a local copy #k $write("%0d", k); // prints 1, 2, 3 (in order) join_none // Output: 1 2 3 — because each k captures the value at its iteration
wait fork or #1). This means all three are already spawned with their unique k values before any of them runs — which is why the output is deterministic.
📌 Automatic Variables Inside fork
This is one of the most practically important SV improvements to fork…join. Without it, spawning loop-indexed processes is a classic race condition.
Without automatic — all processes share j (WRONG)
initial for(int j=1; j<=3; j++) fork #j $write("%0d", j); // j is the SHARED loop variable // by the time any process runs (join_none), // j may already be 4 (loop finished) // All three print "4" — not 1, 2, 3! join_none
With automatic k — each process gets its own copy (CORRECT)
initial for(int j=1; j<=3; j++) fork automatic int k = j; // initialised before any process spawns #k $write("%0d", k); // prints 1, 2, 3 join_none
The initialisation rule
Automatic variables declared in the scope of a fork…join block are initialised when execution enters their scope, before any processes are spawned. So in the loop above, each iteration initialises a fresh k with the current j value before spawning the process that uses it.
// The ordering guarantee matters here: // Iteration 1: k=1 initialised → process P1 spawned (uses k=1) // Iteration 2: k=2 initialised → process P2 spawned (uses k=2) // Iteration 3: k=3 initialised → process P3 spawned (uses k=3) // Loop finishes → parent hits blocking stmt → P1, P2, P3 start executing // P1 fires after 1 time unit, prints "1" // P2 fires after 2 time units, prints "2" // P3 fires after 3 time units, prints "3" // Output: 1 2 3 (deterministic) // Contrast: automatic int m = j inside begin...end is UNDETERMINED initial for(int j=1; j<=3; j++) fork begin automatic int m = j; // INSIDE begin...end — value of j when process starts // may not match the loop iteration that spawned it $write("%0d", m); // output is undetermined end join_none
begin…end) is initialised before the processes start. One declared inside a begin…end sub-block is initialised when that process begins executing — which may be after the loop has advanced.
▶ Process Execution Threads
SystemVerilog creates a thread of execution for each of the following constructs. Understanding this list clarifies what can run concurrently and what can be targeted by process-control constructs.
- Each
initialblock - Each
always/always_comb/always_latch/always_ffblock - Each parallel statement inside a
fork…join,fork…join_any, orfork…join_noneblock - Each dynamic process spawned by a class method or other runtime mechanism
Each continuous assignment (assign a = b & c) can also be considered its own thread — it runs independently whenever its inputs change.
fork…join_any and fork…join_none, where new threads can be spawned at runtime. The total number of active threads can grow and shrink during simulation.
⏸ wait fork
wait fork causes the calling process to block until all of its child processes have completed. It is the cleanup complement to join_none and join_any — use it when you want the calling code to ensure all background threads are done before continuing.
task do_test; // Phase 1: spawn exec1 and exec2, wait for first to finish fork exec1(); exec2(); join_any // unblocks when exec1 OR exec2 finishes // Phase 2: launch two more background processes fork exec3(); exec4(); join_none // parent continues immediately // Ensure ALL FOUR processes (exec1..exec4) are done before returning wait fork; // blocks until exec1, exec2, exec3, exec4 all complete endtask
program block ends simulation when it reaches its end (regardless of child processes). wait fork lets the program block explicitly wait for all spawned children before returning — preventing premature simulation end while child threads are still running.
wait fork scope
wait fork waits for all children of the calling process — it does not wait for processes spawned by unrelated processes. Children spawned through multiple fork blocks across the calling task’s lifetime are all tracked.
⛔ disable fork
disable fork immediately terminates all active descendants of the calling process — its children, their children, and so on. It uses the runtime parent-child relationship, not static block names.
// First-responder pattern: race N workers, take the first result, kill the rest task get_first(output int adr); fork wait_device(1, adr); wait_device(7, adr); wait_device(13, adr); join_any // unblocks when first device responds disable fork; // terminates the other two wait_device calls endtask // adr now holds the address from the first device // Timed-out operation: kill the worker if it takes too long fork slow_task(); #1000; // timeout sentinel join_any disable fork; // kills whichever one did NOT finish first
exec1() itself spawned background workers, disable fork kills those too.
📚 The process Built-in Class
The process class provides fine-grained, object-oriented access to runtime process control. It lets one process inspect or control another process that was spawned and whose handle was captured.
class process; enum state { FINISHED, RUNNING, WAITING, SUSPENDED, KILLED }; static function process self(); // get handle to current process function state status(); // query process state task kill(); // terminate process + all its children task await(); // wait for process to finish task suspend(); // pause the process task resume(); // unpause the process endclass
Five process states
suspend() — waiting for resume()kill() or disableKey rules for the process class
- Process objects are created internally when processes are spawned — you cannot call
new()to create one. Callingprocess::new()is an error. - The
processclass cannot be extended — it is a sealed built-in. - Use
process::self()inside a running process to get a handle to itself. await()— a process cannot callawait()on itself (causes an error).kill()— terminates the process and all descendants. If the process is not currently blocking, termination occurs at an unspecified time in the current time step.suspend()on an already-suspended process has no effect.resume()on a process that was suspended while blocked re-sensitises it to its original blocking condition (event/wait/delay) and schedules it to continue when that condition is met.
// Capturing a process handle inside a forked thread process p1_handle; fork begin p1_handle = process::self(); // capture my own handle @(posedge clk); // now WAITING end join_none // From another thread: query and control p1 #1; $display(p1_handle.status()); // WAITING (blocked on posedge clk) p1_handle.suspend(); // pause p1 even while it's waiting #100; p1_handle.resume(); // re-sensitise to posedge clk
⚡ Worked Example: do_n_way
This example demonstrates all process control features working together: spawning N background processes, waiting for them all to start, waiting for the first to finish, then killing the rest.
task do_n_way(int N); process job[1:N]; // array of process handles // Step 1: spawn N processes with join_none for(int j=1; j<=N; j++) fork automatic int k = j; // capture j before spawning begin job[k] = process::self(); // store my own handle do_work(k); // actual work end join_none // Step 2: wait for all N processes to start // (handle is null until the process has executed at least one statement) for(int j=1; j<=N; j++) wait(job[j] != null); // blocks until process j has stored its handle // Step 3: wait for the FIRST process to finish job[1].await(); // waits for process 1 (the first spawned) to finish // Step 4: kill any remaining processes that have not yet finished for(int k=1; k<=N; k++) if(job[k].status() != process::FINISHED) job[k].kill(); endtask
join_none, the spawned processes do not start executing until the parent hits a blocking statement. The wait(job[j] != null) loop is that first blocking statement — each wait unblocks as soon as the corresponding process has started and stored its handle via process::self(). This guarantees all handles are valid before the parent proceeds to await().
📋 Quick Reference
fork-join variant selection
| Variant | Parent resumes when | Use when |
|---|---|---|
| join | All children finish | Sequential phases that all must complete |
| join_any | First child finishes | Race/competition — take first result, kill rest |
| join_none | Immediately | Background monitors, dynamic loop-spawning |
Automatic variable in fork — the critical rule
- Declare
automatic int k = jdirectly in the fork scope (not inside nestedbegin…end) to get a stable per-iteration copy of the loop variable. - Inside
begin…end, the automatic variable initialises when the process runs — by which time the loop may have advanced.
process class methods
| Method | Type | What it does |
|---|---|---|
| process::self() | static function | Returns handle to the calling process |
| status() | function | Returns current state enum value |
| kill() | task | Terminates process and all its descendants |
| await() | task | Blocks until target process finishes (cannot await self) |
| suspend() | task | Pauses the process (idempotent if already suspended) |
| resume() | task | Unpauses; re-sensitises to original blocking condition |
Rules to remember
returninside any fork branch is a compile error.- After
join_any, remaining processes keep running — usedisable forkto clean up. wait forkwaits for all current children;disable forkkills all current children.disable forkis transitive — kills grandchildren and beyond.- Cannot create process objects with
new()— they are created internally by the simulator. - Cannot extend the
processclass.
ref), default argument values, argument passing by name, void functions, the new return statement, and function output/inout ports.
