Skip to Content
Course content

140: Practice Exercise: Building a Thread-Safe Counter with C11 Threads

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I've seen a lot of developers fall into the same trap when they first move from single-threaded logic to concurrent programming. It usually happens with something as simple as a counter. You think, "It's just one integer; how hard can it be to increment it from a few different threads?" The problem is that counter++ looks like a single operation in C, but the CPU sees something entirely different.

The Trap of the Simple Increment

If you just declare a global integer and let ten different threads hammer away at it in a loop, you'll notice something frustrating: the final total is almost never what you expected. You might expect 100,000, but you get 74,302. This happens because counter++ is actually three distinct steps: the CPU loads the value from memory into a register, increments that register, and then stores the value back into memory.

// The naive, broken way
int shared_counter = 0;

int increment_task(void* arg) {
    for (int i = 0; i < 10000; i++) {
        shared_counter++; // Race condition lives here
    }
    return 0;
}

When two threads do this simultaneously, they can both read the value 10 at the same time. They both increment it to 11 in their own private registers, and then they both write 11 back to memory. You just performed two increments, but the counter only went up by one. I call this "lost updates," and it's the classic symptom of a race condition.

Paying the Locking Tax for Correctness

To fix this using C11 threads, we need to ensure that only one thread can touch that integer at a time. We use mtx_t from <threads.h> to create a mutual exclusion zone. By wrapping the increment in a lock and an unlock call, we force the threads to line up and take turns.

// The thread-safe way
mtx_t counter_lock;
int shared_counter = 0;

int increment_task(void* arg) {
    for (int i = 0; i < 10000; i++) {
        mtx_lock(&counter_lock);
        shared_counter++; 
        mtx_unlock(&counter_lock);
    }
    return 0;
}

Now the result is guaranteed to be correct. But here is the trade-off I want you to consider: performance. Locking is expensive. Every time you call mtx_lock, the thread might have to pause or communicate with the OS kernel. If you spend more time managing the lock than actually doing work, your multi-threaded program might actually run slower than a single-threaded one. I've seen people over-lock their entire main loop and wonder why their "high-performance" server is crawling.

When the Lock is Too Heavy

If you're just incrementing a number, a full mutex is often overkill. In a real-world production environment, I'd tell you to look at <stdatomic.h>. Atomic operations use specialized CPU instructions (like "Compare-and-Swap") to update the value in one indivisible step without needing a formal lock. It's significantly faster because it avoids the overhead of the mutex machinery. However, mutexes are more flexible; they can protect complex structures like linked lists or arrays where a single atomic operation isn't enough to keep the data consistent. Knowing when to use a mutex versus an atomic is what separates a junior dev from a senior engineer in the C world.




📋 Practical Task

Exercise: Implementing a Concurrent Request Tracker

You are building a mock network server. The server uses multiple threads to handle incoming requests, and it needs to keep an accurate count of the total number of requests processed across all threads to report in a final log.

Your Goal: Write a program that spawns 5 threads. Each thread should simulate processing 20,000 requests by incrementing a shared global counter. You must use mtx_t from <threads.h> to ensure that no updates are lost.

Requirements:

  • Initialize a mtx_t object using mtx_init.
  • Implement a thread function that increments the shared counter in a loop.
  • Ensure the mutex is locked before the increment and unlocked immediately after.
  • Join all threads back to the main thread before printing the final count.
  • Clean up the mutex using mtx_destroy.

Expected Result: The final printed value of the counter must be exactly 100,000 regardless of how the threads are scheduled.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.