We close the semaphore tour with a flexible lock, the most famous concurrency puzzle of all, and a look under the hood.
31.5 Reader-Writer Locks
Sometimes one lock is too blunt: many operations only read a structure and could safely run together; only writes need exclusivity. A reader-writer lock reader-writer lock A lock that permits many readers to access a data structure concurrently but grants a writer exclusive access. Built from semaphores: the FIRST reader acquires the write lock (blocking writers) and the LAST reader releases it. Simple, but can starve writers and often costs more than a plain lock. defined in ch. 31 β open in glossary captures that β many readers OR one writer:
typedef struct _rwlock_t {
sem_t lock; // binary semaphore, guards `readers`
sem_t writelock; // held while ANY reader or the writer is active
int readers; // # readers currently inside
} rwlock_t;
void rwlock_acquire_readlock(rwlock_t *rw) {
sem_wait(&rw->lock);
rw->readers++;
if (rw->readers == 1) // FIRST reader...
sem_wait(&rw->writelock); // ...grabs the writelock
sem_post(&rw->lock);
}
void rwlock_release_readlock(rwlock_t *rw) {
sem_wait(&rw->lock);
rw->readers--;
if (rw->readers == 0) // LAST reader...
sem_post(&rw->writelock); // ...releases it
sem_post(&rw->lock);
}
void rwlock_acquire_writelock(rwlock_t *rw) { sem_wait(&rw->writelock); }
void rwlock_release_writelock(rwlock_t *rw) { sem_post(&rw->writelock); }
The clever bit is the first/last reader dance with writelock:
1Empty
Nobody in the critical section: readers = 0, writelock free.
It works, but note the fairness flaw the trace ends on: as long as
readers keep arriving, readers never hits 0, so a waiting writer can
starve. (A better version blocks new readers once a writer is
waiting.) And reader-writer locks often arenβt worth it β the extra
overhead can outweigh the concurrency gained.
Tip: Simple And Dumb Can Be Better (Hillβs Law)
Never underestimate the simple, dumb approach. A plain spin lock is easy to build and fast; reader-writer locks sound cool but are complex, and complex often means slow. Mark Hill found simple direct-mapped caches beat fancy set-associative ones β βbig and dumb is better.β Try the simple approach first.31.6 The Dining Philosophers
Dijkstraβs famous puzzle β the dining philosophers dining philosophers Dijkstra's classic concurrency problem: five philosophers around a table with one fork between each pair; each needs both neighboring forks to eat. The naive 'grab left then right' solution deadlocks (all hold their left fork, all wait forever for their right). Breaking the symmetry β one philosopher grabs right-then-left β removes the cycle. defined in ch. 31 β open in glossary β impractical, but a rite of passage (and a favorite interview question). Five philosophers, five forks; each alternates thinking and eating, and needs both neighboring forks to eat. The loop:
while (1) {
think();
get_forks(p);
eat();
put_forks(p);
}
int left(int p) { return p; }
int right(int p) { return (p + 1) % 5; }
The naive get_forks β grab left, then right β deadlocks. Walk it:
1Everyone thinks
Five philosophers sit around a table with one fork between each pair. To eat, a philosopher needs BOTH neighboring forks: left(p) = fork p, right(p) = fork (p+1) % 5.
Figure 31.15: broken (deadlocks)
void get_forks(int p) {
sem_wait(&forks[left(p)]);
sem_wait(&forks[right(p)]);
}Figure 31.16: the fix
void get_forks(int p) {
if (p == 4) { // highest grabs right first
sem_wait(&forks[right(p)]);
sem_wait(&forks[left(p)]);
} else {
sem_wait(&forks[left(p)]);
sem_wait(&forks[right(p)]);
}
}Changing the acquisition order for just one philosopher breaks the
circular dependency β no cycle can form, so no deadlock. (put_forks
stays the same.) Weβll see this βbreak the cycleβ idea generalized in
the next chapter on deadlock.
31.7 Thread Throttling
A humbler use: throttling thread throttling Admission control via a semaphore: initialize it to N and bracket a region of code with sem_wait()/sem_post() to cap how many threads run that region at once β e.g. keeping a memory-intensive section from over-committing memory and thrashing.
defined in ch. 31 β open in glossary
(admission
control). To keep βtoo manyβ threads out of a dangerous region β say a
memory-hungry section that would exhaust RAM and trigger thrashing if all
threads entered at once β initialize a semaphore to the maximum youβll
allow, and bracket the region with sem_wait()/sem_post(). The
semaphore naturally caps concurrency at that number.
31.8 How To Implement Semaphores
Finally, we can build a semaphore from the primitives it generalizes β one lock, one condition variable, and a value (the authors dub it a βZemaphoreβ):
typedef struct __Zem_t {
int value;
pthread_cond_t cond;
pthread_mutex_t lock;
} Zem_t;
void Zem_wait(Zem_t *s) {
Mutex_lock(&s->lock);
while (s->value <= 0)
Cond_wait(&s->cond, &s->lock);
s->value--;
Mutex_unlock(&s->lock);
}
void Zem_post(Zem_t *s) {
Mutex_lock(&s->lock);
s->value++;
Cond_signal(&s->cond);
Mutex_unlock(&s->lock);
}
One deliberate difference from Dijkstraβs semaphore: the Zemaphoreβs value never goes below 0, so it doesnβt track the waiter count β this is simpler and matches Linux. Curiously, the reverse β building a condition variable out of semaphores β is famously hard; even expert concurrent programmers got it wrong.
Tip: Be Careful With Generalization
Generalization is powerful β one good idea broadened to solve a larger class of problems. But as Lampson warned, βDonβt generalize; generalizations are generally wrong.β Semaphores generalize locks and CVs β yet given how hard a CV-on-semaphores is, maybe the generalization isnβt as general as it looks.31.9 Summary
Semaphores are a powerful, flexible primitive β some programmers use them for everything, shunning locks and CVs. Weβve seen just a few classics (fork/join, bounded buffer, reader-writer, dining philosophers, throttling); Downeyβs free Little Book of Semaphores has many more.
Check yourself: reader-writer, philosophers, and building semaphores
1.In the reader-writer lock, when does a reader touch the writelock semaphore?
2.What fairness problem does this reader-writer lock have?
3.Why does the naive dining-philosophers solution (each grabs left then right) deadlock?
4.How does Dijkstra's fix (philosopher 4 grabs right-then-left) prevent the deadlock?
5.You want to prevent more than 3 threads from entering a memory-hungry region at once. How?
6.The Zemaphore builds a semaphore from a lock + condition variable + value. What's the notable difference from Dijkstra's semaphore?