SystemVerilog Series · SV-09b

SystemVerilog Series — SV-09b: fork…join, Process Threads & Fine-Grain Process Control — VLSI Trainers
SystemVerilog Series · SV-09b

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

VariantParent resumes when…Other processes after parent resumesUse 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

Fig 1 — Fork spawns P1 (#10), P2 (@event), P3 (#20). Each bar = running time.
join
parent blocked
P1(10)
P2(@event)
P3(20)
resumes
parent waits for ALL three; resumes after the last one finishes
join_any
parent blocked
P1(10)
resumes
P2 & P3 still running
parent resumes when P1 (first to finish) completes; P2 and P3 keep going
join_none
parent continues
P1, P2, P3 start when parent hits blocking stmt
parent never waits; all three run in background; they don’t even begin until parent blocks

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 inside fork is illegal. A 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!");
After join_any, unfinished processes are still alive. They keep consuming simulation resources and may interfere with later test phases if not explicitly killed. Always follow 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
join_none processes start after the first blocking statement. In the loop example above, none of the three forked processes actually begin until the parent hits a blocking statement (like 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
The key distinction: An automatic variable declared directly inside the fork block (not inside a nested 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 initial block
  • Each always / always_comb / always_latch / always_ff block
  • Each parallel statement inside a fork…join, fork…join_any, or fork…join_none block
  • 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.

Static vs dynamic processes: Verilog-2001 only had static processes — the number of threads was fixed at elaboration time. SystemVerilog adds dynamic processes through 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
wait fork and program blocks: Normally, a SystemVerilog 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
disable fork is transitive. It kills not only the direct children but also all of their children (grandchildren, great-grandchildren, etc.) — the entire subtree of the calling process. If 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.

process class interface
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

FINISHED
Completed normally — reached the end of its code
RUNNING
Currently executing — not in any blocking statement
WAITING
Blocked in a timing control, event, or wait expression
SUSPENDED
Paused by a call to suspend() — waiting for resume()
KILLED
Forcibly terminated via kill() or disable

Key rules for the process class

  • Process objects are created internally when processes are spawned — you cannot call new() to create one. Calling process::new() is an error.
  • The process class 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 call await() 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
Why wait for job[j] != null? With 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

VariantParent resumes whenUse when
joinAll children finishSequential phases that all must complete
join_anyFirst child finishesRace/competition — take first result, kill rest
join_noneImmediatelyBackground monitors, dynamic loop-spawning

Automatic variable in fork — the critical rule

  • Declare automatic int k = j directly in the fork scope (not inside nested begin…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

MethodTypeWhat it does
process::self()static functionReturns handle to the calling process
status()functionReturns current state enum value
kill()taskTerminates process and all its descendants
await()taskBlocks until target process finishes (cannot await self)
suspend()taskPauses the process (idempotent if already suspended)
resume()taskUnpauses; re-sensitises to original blocking condition

Rules to remember

  • return inside any fork branch is a compile error.
  • After join_any, remaining processes keep running — use disable fork to clean up.
  • wait fork waits for all current children; disable fork kills all current children.
  • disable fork is transitive — kills grandchildren and beyond.
  • Cannot create process objects with new() — they are created internally by the simulator.
  • Cannot extend the process class.
Section 9 complete. Coming next: Section 10 — Tasks and Functions: new port directions (ref), default argument values, argument passing by name, void functions, the new return statement, and function output/inout ports.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top