Skip to Content
Course content

145: Working with pthreads (POSIX Threads)

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

Up until now, we've written programs that follow a linear path. One instruction leads to the next, and the CPU handles them in a predictable sequence. But when you start working with pthreads, you're essentially telling the OS to run multiple instructions simultaneously. It sounds like an easy win for performance, but it's where things get dangerous. I've seen senior devs spend two days chasing a bug that only happens once every thousand runs because they treated shared memory like it was a safe haven.

Let's look at a common scenario: you're building a network packet analyzer. You want to count how many packets have been processed across several different threads to keep the UI updated in real-time. Your first instinct might be to just use a global counter.

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

int packet_count = 0;

void* process_packets(void* arg) {
    for (int i = 0; i < 1000000; i++) {
        packet_count++; // The naive approach
    }
    return NULL;
}

int main() {
    pthread_t t1, t2;
    pthread_create(&t1, NULL, process_packets, NULL);
    pthread_create(&t2, NULL, process_packets, NULL);
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    printf("Total packets: %d\n", packet_count);
    return 0;
}

The Illusion of the Atomic Increment

If you run the code above, you'll expect the output to be 2,000,000. Sometimes it will be. But more often, you'll get something like 1,423,842. You didn't make a logic error in your loop, so why is the number wrong? The problem is that packet_count++ isn't a single operation. To the CPU, it's actually three: load the value from memory into a register, increment the register, and store the value back into memory.

Imagine Thread A loads the value 10. Before it can store the result back, the OS switches context to Thread B. Thread B also loads 10, increments it to 11, and stores it. Then the OS switches back to Thread A, which still has 10 in its register. It increments it to 11 and stores it. Two increments happened, but the counter only went up by one. This is a race condition, and it's the bane of concurrent programming.

Using Mutexes to Enforce Order

To fix this, we need a way to ensure that only one thread is touching that counter at a time. This is where the pthread_mutex_t (mutual exclusion) comes in. Think of a mutex as a physical key to a room; if you have the key, you're the only one allowed inside. Everyone else has to wait in the hallway until you come out and hand over the key.

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

int packet_count = 0;
pthread_mutex_t count_mutex = PTHREAD_MUTEX_INITIALIZER;

void* process_packets(void* arg) {
    for (int i = 0; i < 1000000; i++) {
        pthread_mutex_lock(&count_mutex);
        packet_count++; 
        pthread_mutex_unlock(&count_mutex);
    }
    return NULL;
}

int main() {
    pthread_t t1, t2;
    pthread_create(&t1, NULL, process_packets, NULL);
    pthread_create(&t2, NULL, process_packets, NULL);
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    printf("Total packets: %d\n", packet_count);
    pthread_mutex_destroy(&count_mutex);
    return 0;
}

Paying the Synchronization Tax

Now the count is always 2,000,000. We've solved the data corruption, but we've introduced a performance cost. Locking and unlocking a mutex takes time. Even worse, if your threads are spending all their time waiting for the lock, you've essentially turned your multi-threaded program back into a single-threaded one, but with extra overhead. I call this "contention."

The trick to writing high-performance C is to keep your "critical sections"—the code between the lock and unlock—as short as possible. In the example above, we're locking just for a single increment. If you had to perform a heavy calculation, you'd do that calculation in a local variable first, and then lock the mutex only when it's time to add that result to the global total. Always ask yourself: "Do I actually need to lock this, or can I give each thread its own local counter and sum them up at the end?"




📋 Practical Task

Implementing a Thread-Safe Transaction Ledger

You are tasked with creating a small system that simulates a bank ledger. Multiple threads will be depositing and withdrawing money from a single shared account balance simultaneously.

Requirements:

  • Create a global variable double account_balance initialized to 1000.00.
  • Initialize a pthread_mutex_t to protect the balance.
  • Write a function void* perform_transactions(void* arg) that takes an integer (via the arg) representing the number of transactions to perform.
  • Inside the function, use a loop to randomly either add or subtract a small amount (between 1.00 and 10.00) from the balance.
  • Ensure that the balance never drops below 0.00 (if a withdrawal would make it negative, the thread should skip that transaction).
  • Spawn 4 threads, each performing 100,000 transactions.
  • Join the threads and print the final balance.

Goal: Prove that your mutex is working by comparing the final result against a version where you commented out the lock/unlock calls. You should see that without the mutex, the balance drifts wildly or becomes inconsistent.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.