Why Learn Interprocess Communication? The Threads & Processes chapter covered how to run multiple processes concurrently — but concurrent processes eventually need to coordinate with each other: pass data along, take turns using a shared resource, or signal that something happened. Without a deliberate way to do that, concurrent processes race each other and produce unpredictable results. SystemVerilog gives you three built-in mechanisms for this, and each one solves a distinctly different coordination problem.

Components in a testbench often need to communicate with each other to exchange data and check output values of the design. A few mechanisms allow components or threads to affect each other's control flow:

MechanismWhat it's for
EventsDifferent threads synchronize with each other via event handles
SemaphoresDifferent threads take turns accessing the same shared resource
MailboxThreads or components exchange data — data is put into a mailbox and sent

8 min read | Intermediate Level

What You'll Learn

  • What an event, a semaphore, and a mailbox each solve, and how they differ
  • How to trigger and wait for an event across two separate processes
  • How a semaphore lets only a limited number of processes access a shared resource at once
  • How a mailbox passes data safely from one component to another

What Are Events?

An event is a way to synchronize two or more different processes. One process waits for the event to happen while another process triggers it. When the event is triggered, the process waiting for it resumes execution.

1. Create an event using event


event 	eventA;  	// Creates an event called "eventA"

2. Trigger an event using the -> operator


->eventA; 		// Any process that has access to "eventA" can trigger the event

3. Wait for the event to happen


@eventA; 						// Use "@" to wait for an event
wait (eventA.triggered);		// Or use a wait statement with "eventA.triggered"

4. Pass events as arguments to tasks or functions


module tb_top;
	event eventA; 		// Declare an event handle called  "eventA"

	initial begin
		fork
			waitForTrigger (eventA);    // Task waits for eventA to happen
			#5 ->eventA;                // Triggers eventA
		join
	end

	// The event is passed as an argument to this task. It simply waits for the event
	// to be triggered
	task waitForTrigger (event eventA);
		$display ("[%0t] Waiting for EventA to be triggered", $time);
		wait (eventA.triggered);
		$display ("[%0t] EventA has triggered", $time);
	endtask
endmodule

Output

Output
[0] Waiting for EventA to be triggered
[5] EventA has triggered
ncsim: *W,RNQUIE: Simulation is complete.

SystemVerilog Event covers this in more depth.

Quick Check

Q: If ->eventA; fires, and only 5ns later does a separate process execute @eventA;, will that process see the trigger?

Show Answer

No. An event isn't queued or remembered — a trigger only wakes up processes that are actively waiting for it at that exact moment. A process that starts waiting after the trigger already happened will simply keep waiting for the next trigger, if one ever comes.

What's a Semaphore?

Say you wanted to rent a room in a library for a few hours. The admin desk gives you a key to use the room for the time you've requested. After you're done, you return the key, which is then given to someone else who wants to use the same room — this way, two people never use the room at the same time. The key is a semaphore in this context.

A semaphore is used to control access to a resource and is also known as a mutex (mutually exclusive), because only one entity can hold it at a time:


module tb_top;
   semaphore key; 				// Create a semaphore handle called "key"

   initial begin
      key = new (1); 			// Create only a single key; multiple keys are also possible
      fork
         personA (); 			// personA tries to get the room and puts it back after work
         personB (); 			// personB also tries to get the room and puts it back after work
         #25 personA (); 		// personA tries to get the room a second time
      join_none
   end

   task getRoom (bit [1:0] id);
      $display ("[%0t] Trying to get a room for id[%0d] ...", $time, id);
      key.get (1);
      $display ("[%0t] Room Key retrieved for id[%0d]", $time, id);
   endtask

   task putRoom (bit [1:0] id);
      $display ("[%0t] Leaving room id[%0d] ...", $time, id);
      key.put (1);
      $display ("[%0t] Room Key put back id[%0d]", $time, id);
   endtask

   // This person tries to get the room immediately and puts
   // it back 20 time units later
   task personA ();
      getRoom (1);
      #20 putRoom (1);
   endtask
  
  // This person tries to get the room after 5 time units and puts it back after
  // 10 time units
   task personB ();
      #5  getRoom (2);
      #10 putRoom (2);
   endtask
endmodule

Output

Output
[0] Trying to get a room for id[1] ...
[0] Room Key retrieved for id[1]
[5] Trying to get a room for id[2] ...
[20] Leaving room id[1] ...
[20] Room Key put back id[1]
[20] Room Key retrieved for id[2]
[25] Trying to get a room for id[1] ...
[30] Leaving room id[2] ...
[30] Room Key put back id[2]
[30] Room Key retrieved for id[1]
[50] Leaving room id[1] ...
[50] Room Key put back id[1]

key starts with a single available token. personA grabs it immediately at 0ns. When personB tries at 5ns, key.get(1) blocks — there's nothing left to give — until personA returns the key at 20ns, at which point personB's blocked call immediately succeeds. The same pattern repeats when personA's second call tries to get the key at 25ns while personB still holds it, and has to wait until 30ns.

A few things worth noting about semaphores:

  • A semaphore object like key is declared and created with new(); the argument to new() sets how many keys exist
  • You get a key with get(), which blocks (waits) until one is available
  • You put a key back with put()

SystemVerilog Semaphore covers this in more depth.

Quick Check

Q: If key = new(2) instead of new(1), would personB's getRoom(2) call at 5ns still have to wait for personA to return anything?

Show Answer

No. With 2 keys available, both personA (at 0ns) and personB (at 5ns) could each get their own key without blocking — only a third concurrent request, with both keys already out, would have to wait.

What's a Mailbox?

A mailbox is like a dedicated channel for sending data between two components. A mailbox can be created and its handle passed to a data generator and a driver: the generator pushes a data object into the mailbox, and the driver retrieves it and drives the signals onto the bus.


// Data packet in this environment
class transaction;
   rand bit [7:0] data;

   function display ();
      $display ("[%0t] Data = 0x%0h", $time, data);
   endfunction
endclass

// Generator class - Generate a transaction object and put into mailbox
class generator;
   mailbox mbx;

   function new (mailbox mbx);
      this.mbx = mbx;
   endfunction

   task genData ();
      transaction trns = new ();
      trns.randomize ();
      trns.display ();
      $display ("[%0t] [Generator] Going to put data packet into mailbox", $time);
      mbx.put (trns);
      $display ("[%0t] [Generator] Data put into mailbox", $time);
   endtask
endclass

// Driver class - Get the transaction object from Generator
class driver;
   mailbox mbx;
  
   function new (mailbox mbx);
      this.mbx = mbx;
   endfunction

   task drvData ();
      transaction drvTrns = new ();
      $display ("[%0t] [Driver] Waiting for available data", $time);
      mbx.get (drvTrns);
      $display ("[%0t] [Driver] Data received from Mailbox", $time);
      drvTrns.display ();
   endtask
endclass

// Top Level environment that will connect Gen and Drv with a mailbox
module tb_top;
   mailbox   mbx;
   generator Gen;
   driver    Drv;

   initial begin
      mbx = new ();
      Gen = new (mbx);
      Drv = new (mbx);

      fork
         #10 Gen.genData ();
         Drv.drvData ();
      join_none
   end
endmodule

Output

Output
[0] [Driver] Waiting for available data
[10] Data = 0x9d
[10] [Generator] Put data packet into mailbox
[10] [Generator] Data put into mailbox
[10] [Driver] Data received from Mailbox
[10] Data = 0x9d
ncsim: *W,RNQUIE: Simulation is complete.

The code's $display statement reads "Going to put data packet into mailbox," but the captured output above shows "Put data packet into mailbox" instead, with "Going to" missing. This looks like the code and the captured log have drifted out of sync somewhere — worth verifying against the source before publishing, since the two don't currently match word for word.

Drv starts waiting on an empty mailbox at 0ns. Gen creates and randomizes a transaction at 10ns, then puts it into the mailbox — which immediately unblocks Drv's pending get() call. Both trns and drvTrns print the exact same data value, because mbx.get(drvTrns) reassigns the drvTrns handle to point at the very same object Gen put in — it's not a copy.

SystemVerilog Mailbox covers this in more depth.

Quick Check

Q: If Drv were removed from the fork block entirely, so nothing ever called mbx.get(), what would happen to Gen's mbx.put(trns) call?

Show Answer

It would still succeed immediately. A mailbox created with new() and no size argument is unbounded, so put() doesn't need a waiting receiver to succeed — the transaction would simply sit in the mailbox, unread, until something eventually calls get().

Where Are These Used in a Real Testbench?

Events are common for synchronizing on things like reset deassertion or a test phase starting. Semaphores show up wherever multiple components share one physical resource, like a single bus that only one driver can use at a time. Mailboxes are the standard way a generator hands transactions off to a driver — the same producer-consumer pattern UVM's sequencer-to-driver connection is built on.

Common Beginner Mistakes

Mistake #1: Assuming an event trigger is remembered if no one's waiting yet

As the Quick Check above shows, a trigger that fires with nobody currently waiting on it is simply lost. Why? Events aren't queued the way mailbox data is — if the timing between the trigger and the wait doesn't line up, the waiting process can end up stuck indefinitely.

Mistake #2: Forgetting to put a semaphore key back

Every get() needs a matching put(), or the key never becomes available again. Why? If a task takes a key and then exits early (through an error path, for instance) without calling put(), every other process waiting for that same key blocks forever — a classic deadlock.

Mistake #3: Assuming mailbox.get() always blocks

get() only blocks when the mailbox is currently empty. Why? If data is already sitting in the mailbox when get() is called, it returns immediately — the blocking behavior only kicks in when there's genuinely nothing there yet.

Practice Exercise

Challenge: Give the Library Two Rooms

Using the semaphore example above, change the library to have 2 rooms available instead of 1, and add a third person, personC, who tries to get a room at time 0, alongside personA.

Hint

Only the argument to new() and the fork list need to change — the getRoom/putRoom tasks themselves don't need any modification.

Solution

module tb_top;
   semaphore key;

   initial begin
      key = new (2);    // Two rooms available now
      fork
         personA ();
         personC ();     // Tries to get a room at time 0, alongside personA
         personB ();
         #25 personA ();
      join_none
   end

   // getRoom, putRoom, personA, personB tasks unchanged

   task personC ();
      getRoom (3);
      #15 putRoom (3);
   endtask
endmodule

With 2 keys available, both personA and personC can get a room at 0ns without either one blocking — only once both keys are out does a third concurrent request have to wait, exactly like personB did against a single key in the original example.

Summary

In this tutorial, you learned:

  • Events synchronize two or more processes — one triggers with ->, another waits with @ or wait(event.triggered), but a trigger with nobody waiting is simply lost
  • Semaphores control access to a shared resource using a limited number of keys, obtained with get() and returned with put()
  • Mailboxes pass data between components — put() and get() hand a class handle across, not a copy of the object
  • mailbox.get() only blocks when the mailbox is empty; a semaphore's get() only blocks when no key is currently available
  • Each mechanism solves a different coordination problem — see SystemVerilog Event, SystemVerilog Semaphore, and SystemVerilog Mailbox for the full depth on each
Did you find what you were looking for?

Quiz

Test Your Knowledge

1 of 5 Score: 0 / 5