Part 2 · OOP for Verification · Intermediate

fork...join, join_any, join_none

Blocking semantics of all three variants, the classic loop-variable capture bug, and spawning monitors.

Three variants, three blocking contracts

fork...join spawns each statement in the block as a concurrent child process. The closing keyword decides when the parent resumes: join waits for ALL children; join_any waits for the FIRST child to finish (the others keep running); join_none does not wait at all — and crucially, the children do not even start executing until the parent next blocks or finishes its timestep. That deferred start is a scheduling detail that matters for the loop bug below.

diagram
FORK VARIANT SEMANTICS   (P = parent, A/B/C = children; A is shortest)

  fork ... join              fork ... join_any           fork ... join_none
  ─────────────              ─────────────────           ──────────────────
  P ──┬─► A ──┐              P ──┬─► A ──┐               P ──┬─ A (starts when
      ├─► B ────┐                ├─► B ──│───►               ├─ B  parent next
      └─► C ──────┐              └─► C ──│───►               └─ C  blocks)
                  │                      │                   │
  P resumes ◄─────┘          P resumes ◄─┘               P resumes IMMEDIATELY
  (after A,B,C all end)      (after FIRST end;           (children run in
                              B, C still running)         background)

  Common pairings:
  join       lockstep phases (drive + collect response together)
  join_any   race a task against a timeout, then disable fork
  join_none  spawn daemon monitors/scoreboards that run forever

The classic loop-variable capture bug

Spawn one process per element of a loop and you hit the most famous concurrency bug in SystemVerilog. The child processes do not start until the parent blocks — by which time the loop has finished and the shared loop variable holds its final value . All children then read that same final value. The fix is to declare an automatic variable inside the fork (or inside the loop body) so each child captures its own private copy.

systemverilog
module fork_loop_bug;
  initial begin
    // BUG: all three children print i = 3
    for (int i = 0; i < 3; i++) begin
      fork
        $display("BUGGY  child sees i = %0d", i);  // i shared with parent
      join_none
    end
    #0;   // parent blocks → children finally run; loop already ended, i == 3

    // FIX: automatic copy captured per iteration
    for (int i = 0; i < 3; i++) begin
      fork
        automatic int k = i;        // evaluated NOW, one private k per child
        $display("FIXED  child sees k = %0d", k);   // 0, 1, 2
      join_none
    end
    #0;
  end
endmodule

Why the fix works: the automatic int k = i; declaration-with-initializer inside the fork block is evaluated at spawn time, in the parent's context, once per loop iteration — so each child gets a distinct k frozen at the right value, while the deferred child body reads only its own k. Note that in a class task all locals are automatic by default, but the loop variable of the for statement is still shared across the spawned children, so the explicit per-iteration copy is required there too.


Spawning monitors and racing timeouts

systemverilog
class env;
  monitor    mon[2];
  scoreboard sb;

  task run_all();
    // Daemons: forever-loops that must NOT block the test flow
    foreach (mon[i]) begin
      automatic int idx = i;          // capture fix, again
      fork
        mon[idx].run();               // forever loop inside
      join_none
    end
    fork
      sb.run();                       // also a forever loop
    join_none
    // parent returns immediately; daemons run in background
  endtask

  task run_with_timeout(time limit);
    fork
      main_sequence();                // the real work
      begin                           // the watchdog
        #limit;
        $fatal(1, "TIMEOUT after %0t", limit);
      end
    join_any                          // whichever finishes first wins
    disable fork;                     // kill the loser (see next lesson)
  endtask
endclass

Interview angle

  • "What prints?" with a fork in a loop is a staple screening question — explain deferred child start plus shared variable.

  • "join_any returned — are the other threads dead?" — no, they keep running until disabled or completed.

  • "When do join_none children begin?" — when the parent next blocks (delay, event, end of timestep), not at the fork statement.

Key takeaways

  • join waits for all, join_any for the first, join_none for none — children outlive join_any/join_none.

  • join_none children start only when the parent blocks; this deferral is what enables the loop bug.

  • Fix loop capture with an automatic variable initialized inside the fork — one frozen copy per child.

  • join_any plus disable fork is the standard race-a-timeout pattern; join_none spawns daemons.

Common pitfalls

  • fork inside a loop without an automatic copy — every child sees the loop variable's final value.

  • Assuming join_any killed the slower children — they continue, often corrupting later test phases.

  • Forgetting that a forever-loop child makes plain join wait forever — daemons need join_none.

  • Spawning from a static (module) context where locals are static — declare automatic explicitly.