Part 8 · Senior & Interview Prep · Intermediate
Q&A: Processes & IPC
fork variants, the loop-variable fork bug, disable fork dangers, mailbox vs queue+event, semaphores, and event .triggered.
Q: Explain the three fork variants and when each is used.
fork...join waits for all branches — parallel drive of independent interfaces. fork...join_any resumes when the first branch finishes — the timeout-watchdog pattern. fork...join_none resumes immediately — launching background services like monitors and scoreboards. Key subtlety: with join_any and join_none the other branches keep running; resuming is not killing.
fork // join: wait for ALL
drive_port_a();
drive_port_b();
join
fork // join_any: first wins (timeout idiom)
wait_for_response();
#10us $error("response timeout");
join_any
disable fork; // kill the loser explicitly
fork // join_none: fire and forget
monitor.run();
scoreboard.run();
join_none
// execution continues here immediately; services run all simFollow-up: "When do join_none branches actually start?" — Not at the fork: they are scheduled but begin only when the parent thread blocks or finishes. Code that forks then immediately changes a variable the child reads gets the changed value.
Junior vs senior: a junior defines the three. A senior adds that join_any leaves losers running (hence the disable), and that join_none children start only when the parent yields.
Q: What is wrong with forking inside a loop using the loop variable?
The classic bug: all forked branches capture a reference to the same loop variable , and since join_none branches start only after the loop completes, every branch reads the final value. Four forked drivers all drive port 3. Fix: copy the loop variable into an automatic local inside the fork — each branch then owns a private copy made at fork time.
// BUG: all four branches see i == 4 (loop already done)
for (int i = 0; i < 4; i++)
fork
drive_port(i); // i is shared — reads final value
join_none
// FIX: capture a per-branch copy
for (int i = 0; i < 4; i++)
fork
automatic int idx = i; // evaluated at fork creation, private copy
drive_port(idx);
join_noneFollow-up: "Why does the automatic declaration fix it?" — The initialization expression runs when the branch is created (while i still holds the current iteration value), and automatic storage gives each branch its own idx. Both halves matter: capture time and private storage.
Junior vs senior: a junior may know the fix as folklore. A senior explains both mechanisms — deferred branch start plus shared variable — and can predict the exact wrong value (the loop's final value).
Q: What is dangerous about disable fork?
disable fork kills every child process of the current thread — not just the fork you are thinking about. If the same thread earlier launched monitors with join_none, a disable fork after a timeout join_any silently kills the monitors too: checking stops, the test passes vacuously. The defense is to wrap the disable-able fork in an isolating fork begin ... end join so only the inner children are in scope.
task run();
fork monitor_a.run(); join_none // background service
// BAD: this disable kills monitor_a as well!
fork
wait_response();
#10us;
join_any
disable fork;
// GOOD: isolation wrapper — disable scope limited to inner fork
fork begin
fork
wait_response();
#10us;
join_any
disable fork; // kills only wait/timeout pair
end join
endtaskFollow-up: "Any other option besides the wrapper?" — Capture process handles (process::self() in each branch) and kill() the specific ones, or use named blocks with disable <name>. The wrapper is the idiomatic, least error-prone form.
Junior vs senior: a junior uses disable fork happily. A senior names the kill-scope rule (all children of this thread), the vacuous-pass symptom, and writes the isolation wrapper by reflex.
Q: Mailbox vs queue+event — why does the generator-driver channel use a mailbox?
A mailbox is a thread-safe FIFO with built-in blocking : get() suspends until data exists, put() suspends when a bounded mailbox is full. A bare queue has no synchronization — you must hand-roll an event plus a guard loop, and the naive version has both a race (trigger fires before wait is posted) and a missed-wakeup hazard. The mailbox is the same pattern, pre-built and correct.
mailbox #(fifo_txn) gen2drv = new(8); // bounded: backpressure for free
// generator // driver
t = new(); forever begin
assert(t.randomize()); gen2drv.get(t); // blocks when empty
gen2drv.put(t); // blocks if 8 drive(t);
// in flight end
// the queue+event hand-rolled equivalent needs:
// q.push_back(t); ->ev; (producer)
// while (q.size()==0) @ev; t=q.pop_front(); (consumer)
// and STILL races if the trigger lands before @ev is postedFollow-up: "Why bound the mailbox?" — Flow control: an unbounded mailbox lets a fast generator race thousands of transactions ahead of the driver — memory growth and stimulus that no longer responds to DUT state. A small bound (a few entries) keeps generation loosely coupled to consumption.
Junior vs senior: a junior says "mailbox blocks, queue doesn't." A senior names the race in the hand-rolled version and chooses a bounded mailbox for backpressure, with the runaway-generator failure mode.
Q: When do you use a semaphore?
A semaphore manages N shared resource tokens : get(k) blocks until k keys are available, put(k) returns them. Use it for mutual exclusion on a shared resource — two sequences that both drive one physical bus, a model of a bus with limited outstanding transactions, or an arbiter model. With one key it is a mutex; with N keys it is a resource pool.
semaphore bus_lock = new(1); // 1 key = mutex
task drive_atomic(fifo_txn t);
bus_lock.get(1); // acquire or block
drive(t); // critical section: sole bus owner
bus_lock.put(1); // ALWAYS return the key
endtask
semaphore credits = new(4); // 4 keys = max 4 outstanding reqs
// issue: credits.get(1); completion: credits.put(1);Follow-up: "What goes wrong with semaphores?" — Key leaks: a branch that gets a key and is killed (disable fork!) before put — everyone else blocks forever, a deadlock that looks like a hang. Also: put() can mint extra keys (puts are not checked against gets) — a double-put silently raises the resource count.
Junior vs senior: a junior describes the lock. A senior connects the leak to disable fork (the killed-while-holding-key deadlock) and knows puts are unchecked — semaphores can be inflated by mistake.
Q: What does event .triggered solve that @event does not?
@(ev) only sees a trigger that arrives while the process is already waiting — a trigger in the same time step before the @ is reached is missed forever. ev.triggered is a persistent flag that stays true for the rest of the time step in which the trigger fired, so wait(ev.triggered) succeeds whether the process arrived before or after the trigger — eliminating the classic same-timestep race.
event done;
// Process A (time 100): ->done;
// Process B (time 100, runs after A in the step):
@(done); // MISSED — trigger fired before @ was posted
// B waits forever
wait (done.triggered); // OK — flag is up for the rest of timestep 100
// B passes whether scheduled before or after AFollow-up: "So is wait(ev.triggered) always the right choice?" — Within one time step, yes. But it does not persist across time steps — at the next step the flag clears. For cross-timestep producer/consumer handoffs, use a mailbox or a flag variable; .triggered only fixes the same-step race.
Junior vs senior: a junior knows the syntax. A senior frames it as the same-timestep race fix, states the one-timestep persistence limit, and reaches for a mailbox when the handoff spans time.
Key takeaways
join = all, join_any = first (others live on), join_none = immediate (children start when parent yields).
Loop-variable fork bug: shared variable + deferred start → all branches see the final value; fix with automatic copy.
disable fork kills ALL children of the thread — isolate with fork begin...end join.
Bounded mailbox = thread-safe FIFO + free backpressure; hand-rolled queue+event races.
Semaphore keys leak when holders are killed; wait(ev.triggered) fixes only the same-timestep race.
Common pitfalls
fork join_none in a loop without automatic capture — every branch drives the last index.
disable fork after join_any killing background monitors — tests pass vacuously.
Unbounded mailboxes — generator runs thousands of txns ahead, memory climbs.
@(ev) for same-timestep handshakes — missed trigger, infinite wait, hung test.