Skip to main content

Interprocess Communication

Processes and Fork-Join showed how to run multiple processes concurrently, but said nothing about how they coordinate — how a driver process hands a generated transaction off to a monitor process, or how several processes share one limited resource without stepping on each other. SystemVerilog provides three built-in primitives for exactly this, each suited to a different kind of coordination.

mailbox: passing data between processes​

mailbox #(transaction) mbx = new();

// producer process
task run_producer();
transaction tr;
forever begin
tr = new();
tr.randomize();
mbx.put(tr); // blocks if the mailbox is full (bounded) — hands off tr
end
endtask

// consumer process
task run_consumer();
transaction tr;
forever begin
mbx.get(tr); // blocks until a transaction is available
tr.display();
end
endtask

A mailbox is a FIFO queue built specifically for passing data safely between concurrent processes — put() adds an item (blocking if the mailbox has a bounded size and is currently full), get() removes the oldest item (blocking until one is available). This is exactly the mechanism a driver and a monitor (or a generator and a driver) use to hand transactions to each other without either process needing to poll or manually synchronize timing — get() simply waits until the other side has something ready. mailbox #(transaction) — a parameterized class, exactly like the fifo #(T) class from Static and Parameterized Classes — restricts this specific mailbox to holding only transaction objects, catching a type mismatch at compile time rather than at runtime.

Passing new(N) instead of new() bounds the mailbox to at most N pending items — put() then blocks once the mailbox is full, until a get() makes room. An unbounded mailbox (new(), no argument) never blocks on put(), which is simpler but risks silently accumulating unconsumed items if the consumer falls behind the producer.

A mailbox is not just a queue (from Dynamic and Associative Arrays) with different method names — a queue has no built-in blocking behavior at all, so two processes sharing one queue would still need to hand-roll their own wait/poll logic to avoid a get-equivalent running before anything has been pushed. A mailbox's put()/get() are specifically process-synchronizing: get() genuinely suspends the calling process until data exists, which a plain queue's pop_front() cannot do on its own.

semaphore: limiting concurrent access to a shared resource​

semaphore bus_lock = new(1); // 1 key available — models a single shared resource

task access_bus();
bus_lock.get(); // wait for, then take, a key
// ... use the shared resource ...
bus_lock.put(); // return the key
endtask

A semaphore manages a pool of keys — get() blocks until a key is available, then takes one; put() returns a key to the pool. Initializing with new(1) (as above) models a resource only one process can use at a time (a shared bus, for instance) — any process trying to get() while the single key is checked out blocks until the process holding it calls put(). A larger initial count (new(4), say) models a resource with several interchangeable instances available concurrently (four parallel DMA channels, for instance) rather than a single exclusive one.

event: a pure synchronization signal, no data​

event data_ready;

// process A
task produce();
// ... prepare data ...
-> data_ready; // trigger the event
endtask

// process B
task consume();
@(data_ready); // wait for the event to be triggered
$display("data is ready");
endtask

An event carries no data at all — it's a pure synchronization signal, triggered with -> and waited on with @(event_name), for cases where one process just needs to tell another "something happened now," with the actual data (if any) already passed some other way (a shared variable, or more commonly a mailbox). This is the lightest-weight of the three primitives — reach for event when only timing coordination is needed, mailbox when data needs to move between processes, and semaphore when access to a limited shared resource needs to be rationed.

Like a class handle, an event variable can be assigned to another: event e2 = e1; makes e2 refer to the same underlying synchronization object as e1, not a second independent event — triggering either name (-> e1; or -> e2;) satisfies a @(e1) or @(e2) wait on the other, since both names now point at one shared event.

Non-blocking variants, and looking without removing​

Every primitive above has a blocking default — but each also offers a way to check without blocking, or to look at data without consuming it:

// mailbox: peek without removing, or try without blocking
transaction tr;
if (mbx.try_get(tr)) // non-blocking: returns 0 immediately if empty, nonzero if it got something
tr.display();
mbx.peek(tr); // blocking, but leaves the item in the mailbox — a copy, not a removal
$display("pending items: %0d", mbx.num()); // how many items are currently queued

// semaphore: non-blocking key request, and requesting/returning multiple keys at once
if (bus_lock.try_get()) // non-blocking: returns 0 immediately if no key available
// ... got a key without blocking ...
dma_lock.get(2); // block until 2 keys are available, take both at once
dma_lock.put(2); // return both keys together

try_get()/try_put() are the non-blocking counterparts of get()/put() on both mailbox and semaphore — instead of blocking until an item/key is available, they return immediately: nonzero on success, 0 if the mailbox was empty (or full, for try_put()) or no key was available. peek() on a mailbox is like get() but doesn't remove the item — useful for a process that needs to inspect what's next without consuming it. num() reports how many items are currently queued in a mailbox. On a semaphore, get(N)/put(N) request or return N keys in one call, for a resource where a single operation legitimately needs multiple interchangeable instances at once (two of four DMA channels, for example), rather than calling get()/put() one key at a time.

event.triggered, and the non-blocking trigger ->>​

event data_ready;

// process A
task produce();
->> data_ready; // non-blocking trigger: fires in the NBA region of this time step
endtask

// process B — an alternative to @(data_ready)
task consume();
wait (data_ready.triggered);
$display("data is ready");
endtask

event_name.triggered is a persistent, readable property that evaluates true if that event was triggered at any point during the current time step, and false otherwise — checked with wait (event_name.triggered) as an alternative to @(event_name). The difference matters for a specific race: if the trigger (->) and the wait (@(event_name)) happen in the same time step but in the wrong order, @(event_name) can miss a trigger that already fired earlier that same step — wait (event_name.triggered) doesn't have that ordering hazard, since it reads a state rather than catching a one-shot edge. ->> (non-blocking trigger) fires the event in the NBA (non-blocking assignment) region of the current time step instead of immediately — a delayed variant of ->, useful for the same kind of ordering control non-blocking assignments (<=) give over blocking ones (=) elsewhere in the language.

Choosing between the three​

NeedReach for
Hand data from one process to another, FIFO ordermailbox
Limit how many processes can access a shared resource concurrentlysemaphore
Signal "this happened" with no data attachedevent

Why this matters for testbenches specifically​

A realistic layered testbench is, structurally, exactly this: a generator process randomizing transactions and put()-ing them into a mailbox, a driver process get()-ing them out and driving the DUT, a monitor process independently watching the DUT's pins and put()-ing observed transactions into a different mailbox for a scoreboard to get() and check. Testbench builds that architecture out explicitly; this page's job was only to introduce the raw coordination primitives it's built from. mailbox in particular is exactly what UVM's TLM analysis ports and sequencer/driver handshake are built on top of internally, even though a UVM testbench itself is written against UVM's own higher-level API rather than raw mailboxes.

What's next​

Section D closes out concurrency and randomization. Section E turns to a different kind of testbench code entirely — one that doesn't generate or move stimulus, but checks it: SystemVerilog Assertions, starting with the simplest form, immediate assertions.