A mailbox lets different processes exchange data with each other — similar to a real postbox, where letters are put into the box and retrieved later by someone else. A mailbox can be created with either a bounded or unbounded queue size: a bounded mailbox only holds a limited number of messages, and a process that tries to store more into a full mailbox is suspended until there's room; an unbounded mailbox has no such limit at all.

There are two types of mailboxes: a generic mailbox that accepts items of any data type, and a parameterized mailbox constrained to accept only one specific type.

10 min read | Intermediate Level

What You'll Learn

  • The difference between a mailbox and a plain queue
  • How a bounded mailbox blocks a producer once it's full, and how to read num() safely
  • The full set of mailbox methods — blocking, non-blocking, and look-without-removing variants
  • How to parameterize a mailbox to a specific data type, and why that's good practice
  • The difference between a run-time type mismatch in a generic mailbox and a compile-time one in a parameterized mailbox

SystemVerilog Mailbox vs Queue

Although a mailbox essentially behaves like a queue, it's quite different from the queue data type. A plain queue can push and pop items from either the front or the back, and you can index into it directly. A mailbox, on the other hand, is a built-in class that uses semaphores internally to give atomic control over pushing and popping — you can't access a specific index within it, only retrieve items in FIFO order.

Producer puts items into a bounded mailbox and the consumer gets them in FIFO order

A Generic Mailbox Example (Bounded)

Two processes run concurrently below: one puts data into the mailbox, and the other gets data from it. The mailbox here is bounded to hold at most 2 items at once:


module tb;
	// Create a new mailbox that can hold utmost 2 items
  	mailbox 	mbx = new(2);
  
  	// Block1: This block keeps putting items into the mailbox
  	// The rate of items being put into the mailbox is 1 every ns
  	initial begin
		for (int i=0; i < 5; i++) begin
        	#1 mbx.put (i);
        	$display ("[%0t] Thread0: Put item #%0d, size=%0d", $time, i, mbx.num());
      	end
    end
  
  	// Block2: This block keeps getting items from the mailbox
  	// The rate of items received from the mailbox is 1 item every 2ns
	initial begin
		forever begin
			int idx;
			#2 mbx.get (idx);
          	$display ("[%0t] Thread1: Got item #%0d, size=%0d", $time, idx, mbx.num());
		end
	end
endmodule

Output

Output
ncsim> run
[1] Thread0: Put item #0, size=1
[2] Thread1:   Got item #0, size=0
[2] Thread0: Put item #1, size=1
[3] Thread0: Put item #2, size=2
[4] Thread1:   Got item #1, size=1
[4] Thread0: Put item #3, size=2
[6] Thread1:   Got item #2, size=1
[6] Thread0: Put item #4, size=2
[8] Thread1:   Got item #3, size=1
[10] Thread1:   Got item #4, size=0
ncsim: *W,RNQUIE: Simulation is complete.

Following the producer's own timing on its own, items would be put at 1, 2, 3, 4, and 5ns. But watch what happens to the last one: by 4ns, the mailbox is already full (size 2, its bound), and the producer's fifth put() — due at 5ns — has nothing to do but block. It only completes at 6ns, the moment the consumer's next get() frees up a slot. That's a bounded mailbox's flow control in action: the producer is throttled to however fast the consumer can actually keep up, not however fast it wants to produce.

There's a race between the two threads where the producer can push and the consumer can pop in the exact same delta cycle (as happens at 2, 4, and 6ns above). Because of that, the value num() reports is only valid at the instant it's printed — it can change again on the very next get() or put(), and its exact value at a shared timestamp can depend on which operation's statements happen to execute first.

Quick Check

Q: If the mailbox above were created with new(3) instead of new(2), would the producer's fifth put() still have to block?

Show Answer

No. With room for 3 items, the mailbox would never fill up fast enough to block the producer at this rate — every put() would succeed exactly on schedule, at 1, 2, 3, 4, and 5ns, with no delay.

try_put(): Checking for Room Without Blocking

The example above shows put() blocking once the mailbox is full. Sometimes blocking isn't what you want — you'd rather check whether there's room right now, and do something else immediately if there isn't. That's what try_put() is for:


module tb;
  mailbox mbx = new (1);   // bound = 1, room for only one item

  initial begin
    mbx.put (10);
    $display ("[%0t] Put item 10, size=%0d", $time, mbx.num());

    // The mailbox is already full - try_put() fails instead of blocking
    if (mbx.try_put (20))
      $display ("[%0t] try_put(20) succeeded", $time);
    else
      $display ("[%0t] try_put(20) failed - mailbox is full, moving on instead of blocking", $time);

    #5 begin
      int x;
      mbx.get (x);
      $display ("[%0t] Got item %0d, size=%0d", $time, x, mbx.num());
    end

    // Now that item 10 has been removed, there's room again
    if (mbx.try_put (20))
      $display ("[%0t] try_put(20) succeeded now that there's room", $time);
  end
endmodule

At 0ns, the mailbox is already full after the first put(), so try_put(20) fails immediately and returns 0 — unlike put(), it never waits around to see if room eventually opens up. At 5ns, once get() removes item 10 and frees the only slot, the exact same try_put(20) call now succeeds right away.

Quick Check

Q: If mbx.put(20) (the blocking version) had been used instead of try_put(20) at 0ns in the example above, what would happen?

Show Answer

It would block at 0ns and only complete at 5ns, once get() frees a slot — the same outcome try_put() eventually reaches, just without ever reporting failure or letting the rest of the code run in the meantime.

Mailbox Methods

MethodDescription
function new (int bound = 0);Returns a mailbox handle; bound > 0 sets the maximum queue size (0 means unbounded)
function int num ();Returns the number of messages currently in the mailbox
task put (singular message);Blocking method that stores a message in FIFO order
function int try_put (singular message);Non-blocking version of put(); returns a positive integer on success, 0 if the mailbox is full
task get (ref singular message);Blocking method that retrieves one message, blocking the process if the mailbox is empty
function int try_get (ref singular message);Non-blocking version of get(); returns 0 if the mailbox is empty
task peek (ref singular message);Copies one message without removing it from the queue
function int try_peek (ref singular message);Non-blocking version of peek()

peek() and try_peek(): Looking Without Removing

peek() copies whatever's at the front of the mailbox into a variable, but leaves it in the queue — unlike get(), which removes it:


module tb;
  mailbox mbx = new ();

  initial begin
    int peeked, gotten;

    mbx.put (42);
    $display ("[%0t] Put item 42, size=%0d", $time, mbx.num());

    // Look at the item without removing it
    mbx.peek (peeked);
    $display ("[%0t] Peeked item %0d, size=%0d", $time, peeked, mbx.num());

    // Now actually remove it
    mbx.get (gotten);
    $display ("[%0t] Got item %0d, size=%0d", $time, gotten, mbx.num());

    // Mailbox is empty now - try_peek() fails instead of blocking
    if (mbx.try_peek (peeked))
      $display ("[%0t] try_peek succeeded", $time);
    else
      $display ("[%0t] try_peek found the mailbox empty", $time);
  end
endmodule

After peek(peeked), peeked holds 42, but num() still reports 1 — the item is still sitting in the mailbox. Only the subsequent get(gotten) actually removes it, dropping the size to 0. Once empty, try_peek() behaves like try_get(): it fails and returns immediately instead of blocking.

Like peek() and get(), try_peek() and try_get() form the same pair — one leaves the item in place, one removes it — just without ever blocking if the mailbox is empty.

Parameterized Mailboxes

By default, a mailbox is typeless, and can send and receive objects of mixed data types. That flexibility can also cause type mismatches that only surface at simulation time. Here's exactly what that looks like — a generic mailbox accepts a string being put in, with nothing stopping a completely unrelated type from trying to read it back out:


module tb;
  mailbox mbx = new ();   // generic - accepts any type

  initial begin
    string s = "hello";
    int    received;

    mbx.put (s);            // put a string in
    mbx.get (received);     // try to get it out as an int
    $display ("[%0t] Received = %0d", $time, received);
  end
endmodule

This compiles without complaint, since a generic mailbox doesn't check that what goes in matches what comes out. At run time, though, get(received) is trying to unpack a string into an int — a mismatch most simulators catch as a run-time error, well after the code has already been compiled, built, and started running.

This is the run-time counterpart to the compile-time TYCMPAT error shown later in this article for a mismatched parameterized mailbox. The failure mode is the same idea — sending one type where another is expected — but a generic mailbox only catches it once the simulation is already running, while a parameterized mailbox catches it before simulation even starts.

Parameterizing a mailbox to a specific data type avoids that risk entirely, catching mismatches at compile time instead.

Below, an alias for a string-typed mailbox is created with typedef (optional, but good practice), and two components — comp1 and comp2 — connect through it, with comp1 sending a few strings that comp2 receives:


// Create alias for parameterized "string" type mailbox
typedef mailbox #(string) s_mbox;

// Define a component to send messages
class comp1;

  	// Create a mailbox handle to put items
  	s_mbox 	names;
  
	// Define a task to put items into the mailbox
	task send ();
		for (int i = 0; i < 3; i++) begin
			string s = $sformatf ("name_%0d", i);
          #1 $display ("[%0t] Comp1: Put %s", $time, s);
			names.put(s);  
		end
	endtask
endclass

// Define a second component to receive messages
class comp2;
  
	// Create a mailbox handle to receive items
	s_mbox 	list;
  
  
  	// Create a loop that continuously gets an item from
  	// the mailbox
	task receive ();
		forever begin
			string s;
			list.get(s);
          	$display ("[%0t]    Comp2: Got %s", $time, s);
		end
	endtask
endclass

// Connect both mailbox handles at a higher level
module tb;
  	// Declare a global mailbox and create both components
  	s_mbox 	m_mbx    = new();
  	comp1 	m_comp1  = new();
  	comp2 	m_comp2  = new();
  
  	initial begin
      // Assign both mailbox handles in components with the
      // global mailbox
      m_comp1.names = m_mbx;
      m_comp2.list = m_mbx;
  
      // Start both components, where comp1 keeps sending
      // and comp2 keeps receiving
      fork
      	m_comp1.send();
        m_comp2.receive();
      join
    end
endmodule

Output

Output
ncsim> run
[1] Comp1: Put name_0
[1]    Comp2: Got name_0
[2] Comp1: Put name_1
[2]    Comp2: Got name_1
[3] Comp1: Put name_2
[3]    Comp2: Got name_2
ncsim: *W,RNQUIE: Simulation is complete.

m_mbx is created with new() and no bound, so it's unbounded — every put() succeeds immediately, and since comp2's get() is already waiting the moment each string arrives, both the put and the get for a given string happen in the same time step.

Quick Check

Q: Both m_comp1.names and m_comp2.list are assigned m_mbx in the testbench. Does each component get its own independent copy of the mailbox?

Show Answer

No. A mailbox handle behaves like any other class handle — assigning it doesn't copy anything, it just makes both variables reference the exact same underlying mailbox object. That's precisely what lets comp1's put() and comp2's get() operate on the same queue.

Matching Different Type Mailboxes

Consider what happens if comp1 used a string-typed mailbox but comp2 used a byte-typed one instead:


class comp2;
	mailbox #(byte) 	list;

	...
endclass

module tb;
	s_mbox 	m_mbx;
	...

	initial begin
		m_comp1.names = m_mbx;
		m_comp2.list  = m_mbx;
	end
endmodule

Output

Output
 m_comp2.list = m_mbx;
                         |
ncvlog: *E,TYCMPAT (testbench.sv,34|25): assignment operator type check failed (expecting datatype compatible with 'mailbox' but found '$unit::s_mbox (mailbox)' instead).

This is a compile-time error, not a run-time one — exactly the benefit of parameterizing a mailbox instead of leaving it typeless. A typeless mailbox connecting a string producer to a byte consumer would compile fine and only fail (or silently misbehave) once the mismatched data actually flowed through it.

Where Is a Mailbox Used?

A mailbox is the classic way to connect a generator to a driver — the generator creates transaction objects and puts them in, the driver gets them out and drives the corresponding signals onto the bus. A bounded mailbox specifically models a slower consumer applying back-pressure on a faster producer, which is exactly the flow-control behavior shown in the first example above.

Common Beginner Mistakes

Mistake #1: Assuming an unbounded mailbox provides flow control

A mailbox created with plain new() (no bound) never blocks a producer, no matter how far ahead of the consumer it gets. Why? If backpressure is actually what you want — keeping a fast producer from running arbitrarily far ahead of a slow consumer — the mailbox needs an explicit bound; an unbounded one just keeps growing.

Mistake #2: Trusting num() as a stable snapshot

As the note in the first example explains, num() can change on the very next get() or put(), including ones happening in the same delta cycle. Why? Using it to make a decision (like "should I put more items?") without accounting for that race can lead to logic that behaves differently across simulator runs or seeds.

Mistake #3: Leaving a mailbox typeless when a fixed type is known

A generic mailbox accepts any data type, which means a type mismatch between a producer and consumer only shows up when the wrong data actually gets used, if it's ever caught at all. Why? Parameterizing the mailbox to the type you actually intend to send catches that mismatch at compile time instead, exactly as the TYCMPAT error above demonstrates.

Practice Exercise

Challenge: Predict the Blocking Point

Using the first bounded mailbox example, suppose the bound were changed from new(2) to new(1), with the same producer (1 item/ns) and consumer (1 item/2ns) rates. At what time would the producer's second put() (item 1) actually complete?

Hint

Work out the mailbox's size at each timestamp with a bound of 1, the same way the article traces it for a bound of 2, and find the first point where a put() would have to wait for the consumer.

Solution

2ns. Item 0 is put at 1ns (size 0→1, now full since the bound is 1). The producer's second put() is due at 2ns, but the mailbox is still full at that instant — until the consumer's own get(), also scheduled for 2ns, retrieves item 0 first (size 1→0). Since both events land at exactly 2ns, the get frees the single slot just in time for the put to complete in that same time step, landing right at 2ns rather than being pushed further out.

Summary

In this tutorial, you learned:

  • A mailbox exchanges data between processes in FIFO order, using semaphores internally for atomic push/pop control — unlike a plain queue, you can't index into it directly
  • A bounded mailbox blocks put() once full, providing flow control between a producer and a slower consumer; an unbounded one never blocks a producer at all
  • num() is only a snapshot valid at the instant it's read, and can change on the very next operation
  • put()/get() block; try_put()/try_get() don't; peek()/try_peek() copy a message without removing it
  • A generic mailbox can accept a type mismatch that only fails at run time; parameterizing a mailbox to a specific type catches that same class of mistake at compile time instead
Did you find what you were looking for?

Quiz

Test Your Knowledge

1 of 5 Score: 0 / 5