Programming lesson
Mastering Thread Synchronization in C: Traffic Simulation with Mutexes, Condition Variables, and Semaphores
Learn how to implement a traffic simulation in C using mutexes, condition variables, and semaphores. Understand fairness, thread pools, and concurrency with practical code examples.
Introduction to Thread Synchronization in C
In modern computing, concurrency is everywhere—from AI models processing data in parallel to multiplayer gaming servers handling thousands of players. Understanding thread synchronization is crucial for writing efficient, bug-free concurrent programs. This tutorial explores a classic traffic simulation problem that demonstrates key synchronization primitives: mutexes, condition variables, and semaphores. By the end, you'll grasp how to manage shared resources and avoid race conditions, a skill essential for systems programming, cloud computing, and high-performance applications.
The Traffic Problem: A Real-World Analogy
Imagine a one-lane street that cars from east and west can enter, but only in one direction at a time. The street can hold up to three cars, all moving the same way. This is the Traffic problem: a classic synchronization challenge. It mirrors real-world scenarios like database access control or network packet processing. In our simulation, threads represent cars, and the street is a shared resource. Each thread loops a fixed number of times, trying to enter the street, yielding the CPU, and then leaving. The goal is to ensure mutual exclusion (no two cars from opposite directions) and bounded occupancy (max three cars).
Setting Up the Environment
We'll use the uthread library for thread management. Start with the provided skeleton traffic.c. Compile with: gcc -pthread traffic.c -o traffic. For this tutorial, we assume N=20 threads, each performing 100 iterations. We'll use assert statements to enforce constraints and print a histogram of waiting times.
Implementation with Mutexes and Condition Variables
First, declare global variables: a mutex lock, condition variables eastQueue and westQueue, counters for cars in each direction, and a total waiting histogram. Each thread picks a random direction (0 for east, 1 for west) at creation. In the loop:
- Acquire the mutex.
- Wait on the appropriate condition variable until the street can accommodate (direction matches and occupancy < 3).
- Increment occupancy and record waiting time.
- Release mutex, enter street, yield N times, then reacquire mutex to leave.
- Decrement occupancy, signal the opposite direction if possible, record histogram data, release mutex, and yield N times before next attempt.
// Pseudocode for thread function
void *car(void *arg) {
int dir = *(int*)arg;
for (int i = 0; i < 100; i++) {
mutex_lock(&lock);
while (!can_enter(dir)) {
cond_wait(dir == EAST ? &eastQueue : &westQueue, &lock);
}
// Record waiting time
int wait = total_entries - start_wait;
if (wait < HIST_SIZE) hist[wait]++; else overflow++;
occupancy[dir]++;
mutex_unlock(&lock);
// In street
for (int j = 0; j < N; j++) uthread_yield();
mutex_lock(&lock);
occupancy[dir]--;
total_entries++;
// Signal opposite direction
if (occupancy[dir] == 0 && waiting_opposite > 0)
cond_signal(dir == EAST ? &westQueue : &eastQueue);
mutex_unlock(&lock);
for (int j = 0; j < N; j++) uthread_yield();
}
return NULL;
}
Fairness Issues with Condition Variables
Even with a FIFO condition variable queue, unfairness arises due to races between awoken threads and new threads acquiring the mutex. A car that has been waiting longest may be bypassed by a new car that grabs the mutex first. This causes the awoken thread to re-queue at the back, potentially leading to starvation. The uthread_yield() loops mitigate this by giving awoken threads a chance to reacquire the mutex, but not perfectly. Experiment with more or fewer yields to see the effect.
Semaphore Implementation
Copy traffic.c to traffic_sem.c and replace mutexes/condition variables with semaphores. Use a binary semaphore for mutual exclusion and counting semaphores for signalling. The logic remains similar, but semaphores inherently provide a different fairness model: semaphores typically wake the longest-waiting thread, but the race condition still exists. However, the semaphore implementation tends to be fairer because the semaphore's internal queue and the mutex acquisition are more tightly coupled. You'll observe fewer long waits in the histogram.
// Semaphore version key differences
sem_t mutex; // binary semaphore
sem_t eastSem, westSem; // counting semaphores
// In thread:
sem_wait(&mutex);
while (!can_enter(dir)) {
sem_post(&mutex);
sem_wait(dir == EAST ? &eastSem : &westSem);
sem_wait(&mutex);
}
// ... critical section ...
sem_post(&mutex);
// After leaving street:
sem_wait(&mutex);
// update occupancy, signal
if (occupancy[dir] == 0 && waiting_opposite > 0)
sem_post(dir == EAST ? &westSem : &eastSem);
sem_post(&mutex);
Thread Pools: A Higher-Level Abstraction
A thread pool maintains a set of worker threads that execute tasks from a queue. This avoids the overhead of creating/destroying threads per task and caps concurrency. In cloud computing, platforms like AWS Lambda or Google Cloud Functions use thread pools to handle millions of requests efficiently. For our traffic simulation, a thread pool could manage car threads, but the problem is more about synchronization. Nonetheless, understanding thread pools is vital for scalable systems. In PrairieLearn, a thread pool processes code grading requests, ensuring fair resource usage.
Testing and Observing Fairness
Run your program with N=20 and 100 iterations. Print the histogram of waiting times. You'll see most cars wait short times, but a few may wait very long (overflow bucket). Compare the condition variable and semaphore versions. The semaphore version should have fewer extreme waits. This demonstrates the subtle differences in scheduling. For a deeper dive, try varying the number of yields after leaving the street.
Conclusion
This traffic simulation teaches core concurrency concepts: mutual exclusion, condition synchronization, and fairness. Whether you're building a game server, a high-frequency trading system, or an AI inference pipeline, these skills are essential. By mastering mutexes, condition variables, and semaphores, you'll write robust multithreaded programs. Experiment with the code, tweak parameters, and observe the behavior. Happy coding!