Skip to content

SystemVerilog · Module 14

fork-join_none

SystemVerilog fork-join_none — the variant where the parent does not block at all. The basis for daemon threads (clocks, monitors, coverage samplers), loop-spawned N-channel agents, the canonical fork-join_none + wait fork pattern for variable N, and the $finish race that silently kills the last few transactions.

Module 14 · Page 14.3

fork-join_none spawns threads and immediately returns — the parent does not wait at all. Every spawned thread runs entirely in the background. This is the right tool for launching continuous monitors, clock generators, coverage samplers, and N-channel stimulus loops where N is determined at runtime. The critical rule juniors miss: $finish kills background threads instantly — if you need them to complete their last work, use wait fork first.

1. Engineering Problem — Why fork-join_none Exists

Every real testbench has three classes of work that must run concurrently:

  • Persistent infrastructure — a clock generator that toggles forever, a protocol monitor that captures every transaction, a coverage sampler that fires every cycle. These threads have no termination; they live for the entire simulation.
  • Variable-count parallel stimulus — "drive N channels concurrently" where N comes from the test's config object, the regression seed, or a runtime calculation. fork-join cannot express "N parallel threads where N is a variable" because the threads must be listed literally.
  • Fire-and-continue spawning — start a long task and let the parent immediately move to the next step; pick up the spawned task's result later via an event or wait fork.

fork-join (the previous-previous lesson) cannot do any of these because it blocks until every thread completes. fork-join_any (the previous lesson) blocks until one completes. fork-join_none is the IEEE 1800 answer to "spawn threads and immediately continue, don't block on anything."

2. Mental Model — Fire-and-Continue

The picture every engineer carries:

fork-join_none is a daemon spawner. Every thread inside it goes to a background process pool. The parent thread crosses the join_none bar in zero simulation time — no waiting, no checking, no synchronisation. The threads run independently of the parent from the instant of spawning.

Three invariants this picture preserves:

  • Zero blocking. The parent advances to the statement after join_none in the same time slot, before any spawned thread has executed even one statement. This is the defining behaviour — and the source of the "I forgot the spawn hasn't happened yet" bug.
  • Background threads outlive their spawn site. A fork-join_none inside a task: spawned threads keep running after the task returns. A fork-join_none inside a class method: threads outlive the method call (and need careful object-lifetime management — §7).
  • $finish is a guillotine. Every background thread is killed the instant $finish runs, with no chance to drain. If a monitor is mid-check, the check is abandoned. The fix is wait fork before $finish, or an explicit drain window.

3. Visual Explanation — The join_none Bar

SystemVerilog fork/join concurrency blockfork-join_none — parent does NOT block; all threads run in backgroundFORKThread 1Thread 1forever #5 clk =~clkThread 2Thread 2forevermonitor_apb()Thread 3Thread 3drive_channel(ch)JOIN_NONEfire and continue

The figure captures the canonical daemon-spawn pattern: a clock generator, a protocol monitor, and a finite per-channel driver — all spawned together and the parent immediately moves on. The JOIN_NONE variant is the default-shape sibling of join and join_any precisely because of this "spawn then continue" behaviour.

4. Syntax & Semantics — fork, join_none, wait fork

4.1 The basic form

SystemVerilog — fork-join_none: spawn and continue immediately
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// ── Syntax ────────────────────────────────────────────────────
fork
    thread_1;
    thread_2;
    thread_3;
join_none
// parent resumes HERE immediately — before any thread has run
next_statement;   // executes right away
 
// ── Concrete demonstration of the immediate-return behaviour ──
$display("[MAIN] Before fork at t=%0t", $time);
 
fork
    begin #20; $display("[A] done at t=%0t", $time); end
    begin #10; $display("[B] done at t=%0t", $time); end
join_none
 
$display("[MAIN] After fork-join_none at t=%0t — threads not done yet!", $time);
#30;
$display("[MAIN] t=%0t — both threads have completed in background", $time);
 
// Output:
//   [MAIN] Before fork at t=0
//   [MAIN] After fork-join_none at t=0  ← parent continues INSTANTLY at t=0
//   [B] done at t=10                    ← background thread completes
//   [A] done at t=20                    ← background thread completes
//   [MAIN] t=30

4.2 The three join variants — final side-by-side

Use caseBest variantWhy
N finite tasks, wait for all (N fixed at compile time)fork-joinAll threads finite; need all to finish; N known statically
Task or N-cycle deadline, whichever firstfork-join_any + disable forkReact to first completion; clean up the loser
Clock generator that runs forever alongside stimulusfork-join_noneNever waits; clock runs independently
N agents in a loop where N is a runtime valuefork-join_none + wait forkSpawn first; synchronise after the loop
Continuous background monitor / coverage / assertion driverfork-join_noneLives for the entire simulation alongside stimulus

4.3 wait fork — the complement to join_none

wait fork blocks the calling process until every thread spawned in the current scope has completed. It's the only way to "wait for N background threads where N was determined at runtime."

SystemVerilog — wait fork: barrier for all in-scope background threads
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
initial begin
    // Spawn 5 background threads
    for (int i = 0; i < 5; i++) begin
        automatic int idx = i;
        fork
            begin
                repeat (idx + 1) @(posedge clk);
                $display("Thread %0d done at %0t", idx, $time);
            end
        join_none
    end
 
    $display("[MAIN] All 5 threads spawned, now waiting...");
 
    wait fork;   // blocks here until ALL 5 threads have completed
 
    $display("[MAIN] All threads done at %0t", $time);
    $finish;
end
 
// ── wait fork with a timeout (combine with fork-join_any) ─────
fork
    wait fork;                       // wait for all background threads
    #1_000_000 $fatal(1, "timeout"); // give up after 1M time units
join_any
disable fork;

Critical scoping rule: wait fork waits for threads spawned in the calling process's scope — not globally. Inside a task, it waits only for threads spawned by that task call, not for threads spawned by the caller.

5. Simulation View — When the Parent Continues, Where the Spawns Go

At the fork keyword, the simulator:

  1. Spawns every statement inside the block as a separate process, sharing the parent's local variables (subject to the loop-capture bug — §8.1).
  2. Schedules all spawned threads at the current simulation time in the Active region.
  3. Does not suspend the parent at join_none — the parent continues to the next statement in the same Active region, before any spawned thread has executed even one statement.
  4. The spawned threads run only when the simulator's normal scheduling reaches them — typically after the parent advances or yields.

The "spawn order vs execution order" subtlety matters: if the parent's next statement is $display, the spawned threads have not yet executed their bodies when the display runs. The threads are scheduled but not started.

wait fork blocks the calling process in the Active region of the time slot where the call is made. The process resumes only when every previously-spawned background thread in the same scope has completed (including ones spawned via nested forks). disable fork from the calling process kills every still-running background thread spawned by that process.

6. Waveform — Parent Continues at t=0, Threads Resolve Later

The waveform below shows the §4.1 example. The parent forks 3 threads at cycle 0 and immediately moves on in the same time slot — there is no parent-blocked window at all. The threads resolve at cycles 2, 4, 6 entirely in the background while the parent is already executing post-fork work.

Figure — fork-join_none: parent continues at cycle 0 while threads resolve in background

12 cycles
Figure — fork-join_none: parent continues at cycle 0 while threads resolve in backgroundfork spawns; parent continues at SAME cyclefork spawns; parent co…B done (background)B done (background)A done (background)A done (background)clkthread ARUNRUNRUNRUNRUNRUNdonedonedonedonedonedonethread BRUNRUNdonedonedonedonedonedonedonedonedonedonethread CRUNRUNRUNRUNdonedonedonedonedonedonedonedoneparentRUNRUNRUNRUNRUNRUNRUNRUNRUNRUNRUNRUNt0t1t2t3t4t5t6t7t8t9t10t11
At cycle 0 the parent forks 3 threads AND immediately moves on — no blocked window. Thread B finishes at cycle 2, thread C at cycle 4, thread A at cycle 6, all entirely in the background. The parent is doing post-fork work the whole time. Contrast with fork-join (parent waits until cycle 6) and fork-join_any (parent unblocks at cycle 2).

The parent row is RUN for every cycle — there is no BLOCKED window like in fork-join (where the parent waited for the last thread) or fork-join_any (where the parent waited for the first).

7. Synthesis — Not Applicable

fork-join_none is a procedural simulation construct. It has no hardware footprint and no place in synthesisable RTL — synthesis tools reject it outright. This section is intentionally omitted; the topic does not warrant it.

The corresponding hardware idiom for "run these N things in parallel forever" is just always @(posedge clk) begin ... end blocks per concurrent activity — the synthesisable equivalent is built into RTL semantics, not a procedural primitive.

8. Verification View — The Canonical Patterns

8.1 The loop-spawn pattern with wait fork

The combination fork-join_none inside a loop + wait fork after the loop is the only way to spawn a runtime-variable number of parallel threads and then synchronise on their completion. The same automatic loop-variable capture discipline from fork-join applies.

❌ Wrong — shared loop variable
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
parameter N = 8;
for (int i = 0; i < N; i++) begin
    fork
        drive_channel(i);   // all 8 see i=8!
    join_none
end
wait fork;

All 8 spawned threads read the loop's i when they run — by then i = 8. Every thread drives channel 8.

✅ Right — automatic per-iteration capture
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
parameter N = 8;
for (int i = 0; i < N; i++) begin
    automatic int ch = i;   // NEW per iteration
    fork
        drive_channel(ch);
    join_none
end
wait fork;

Each iteration creates a fresh ch. Each spawned thread holds its own private copy of the correct channel index.

8.2 The standard testbench skeleton

The canonical SV testbench uses fork-join_none to launch all persistent infrastructure (clock, monitors, coverage) in one block, then runs the test sequence on the main thread alongside them.

SystemVerilog — complete testbench with background + foreground phases
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
module tb;
logic clk = 0, rst_n;
 
// ── Phase 1: launch all persistent background processes ───────
initial fork
    begin forever #5 clk = ~clk;                       end  // clock
    begin forever monitor_apb();                       end  // APB monitor
    begin forever monitor_irq();                       end  // IRQ monitor
    begin forever @(posedge clk) sample_cov();         end  // coverage
join_none
 
// ── Phase 2: reset ────────────────────────────────────────────
initial begin
    rst_n = 0;
    repeat (4) @(posedge clk);
    rst_n = 1;
 
    // ── Phase 3: directed tests ───────────────────────────────
    test_register_access();
    test_burst_mode();
    test_error_injection();
 
    // ── Phase 4: random tests with parallel channels ──────────
    for (int i = 0; i < 4; i++) begin
        automatic int ch = i;
        fork random_test_channel(ch); join_none
    end
    wait fork;   // wait for all 4 channel tests
 
    // ── Phase 5: drain and check ──────────────────────────────
    repeat (100) @(posedge clk);
    check_final_scoreboard();
    $display("[TB] TEST PASSED at %0t", $time);
    $finish;   // monitors get killed here — that is fine for end-of-test
end
 
endmodule

Two initial blocks: one for persistent infrastructure (everything in fork-join_none), one for the sequential test phases. The wait fork in phase 4 is scoped to the test-initial's spawns, so it waits for the 4 channel tests without trying to wait for the persistent monitors (which would block forever — see §10).

8.3 Class-based agent pool

SystemVerilog — spawning an array of agent objects with fork-join_none
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
ApbAgent agents [4];
 
initial begin
    // Construct all agents
    foreach (agents[i]) agents[i] = new();
 
    // Start all agents as background tasks
    foreach (agents[i]) begin
        automatic int idx = i;
        fork
            agents[idx].run();   // each agent runs independently
        join_none
    end
 
    // Main test runs while agents are active
    do_test_stimulus();
 
    // Signal all agents to stop
    foreach (agents[i]) agents[i].stop();
 
    // Wait for all agent threads to finish their cleanup
    wait fork;
    $display("[TB] All agents stopped");
    $finish;
end

The agents own their lifetime via their start() / stop() API (§9). The testbench spawns the run() task via fork-join_none, does its work, signals stop, then wait forks for the agents to drain.

8.4 The $finish race — drain or lose

$finish terminates simulation immediately. Every background thread is killed without warning. Monitors mid-check, scoreboards mid-compare, coverage samplers mid-sample — all are abandoned.

SystemVerilog — drain before $finish to prevent silent transaction loss
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
initial begin
    fork
        begin : monitor
            forever begin
                @(posedge clk iff valid);
                check_output();
            end
        end
    join_none
 
    send_100_packets();
    repeat (50) @(posedge clk);    // drain: allow last transactions to propagate
    check_scoreboard_empty();      // verify all packets were checked
    $finish;                       // now safe to finish
end

The drain window (50 cycles after the last stimulus) lets the in-flight packets reach the monitor and get checked. Without it, the last few packets often arrive after send_100_packets() returns but before the next statement — and $finish cuts off the monitor mid-flow.

9. Class-Method Spawning — Object Lifetime Discipline

When a class method uses fork-join_none, the spawned threads outlive the method call. They continue running after the method returns. If the threads access this (the object's properties) and the object goes out of scope, the threads dangle on a soon-to-be-garbage-collected object.

The canonical pattern is to give the class an explicit start() / stop() lifecycle:

SystemVerilog — class with explicit start/stop lifecycle for spawned threads
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
class ApbAgent;
    bit running = 0;
 
    task start();
        running = 1;
        fork
            begin : drive_loop
                forever drive_next_txn();
            end
            begin : monitor_loop
                forever monitor_response();
            end
        join_none
        // start() returns immediately; both loops run in background
    endtask
 
    task stop();
        running = 0;
        disable drive_loop;     // kill the drive thread by name
        disable monitor_loop;   // kill the monitor thread by name
    endtask
endclass
 
initial begin
    ApbAgent agent = new();
    agent.start();           // threads start running in background
    #10_000;                 // object stays alive (still in scope)
    agent.stop();            // cleanly kill the threads
    $finish;
end

The start() / stop() discipline keeps thread lifetime explicit:

  • The agent's threads live exactly between start() and stop().
  • stop() uses named-block disable (disable drive_loop;) to kill exactly the threads this agent spawned — not every background thread in the testbench (which disable fork would do).
  • The agent's handle stays in scope through start() → ... → stop(), so the threads never reference a garbage-collected object.

10. Industry Usage — Where fork-join_none Lands in Real Verification

  • UVM run_phase() infrastructure — every uvm_component's run_phase() spawns its driver / monitor / sequencer threads via fork-join_none internally; the component tree's spawn cascade is what gives UVM its "everything starts together at run_phase" behaviour.
  • UVM monitor tasks — every uvm_monitor::run_phase() ends with forever sample() inside a fork-join_none; the monitor lives for the entire run phase. Stopping it requires phase.drop_objection() (which kicks off a controlled shutdown).
  • Coverage collectorsforever @(posedge clk) cg.sample(); inside fork-join_none is the standard pattern for cycle-accurate coverage sampling, surviving alongside the rest of the test.
  • Multi-channel protocol VIPs — APB / AHB / AXI / DDR / PCIe / Ethernet agents start their per-channel monitors and drivers via fork-join_none at agent construction; the agents live for the test duration.
  • uvm_objection drain time — the canonical way to give a forever-loop monitor time to flush before $finish is phase.phase_done.set_drain_time(this, 1us); under the hood this is the same drain-before-$finish pattern from §8.4.
  • Regression-level reset injectors — a reset-injection sequence spawned via fork-join_none runs alongside the main stimulus, injecting random resets at random times without blocking the test's foreground path.
  • Per-seed instrumentation — performance probes, transaction recorders, and lint-collectors all run as fork-join_none daemons in serious verification environments.

11. Design Review Notes — What a Senior Will Flag

Pattern in the diffWhat review will say
for (int i = 0; i < N; i++) fork drive(i); join_none end (no automatic)"Loop-variable capture — every spawned thread sees i = N. Add automatic int idx = i; inside the loop body before the fork. Same rule as fork-join; same fix."
send_stimulus(); $finish; after a forever-loop monitor was spawned"Monitor gets guillotined mid-check. Add a drain window: repeat (N) @(posedge clk); between stimulus and $finish, then check_scoreboard_empty();."
wait fork; after spawning a forever thread"Blocks forever — wait fork waits for every in-scope spawn, and the forever thread never ends. Either don't spawn the forever thread in the same scope, or use named-block disable <monitor_name>; before wait fork."
fork init_memory(); join_none configure_dut();"join_none returns immediately — init_memory() has not run yet. configure_dut() will execute against uninitialised memory. Use fork-join (block until init done) or wait on a mem_ready event."
Anonymous fork branches in a fork-join_none block where multiple branches might need disable"Name the branches — begin : drive_loop forever ... end. The named form enables targeted disable drive_loop; instead of the nuclear disable fork;."
Class method spawns fork-join_none accessing this.field with no explicit lifecycle"Object lifetime is implicit. Add start() / stop() methods; keep the agent handle in scope for the agent's intended lifetime; use named-block disable for clean shutdown."
wait fork; inside a task that the caller doesn't expect to block"wait fork waits for threads spawned inside this task call only — but the caller may not realise the task blocks until its spawns drain. Document the blocking behaviour in the task's header comment."
fork-join_none spawning a single statement"Probably the wrong variant — fork {one statement} join_none is the same as fork-join with no body wait, but harder to read. If you genuinely want fire-and-continue, the prose intent should be clear."

The single highest-value rule: fork-join_none is for daemons or runtime-variable spawning — not "I want fork-join but lazier." If the spawned work is finite and you eventually need its completion, use fork-join_none + wait fork explicitly, not fork-join_none and hope.

12. Debugging Guide — Real Failures, Real Fixes

1

Test reports 'all done' but coverage shows last 5 transactions never checked

$FINISH-RACE
Buggy Code
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
fork
    begin : monitor
        forever begin
            @(posedge clk iff valid);
            check_output();
        end
    end
join_none
 
send_100_packets();
$finish;            // BUG: monitor killed mid-flight
Symptom
Test log says "complete." Coverage report shows the last 3–5 transactions of every seed unchecked. Scoreboard counts mismatch: producer says 100 sent, scoreboard checked 96. Bug never reproduces in single-cycle traces — only at high transaction counts.
Root Cause
send_100_packets() returns when the producer's loop ends, but the last few packets are still in flight to the monitor when $finish runs. The monitor is mid-check or pending the next iff valid. $finish kills it instantly.
Fix
Add a drain window: repeat (50) @(posedge clk); check_scoreboard_empty(); $finish;. The drain gives the in-flight packets time to reach the monitor; the empty-check verifies the scoreboard actually drained. Alternatively, use an event the monitor fires after the last check.
2

Test hangs after the loop with `wait fork`; monitor never ends

WAIT-FORK-FOREVER
Buggy Code
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
initial begin
    fork forever monitor(); join_none      // background monitor
    for (int i = 0; i < N; i++) begin
        automatic int ch = i;
        fork drive_channel(ch); join_none
    end
    wait fork;                              // BUG: includes the monitor
    $finish;
end
Symptom
All N channels complete, then the testbench hangs. The wait fork returns... never. Watchdog eventually fires. Log shows "all channels done" then silence until the regression timer.
Root Cause
wait fork waits for every in-scope spawn. The forever monitor() was spawned in the same scope; it never ends; wait fork blocks indefinitely.
Fix
Move the forever monitor() to a different initial block so it's not in the same spawn scope, OR name the monitor branch and disable monitor_branch; before wait fork;. The standard testbench skeleton (§8.2) uses two separate initial blocks for exactly this reason.
3

configure_dut() runs against uninitialised memory

JOIN_NONE-NOT-DONE
Buggy Code
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
fork
    init_memory();          // BUG: spawned, NOT executed yet
join_none
configure_dut();            // runs immediately — sees old memory
Symptom
DUT configuration writes happen at the right addresses but read back the wrong values. Coverage shows config registers in unexpected states. Looks like a DUT bug; isn't.
Root Cause
fork ... join_none returns immediately — init_memory() has been spawned but has not yet executed. configure_dut() runs in the same time slot and reads the still-uninitialised memory.
Fix
Either use fork-join (block until init completes), or signal completion with an event: fork begin init_memory(); ->mem_ready; end join_none configure_dut(); // ... @mem_ready; do_writes();. Use fork-join_none only when the spawned work genuinely shouldn't block the next step.
4

All N spawned drivers send stimulus to channel N-1 only

LOOP-CAPTURE
Buggy Code
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
for (int i = 0; i < 8; i++) begin
    fork drive_channel(i); join_none   // captures shared i
end
wait fork;
Symptom
8 driver threads spawn. Coverage shows channel 7 received 8x the expected stimulus; channels 0–6 received zero. DUT response is uniformly from channel 7 only.
Root Cause
Same loop-variable capture bug as fork-join. The threads are scheduled immediately but don't execute until later. By then the loop has finished and i = 8. Every thread reads i = 8 and calls drive_channel(8) — which probably wraps to channel 7 or asserts out-of-range.
Fix
automatic int idx = i; inside the loop body, before the fork. Each iteration captures its own idx. Same rule as fork-join; the lint check should fire on both.
5

Agent threads keep running after agent.stop() returns

OBJECT-LIFETIME
Buggy Code
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
class ApbAgent;
    task start();
        fork
            forever drive_next_txn();
            forever monitor_response();
        join_none
    endtask
    task stop();
        running = 0;
        // BUG: never killed the spawned threads
    endtask
endclass
Symptom
After agent.stop(); the log keeps showing [AGENT] driving txn ... messages for many more cycles. Other tests' stimulus collides with the still-running agent's drives.
Root Cause
stop() set the running flag but the threads aren't checking it — and the anonymous fork branches can't be killed by name. The threads outlive the stop() call.
Fix
Name the branches (begin : drive_loop forever ... end) and disable drive_loop; inside stop(). Also have the thread bodies check running between iterations so they can drain cleanly: forever begin if (!running) break; drive_next_txn(); end. The combination of cooperative break + targeted disable gives reliable shutdown.

13. Interview Insights — What Interviewers Actually Probe

fork-join_none spawns every thread inside the block as a separate process — identical to fork-join and fork-join_any in the spawn semantics. The difference is the join: the parent does not block at all. The parent continues to the statement after join_none in the same simulation time slot, before any spawned thread has executed even one statement. The spawned threads run entirely in the background.

14. Exercises

1. Design — testbench skeleton (Foundation)
Write a complete module tb; with two initial blocks: one launches a clock generator + APB monitor + coverage sampler via fork-join_none; the other does reset → 3 directed tests → 4 parallel channels via fork-join_none + wait fork → drain → $finish. Use the canonical pattern from §8.2 as your skeleton.

2. Debug — the missing transactions (Intermediate)
A test sends 1000 packets and the monitor spawned with fork-join_none is supposed to check each one. The scoreboard reports 994 checked. Walk through the diagnostic: what is the most likely cause, what 2 lines fix it, and what is the second-best fix using an event?

3. Code review — the agent lifecycle (Intermediate)
A teammate submits an ApbAgent class with task start(); fork forever drive(); forever monitor(); join_none endtask and task stop(); /* empty */ endtask. The agent's threads keep running after stop() is called. Identify all three bugs and propose the canonical fix.

4. Trade-off — fork-join_none + wait fork vs fork-join (Advanced)
A junior teammate proposes a coding rule: "always use fork-join for parallel work, never fork-join_none + wait fork — fewer lines, less error-prone." Argue the case against. Construct a real verification scenario where the proposed rule cannot express what's needed, and explain why the two-step pattern is fundamentally more powerful.

15. Summary

fork-join_none spawns threads and immediately returns — the parent does not block. Every thread runs in the background from the moment of spawning. The two canonical patterns are daemons (clock generators, monitors, coverage samplers in forever loops) and loop-spawned variable-N agents (fork-join_none inside a for loop, then wait fork; to converge).

Defaults to memorise. Use fork-join_none for daemons and runtime-variable spawning, never for "I want fork-join but lazier." Pair the loop pattern with wait fork; for synchronisation. Drain before $finish — the guillotine kills background threads mid-check. Apply the same automatic loop-capture discipline as fork-join. Use named-block disable in class agents' stop() methods to kill exactly the agent's threads without nuking the rest of the testbench.

This closes the three join variants. Together with the previous lessons, the rule is: join for "wait for all," join_any for "wait for first," join_none for "don't wait." Most testbenches use all three.

Next up: 14.4 — disable fork & wait fork — the deeper rules for selectively killing fork branches by name, the disable fork vs disable <named_block> distinction, and the cleanup discipline that keeps long-running testbenches from leaking background threads.