The short answer: SystemVerilog testbenches run many threads at once. fork…join starts threads and waits for all of them; join_any waits for the first; join_none returns immediately. Threads exchange data through mailboxes (typed FIFOs), share resources with semaphores, and synchronise with events. Use disable fork to stop leftover threads and wait fork to wait for all children, and copy loop variables into automatic variables before forking inside a loop.
A testbench is naturally parallel: a generator creates transactions, a driver sends them, a monitor watches the bus and a scoreboard checks results, all at the same time. This post covers the tools SystemVerilog gives you to start those threads and let them work together.
- The building blocks
- fork-join variants
- The classic loop bug
- Mailboxes
- Semaphores
- Events
- How UVM uses this
Table of Contents
Part 1: The building blocks

Part 2: fork-join variants
fork
begin #10 $display("A"); end
begin #20 $display("B"); end
join // continues at time 20
fork
wait_for_response();
begin #1000; $error("timeout"); end
join_any // continues when either finishes
disable fork; // stop the one still running
fork
monitor.run(); // runs forever in the background
join_none
Part 3: The classic loop bug
// WRONG: all threads see i == 4 by the time they run
for (int i = 0; i < 4; i++)
fork
drive_port(i);
join_none
// RIGHT: copy into an automatic variable per iteration
for (int i = 0; i < 4; i++) begin
automatic int idx = i;
fork
drive_port(idx);
join_none
end
wait fork; // wait for all four
Threads started by join_none don’t run until the parent blocks, so by then the loop has finished. This is one of the most common SystemVerilog interview questions.
Part 4: Mailboxes
mailbox #(bus_txn) gen2drv = new(); // unbounded, typed
// generator
repeat (10) begin
bus_txn t;
t = new();
assert(t.randomize());
gen2drv.put(t); // blocks only if the mailbox is bounded and full
end
// driver
forever begin
bus_txn t;
gen2drv.get(t); // blocks until an item is available
drive(t);
end
try_get and try_put are non-blocking versions; peek reads without removing. A bounded mailbox (new(4)) gives back-pressure.
Part 5: Semaphores
semaphore bus_lock = new(1); // one key
task automatic use_bus();
bus_lock.get(1); // wait for the key
// ... drive the shared bus ...
bus_lock.put(1); // give it back
endtask
Part 6: Events
event reset_done;
initial begin
apply_reset();
-> reset_done; // trigger
end
initial begin
wait (reset_done.triggered); // safe even if triggered in the same time step
start_traffic();
end
@reset_done misses a trigger that happened earlier in the same time step; wait(reset_done.triggered) doesn’t, which avoids a common race.
Part 7: How UVM uses this
UVM wraps these ideas: phases decide when threads start, TLM FIFOs and analysis ports replace raw mailboxes, and objections replace hand-written “wait until done” logic. Knowing the underlying constructs makes UVM much easier to debug. See testbench architecture.
Practise this on VLSI Forge
I built VLSI Forge so you can write RTL in your browser, run it on a real simulator and check every signal in the waveform. Free, nothing to install.
Previous in my SystemVerilog series: Constrained randomisation. Next: SystemVerilog assertions.
FAQ
What is the difference between join, join_any and join_none?
join waits for all forked threads, join_any for the first one, and join_none doesn’t wait at all.
What is a mailbox in SystemVerilog?
A built-in FIFO for passing data, usually transaction objects, between threads. get blocks until data is available.
What is the difference between a mailbox and a queue?
A queue is just a data structure. A mailbox adds blocking put and get and optional bounding, so it’s safe for thread synchronisation.
Why do all my forked threads see the same loop value?
Because join_none threads start after the loop ends. Copy the loop variable into an automatic variable inside the loop before forking.
