Skip to content
Published on

[OS Concepts] 07. Synchronization Problems: Producer-Consumer, Dining Philosophers

Authors

Classic Synchronization Problems

To understand the correct use of synchronization tools, let us examine three classic problems traditionally studied in the operating systems field.


1. Bounded-Buffer Problem

The producer creates data and places it in the buffer, while the consumer takes data from the buffer and uses it. Since the buffer size is finite, the producer must wait when the buffer is full, and the consumer must wait when it is empty.

[Bounded Buffer Structure]

Producer -->  [  |  |  |  |  ]  --> Consumer
              0  1  2  3  4
              ^           ^
             out          in

Semaphores:
  mutex = 1      (mutual exclusion for buffer access)
  empty = N      (number of empty slots, initial = buffer size)
  full  = 0      (number of filled slots, initial = 0)
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <semaphore.h>
#include <unistd.h>

#define BUFFER_SIZE 5

int buffer[BUFFER_SIZE];
int in = 0, out = 0;

sem_t empty;    // Number of empty slots
sem_t full;     // Number of filled slots
pthread_mutex_t mutex;

void *producer(void *arg) {
    int id = *(int *)arg;
    for (int i = 0; i < 10; i++) {
        int item = rand() % 100;

        sem_wait(&empty);           // Wait until empty slot available
        pthread_mutex_lock(&mutex); // Lock buffer access

        // Critical section: add item to buffer
        buffer[in] = item;
        printf("Producer %d: buffer[%d] = %d produced\n", id, in, item);
        in = (in + 1) % BUFFER_SIZE;

        pthread_mutex_unlock(&mutex);
        sem_post(&full);            // Increase filled slot count

        usleep(rand() % 500000);
    }
    return NULL;
}

void *consumer(void *arg) {
    int id = *(int *)arg;
    for (int i = 0; i < 10; i++) {
        sem_wait(&full);            // Wait until filled slot available
        pthread_mutex_lock(&mutex); // Lock buffer access

        // Critical section: remove item from buffer
        int item = buffer[out];
        printf("Consumer %d: buffer[%d] = %d consumed\n", id, out, item);
        out = (out + 1) % BUFFER_SIZE;

        pthread_mutex_unlock(&mutex);
        sem_post(&empty);           // Increase empty slot count

        usleep(rand() % 800000);
    }
    return NULL;
}

int main() {
    pthread_mutex_init(&mutex, NULL);
    sem_init(&empty, 0, BUFFER_SIZE);
    sem_init(&full, 0, 0);

    pthread_t prod[2], cons[2];
    int ids[] = {0, 1};

    for (int i = 0; i < 2; i++) {
        pthread_create(&prod[i], NULL, producer, &ids[i]);
        pthread_create(&cons[i], NULL, consumer, &ids[i]);
    }

    for (int i = 0; i < 2; i++) {
        pthread_join(prod[i], NULL);
        pthread_join(cons[i], NULL);
    }

    pthread_mutex_destroy(&mutex);
    sem_destroy(&empty);
    sem_destroy(&full);
    return 0;
}

2. Readers-Writers Problem

When multiple processes share a database, processes that only read (readers) can access concurrently without issues, but writing processes (writers) need exclusive access.

[Readers-Writers Rules]

Reader + Reader = Allowed (concurrent reads OK)
Reader + Writer = Not Allowed (conflict)
Writer + Writer = Not Allowed (conflict)

First Variation: Reader Priority

Writers wait while readers are present. If readers keep arriving, writers may starve.

#include <pthread.h>
#include <stdio.h>

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_t rw_mutex = PTHREAD_MUTEX_INITIALIZER;
int read_count = 0;
int shared_data = 0;

void *reader(void *arg) {
    int id = *(int *)arg;

    pthread_mutex_lock(&mutex);
    read_count++;
    if (read_count == 1) {
        // First reader blocks writers
        pthread_mutex_lock(&rw_mutex);
    }
    pthread_mutex_unlock(&mutex);

    // Critical section: read
    printf("Reader %d: data read = %d (current readers: %d)\n",
           id, shared_data, read_count);

    pthread_mutex_lock(&mutex);
    read_count--;
    if (read_count == 0) {
        // Last reader unblocks writers
        pthread_mutex_unlock(&rw_mutex);
    }
    pthread_mutex_unlock(&mutex);

    return NULL;
}

void *writer(void *arg) {
    int id = *(int *)arg;

    pthread_mutex_lock(&rw_mutex);  // Exclusive access

    // Critical section: write
    shared_data++;
    printf("Writer %d: data updated = %d\n", id, shared_data);

    pthread_mutex_unlock(&rw_mutex);

    return NULL;
}

Second Variation: Writer Priority

When a writer is ready, new readers cannot enter. After all existing readers leave, the writer executes.


3. Dining Philosophers Problem

Five philosophers sit at a round table. There is one chopstick between each pair of philosophers, and eating requires two chopsticks.

[Dining Philosophers]

        P0
    C4      C0
  P4          P1
    C3      C1
        P3
      C2
        P2

P: Philosopher
C: Chopstick

Philosopher i uses chopstick i and chopstick (i+1)%5

Simple Solution Using Semaphores (Deadlock Risk)

// Warning: This solution can cause deadlock!
sem_t chopstick[5];

void *philosopher(void *arg) {
    int id = *(int *)arg;

    while (1) {
        printf("Philosopher %d: thinking\n", id);
        usleep(rand() % 1000000);

        // Pick up both chopsticks
        sem_wait(&chopstick[id]);           // Left
        sem_wait(&chopstick[(id + 1) % 5]); // Right

        printf("Philosopher %d: eating\n", id);
        usleep(rand() % 1000000);

        // Put down chopsticks
        sem_post(&chopstick[id]);
        sem_post(&chopstick[(id + 1) % 5]);
    }
}

// Deadlock: If all 5 pick up their left chopstick simultaneously
// no one can get their right chopstick!

Deadlock Prevention Solutions

// Solution 1: Asymmetric - Even philosophers pick left first, odd pick right first
void *philosopher_asymmetric(void *arg) {
    int id = *(int *)arg;

    while (1) {
        printf("Philosopher %d: thinking\n", id);

        if (id % 2 == 0) {
            sem_wait(&chopstick[id]);
            sem_wait(&chopstick[(id + 1) % 5]);
        } else {
            sem_wait(&chopstick[(id + 1) % 5]);
            sem_wait(&chopstick[id]);
        }

        printf("Philosopher %d: eating\n", id);
        usleep(rand() % 1000000);

        sem_post(&chopstick[id]);
        sem_post(&chopstick[(id + 1) % 5]);
    }
}

Monitor-Based Solution

// Monitor-based solution (using Pthreads condition variables)
#include <pthread.h>
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>

#define N 5
#define THINKING 0
#define HUNGRY   1
#define EATING   2

int state[N];
pthread_mutex_t monitor_mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t self[N];

// Left and right neighbor indices
#define LEFT(i)  ((i + N - 1) % N)
#define RIGHT(i) ((i + 1) % N)

void test(int i) {
    // If I'm hungry and neither neighbor is eating, I can eat
    if (state[i] == HUNGRY &&
        state[LEFT(i)] != EATING &&
        state[RIGHT(i)] != EATING) {
        state[i] = EATING;
        pthread_cond_signal(&self[i]);
    }
}

void pickup(int i) {
    pthread_mutex_lock(&monitor_mutex);

    state[i] = HUNGRY;
    printf("Philosopher %d: hungry\n", i);
    test(i);  // Check if can eat immediately

    while (state[i] != EATING) {
        pthread_cond_wait(&self[i], &monitor_mutex);
    }

    printf("Philosopher %d: starts eating\n", i);
    pthread_mutex_unlock(&monitor_mutex);
}

void putdown(int i) {
    pthread_mutex_lock(&monitor_mutex);

    state[i] = THINKING;
    printf("Philosopher %d: done eating, starts thinking\n", i);

    // Check if neighbors can eat
    test(LEFT(i));
    test(RIGHT(i));

    pthread_mutex_unlock(&monitor_mutex);
}

void *philosopher(void *arg) {
    int id = *(int *)arg;
    while (1) {
        usleep(rand() % 1000000);  // Think
        pickup(id);                 // Pick up chopsticks
        usleep(rand() % 1000000);  // Eat
        putdown(id);                // Put down chopsticks
    }
}

POSIX Synchronization

POSIX Mutex

#include <pthread.h>

pthread_mutex_t lock;

// Static initialization
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;

// Dynamic initialization
pthread_mutex_init(&lock, NULL);

// Usage
pthread_mutex_lock(&lock);
// Critical section
pthread_mutex_unlock(&lock);

// Non-blocking attempt
if (pthread_mutex_trylock(&lock) == 0) {
    // Lock acquired successfully
    pthread_mutex_unlock(&lock);
} else {
    // Another thread holds the lock
}

// Cleanup
pthread_mutex_destroy(&lock);

POSIX Semaphores

POSIX provides named and unnamed semaphores.

#include <semaphore.h>

// Unnamed semaphore (between threads)
sem_t sem;
sem_init(&sem, 0, 1);  // 0: between threads, initial value 1
sem_wait(&sem);
sem_post(&sem);
sem_destroy(&sem);

// Named semaphore (between processes)
sem_t *sem = sem_open("/my_sem", O_CREAT, 0644, 1);
sem_wait(sem);
sem_post(sem);
sem_close(sem);
sem_unlink("/my_sem");

POSIX Condition Variables

#include <pthread.h>

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
int data_ready = 0;

// Producer
void *producer(void *arg) {
    pthread_mutex_lock(&mutex);

    // Generate data
    data_ready = 1;
    printf("Producer: data ready\n");

    pthread_cond_signal(&cond);    // Wake up waiting consumer
    pthread_mutex_unlock(&mutex);
    return NULL;
}

// Consumer
void *consumer(void *arg) {
    pthread_mutex_lock(&mutex);

    while (!data_ready) {
        // Wait until condition is met
        // Mutex is automatically released during wait
        pthread_cond_wait(&cond, &mutex);
    }

    printf("Consumer: data consumed\n");
    data_ready = 0;

    pthread_mutex_unlock(&mutex);
    return NULL;
}

Java Synchronization

synchronized Keyword

public class Counter {
    private int count = 0;

    // Method-level synchronization
    public synchronized void increment() {
        count++;
    }

    public synchronized int getCount() {
        return count;
    }

    // Block-level synchronization
    public void incrementBlock() {
        synchronized (this) {
            count++;
        }
    }
}

ReentrantLock

A more flexible locking mechanism than synchronized.

import java.util.concurrent.locks.ReentrantLock;
import java.util.concurrent.locks.Condition;

public class BoundedBufferLock {
    private final Object[] buffer;
    private int count = 0, in = 0, out = 0;

    private final ReentrantLock lock = new ReentrantLock();
    private final Condition notFull = lock.newCondition();
    private final Condition notEmpty = lock.newCondition();

    public BoundedBufferLock(int size) {
        buffer = new Object[size];
    }

    public void produce(Object item) throws InterruptedException {
        lock.lock();
        try {
            while (count == buffer.length) {
                notFull.await();    // Wait until buffer has space
            }
            buffer[in] = item;
            in = (in + 1) % buffer.length;
            count++;
            notEmpty.signal();      // Wake up consumer
        } finally {
            lock.unlock();
        }
    }

    public Object consume() throws InterruptedException {
        lock.lock();
        try {
            while (count == 0) {
                notEmpty.await();   // Wait until data available
            }
            Object item = buffer[out];
            out = (out + 1) % buffer.length;
            count--;
            notFull.signal();       // Wake up producer
            return item;
        } finally {
            lock.unlock();
        }
    }
}

Alternative Approaches

Transactional Memory

Applies the database transaction concept to memory access.

// Conceptual code (actual syntax varies by implementation)
atomic {
    // All memory operations in this block execute atomically
    // Automatically retries on conflict
    account1.balance -= amount;
    account2.balance += amount;
}

Functional Programming

Using immutable data structures means shared state is never modified, making synchronization unnecessary. Functional languages like Erlang, Scala, and Haskell utilize this approach.


Summary

The bounded buffer, readers-writers, and dining philosophers problems are classic examples that illustrate the core challenges of synchronization. POSIX and Java provide various synchronization tools including mutexes, semaphores, and condition variables. Alternative approaches like transactional memory and functional programming are also gaining attention as methods for solving concurrency problems.