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.

systemverilog
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 sim

Follow-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.

systemverilog
// 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_none

Follow-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.

systemverilog
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
endtask

Follow-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.

systemverilog
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 posted

Follow-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.

systemverilog
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.

systemverilog
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 A

Follow-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.