Practical 6: the Readers-Writers Problem
Chapter Eleven
Syllabus topic Module 1, "Readers-Writers Problem, Synchronization in Shared Access: Implement reader and writer prioritization. Use semaphores to allow multiple readers or exclusive writer access. Extend to fairness in access and deadlock prevention."
Pages 86 to 94 of 300
Aim
To let several readers share data while a writer has it exclusively, using semaphores; to implement reader and writer prioritisation; and to make access fair while preventing deadlock.
What you need to know before you start
A mutex treats every thread the same: one in at a time. For a great deal of real data that is needlessly strict, because reading does not change anything. Twenty threads may read the same table at once with no harm at all. It is only writing that has to be alone.
The readers-writers problem is that rule made precise:
| Who is inside | Allowed |
|---|---|
| any number of readers, no writer | yes |
| one writer, no readers | yes |
| a writer and any reader | no |
| two writers | no |
The purpose is easy to see in a college database: hundreds of students looking up a result at once is fine; one clerk changing a result must have the record to themselves, or a student may read a mark that is half updated.
What "half updated" means, and how this chapter proves it
Every program in this chapter shares a record of two fields that must agree:
struct record { int value; int doubled; };The writer sets value to n and doubled to 2n. Between those two assignments the record is inconsistent, and a reader that looks at that moment sees a doubled that is not twice the value. Each program counts how many readers saw such a record, and prints the count.
That is the measurement this chapter rests on. A right answer is 0 and a wrong one is not, and neither depends on the order the threads happened to run in.
The first solution: readers preferred
This is the classical answer and the one an examiner expects first. One semaphore guards the data, and the readers take it as a group: the first reader in locks it, the last reader out unlocks it, and the readers in between simply walk in.
#include <stdio.h>
#include <time.h>
#include <pthread.h>
#include <semaphore.h>
#define READERS 4
#define WRITERS 2
#define ROUNDS 4
struct record { int value; int doubled; };
static struct record shared = { 0, 0 };
static int readers_inside; /* how many readers are in right now */
static int max_readers; /* the most ever in at once */
static int torn_reads; /* readers that saw a half-written record */
static sem_t resource; /* one writer, or the readers as a group */
static pthread_mutex_t count_lock = PTHREAD_MUTEX_INITIALIZER; /* guards readers_inside */
static pthread_mutex_t tally = PTHREAD_MUTEX_INITIALIZER; /* guards torn_reads */
static void nap(long ms)
{
struct timespec t = { ms / 1000, (ms % 1000) * 1000000L };
nanosleep(&t, NULL);
}
static void *reader(void *arg)
{
(void) arg;
for (int r = 0; r < ROUNDS; r++) {
/* entry protocol */
pthread_mutex_lock(&count_lock);
if (++readers_inside == 1)
sem_wait(&resource); /* the FIRST reader locks writers out */
if (readers_inside > max_readers)
max_readers = readers_inside;
pthread_mutex_unlock(&count_lock);
/* the reading itself: several readers may be here at once */
int v = shared.value;
nap(5);
int d = shared.doubled;
if (d != 2 * v) {
pthread_mutex_lock(&tally);
torn_reads++;
pthread_mutex_unlock(&tally);
}
/* exit protocol */
pthread_mutex_lock(&count_lock);
if (--readers_inside == 0)
sem_post(&resource); /* the LAST reader lets writers in */
pthread_mutex_unlock(&count_lock);
nap(3);
}
return NULL;
}
static void *writer(void *arg)
{
(void) arg;
for (int r = 0; r < ROUNDS; r++) {
sem_wait(&resource); /* a writer needs it all to itself */
int n = shared.value + 1;
shared.value = n;
nap(5); /* the record is inconsistent here */
shared.doubled = 2 * n;
sem_post(&resource);
nap(3);
}
return NULL;
}
int main(void)
{
if (sem_init(&resource, 0, 1) == -1) {
perror("sem_init");
return 1;
}
pthread_t rd[READERS], wr[WRITERS];
for (long i = 0; i < READERS; i++)
if (pthread_create(&rd[i], NULL, reader, (void *) (i + 1)) != 0)
return 1;
for (long i = 0; i < WRITERS; i++)
if (pthread_create(&wr[i], NULL, writer, (void *) (i + 1)) != 0)
return 1;
for (int i = 0; i < READERS; i++)
pthread_join(rd[i], NULL);
for (int i = 0; i < WRITERS; i++)
pthread_join(wr[i], NULL);
printf("writes made, %d writers x %d rounds : %d\n", WRITERS, ROUNDS, shared.value);
printf("the record is still consistent : %s\n",
shared.doubled == 2 * shared.value ? "yes" : "NO");
printf("readers that saw a half-written one : %d\n", torn_reads);
printf("more than one reader inside at once : %s\n",
max_readers > 1 ? "yes" : "NO");
sem_destroy(&resource);
return 0;
}Practical 6: the Readers-Writers Problem
writes made, 2 writers x 4 rounds : 8
the record is still consistent : yes
readers that saw a half-written one : 0
more than one reader inside at once : yesFour lines, and every one of them is a claim the program checked.
- 8 writes from two writers of four rounds each, so no write was lost: the two writers were
never inside together.
- The record is consistent at the end, and no reader ever saw a half-written one, so no
reader was ever inside while a writer was.
- Four readers were inside at once, which is the whole point. A plain mutex would have made
that number 1, and the program would have been correct and needlessly slow.
That last number is what tells you the solution is a readers-writers solution and not just a lock. It is worth printing in your own program for exactly that reason.
Practical 6: the Readers-Writers Problem
The three pieces of that solution
resource, a binary semaphore, is the right to touch the data. A writer takes it for itself. The readers take it collectively.
readers_inside, a counter, is how the readers know whether they are the first or the last. It is shared, so it needs its own lock.
count_lock, a mutex, guards that counter. Without it, two readers arriving together could both see readers_inside go from 0 to 1 and both call sem_wait(&resource), and the second one would block for ever on a semaphore the first one is holding.
Notice that sem_wait(&resource) is called while holding count_lock, which contradicts the rule from Practical 5 about not blocking while holding a lock. It is safe here, and knowing why is worth a mark: the only thread that can be holding resource is a writer, and a writer never asks for count_lock, so there is no cycle. The four Coffman conditions need a circular wait and there is none. That is the difference between a rule of thumb and a proof.
Writer starvation, run
The solution above has a defect, and it is not a bug: it does exactly what it was designed to do, and what it was designed to do is unfair.
resource is released only when the last reader leaves. If readers keep arriving, there is never a moment with no reader inside, so the writer waits for ever. That is starvation: not a deadlock, because everybody else is making progress, but one thread that never gets its turn.
Here is that made concrete. Two readers, staggered so that one is always inside, and a writer that asks once.
#include <stdio.h>
#include <time.h>
#include <pthread.h>
#include <semaphore.h>
static int readers_inside;
static long reads_done;
static sem_t resource;
static pthread_mutex_t count_lock = PTHREAD_MUTEX_INITIALIZER;
static void nap(long ms)
{
struct timespec t = { ms / 1000, (ms % 1000) * 1000000L };
nanosleep(&t, NULL);
}
static void *reader(void *arg)
{
long stagger = (long) arg;
nap(stagger); /* so the two readers overlap */
for (;;) {
pthread_mutex_lock(&count_lock);
if (++readers_inside == 1)
sem_wait(&resource);
pthread_mutex_unlock(&count_lock);
nap(40); /* a long read */
pthread_mutex_lock(&count_lock);
reads_done++;
if (--readers_inside == 0)
sem_post(&resource);
pthread_mutex_unlock(&count_lock);
/* and straight back round: no pause at all */
}
return NULL;
}
static void *writer(void *arg)
{
(void) arg;
sem_wait(&resource); /* the writer waits here for ever */
printf("the writer got in after %ld reads\n", reads_done);
sem_post(&resource);
return NULL;
}
int main(void)
{
sem_init(&resource, 0, 1);
pthread_t r1, r2, w;
pthread_create(&r1, NULL, reader, (void *) 0L);
pthread_create(&r2, NULL, reader, (void *) 20L);
nap(100); /* let the readers get going */
pthread_create(&w, NULL, writer, NULL);
pthread_join(w, NULL);
printf("main: finished\n");
return 0;
}Practical 6: the Readers-Writers Problem
That program prints nothing at all, ever. The two readers overlap, readers_inside never reaches 0, resource is never released, and the writer sits in sem_wait until the machine is switched off:
$ gcc -Wall -Wextra -pthread -o starve starve.c
$ timeout 5 ./starve
$ echo $?
124Three runs, three timeouts. The listing is marked in this book as a program that never finishes, so a version of it that did get the writer in would have been reported as a failure. The starvation is measured, not asserted.
Starvation is not deadlock, and an examiner will ask for the difference:
| Deadlock | Starvation | |
|---|---|---|
| Who is stuck | every thread in the cycle | one thread, or one kind of thread |
| Is anything getting done | no, the system is frozen | yes, the others are working normally |
| Cause | a circular wait for resources | a scheduling or priority rule that keeps skipping somebody |
| Detectable by | looking for a cycle | noticing that somebody has waited a very long time |
| Cured by | breaking one of Coffman's four conditions | a fairness rule, such as a queue everybody joins |
The second solution: writers preferred
The mirror image. A writer that is waiting stops new readers from going in, so the readers already inside finish and then the writer gets its turn.
It is built by counting the waiting writers as well, and giving them a semaphore of their own:
/* the writer's entry protocol */
lock(writer_count_lock);
if (++writers_waiting == 1) wait(readers_may_enter); /* shut the door on readers */
unlock(writer_count_lock);
wait(resource);Now the writers do not starve. The readers do: a steady stream of writers keeps readers_may_enter locked and no reader ever gets in. The problem has simply moved.
That is the honest position, and it is the answer to MU's bullet about prioritisation: neither priority is fair, and choosing one is choosing whose starvation you can live with.
| Solution | Good for | Starves |
|---|---|---|
| Readers preferred | data read far more often than written | writers |
| Writers preferred | data that must never be stale, such as a booking system | readers |
| Fair, with a queue | almost everything | nobody |
The third solution: fair, with a turnstile
The fix is a single extra semaphore that everybody must pass through before joining the readers or the writers. A writer holds it while it waits, so readers arriving after the writer queue behind it; the readers already inside finish, the writer goes in, and then the queue moves again.
It is called a turnstile, and it is four lines of difference from the first program.
#include <stdio.h>
#include <time.h>
#include <pthread.h>
#include <semaphore.h>
#define READERS 4
#define WRITERS 2
#define ROUNDS 4
struct record { int value; int doubled; };
static struct record shared = { 0, 0 };
static int readers_inside, max_readers, torn_reads;
static sem_t resource; /* one writer, or the readers as a group */
static sem_t turnstile; /* everybody queues here first */
static pthread_mutex_t count_lock = PTHREAD_MUTEX_INITIALIZER;
static pthread_mutex_t tally = PTHREAD_MUTEX_INITIALIZER;
static void nap(long ms)
{
struct timespec t = { ms / 1000, (ms % 1000) * 1000000L };
nanosleep(&t, NULL);
}
static void *reader(void *arg)
{
(void) arg;
for (int r = 0; r < ROUNDS; r++) {
sem_wait(&turnstile); /* queue, so a waiting writer is not overtaken */
sem_post(&turnstile); /* and let the next one queue at once */
pthread_mutex_lock(&count_lock);
if (++readers_inside == 1)
sem_wait(&resource);
if (readers_inside > max_readers)
max_readers = readers_inside;
pthread_mutex_unlock(&count_lock);
int v = shared.value;
nap(5);
int d = shared.doubled;
if (d != 2 * v) {
pthread_mutex_lock(&tally);
torn_reads++;
pthread_mutex_unlock(&tally);
}
pthread_mutex_lock(&count_lock);
if (--readers_inside == 0)
sem_post(&resource);
pthread_mutex_unlock(&count_lock);
nap(3);
}
return NULL;
}
static void *writer(void *arg)
{
(void) arg;
for (int r = 0; r < ROUNDS; r++) {
sem_wait(&turnstile); /* HOLD it: no new reader may join */
sem_wait(&resource); /* wait for the readers inside to leave */
int n = shared.value + 1;
shared.value = n;
nap(5);
shared.doubled = 2 * n;
sem_post(&resource);
sem_post(&turnstile); /* let everybody through again */
nap(3);
}
return NULL;
}
int main(void)
{
sem_init(&resource, 0, 1);
sem_init(&turnstile, 0, 1);
pthread_t rd[READERS], wr[WRITERS];
for (long i = 0; i < READERS; i++)
if (pthread_create(&rd[i], NULL, reader, (void *) (i + 1)) != 0)
return 1;
for (long i = 0; i < WRITERS; i++)
if (pthread_create(&wr[i], NULL, writer, (void *) (i + 1)) != 0)
return 1;
for (int i = 0; i < READERS; i++)
pthread_join(rd[i], NULL);
for (int i = 0; i < WRITERS; i++)
pthread_join(wr[i], NULL);
printf("writes made, %d writers x %d rounds : %d\n", WRITERS, ROUNDS, shared.value);
printf("the record is still consistent : %s\n",
shared.doubled == 2 * shared.value ? "yes" : "NO");
printf("readers that saw a half-written one : %d\n", torn_reads);
printf("more than one reader inside at once : %s\n",
max_readers > 1 ? "yes" : "NO");
sem_destroy(&resource);
sem_destroy(&turnstile);
return 0;
}Practical 6: the Readers-Writers Problem
writes made, 2 writers x 4 rounds : 8
the record is still consistent : yes
readers that saw a half-written one : 0
more than one reader inside at once : yesExactly the same four answers, and readers still get in together, so nothing was given up to buy the fairness. The readers still share; they merely queue politely.
And the starvation is gone
The proof is the starving program from earlier with the turnstile added, under exactly the same load of readers that never stop.
#include <stdio.h>
#include <time.h>
#include <pthread.h>
#include <semaphore.h>
static int readers_inside;
static long reads_done;
static sem_t resource;
static sem_t turnstile; /* the only difference */
static pthread_mutex_t count_lock = PTHREAD_MUTEX_INITIALIZER;
static void nap(long ms)
{
struct timespec t = { ms / 1000, (ms % 1000) * 1000000L };
nanosleep(&t, NULL);
}
static void *reader(void *arg)
{
long stagger = (long) arg;
nap(stagger);
for (;;) {
sem_wait(&turnstile);
sem_post(&turnstile);
pthread_mutex_lock(&count_lock);
if (++readers_inside == 1)
sem_wait(&resource);
pthread_mutex_unlock(&count_lock);
nap(40);
pthread_mutex_lock(&count_lock);
reads_done++;
if (--readers_inside == 0)
sem_post(&resource);
pthread_mutex_unlock(&count_lock);
}
return NULL;
}
static void *writer(void *arg)
{
(void) arg;
sem_wait(&turnstile); /* holding this stops new readers */
sem_wait(&resource);
printf("the writer got in\n");
sem_post(&resource);
sem_post(&turnstile);
return NULL;
}
int main(void)
{
sem_init(&resource, 0, 1);
sem_init(&turnstile, 0, 1);
pthread_t r1, r2, w;
pthread_create(&r1, NULL, reader, (void *) 0L);
pthread_create(&r2, NULL, reader, (void *) 20L);
nap(100);
pthread_create(&w, NULL, writer, NULL);
pthread_join(w, NULL);
printf("main: finished\n");
return 0;
}Practical 6: the Readers-Writers Problem
the writer got in
main: finishedThe writer gets in, on every run. The readers are still going round with no pause at all; the turnstile is what lets the writer put itself at the head of the queue.
Those two programs are the answer to MU's bullet about fairness. They differ by four lines: two in the reader and two in the writer. One never lets the writer in, and the other always does.
Deadlock prevention here
MU's third bullet ends with deadlock prevention, and in this exercise it comes down to one rule about the order the semaphores are taken.
The writer takes turnstile and then resource. Every thread that takes both takes them in that order. Reverse it in one place, and a writer holding resource while waiting for turnstile, and another holding turnstile while waiting for resource, are a circular wait.
| Rule | Why |
|---|---|
| Always take several locks in the same global order | breaks the circular-wait condition, so no cycle can form |
| Release in the opposite order | keeps the nesting tidy and avoids holding what you no longer need |
| Never hold a lock you do not need while waiting | breaks the hold-and-wait condition |
| Hold a lock for as little code as possible | reduces the chance of any of the above going wrong |
The sem_wait(&resource) inside count_lock in every program on this page looks like a breach of the third rule, and it is safe for the reason given earlier: no writer ever asks for count_lock, so there is no cycle for the readers to complete. A rule of thumb is not a proof, and the proof is always "is there a cycle".
The three solutions, side by side
| Readers preferred | Writers preferred | Fair, with a turnstile | |
|---|---|---|---|
| Semaphores | resource | resource, readers_may_enter | resource, turnstile |
| Counters | readers inside | readers inside, writers waiting | readers inside |
| Readers share | yes | yes, between writers | yes |
| Writers exclusive | yes | yes | yes |
| Starves | writers | readers | nobody |
| Complexity | lowest | highest | low |
| Use it when | reads far outnumber writes and staleness is acceptable | writes must never be overtaken | almost always |
Practical 6: the Readers-Writers Problem
Procedure
- Write the first program. Compile with
gcc -Wall -Wextra -pthread -o rw1 rw1.cand run it five
times. Check all four printed lines each time.
- Replace the whole reader protocol with a plain
sem_wait(&resource)andsem_post(&resource),
so readers are exclusive too. Run it again: the answers are still right, and the most readers inside at once is now 1. That is a lock, not a readers-writers solution.
- Remove
count_lockfrom the reader's entry protocol and run it twenty times. Note what
happens, and why two readers arriving together can both try to take resource.
- Write the starving program. Run it as
timeout 5 ./starve; echo $?and record the 124. - Add the turnstile to it, four lines, and run it again. Record that the writer gets in.
- Write the fair version of the full program and confirm the four answers are unchanged.
- In your journal, write out the difference between deadlock and starvation in two sentences.
Result
Several readers were allowed into shared data at once while a writer had it exclusively, using one semaphore taken collectively by the readers and a counter under its own mutex. More than one reader was inside together on every run, no reader ever observed a half-written record, and no write was lost. Under a continuous load of readers the reader-preference solution starved the writer entirely, confirmed by the program being killed by a five-second timeout on every run. Adding a turnstile of one semaphore let the writer in on every run, with the four readers still sharing, which is fairness without loss.
Where marks are lost
- Using a plain mutex for the readers. It is correct and it is not the exercise. The number of
readers inside at once must be able to exceed one, and reporting that is how you show it.
- Printing the greatest number of readers inside as though it were fixed. It is not: it
depends on the scheduler, and this page saw both 4 and 3 with four readers.
- No mutex on
readers_inside. Two readers arriving together can both believe they are the
first, and the second blocks for ever.
- The first reader locking and every reader unlocking, or the other way round. It is the
first in that locks and the last out that unlocks.
- Saying starvation is a deadlock. In a deadlock nothing progresses; under starvation
everybody else is working normally.
- Claiming a solution is fair because it worked once. Fairness shows up only under load, which
Practical 6: the Readers-Writers Problem
is why the starving program has readers that never pause.
- Forgetting that writers-preferred starves readers. Neither priority is fair; that is the
point of the third solution.
- Taking two semaphores in different orders in different threads, which is a circular wait.
- Not saying why
sem_waitinside a mutex is safe here, when the examiner points at it.
For the journal
Write the aim, MU's own wording, and the table of who may be inside with whom. Then the first program in full with its four printed lines, and say what each line proves. Then the starving program, the timeout 5 command and the 124, and the turnstile version with its output, as a pair: those two are the evidence for the fairness bullet. Add the deadlock-against-starvation table. The conclusion: readers may share because reading changes nothing, a writer must be alone, the classical solution starves writers, and one extra semaphore that everybody queues at makes it fair without taking the sharing away.
Quick revision
- Many readers together, or one writer alone, and never both.
- Reader preference: the first reader in takes
resource, the last reader out releases it, and
readers_inside is a shared counter needing its own mutex.
- Report whether more than one reader was ever inside at once. If it never was, you have written a
lock and not a readers-writers solution. Do not print the greatest number as a fact: it depends on the scheduler and changes between runs.
- Prove correctness with two fields that must agree, such as
valueanddoubled. A reader that
sees them disagree has read a half-written record.
- Reader preference starves writers; writer preference starves readers. Neither is fair.
- Starvation is not deadlock: under starvation the rest of the system is working.
- A turnstile is one semaphore everybody passes through before entering. A writer holds it while
waiting, so readers arriving later queue behind it. Four lines, and the starvation is gone.
- Deadlock prevention here is one rule: every thread takes the semaphores in the same order, and
releases in the opposite order.
- Blocking while holding a lock is safe only when no cycle can form. The proof is always to look
for a cycle, not to recite the rule.
Questions you should be able to answer
1. State the readers-writers rule. Any number of readers may be inside together, or one writer alone. A writer may never be inside with a reader or with another writer.
2. Why may readers share when a mutex would not allow it? Because reading does not change the data, so two readers cannot interfere with each other. Only writing can leave the data in a state that another thread must not see.
Practical 6: the Readers-Writers Problem
3. In the reader-preference solution, which reader takes the semaphore and which releases it? The first reader to arrive takes it and the last to leave releases it. The readers in between do not touch it.
4. Why does readers_inside need a mutex of its own? Because it is shared. Without it two readers arriving together could both see the count go from 0 to 1 and both try to take resource, and the second would block for ever.
5. Why does the program report only that more than one reader was inside, and not how many? Because the greatest number depends on when the scheduler ran each thread and changes between runs: with four readers this page saw both 4 and 3. That more than one was inside together is a property of the program; the exact number is a coincidence of one run.
6. What is writer starvation, and how was it shown on this page? The writer never gets its turn because readers keep arriving and the count never reaches zero. It was shown by running the program under two readers that never pause: it printed nothing and was killed by a five-second timeout, three times out of three.
7. What is the difference between starvation and deadlock? In a deadlock every thread in the cycle is stuck and nothing at all progresses. Under starvation one thread never gets its turn while the rest of the system works normally.
8. What does a turnstile do, and what does it cost? It is one semaphore that every thread passes through before entering. A writer holds it while it waits, so readers that arrive later queue behind the writer. It costs two extra semaphore operations per reader and nothing in sharing: four readers still get in together.
9. What is the one rule that prevents deadlock in this exercise? Every thread takes the semaphores in the same order, turnstile before resource, and releases in the opposite order. That makes a circular wait impossible.
10. The reader calls sem_wait(&resource) while holding count_lock. Is that not blocking while holding a lock? It is, and it is safe here because the only thread that can hold resource is a writer, and a writer never asks for count_lock. No cycle can form, so no deadlock is possible.
The rest of this subject
These notes are cut from the University's printed syllabus. Open the syllabus itself for the same subject.