Semaphores, ownership and deadlock

Guard a shared resource with a semaphore, give back every key you take, and take locks in one order so two processes never wait for each other.

Lesson
3.6
Time
25 min
Runs on
Verilator
Check
5 questions

Two sequences share one bus, and each sends a three-beat burst to update a block of registers. The bus checker fails on the second beat, because beats from the two sequences alternate. Each sequence drives correct beats on its own, but nothing stops the other one from driving in between.

Two bursts on one bus, with no lock
012345bus beatA0B0A1B1A2B2sequence Adrivewaitdrivewaitdrivedonesequence Bidledrivewaitdrivewaitdriveburst A broken: B0 arrives

One column per bus cycle. Every beat is correct; their order is not.

Keys in a bucket

A semaphore is a bucket of keys shared by processes. new(N) creates it with N keys. get(n) blocks until n keys are free and then takes them, and put(n) returns n keys. try_get(n) never blocks: it takes the keys and returns nonzero if they are free, or returns 0.

With one key, a semaphore is a lock. Only the process that holds the key may run the critical section, the code that uses the shared resource, and every other process waits in get(). When several processes wait, they are served in the order in which they asked.

A semaphore with more than one key models a pool. A memory model that accepts at most four outstanding reads can use new(4). Each read takes a key when it starts and returns it when its data comes back, so a fifth read waits until one of the four finishes.

TimeSequence ASequence BKeys free
t=0get(1) returns; drives beat 0not started0
t=1drivingget(1) blocks0
t=2, t=4drives beats 1 and 2blocked0
t=6put(1); doneget(1) returns; drives beat 00
t=12doneput(1) after beat 2; done1

The two sequences from the opening story, with a one-key semaphore around each burst.

Protect the whole operation

The lock must cover everything that has to happen without interruption. Here that is the whole three-beat burst, so get(1) goes before the first beat and put(1) after the last. A lock around each single beat would still let the beats of two bursts alternate.

Keep the critical section that size and no bigger. Every cycle you hold the key is a cycle in which no other sequence can use the bus. If a sequence holds the key while it waits for a slow read response, every other sequence stalls for that whole wait, and the test runs slower without failing.

Take and return the key in the same task that does the work, so the pair is easy to see. When get() and put() sit in different tasks or different classes, it becomes hard to prove that every path gives the key back.

The semaphore trusts you

A semaphore keeps a count of keys and nothing else. It does not record which process took a key, so any process may call put(), and nothing checks that a process returns exactly what it took.

Two mistakes follow from that. A leaked key is one that is never returned, for example because an error path returns early, and every later get() then blocks forever. An extra put() adds a key that was never taken, so two processes can be inside the critical section at once.

Early return holds the key

bus_key.get(1);
if (!item_ok) return;   // key never returned
drive(item);
bus_key.put(1);

After the first bad item, every sequence blocks in get() forever.

Check before taking the key

if (!item_ok) return;   // no key held yet
bus_key.get(1);
drive(item);
bus_key.put(1);

Every path through the task returns the key it took.

A process killed with disable fork while it holds a key leaks that key too, because it never reaches its put(). Stop such processes only at points where they hold no keys, or return the key on their behalf.

A leak is cheap to catch at the end of the test. Once all sequences have finished, call try_get for the full number of keys, and report an error if it fails, because some path kept a key.

Deadlock: two processes, two semaphores

Some operations need two resources. Process P1 takes the bus key and then the mem key, while process P2 takes mem first and then bus.

TimeP1P2Keys free
t=0takes bustakes memnone
t=1waits for memwaits for busnone
t=2 and laterwaits for memwaits for busnone

Each process holds the key the other one needs, so neither can ever continue.

This is a deadlock. If nothing else is scheduled, for example when no clock is running, the simulation simply ends at that point. A test that never checks how much work was finished can then report a pass.

Opposite orders

// P1                 // P2
bus.get(1);            mem.get(1);
#1 mem.get(1);         #1 bus.get(1);

Deadlocks at t=1: each process holds what the other needs.

One order for everyone

// P1                 // P2
bus.get(1);            bus.get(1);
#1 mem.get(1);         #1 mem.get(1);

Whoever gets bus first also gets mem; the other waits at bus.

The rule is to give the semaphores a fixed order and to have every process take them in that order. Write the order down next to the declarations, because one new sequence that breaks it brings the deadlock back.

One semaphore is enough for a deadlock, too. A task that holds the bus key and then calls a helper task that also takes the bus key waits for itself. The semaphore cannot tell that both requests come from the same process, so it makes the helper wait for a key that only its caller can return.

Try it

Without the key, the beats alternate exactly as in the opening story. With it, B blocks in get() at t=1 and starts only when A returns the key at t=6.

The stray put() raises the count to two keys, so both bursts get in and the beats alternate again, with no error from the semaphore. Now delete the put(1) at the end of burst and predict what the second run prints.

RUNVerilator · simulator service
Edit the code, then run it
Expected: no key: A0 B0 A1 B1 A2 B2 with key: A0 A1 A2 B0 B1 B2 after extra put: A0 B0 A1 B1 A2 B2

Check yourself

Predict the outputPredict the exact output.
module tb;
  semaphore bus = new(1), mem = new(1);
  int done;
  initial begin
    fork
      begin bus.get(1); #1 mem.get(1); done++; mem.put(1); bus.put(1); end
      begin mem.get(1); #1 bus.get(1); done++; bus.put(1); mem.put(1); end
    join_none
    #50 $display("t=%0t done=%0d", $time, done);
  end
endmodule
Spot the bugEach option is task automatic send_burst(bit ok). A rejected item (ok = 0) must not be driven, and the next two good items must still go out. Which option stops all later bursts?
ConceptA one-key semaphore guards a bus. An error handler calls bus_key.put(1) although its process never called get(). Two sequences then call get(1) at the same time. What happens?
Read the waveformA one-key semaphore guards the bus. Sequence A takes the key at t=0 and holds it for three beats. Sequence B calls get(1) at t=1. When does B drive its first beat?
Sequence B waits for the key
01234567sequence Aget, beat 0beat 0beat 1beat 2putdonesequence B-get(1)?keys free0?B asks for the key

One column per time unit.

ConceptProcesses P1 and P2 both need the bus and mem semaphores and sometimes deadlock. Which change removes the deadlock?

Remember

  • A semaphore holds keys: get() blocks until enough keys are free, and put() returns them.
  • Hold the key around the whole operation that must not be interleaved, and no longer.
  • The semaphore only counts; every path, including errors and kills, must return exactly the keys it took.
  • Take several semaphores in one fixed order everywhere, or two processes can deadlock.
  • A deadlock can end the simulation quietly, so check at the end that all the expected work was done.

Practice