CS110: Principles of Computer Systems
Autumn 2021
Jerry Cain
PDF
Multithreading, Semaphores, and Rendezvous
- New concurrency pattern!
- semaphore::wait and semaphore::signal can be leveraged to support a different form of communication: thread rendezvous.
- Thread rendezvous is a generalization of thread::join. It allows one thread to stall—via semaphore::wait—until another thread calls semaphore::signal, often because the signaling thread just prepared
some data that the waiting thread needs
before it can continue.
- To illustrate when thread rendezvous is useful,
we'll implement a simple program without it, and see how thread rendezvous can be used to repair some of its problems.- The program has two meaningful threads of execution: one thread publishes content to a shared buffer, and a second reads content as it becomes available.
- The program is a nod to the communication in place between a web server and a browser. The server publishes content over a dedicated communication channel, and the browser consumes it.
- The program also reminds me of how two independent processes behave when one writes to a pipe, a second reads from it, and how the write and read processes behave when the pipe is full (in principle, a possibility) or empty.
Multithreading, Semaphores, and Rendezvous
- Consider the following program, where concurrency directives have been intentionally omitted. The full, very buggy example is right here.
static void writer(char buffer[]) {
cout << oslock << "Writer: ready to write." << endl << osunlock;
for (size_t i = 0; i < 320; i++) { // 320 is 40 cycles around the circular buffer of length 8
char ch = prepareData();
buffer[i % 8] = ch;
cout << oslock << "Writer: published data packet with character '" << ch << "'." << endl << osunlock;
}
}
static void reader(char buffer[]) {
cout << oslock << "\t\tReader: ready to read." << endl << osunlock;
for (size_t i = 0; i < 320; i++) { // 320 is 40 cycles around the circular buffer of length 8
char ch = buffer[i % 8];
processData(ch);
cout << oslock << "\t\tReader: consumed data packet " << "with character '" << ch << "'." << endl << osunlock;
}
}
int main(int argc, const char *argv[]) {
char buffer[8];
thread w(writer, buffer);
thread r(reader, buffer);
w.join();
r.join();
return 0;
}
Multithreading, Semaphores, and Rendezvous
- Here's what works:
- Because the main thread declares a circular buffer and shares it with both children, the children each agree where content is stored.
- Think of the buffer as the state maintained by the implementation of pipe, or the state maintained by an internet connection between a server and a client.
- The writer thread publishes content to the circular buffer, and the reader thread consumes that same content as it's written. Each thread cycles through the buffer the same number of times, and they both agree that i % 8 identifies the next slot of interest.
- Here's what's broken:
- Each thread runs more or less independently of the other, without consulting the other to see how much progress it's made.
- In particular, there's nothing in place to inform the reader that the slot it wants to read from has meaningful data in it. It's possible the writer just hasn't gotten that far yet.
- Similarly, there's nothing preventing the writer from advancing so far ahead that it begins to overwrite content that has yet to be consumed by the reader.
Multithreading, Semaphores, and Rendezvous
- One solution? Maintain two semaphores.
- One can track the number of slots that can be written to without clobbering yet-to-be-consumed data. We'll call it emptyBuffers, and we'll initialize it to 8.
- A second can track the number of slots that contain yet-to-be-consumed data that can be safely read. We'll call it fullBuffers, and we'll initialize it to 0.
- Here's the new main program that declares, initializes, and shares the two semaphores.
- The writer thread waits until at least one buffer is empty before writing. Once it writes, it'll increment the full buffer count by one.
- The reader thread waits until at least one buffer is full before reading. Once it reads, it increments the empty buffer count by one.
int main(int argc, const char *argv[]) {
char buffer[8];
semaphore fullBuffers, emptyBuffers(8);
thread w(writer, buffer, ref(fullBuffers), ref(emptyBuffers));
thread r(reader, buffer, ref(fullBuffers), ref(emptyBuffers));
w.join();
r.join();
return 0;
}
Multithreading, Semaphores, and Rendezvous
- Here are the two new thread routines:
- The reader and writer rely on these semaphores to inform the other how much work they can do before being necessarily forced off the CPU.
- Thought question: can we rely on just one semaphore instead of two? Why or why not?
static void writer(char buffer[], semaphore& full, semaphore& empty) {
cout << oslock << "Writer: ready to write." << endl << osunlock;
for (size_t i = 0; i < 320; i++) { // 320 is 40 cycles around the circular buffer of length 8
char ch = prepareData();
empty.wait(); // don't try to write to a slot unless you know it's empty
buffer[i % 8] = ch;
full.signal(); // signal reader there's more stuff to read
cout << oslock << "Writer: published data packet with character '" << ch << "'." << endl << osunlock;
}
}
static void reader(char buffer[], semaphore& full, semaphore& empty) {
cout << oslock << "\t\tReader: ready to read." << endl << osunlock;
for (size_t i = 0; i < 320; i++) { // 320 is 40 cycles around the circular buffer of length 8
full.wait(); // don't try to read from a slot unless you know it's full
char ch = buffer[i % 8];
empty.signal(); // signal writer there's a slot that can receive data
processData(ch);
cout << oslock << "\t\tReader: consumed data packet " << "with character '" << ch << "'." << endl << osunlock;
}
}
Lecture 15: Threads and Semaphores
By Jerry Cain
Lecture 15: Threads and Semaphores
- 1,600