Skip to main content

Welcome to a Grasp lesson

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

Lesson 7

Thread-Safe Counter with Mutex

Recommended resources

Hello! Welcome to the final lesson in our module on core thread management and mutex synchronization.

In previous lessons, we've explored creating, joining, and detaching threads, as well as using pthread_mutex_lock and pthread_mutex_trylock to enforce mutual exclusion. We've established the fundamental patterns for protecting shared resources.

Today, we'll apply these concepts to solve one of the most classic and fundamental problems in concurrent programming. This lesson directly addresses the learning outcome: Implement a practical thread-safe counter using a mutex to prevent race conditions. We will see firsthand why multithreading can produce incorrect results without proper synchronization and build a robust, correct solution from the ground up.

The Problem: Race Conditions

Let's start by understanding the problem we're trying to solve. Imagine you have a simple global counter that you want multiple threads to increment. Each thread will increment the counter a million times. If you have two threads, you'd expect the final value to be two million. Let's see what actually happens.

The following video does an excellent job of demonstrating this exact scenario.

What is a mutex in C? (pthread_mutex)

The video 'What is a mutex in C?' from CodeVault clearly illustrates the problem of a race condition with a shared counter.

Watch the first part of the video, from 00:00 to 02:26. Observe how running the same program with multiple threads produces a different, incorrect result each time.

The unpredictable result you just saw is caused by a race condition. This occurs because the increment operation, mails++, is not a single, indivisible (or atomic) operation. At the machine level, it's a sequence of distinct steps:

  1. Read: Load the current value of the counter from memory into a CPU register.
  2. Modify: Increment the value in the CPU register.
  3. Write: Store the new value from the CPU register back into memory.

The operating system can pause a thread and switch to another at any point. Imagine two threads running this sequence.

Time Thread 1 Action Thread 2 Action mails in Memory T1 Register T2 Register
1 Read mails (value is 100) 100 100
2 Increment register (to 101) 100 101
3 -- Context Switch -- Read mails (value is 100) 100 101 100
4 Increment register (to 101) 100 101 101
5 Write mails (value is 101) 101 101 101
6 -- Context Switch -- 101 101
7 Write mails (value is 101) 101 101

Both threads performed an increment, but the final value only increased by one. An update was lost. This is a classic data race.


The Solution: Mutual Exclusion with a Mutex

To prevent this, we must ensure that the read-modify-write sequence is executed by only one thread at a time. The block of code that accesses the shared resource (in this case, the increment operation) is called a critical section. We need to guarantee mutual exclusion—if one thread is in the critical section, no other thread can enter it.

This is precisely what a mutex (mutual exclusion lock) is for.

Threads, Mutexes and Concurrent Programming in C

For a clear definition and a great analogy, let's turn to the article 'Threads, Mutexes and Concurrent Programming in C'.

Read the section 'What is a Mutex?'. The bathroom door analogy is a very effective way to conceptualize how a mutex works.

A mutex acts as a gatekeeper for the critical section. Before entering, a thread must acquire the lock. After it's done, it must release the lock.

Mutex Synchronization Sequence Diagram
This diagram illustrates the mutex mechanism. Thread A acquires the lock and enters its critical section. When Thread B tries to acquire the same lock, it is forced to block (wait) until Thread A releases the lock. Only then can Thread B acquire the lock and proceed.

Implementing a Thread-Safe Counter

Now, let's put this into practice and fix our counter. This involves four key pthread functions:

  • pthread_mutex_init(): To initialize the mutex before any thread can use it.
  • pthread_mutex_lock(): To acquire the lock before the critical section.
  • pthread_mutex_unlock(): To release the lock after the critical section.
  • pthread_mutex_destroy(): To clean up the mutex when it's no longer needed.

The video you watched earlier continues to show exactly how to implement this solution.

What is a mutex in C? (pthread_mutex)

Let's continue with the CodeVault video to see the step-by-step implementation of the thread-safe counter.

Watch from 02:26 to 05:44. This segment covers declaring, initializing, locking, unlocking, and destroying the mutex to correctly protect the shared counter.

Here is a complete, robust implementation that incorporates the principles from the video as well as the error checking we covered in a previous lesson.

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

// Shared global variable
long long counter = 0;

// Mutex to protect the shared counter
pthread_mutex_t counter_mutex;

// Thread function to increment the counter
void* thread_routine(void* arg) {
    int iterations = *(int*)arg;
    for (int i = 0; i < iterations; i++) {
        // --- Start of Critical Section ---
        if (pthread_mutex_lock(&counter_mutex) != 0) {
            perror("Failed to lock mutex");
            return NULL;
        }

        counter++;

        if (pthread_mutex_unlock(&counter_mutex) != 0) {
            perror("Failed to unlock mutex");
            return NULL;
        }
        // --- End of Critical Section ---
    }
    return NULL;
}

int main(int argc, char* argv[]) {
    int num_threads = 10;
    int iterations_per_thread = 1000000;

    pthread_t threads[num_threads];

    // Initialize the mutex
    if (pthread_mutex_init(&counter_mutex, NULL) != 0) {
        perror("Failed to initialize mutex");
        return 1;
    }

    // Create threads
    for (int i = 0; i < num_threads; i++) {
        if (pthread_create(&threads[i], NULL, &thread_routine, &iterations_per_thread) != 0) {
            perror("Failed to create thread");
            return 1;
        }
    }

    // Wait for all threads to finish
    for (int i = 0; i < num_threads; i++) {
        if (pthread_join(threads[i], NULL) != 0) {
            perror("Failed to join thread");
            return 1;
        }
    }

    // Destroy the mutex
    pthread_mutex_destroy(&counter_mutex);

    printf("Expected counter value: %d\n", num_threads * iterations_per_thread);
    printf("Actual counter value:   %lld\n", counter);

    return 0;
}

Compile and run this code. You will see that the actual counter value now consistently matches the expected value, no matter how many times you run it.

gcc -o safe_counter safe_counter.c -lpthread
./safe_counter

Test your understanding!

Consider the code above.

  1. What would happen if a thread successfully locks the mutex but then an error occurs and it returns before calling pthread_mutex_unlock?
  2. What is the purpose of holding the lock for the shortest possible time? What would be the performance impact of putting the entire for loop inside the pthread_mutex_lock/unlock block?
Show answer
  1. If a thread locks the mutex and never unlocks it, any other thread that subsequently calls pthread_mutex_lock on that same mutex will block forever. The program will hang in a state of deadlock. This is why it's crucial to ensure every lock is paired with an unlock on all possible execution paths.
  2. The purpose of minimizing lock hold time is to maximize concurrency. While a mutex is locked, all other threads waiting for it are stalled. By placing the lock/unlock pair around only the single counter++ operation, we allow other threads to run freely while the current thread is executing the non-critical parts of its loop. If we locked the mutex before the for loop and unlocked it after, we would completely eliminate the race condition, but we would also eliminate all parallelism. The threads would execute their loops one after another (serially), and the program would be no faster than a single-threaded version.

Conclusion

Today you've tackled one of the most fundamental challenges in concurrent programming. By understanding the non-atomic nature of simple operations and applying a mutex, you can now write code that correctly and safely handles shared data.

Key Takeaways:

  • Race Conditions: Occur when multiple threads access shared data concurrently, and the final outcome depends on the unpredictable timing of their execution. This is often caused by non-atomic read-modify-write operations.
  • Critical Section: A piece of code that accesses a shared resource and must not be executed by more than one thread at a time.
  • Mutex for Thread Safety: The pthread_mutex_lock and pthread_mutex_unlock functions are used to create a critical section, ensuring mutual exclusion and preventing race conditions, leading to correct and predictable program behavior.

Preview of the Next Lesson:
We've successfully used a mutex to make threads wait their turn to access data. However, sometimes threads need to wait for a specific condition to become true—for example, a "consumer" thread might need to wait for a "producer" thread to add an item to an empty queue. A mutex alone is not sufficient for this kind of signaling. In our next lesson, we will begin Module 2 by introducing a new synchronization tool as we learn to initialize and destroy condition variables alongside their associated mutexes.