SystemVerilog Basics

Chapter 14 of 16

Processes and Interprocess Communication: fork-join, Events, Semaphores, and Mailboxes

Launch concurrent processes explicitly with the three fork-join variants, clean up background processes with disable fork/wait fork, then coordinate them with event, semaphore, and mailbox — synchronizing, limiting resource access, and passing data between processes, the concurrency foundation real testbenches are built on.

Chapter 9 used fork...join to demonstrate the automatic lifetime gotcha, without formally covering it. A real testbench usually has several concurrent parts running at once — generating stimulus, driving signals, monitoring outputs, a scoreboard comparing results — this chapter properly covers how to explicitly launch these concurrent processes, and how they coordinate with each other.

fork...join: explicitly launching concurrent processes

Every initial/always block already runs concurrently with the others; fork...join launches additional concurrent processes from inside a single procedural block:

initial begin
  fork
    $display("process A at time %0t", $time);
    $display("process B at time %0t", $time);
  join
  $display("both done");
end

fork...join comes in three variants, differing in when the surrounding code resumes:

VariantBehavior
joinWaits for every forked process to finish before continuing
join_anyContinues as soon as any one forked process finishes (the rest keep running in the background)
join_noneDoesn't wait at all — continues immediately (every forked process runs in the background)
initial begin
  fork
    begin #10; $display("A done at %0t", $time); end
    begin #20; $display("B done at %0t", $time); end
  join_any
  $display("continuing at %0t (B might still be running)", $time);
end

In the example above, join_any lets the surrounding code continue as soon as A finishes (after 10 time units), while B is still running and continues in the background until it finishes at time unit 20.

joinjoin_anyjoin_noneresumes (waits for A and B)resumes (A done, B backgrounded)resumes now (A, B backgrounded)

disable fork and wait fork: cleaning up processes left running in the background

After join_any/join_none, some forked processes can be left running in the background — in the example above, B keeps running until time unit 20 even after join_any moves on. Left alone, that process runs to its natural end, which can cause two kinds of trouble: it might keep consuming resources or mutating shared state after you've stopped caring about it, or you might actually want to wait for it to fully finish too. Two built-in constructs exist specifically for cleaning up processes still running in the background:

initial begin
  fork
    begin #10; $display("A done at %0t", $time); end
    begin #20; $display("B done at %0t", $time); end
  join_any
  // A has finished here; B is still running in the background
 
  disable fork;   // immediately terminates every still-running child process this process forked (B, here)
  $display("B has been killed, continuing at %0t", $time);
end
  • disable fork: immediately terminates every child process the current process launched via fork that hasn't finished yet — a common case is join_any moving on once the first process wins, with the still-running loser no longer needed and killed outright to stop it from consuming resources or causing unwanted side effects.
  • wait fork: terminates nothing; instead it blocks until every child process the current process forked has finished (including ones still running in the background after join_any/join_none) — for when you genuinely need to wait for all of them, just not by blocking right at the join line.

The two are opposites: disable fork means "stop waiting, kill it now"; wait fork means "still waiting, just somewhere else."

event: a synchronization signal between processes

An event is a lightweight synchronization signal that carries no data of its own — purely a way to coordinate "has this happened yet": one process triggers it, another waits for it to be triggered:

event data_ready;
 
initial begin
  #10;
  -> data_ready;   // trigger the event
end
 
initial begin
  @(data_ready);   // block until the event is triggered
  $display("data is ready at time %0t", $time);
end

The second initial block blocks at @(data_ready); until the first block triggers the event at time 10, at which point it continues.

semaphore: managing a limited pool of shared resources

A semaphore maintains a count of "keys": a process calls get(n) to try to take n keys, blocking if there aren't enough available; once done, it calls put(n) to return them. The most common use sets the key count to 1, implementing mutual exclusion (similar to a lock in other languages):

semaphore sem = new(1);  // only 1 key — equivalent to a mutex
 
task automatic access_resource(int id);
  sem.get(1);
  $display("process %0d entering critical section at %0t", id, $time);
  #5;
  $display("process %0d leaving critical section at %0t", id, $time);
  sem.put(1);
endtask
 
initial begin
  fork
    access_resource(1);
    access_resource(2);
  join
end

Since there's only 1 key, the two concurrent calls can never both be inside the "critical section" at once: whichever process gets the key first must finish its #5 delay and call put(1) to return it before the other process can acquire the key and continue — the entering/leaving prints from the two processes never interleave.

mailbox: passing data between processes

An event can only synchronize — it carries no data. A mailbox is a first-in-first-out queue built specifically for passing data between processes: one process put()s data in, another get()s it out:

mailbox #(int) mbx = new();  // #(int) means this mailbox only carries int data
 
initial begin  // producer
  for (int i = 0; i < 3; i++) begin
    mbx.put(i);
    $display("produced %0d", i);
  end
end
 
initial begin  // consumer
  int val;
  repeat (3) begin
    mbx.get(val);
    $display("consumed %0d", val);
  end
end

get() blocks until there's data available to retrieve; if the mailbox was created with a capacity limit (e.g. new(4)), put() also blocks once the mailbox is full, until there's room. This is exactly the most common communication pattern between a "stimulus generator" and a "driver" in a classic testbench — the generator put()s transaction objects into the mailbox one by one, and the driver get()s them out and drives them onto the DUT, with the two running as independent concurrent processes that only exchange data through the mailbox.

Summary

MechanismCarriesTypical use
fork...join/join_any/join_noneExplicitly launching multiple concurrent processes, choosing how long to wait
disable fork / wait forkCleaning up child processes left running in the background after join_any/join_none: disable fork kills them, wait fork blocks until they finish
eventNo data, a pure synchronization signalNotifying "this has happened"
semaphoreA count of access permitsMutual exclusion (key count = 1) or limiting concurrent resource usage
mailboxActual data/objects, first-in-first-outPassing transactions between concurrent processes, like a stimulus generator and a driver

What's the main difference between fork ... join_any and fork ... join?

After fork ... join_any, one forked child process is still running in the background. To terminate it immediately instead of waiting for it to finish, which construct should you use?

Wrapping a block of code with semaphore sem = new(1); plus get(1)/put(1) mainly achieves what?

Which method retrieves a piece of data from a mailbox? (lowercase, no parentheses)