Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 61

Mutex vs. R/W Lock Performance for Read-Heavy Workloads

Recommended resources

Hello! Welcome to your next lesson in our "Performance Analysis and Optimization" module.

In our last session, we contrasted the "sleep-when-blocked" strategy of a mutex with the "busy-wait" strategy of a spinlock. We concluded that while mutexes are the safe, general-purpose choice, spinlocks can offer a significant performance advantage for extremely short, highly contended critical sections.

Today, we'll explore another powerful optimization for a very common scenario. Many applications read shared data far more frequently than they write to it. A standard mutex, however, forces all operations—reads and writes alike—to happen one at a time. This can create a significant bottleneck.

In this lesson, you will learn to compare the performance of a mutex versus a read-write lock for a workload with a high read-to-write ratio. We'll cover the concept, the pthreads API, and, most importantly, build a benchmark to see the performance difference for yourself.

The Problem: Unnecessary Serialization

Imagine a scenario with ten threads. Nine need to read a configuration value, and one needs to update it. With a standard pthread_mutex_t, only one thread can hold the lock at a time. This means the nine reader threads, which could all safely read the data concurrently without interfering with each other, are forced to execute serially.

This image perfectly illustrates the inefficiency:

Comparison of RwLock and Mutex Performance
This diagram shows how a `Mutex` serializes all operations, creating a queue. In contrast, a Read-Write Lock (`RwLock`) allows multiple readers to proceed in parallel, significantly improving throughput in read-heavy scenarios.

The Solution: pthread_rwlock_t

The POSIX threads library provides a specialized tool for this exact problem: the read-write lock (pthread_rwlock_t). It allows for a more granular locking policy:

  • Shared Read Lock: Multiple threads can acquire a read lock simultaneously.
  • Exclusive Write Lock: Only one thread can acquire a write lock at a time. While a write lock is held, no other threads (readers or writers) can acquire any lock.

This allows you to maximize concurrency for readers while still ensuring correctness and safety for writers.

The following video provides a concise introduction to the concept and walks through a C example using the pthreads API.

Using read write locks (example in C)

Watch this segment from Jacob Sorber's video, 'Using read write locks (example in C)', to see how to declare, initialize, and use pthread_rwlock_t for both reading and writing.

Watch from 13:23 to 16:16. Pay close attention to the API differences from a standard mutex: pthread_rwlock_t, pthread_rwlock_init, and the distinct locking functions pthread_rwlock_rdlock() and pthread_rwlock_wrlock().


The Performance Trade-Off: Overhead vs. Parallelism

A read-write lock sounds strictly better, but there's a trade-off. Its internal logic is more complex than a simple mutex. It needs to track the number of active readers, whether a writer is waiting, and manage queues for both. This additional complexity means that the individual lock and unlock operations for a pthread_rwlock_t are more expensive than for a pthread_mutex_t.

Therefore, a read-write lock is beneficial only when the performance gained from running readers in parallel outweighs the higher overhead of the lock itself.

The following video discusses this trade-off and shows benchmark results that quantify it.

Is RWMutex slower than Mutex?

This video, 'Is RWMutex slower than Mutex?' from the Software Chats channel, provides excellent intuition about the performance trade-offs.

First, watch from 01:20 to 01:53 to understand the underlying performance considerations. Then, skip to 02:40 and watch until 03:33 to see an analysis of benchmark results, which clearly shows how performance varies with the read-to-write ratio.

As the video demonstrates, the "magic ratio" of readers to writers is what determines whether an rwlock is the right choice. If your workload is mostly writes, the higher overhead of the rwlock can actually make your application slower than if you had used a simple, lower-overhead mutex.

Test your understanding!

You have a data structure that is protected by a lock. You profile your application and find that the critical section is entered 1,000,000 times per second. In 99.9% of those cases, the operation is a read, and the lock is held for 50 microseconds. Would you expect a pthread_rwlock_t to improve performance over a pthread_mutex_t? Why or why not?

Show answer

Yes, a pthread_rwlock_t is very likely to improve performance significantly. The workload is extremely read-heavy (99.9% reads). A mutex would serialize all these reads, while a read-write lock would allow them to execute in parallel. The 50 microsecond hold time is long enough that the gains from parallelism will almost certainly outweigh the slightly higher per-operation overhead of the rwlock.


Practical Benchmark: Mutex vs. Read-Write Lock

Now, let's put this to the test. Your task is to write a C program that benchmarks the two lock types under different read/write workloads.

Here is a skeleton to get you started. The goal is to fill in the // TODO sections.

#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <time.h>
#include <unistd.h> // For usleep

#define NUM_LOOPS 10000

// Shared data
long long shared_counter = 0;

// TODO 1: Declare both a mutex and a read-write lock
pthread_mutex_t mutex_lock;
pthread_rwlock_t rw_lock;

// A flag to choose which lock to use
int use_rw_lock = 0;

// Reader thread function
void* reader(void* arg) {
    (void)arg; // Unused
    for (int i = 0; i < NUM_LOOPS; ++i) {
        if (use_rw_lock) {
            // TODO 2: Acquire the read lock
            pthread_rwlock_rdlock(&rw_lock);
        } else {
            pthread_mutex_lock(&mutex_lock);
        }

        // Simulate some "read" work inside the critical section
        long long val = shared_counter;
        (void)val; // Suppress unused variable warning
        usleep(1); // Hold the lock for 1 microsecond

        if (use_rw_lock) {
            // TODO 3: Release the read lock
            pthread_rwlock_unlock(&rw_lock);
        } else {
            pthread_mutex_unlock(&mutex_lock);
        }
    }
    return NULL;
}

// Writer thread function
void* writer(void* arg) {
    (void)arg; // Unused
    for (int i = 0; i < NUM_LOOPS; ++i) {
        if (use_rw_lock) {
            // TODO 4: Acquire the write lock
            pthread_rwlock_wrlock(&rw_lock);
        } else {
            pthread_mutex_lock(&mutex_lock);
        }

        // Simulate some "write" work inside the critical section
        shared_counter++;
        usleep(1); // Hold the lock for 1 microsecond

        if (use_rw_lock) {
            // TODO 5: Release the write lock
            pthread_rwlock_unlock(&rw_lock);
        } else {
            pthread_mutex_unlock(&mutex_lock);
        }
    }
    return NULL;
}

int main(int argc, char *argv[]) {
    if (argc != 4) {
        fprintf(stderr, "Usage: %s <num_readers> <num_writers> <use_rw_lock: 0 or 1>\n", argv[0]);
        return 1;
    }

    int num_readers = atoi(argv[1]);
    int num_writers = atoi(argv[2]);
    use_rw_lock = atoi(argv[3]);

    int total_threads = num_readers + num_writers;
    pthread_t threads[total_threads];

    // TODO 6: Initialize the chosen lock
    if (use_rw_lock) {
        pthread_rwlock_init(&rw_lock, NULL);
    } else {
        pthread_mutex_init(&mutex_lock, NULL);
    }

    struct timespec start, end;
    clock_gettime(CLOCK_MONOTONIC, &start);

    // Create reader threads
    for (int i = 0; i < num_readers; ++i) {
        pthread_create(&threads[i], NULL, reader, NULL);
    }

    // Create writer threads
    for (int i = 0; i < num_writers; ++i) {
        pthread_create(&threads[num_readers + i], NULL, writer, NULL);
    }

    // Join all threads
    for (int i = 0; i < total_threads; ++i) {
        pthread_join(threads[i], NULL);
    }

    clock_gettime(CLOCK_MONOTONIC, &end);
    
    // TODO 7: Destroy the chosen lock
    if (use_rw_lock) {
        pthread_rwlock_destroy(&rw_lock);
    } else {
        pthread_mutex_destroy(&mutex_lock);
    }

    double time_taken = (end.tv_sec - start.tv_sec) * 1e9;
    time_taken = (time_taken + (end.tv_nsec - start.tv_nsec)) * 1e-9;

    printf("Lock Type: %s, Readers: %d, Writers: %d\n",
           use_rw_lock ? "RW Lock" : "Mutex", num_readers, num_writers);
    printf("Time taken: %f seconds\n\n", time_taken);

    return 0;
}

Your Experiment:

  1. Complete the Code: Fill in the TODO sections. The function names are provided in the video and this lesson text.
  2. Compile: gcc -Wall -Wextra -O2 -pthread your_file.c -o benchmark
  3. Run and Measure: Execute the program with different configurations. Try to keep the total number of threads constant to make comparisons fair. For example, on a machine with 8 cores, you could test:
    • ./benchmark 8 0 0 (Mutex, 8 readers) vs ./benchmark 8 0 1 (RW Lock, 8 readers)
    • ./benchmark 7 1 0 (Mutex, 7 readers, 1 writer) vs ./benchmark 7 1 1 (RW Lock, 7 readers, 1 writer)
    • ./benchmark 4 4 0 (Mutex, 4 readers, 4 writers) vs ./benchmark 4 4 1 (RW Lock, 4 readers, 4 writers)
    • ./benchmark 1 7 0 (Mutex, 1 reader, 7 writers) vs ./benchmark 1 7 1 (RW Lock, 1 reader, 7 writers)
    • ./benchmark 0 8 0 (Mutex, 8 writers) vs ./benchmark 0 8 1 (RW Lock, 8 writers)

Interpreting Your Results

After running your tests, you should see a clear pattern emerge. For read-heavy workloads, the rwlock version should be substantially faster. As the number of writers increases, this advantage will diminish and eventually disappear. In the write-only case, you might even find the mutex is slightly faster due to its lower overhead.

Your results should align with the trend shown in this benchmark graph:

Benchmark Result: Mutex vs. Read-Write Lock vs. Atomic Operations
This graph plots execution time against the number of writer threads (out of a total of 20 threads). The `rwlock` line (red) shows excellent performance with few writers (many readers) but degrades as the write ratio increases. The `mutex` line (blue) is less sensitive to the ratio but is clearly slower in read-heavy scenarios.

A Final Detail: Preventing Writer Starvation

What happens if a writer wants the lock, but new readers keep arriving and acquiring read locks? The writer could be "starved" and wait indefinitely. To prevent this, most pthread_rwlock_t implementations give some preference to waiting writers.

The resource below explains this behavior.

Read-Write Locks

The article 'Read-Write Locks' explains how implementations typically handle contention between readers and waiting writers.

Read the section titled 'Read-Write Lock Implementation'. Focus on the third bullet point, which describes what happens when a reader arrives while a writer is already waiting. This explains the mechanism used to prevent writer starvation.


Conclusion

You have now explored a crucial optimization technique for concurrent data structures. By understanding the access patterns of your application, you can choose a locking primitive that maximizes performance.

Key Takeaways:

  • A pthread_mutex_t serializes all access, which is inefficient for read-heavy workloads where concurrent reads are safe.
  • A pthread_rwlock_t provides a more nuanced approach, allowing multiple concurrent readers or a single exclusive writer.
  • This performance benefit comes at the cost of higher per-operation overhead for the lock itself.
  • The choice is data-driven: A read-write lock is only a net win when the performance gain from parallelizing reads outweighs its higher overhead. This typically requires a high read-to-write ratio and a non-trivial amount of work within the read-side critical section.
  • Always benchmark your specific workload to validate your choice of synchronization primitive.

Preview of the Next Lesson:
We've spent this module optimizing how we use locks. We've compared mutexes to spinlocks for short critical sections and to read-write locks for read-heavy workloads. But what if we could get rid of locks entirely for simple operations? In our next lesson, we will begin exploring the world of lock-free programming by comparing the performance of a lock-based versus a lock-free implementation of a shared counter using C11 atomic operations.