Skip to main content

Welcome to a Grasp lesson

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

Lesson 5

Mutex Locks for Thread Synchronization

Recommended resources

Hello! Let's dive into the core of thread synchronization.

In our last lesson, we established a solid foundation for creating threads and initializing mutexes with robust error checking. We now have the tools, but we haven't put them to their primary use. This lesson transitions from setup to application, directly addressing the learning outcome: Protect critical sections and enforce mutual exclusion using pthread_mutex_lock and pthread_mutex_unlock.

We'll start by formalizing the problem of a "race condition" and defining the "critical section" of code that needs protection. Then, we will introduce and implement the fundamental Pthreads functions that ensure only one thread can enter that critical section at a time, thereby guaranteeing data consistency. This is the cornerstone of building the correct and complex concurrent data structures you're aiming for.

The Problem: Race Conditions and Critical Sections

You've encountered the concept of race conditions before. They occur when multiple threads access and manipulate a shared piece of data concurrently, and the final outcome depends on the unpredictable timing of their execution.

Let's watch a brief video that provides an excellent demonstration of this problem using a bank account analogy, which is a classic example in concurrency.

Mutex Introduction (pthreads) | C Programming Tutorial

The video 'Mutex Introduction' from Portfolio Courses clearly illustrates a race condition. Pay close attention to how two separate deposit operations result in a wrong final balance.

Watch the beginning of the video, from 00:08 to 04:37. The narrator will set up a scenario, run the code to show the error, and then provide a visualization of how the thread operations interleave to cause the bug.

The fundamental issue is that an operation like balance = balance + amount is not a single, indivisible (or atomic) instruction. At the machine level, it involves three distinct steps:

  1. Read: Load the value of balance from memory into a CPU register.
  2. Modify: Add amount to the value in the register.
  3. Write: Store the new value from the register back into memory.

A race condition occurs when the scheduler pauses one thread after it has read the value, and another thread then runs through all three steps. When the first thread resumes, it's working with an outdated, or stale, value.

The section of your code that accesses the shared resource (like the balance variable) is called a critical section. To prevent race conditions, we must guarantee mutual exclusion, meaning only one thread can be executing within that critical section at any given time.


The Solution: pthread_mutex_lock and pthread_mutex_unlock

The Pthreads library provides a simple and powerful mechanism to enforce mutual exclusion: the mutex. Think of a mutex as a key to a room (the critical section). A thread must acquire the key before entering the room. If another thread arrives and the key is gone, it must wait outside until the first thread leaves and returns the key.

The functions for this are pthread_mutex_lock() and pthread_mutex_unlock().

  • int pthread_mutex_lock(pthread_mutex_t *mutex);
    • Attempts to acquire the lock.
    • If the mutex is currently unlocked, the calling thread acquires it immediately and continues execution.
    • If the mutex is already locked by another thread, the calling thread blocks (it pauses execution and waits) until the mutex is unlocked.
  • int pthread_mutex_unlock(pthread_mutex_t *mutex);
    • Releases a lock that the calling thread currently holds.
    • If other threads are blocked waiting for this mutex, one of them will be woken up and will acquire the lock.

Let's see this in action. The following video takes a simple counter example, which has a race condition, and fixes it using these two functions.

What is a mutex in C? (pthread_mutex)

The video 'What is a mutex in C?' from CodeVault provides a very direct demonstration of fixing a race condition with mutexes.

Watch from 03:59 to 06:30. The narrator wraps the mails++ operation between pthread_mutex_lock and pthread_mutex_unlock and shows that the program now produces the correct result.

The core logic is as simple as it looks:

// Before entering the critical section
pthread_mutex_lock(&my_mutex);

// --- Critical Section Start ---
// Only one thread can be here at a time.
shared_variable++;
// --- Critical Section End ---

// After leaving the critical section
pthread_mutex_unlock(&my_mutex);

The following diagram illustrates this sequence. Imagine G1 and G2 are two threads trying to access a shared resource n.

Mutual Exclusion with Locks in a Multithreaded Environment
This diagram shows Thread G1 acquiring a lock, performing its operation, and releasing the lock. Thread G2 attempts to access the resource while G1 holds the lock, causing G2 to wait. G2 can only proceed after G1 releases the lock. This enforces sequential access to the critical section.

Practical Implementation and Performance

Let's combine what you've learned about robust initialization (last lesson) and locking (this lesson) into a complete, correct program. We'll use the textual resource from the University of Illinois, which provides excellent examples and analysis.

Synchronization

The 'Synchronization' course material from UIUC provides a full code example and discusses important nuances of mutex usage.

Please read the section titled 'Mutex Usages'. It contains three code snippets. The first shows the lock surrounding the entire loop. The second shows the lock inside the loop, around just the sum += 1; line. The third, and most important, shows a much more performant pattern where each thread calculates a local sum and only uses the lock once at the end to update the global sum. Focus on understanding why this third pattern is more efficient.

The Cost of Locking

As the reading points out, locking and unlocking a mutex is not free. It involves system calls and memory synchronization that are much more expensive than a simple variable increment.

The second example in the reading, where the lock is inside the loop, is correct but inefficient:

for(i = 0; i < 10000000; i++) {
    pthread_mutex_lock(&m);
    sum += 1;
    pthread_mutex_unlock(&m);
}

This code forces the threads to serialize their work entirely. You've effectively lost any benefit of parallelism because the threads spend most of their time waiting for the lock.

The "Minimize Lock Hold Time" Pattern

The third example demonstrates a crucial optimization pattern: do as much work as possible outside the critical section.

int local_sum = 0;
for(i = 0; i < 10000000; i++) {
    local_sum += 1; // No lock needed, 'local_sum' is private to the thread
}

pthread_mutex_lock(&m);
global_sum += local_sum; // Lock held for just one operation
pthread_mutex_unlock(&m);

Here, each thread computes its sub-total using its own private local_sum variable. Since this variable isn't shared, no lock is needed. Only at the very end does the thread acquire the lock for the single, brief operation of adding its local result to the shared global_sum. This minimizes lock contention and maximizes parallel execution. This principle is fundamental to writing high-performance concurrent code.


Rules of Engagement for Mutexes

When using mutexes, a few strict rules apply. Breaking them can lead to bugs that are difficult to diagnose, including deadlocks (which we'll cover in a future module).

  1. A mutex protects a section of code, not data. A mutex is a signaling mechanism. It's up to you, the programmer, to ensure that every critical section that accesses a piece of shared data is protected by the same mutex.
  2. The thread that locks a mutex must be the one to unlock it. The concept of ownership is built into standard Pthreads mutexes.
  3. Always unlock a mutex. Be careful in functions with multiple return paths. If you lock a mutex at the start of a function and then return early due to an error, you must ensure you unlock the mutex before returning. Failure to do so will cause any other thread waiting on that mutex to block forever.
  4. Keep critical sections as small and fast as possible. As we saw with the performance example, holding a lock for a long time creates a bottleneck. Move any work that doesn't strictly need the shared data outside the lock/unlock block.
Test your understanding!

A colleague has written the following code to manage deposits and withdrawals between two bank accounts, account[0] and account[1]. They've created two separate mutexes, m_deposits and m_withdrawals, thinking this will be more efficient than using a single lock.

long accounts[2] = {1000, 1000};
pthread_mutex_t m_deposits = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_t m_withdrawals = PTHREAD_MUTEX_INITIALIZER;

// Function run by one thread
void* deposit_thread(void* arg) {
    for (int i = 0; i < 10000; ++i) {
        pthread_mutex_lock(&m_deposits);
        accounts[0] += 10;
        accounts[1] += 10;
        pthread_mutex_unlock(&m_deposits);
    }
    return NULL;
}

// Function run by another thread
void* withdrawal_thread(void* arg) {
    for (int i = 0; i < 10000; ++i) {
        pthread_mutex_lock(&m_withdrawals);
        accounts[0] -= 10;
        accounts[1] -= 10;
        pthread_mutex_unlock(&m_withdrawals);
    }
    return NULL;
}

Is this code thread-safe? Why or why not?

Show answer

No, this code is not thread-safe.

The problem is that the two threads are using different mutexes (m_deposits and m_withdrawals) to protect the same shared data (the accounts array).

A mutex does not intrinsically lock a variable; it locks a section of code. Since deposit_thread acquires m_deposits and withdrawal_thread acquires m_withdrawals, nothing stops them from entering their respective critical sections at the same time and concurrently modifying the accounts array. This creates a race condition.

To fix this, all operations on the accounts array must be protected by a single, shared mutex.

pthread_mutex_t m_accounts = PTHREAD_MUTEX_INITIALIZER;

// ...

// In deposit_thread:
pthread_mutex_lock(&m_accounts);
// ...
pthread_mutex_unlock(&m_accounts);

// In withdrawal_thread:
pthread_mutex_lock(&m_accounts);
// ...
pthread_mutex_unlock(&m_accounts);

Conclusion

You have now learned the most fundamental synchronization pattern in threaded programming. By wrapping critical sections with pthread_mutex_lock and pthread_mutex_unlock, you can prevent race conditions and ensure the integrity of shared data.

Key Takeaways:

  • A critical section is a piece of code that accesses a shared resource.
  • Mutual exclusion, which prevents multiple threads from executing in a critical section simultaneously, is necessary for correctness.
  • The pthread_mutex_lock() and pthread_mutex_unlock() functions are the standard tools for enforcing mutual exclusion.
  • If a thread calls pthread_mutex_lock() on a mutex held by another thread, it will block until the mutex is released.
  • For performance, always aim to minimize lock hold time by keeping critical sections as short as possible.

Preview of the Next Lesson:
The pthread_mutex_lock function is a blocking call. The thread simply stops and waits. But what if you don't want to wait? What if you want to test if a lock is available and, if it isn't, go do some other useful work instead of blocking? In the next lesson, we will explore this scenario by learning to use pthread_mutex_trylock for non-blocking lock acquisition strategies.