Hello! Welcome to the sixth lesson in our module on condition variables.
In the previous lesson, we established the definitive design for a thread-safe bounded buffer to solve the producer-consumer problem. We concluded that the correct and robust approach uses one mutex to ensure mutual exclusion and two separate condition variables to handle the signaling between producers and consumers.
Today, we transition from design to practice. Your learning outcome is to implement a bounded buffer using one mutex and two condition variables for synchronization. This lesson is all about turning that proven design into working C code, focusing on the data structures and the specific pthreads API calls required. Given your background, we'll dive straight into creating an efficient and complete implementation.
1. The Anatomy of a Bounded Buffer in C
To implement our buffer, we need a struct to encapsulate all the necessary components. This includes not just the data buffer itself, but also all the state and synchronization primitives.
A simple array where we shift elements on removal is inefficient. A much better approach, common in systems programming, is a circular buffer (or ring buffer). This uses a fixed-size array and two indices: one for insertion (nextin or fill_ptr) and one for removal (nextout or use_ptr). These indices wrap around to the start of the array when they reach the end, which is efficiently handled using the modulo operator (%).
Our complete struct will contain:
- The data array itself (
buf). - The maximum capacity (
BSIZE). - The current number of items (
occupiedorcount). - Indices to manage the circular buffer (
nextin,nextout). - A single mutex (
pthread_mutex_t). - Two condition variables (
pthread_cond_t): one for "buffer is not full" and one for "buffer is not empty".

2. Implementing the Producer and Consumer Functions
With the data structure defined, we can now implement the producer and consumer functions that operate on it. The logic will directly follow the design from our last lesson.
The Oracle POSIX threads documentation provides an excellent, complete C implementation of this pattern. It's a canonical example and serves as a perfect reference for our implementation.
Producer and Consumer Problem With Condition Variables
This documentation provides a complete and well-commented C implementation of the producer-consumer problem. This will be our primary reference for the code.
Please read the section titled 'Producer and Consumer Problem'. Study the buffer_t struct and the full implementations of the producer and consumer functions in Example 4-12 and 4-13. Pay close attention to how the two condition variables, less and more, are used to signal state changes, and how the circular buffer is managed with the nextin and nextout indices.
3. Dissecting the Implementation
Let's break down the key pieces of the code you just studied.
The Producer (producer function)
void producer(buffer_t *b, char item) {
// 1. Lock the mutex to gain exclusive access.
pthread_mutex_lock(&b->mutex);
// 2. Wait while the buffer is full.
// This uses the 'wait-in-a-loop' pattern.
while (b->occupied >= BSIZE)
pthread_cond_wait(&b->less, &b->mutex);
// 3. Add the item to the circular buffer.
b->buf[b->nextin++] = item;
b->nextin %= BSIZE; // Wrap around if necessary.
b->occupied++;
// 4. Signal that the buffer is no longer empty.
// This wakes up a waiting consumer.
pthread_cond_signal(&b->more);
// 5. Unlock the mutex.
pthread_mutex_unlock(&b->mutex);
}
This code precisely implements our design. The while loop is critical for handling spurious wakeups and ensuring the buffer actually has space before proceeding. The pthread_cond_wait function atomically unlocks the mutex before waiting and re-locks it before returning.
The resource also mentions invariants—logical statements about the state of the program. For a CS practitioner, thinking in terms of invariants is a powerful way to reason about correctness. For example, a key invariant is that 0 <= b->occupied <= BSIZE must always be true outside of the critical section.
The Consumer (consumer function)
The consumer logic is perfectly symmetric:
char consumer(buffer_t *b) {
char item;
// 1. Lock the mutex.
pthread_mutex_lock(&b->mutex);
// 2. Wait while the buffer is empty.
while(b->occupied <= 0)
pthread_cond_wait(&b->more, &b->mutex);
// 3. Remove an item from the circular buffer.
item = b->buf[b->nextout++];
b->nextout %= BSIZE; // Wrap around.
b->occupied--;
// 4. Signal that the buffer is no longer full.
// This wakes up a waiting producer.
pthread_cond_signal(&b->less);
// 5. Unlock the mutex and return the item.
pthread_mutex_unlock(&b->mutex);
return(item);
}
Here, the consumer waits on the more condition and signals the less condition, ensuring producers and consumers wake each other up correctly.
4. A Second Canonical Implementation (OSTEP)
To reinforce that this is a standard, fundamental pattern, let's look at another canonical implementation from the Operating Systems: Three Easy Pieces textbook. It's conceptually identical but uses slightly different variable names (empty, fill). Seeing this reinforces the pattern itself, separate from any specific variable naming convention.
The Correct Producer/Consumer Solution
The OSTEP textbook also provides a canonical implementation. It's useful to see the same pattern with slightly different variable names (empty, fill) to solidify your understanding of the underlying logic.
Review Figure 30.13 and Figure 30.14 in the section titled 'The Correct Producer/Consumer Solution'. Compare this implementation of put() and get() with the producer() and consumer() functions from the Oracle docs. Note the identical use of one mutex and two condition variables.
5. A Practical Build-Up: From Naive to Correct
The examples we've studied present the final, correct solution. However, it can be illuminating to see how one might arrive at this solution from a more naive starting point. The following video, while building a thread pool, effectively implements a bounded buffer (as a task queue) from scratch. It clearly demonstrates the progression of thought: first protecting the data with a mutex, realizing this leads to inefficient busy-waiting, and finally adding condition variables for an efficient solution.
Thread Pools in C (using the PTHREAD API)
This video from CodeVault is valuable because it shows the step-by-step evolution from a simple, buggy implementation to a correct one, reinforcing why we need each synchronization primitive.
Watch the following segments to see the step-by-step process: 10:58 - 12:27: Introduction of a mutex to protect the queue (the critical section). 15:33 - 17:32: Identifying the high CPU usage from busy-waiting, motivating the need for condition variables. 17:32 - 19:21: Implementing pthread_cond_wait within a while loop to wait efficiently when the queue is empty. 19:21 - 20:11: Implementing pthread_cond_signal to notify a waiting thread when a new task is added.
Note that this video uses a simple array-shifting approach for its queue. This is less performant than the circular buffer we've implemented but serves well to illustrate the synchronization logic in isolation.
Test your understanding!
In our bounded buffer implementation, what would be the effect of changing pthread_cond_signal to pthread_cond_broadcast in both the producer and consumer functions? Would the code still be correct? What are the performance implications?
Show answer
Correctness: Yes, the code would still be functionally correct. When a producer calls broadcast on the more condition, it wakes up all waiting consumers. One of those consumers will acquire the mutex, consume the item, and proceed. The other consumers, upon waking and eventually re-acquiring the mutex, would re-check the while condition (while (b->occupied <= 0)). They would find the buffer empty again and go back to waiting with pthread_cond_wait. The "wait-in-a-loop" pattern prevents incorrect behavior. The same logic applies to consumers waking up producers.
Performance Implications: The performance would likely be significantly worse, especially with many waiting threads. This is known as the "thundering herd" problem. Waking up dozens or hundreds of threads just for one of them to be able to do work is very inefficient. These threads will all contend for the mutex, leading to increased context switching and system overhead, only to immediately go back to sleep. pthread_cond_signal is much more efficient in this scenario because we know only one thread can consume the single item that was just produced (or one producer can fill the single space that was just freed).
Conclusion
In this lesson, we have successfully translated the design of a bounded buffer into a complete, efficient, and robust C implementation. You now have the code for one of the most fundamental data structures in concurrent programming.
Key Takeaways:
- A bounded buffer is best implemented with a circular array to avoid inefficient data shifting. This is managed with two indices and the modulo operator.
- The entire state, including the buffer, indices, count, mutex, and two condition variables, should be encapsulated in a single
struct. - The producer function waits on a "not full" condition (
lessorempty) and signals a "not empty" condition (moreorfill). - The consumer function waits on the "not empty" condition and signals the "not full" condition.
- This two-condition-variable pattern is the standard, correct way to prevent deadlocks and ensure that producers wake consumers and vice-versa.
Preview of the Next Lesson:
You have now implemented the core data structure. In the next lesson, you will write a program with multiple producer and consumer threads interacting via the bounded buffer. We will create several producer and consumer threads and have them all operate on a single instance of your buffer_t, putting your implementation to the test in a true multi-threaded environment.