An event is a static object handle used to synchronize between two or more concurrently active processes. One process triggers the event, and another process waits for it. Events can also be assigned or compared to other event variables, assigned null, and passed to queues, functions, and tasks — and this article covers a genuine race condition between the two different ways of waiting on one, which is worth understanding well before relying on events in a real testbench.
event over; // a new event is created called over
event over_again = over; // over_again becomes an alias to over
event empty = null; // event variable with no synchronization object
8 min read | Intermediate Level
What You'll Learn
- How to trigger and wait for an event using
->and either@or.triggered - The race condition that can make
@silently miss a trigger, and why.triggereddoesn't have that problem - How the non-blocking
->>trigger operator avoids that same race without needing.triggered - How to enforce a specific firing order across multiple events with
wait_order - What happens when one event variable is assigned to another while other processes are already waiting
How to Trigger and Wait for an Event
Named events are triggered using the -> or ->> operator. Processes can wait for an event using the @ operator, or by checking .triggered:
module tb;
// Create an event variable that processes can use to trigger and wait
event event_a;
// Thread1: Triggers the event using "->" operator
initial begin
#20 ->event_a;
$display ("[%0t] Thread1: triggered event_a", $time);
end
// Thread2: Waits for the event using "@" operator
initial begin
$display ("[%0t] Thread2: waiting for trigger ", $time);
@(event_a);
$display ("[%0t] Thread2: received event_a trigger ", $time);
end
// Thread3: Waits for the event using ".triggered"
initial begin
$display ("[%0t] Thread3: waiting for trigger ", $time);
wait(event_a.triggered);
$display ("[%0t] Thread3: received event_a trigger", $time);
end
endmodule
Output
ncsim> run [0] Thread2: waiting for trigger [0] Thread3: waiting for trigger [20] Thread1: triggered event_a [20] Thread2: received event_a trigger [20] Thread3: received event_a trigger ncsim: *W,RNQUIE: Simulation is complete.
Both Thread2 and Thread3 are already parked and waiting well before Thread1 triggers event_a at 20ns, so both wake up at exactly that time — here, @ and .triggered behave identically. That's not always the case, though.
What's the Difference Between @ and .triggered?
An event's triggered state persists for the entire current time step, until simulation advances. If both the wait and the trigger happen at the same simulation time, there's a race condition — and .triggered is specifically designed to avoid it. A process waiting on .triggered always unblocks, regardless of whether the wait or the trigger happened first within that time step. A process waiting with @ does not have that guarantee — it only catches a trigger if it's already actively parked waiting at the exact moment the trigger occurs:
module tb;
// Create an event variable that processes can use to trigger and wait
event event_a;
// Thread1: Triggers the event using "->" operator at 20ns
initial begin
#20 ->event_a;
$display ("[%0t] Thread1: triggered event_a", $time);
end
// Thread2: Starts waiting for the event using "@" operator at 20ns
initial begin
$display ("[%0t] Thread2: waiting for trigger ", $time);
#20 @(event_a);
$display ("[%0t] Thread2: received event_a trigger ", $time);
end
// Thread3: Starts waiting for the event using ".triggered" at 20ns
initial begin
$display ("[%0t] Thread3: waiting for trigger ", $time);
#20 wait(event_a.triggered);
$display ("[%0t] Thread3: received event_a trigger", $time);
end
endmodule
Output
ncsim> run [0] Thread2: waiting for trigger [0] Thread3: waiting for trigger [20] Thread1: triggered event_a [20] Thread3: received event_a trigger ncsim: *W,RNQUIE: Simulation is complete.
Thread2 never prints "received." Both Thread2 and Thread1 reach their key statements at the exact same simulation time, 20ns, and it comes down to scheduling order which one runs first — if Thread1's trigger fires before Thread2 has actually started its @(event_a) wait, the trigger is simply missed, since @ only catches an edge it's already watching for. Thread3 has no such problem: .triggered stays true for the rest of that same time step, so it doesn't matter which statement happened to execute first.
Quick Check
Q: In the example above, why does Thread3 still succeed even though it also only starts waiting at exactly 20ns, the same time Thread1 triggers the event?
Show Answer
Because event_a.triggered remains true for the entire 20ns time step once the trigger fires, not just for the instant it happens. Whether Thread3 checks it slightly before or slightly after Thread1's trigger within that same time step, the check still succeeds — unlike @, which only catches a trigger it's already actively parked waiting for.
Non-Blocking Trigger: ->>
There's a second way to trigger an event: the ->> operator. Where -> notifies every waiting process instantly, ->> schedules that notification for slightly later in the very same time step — specifically, the non-blocking region, the same scheduling region where <= assignments settle. That small delay is exactly what fixes the race condition from the previous section:
module tb;
// Create an event variable that processes can use to trigger and wait
event event_a;
// Thread1: Triggers the event using the non-blocking "->>" operator at 20ns
initial begin
#20 ->>event_a;
$display ("[%0t] Thread1: triggered event_a", $time);
end
// Thread2: Also starts waiting for the event using "@" at 20ns - the exact
// same race setup that made Thread2 miss the trigger with "->" earlier
initial begin
$display ("[%0t] Thread2: waiting for trigger ", $time);
#20 @(event_a);
$display ("[%0t] Thread2: received event_a trigger ", $time);
end
endmodule
This is the identical race condition from the @ vs .triggered example earlier — both processes reach their key statement at the exact same simulation time, 20ns. But this time, Thread2 does receive the trigger. Because ->> defers the actual notification to the non-blocking region, Thread2's @(event_a) has already registered itself as a waiter (which happens in the active region, earlier in the same time step) before the deferred notification ever fires — regardless of which of the two processes happened to run first.
This is what makes ->> useful for deterministic behavior when several processes are waiting on the same event: unlike ->, the outcome no longer depends on which process the simulator happens to schedule first within that time step.
Quick Check
Q: Why doesn't ->> have the same missed-trigger problem that plain -> has with @?
Show Answer
Because ->> doesn't notify waiting processes immediately — it schedules that notification for the non-blocking region, which always runs after every process's active-region code (including a process just now reaching its @(event) statement) has already executed for that time step. By the time the notification actually fires, any process that reached its wait statement in the same time step is already registered and waiting for it.
wait_order: Enforcing a Specific Order
wait_order waits for a list of events to be triggered in the given order, and takes an else branch if any of them fire out of order:
module tb;
// Declare three events that can be triggered separately
event a, b, c;
// This block triggers each event one by one
initial begin
#10 -> a;
#10 -> b;
#10 -> c;
end
// This block waits until each event is triggered in the given order
initial begin
wait_order (a,b,c)
$display ("Events were executed in the correct order");
else
$display ("Events were NOT executed in the correct order !");
end
endmodule
Output
Compiler version J-2014.12-SP1-1; Runtime version J-2014.12-SP1-1;
Events were executed in the correct order
V C S S i m u l a t i o n R e p o r t
a, b, and c fire at 10ns, 20ns, and 30ns respectively — exactly the order wait_order was told to expect, so the correct-order branch runs.
Where Is wait_order Used?
wait_order is useful for verifying a protocol handshake happens in the right sequence — for example, confirming a request is always asserted before a grant, and a grant before data is driven, flagging a violation immediately if any of those events ever fire out of sequence.
Merging Events
When one event variable is assigned to another, both variables end up pointing to the same underlying synchronization object. What happens to a process that's already waiting depends on exactly how it's waiting — the example below, and the note right after it, cover that distinction directly:
module tb;
// Create event variables
event event_a, event_b;
initial begin
fork
// Thread1: waits for event_a to be triggered
begin
wait(event_a.triggered);
$display ("[%0t] Thread1: Wait for event_a is over", $time);
end
// Thread2: waits for event_b to be triggered
begin
wait(event_b.triggered);
$display ("[%0t] Thread2: Wait for event_b is over", $time);
end
// Thread3: triggers event_a at 20ns
#20 ->event_a;
// Thread4: triggers event_b at 30ns
#30 ->event_b;
// Thread5: Assigns event_b to event_a at 10ns
begin
// Comment code below and try again to see Thread2 finish later
#10 event_b = event_a;
end
join
end
endmodule
Output
ncsim> run [20] Thread1: Wait for event_a is over [20] Thread2: Wait for event_b is over ncsim: *W,RNQUIE: Simulation is complete.
Thread2 starts out waiting on the original event_b object at 0ns. At 10ns, Thread5 reassigns event_b to point to the same underlying object as event_a. Because wait(event_b.triggered) checks whatever object event_b currently refers to, not a snapshot taken when the wait began, Thread2 ends up tied to event_a's trigger instead — finishing at 20ns right alongside Thread1, rather than waiting until 30ns for Thread4's separate trigger on the now-orphaned original event_b. This redirection is specific to wait(event.triggered), though — a process already blocked on @event_b at the moment of the reassignment would instead be permanently orphaned on the old synchronization object and never unblock at all, since @ binds to one specific object the moment the wait begins rather than re-checking what the variable currently points to.
Quick Check
Q: If Thread5's reassignment (event_b = event_a;) were removed entirely, at what time would Thread2 finish?
Show Answer
30ns. Without the reassignment, event_b stays its own separate object, and only Thread4's ->event_b at 30ns would trigger it — Thread2 would have no reason to finish any earlier.
Common Beginner Mistakes
Mistake #1: Using @ when a wait and a trigger might race
As the race condition example shows, @ only catches a trigger if the process is already actively parked waiting for it at that exact moment. Why? If there's any chance the wait and the trigger could land on the same simulation time, wait(event.triggered) or a ->> trigger is the safer choice — neither depends on which one happens to execute first.
Mistake #2: Assuming -> and ->> are interchangeable
-> notifies waiting processes instantly; ->> defers that notification to the non-blocking region of the same time step. Why? Code that depends on the trigger's side effects happening immediately — before the very next statement runs — can behave differently with ->>, since anything waiting on that event won't actually wake up until slightly later in that same time step.
Mistake #3: Assuming event assignment makes an independent copy
event_b = event_a; doesn't give event_b its own separate copy of anything — it makes event_b point to the exact same synchronization object as event_a. Why? A process already waiting via wait(event.triggered) picks up the new binding and ends up affected by whichever event fires next — but a process already blocked on @event_b at the moment of reassignment is orphaned on the old object instead, and never unblocks at all. Which form of waiting is in use matters here, not just whether a reassignment happened.
Mistake #4: Checking .triggered outside the time step it fired in
.triggered only stays true for the remainder of the time step the trigger occurred in. Why? Once simulation advances to a new time step, checking .triggered again correctly returns false — it's not a persistent flag that stays set until manually cleared, so code that assumes it remains true indefinitely will behave unexpectedly on a later check.
Practice Exercise
Challenge: Verify a Two-Step Handshake
Using wait_order, write a check that a req event always fires before a gnt event. Trigger gnt first at 15ns and req second at 25ns, and confirm the check correctly reports the out-of-order violation.
Hint
Follow the same wait_order (event1, event2) ... else ... pattern shown earlier, just with two events instead of three, and trigger them in the wrong order on purpose.
Solution
module tb;
event req, gnt;
initial begin
#15 -> gnt; // fires first, out of order
#10 -> req; // fires second
end
initial begin
wait_order (req, gnt)
$display ("req then gnt - correct order");
else
$display ("Violation: gnt fired before req!");
end
endmodule
Since gnt actually fires at 15ns, before req fires at 25ns, wait_order (req, gnt) detects the mismatch against the order it was told to expect and takes the else branch, reporting the violation.
Summary
In this tutorial, you learned:
- An event synchronizes two or more processes — one triggers with
->, another waits with@orwait(event.triggered) .triggeredstays true for the entire time step it fired in, so it always unblocks a waiting process regardless of execution order;@can miss a trigger if it isn't already actively waiting at that exact moment->>triggers non-blockingly, deferring notification to the non-blocking region of the same time step — which fixes the same race@is vulnerable to, without needing.triggeredwait_orderchecks that a list of events fire in a specific sequence, and branches into anelseclause if they don't- Assigning one event variable to another merges them into the same underlying synchronization object — a process already waiting via
wait(event.triggered)picks up the new binding, but a process already blocked on@eventis permanently orphaned on the old object instead - Events can be compared, aliased, set to
null, and passed to functions and tasks like any other handle
Continuing the Interprocess Communication Chapter
Continue with SystemVerilog Semaphore for controlling access to a shared resource, and SystemVerilog Mailbox for passing data between components.