The Problem: Shared Variables & Critical Sections

 

← Chapter 30 Index

The Problem: Shared Variables & Critical Sections

Section 30.1 — Understanding why threads need synchronization

Key Terms in This File

shared variable race condition critical section atomicity nondeterministic time slice mutex

Why Do Threads Share Memory?

One of the biggest advantages of threads over processes is that all threads in a program share the same memory. This means any thread can read or write the same global variable. You don’t need pipes, sockets, or shared memory segments like you would between processes.

But this convenience comes with a serious problem: if two threads try to modify the same variable at the same time, the result can be completely wrong. This is called a race condition.

A Real Example of the Problem

Consider this simple task: two threads each increment a shared global variable glob one million times each. At the end, glob should be 2,000,000. But it isn’t. Let’s understand why.

Here is what each thread does inside its loop:

/* Each thread does this many times: */
loc = glob;   /* Step 1: Read glob into a local variable */
loc++;         /* Step 2: Increment the local copy */
glob = loc;   /* Step 3: Write it back to glob */

These are THREE separate steps. The kernel scheduler can pause a thread after any one of these steps and give the CPU to the other thread. When that happens, the first thread’s work gets overwritten.

Step-by-Step: What Goes Wrong

Time Thread 1 (CPU) Thread 2 (CPU) glob value
T1 loc1 = glob → loc1 = 2000 waiting 2000
T2 TIME SLICE EXPIRES — paused starts running 2000
T3 waiting runs 1000 loops: glob becomes 3000 3000
T4 resumes: loc1++ → loc1 = 2001 paused 3000
T5 glob = loc1 → glob = 2001 ❌ paused 2001 (lost 1000 increments!)

Thread 1 read glob as 2000, then Thread 2 incremented it to 3000, then Thread 1 wrote back 2001 — erasing all of Thread 2’s work. This is the race condition.

Code Example 1: The Broken Program (No Synchronization)

This program creates two threads, each incrementing a shared glob variable many times. The final value is unpredictable.

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

static int glob = 0;   /* Shared global variable */

/* Thread function: increments glob 'loops' times */
static void *threadFunc(void *arg)
{
    int loops = *((int *) arg);
    int loc, j;

    for (j = 0; j < loops; j++) {
        loc = glob;   /* Read */
        loc++;         /* Increment */
        glob = loc;   /* Write back */
    }
    return NULL;
}

int main(int argc, char *argv[])
{
    pthread_t t1, t2;
    int loops = (argc > 1) ? atoi(argv[1]) : 1000000;
    int s;

    s = pthread_create(&t1, NULL, threadFunc, &loops);
    if (s != 0) { perror("pthread_create"); exit(1); }

    s = pthread_create(&t2, NULL, threadFunc, &loops);
    if (s != 0) { perror("pthread_create"); exit(1); }

    pthread_join(t1, NULL);
    pthread_join(t2, NULL);

    /* Expected: 2 * loops. Actual: UNPREDICTABLE */
    printf("glob = %d (expected = %d)\n", glob, 2 * loops);
    return 0;
}

/*
 * Compile: gcc -o thread_incr thread_incr.c -lpthread
 *
 * Sample output (varies every run!):
 *   glob = 1234567   (expected = 2000000)
 *   glob = 1876432   (expected = 2000000)
 *   glob = 2000000   (got lucky this time)
 */
⚠ This program has a race condition. When run with a small loop count (e.g., 100), it may accidentally give the right answer — because Thread 1 finishes before Thread 2 even starts. But with a large loop count (e.g., 1,000,000), the value of glob will almost always be wrong.

What Is a “Critical Section”?

A critical section is a piece of code that accesses a shared resource (like a shared variable), and which must be executed atomically — meaning: no other thread should be allowed to access the same resource while this code is running.

In our example, the three lines loc = glob; loc++; glob = loc; together form the critical section. They must all complete as one uninterrupted unit.

Atomic means “indivisible” — either all three steps happen, or none. But without any synchronization mechanism, the OS scheduler can interrupt between any two steps.

Isn’t glob++ Atomic?

Common Beginner Mistake: You might think that replacing the three lines with a single glob++ would fix the problem. It does NOT.

On most hardware (especially RISC processors), even glob++ compiles down to the same three steps: load, add, store. The C language does not guarantee that increment is atomic. The race condition still exists.

To truly fix it, you need a mutex.

Why Is the Result Nondeterministic?

The exact wrong value of glob changes every time you run the program. Why? Because the Linux kernel decides when to switch threads based on many factors: system load, hardware timer interrupts, CPU scheduling algorithm. You have no control over this.

In complex programs, race conditions may only manifest under specific timing conditions — making them very hard to detect and reproduce. This is why correct synchronization is critical from the start.

Code Example 2: Observing the Nondeterminism

This version prints the value of glob from inside each thread, so you can see threads interleaving:

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

static int glob = 0;

/* Struct to pass both thread ID and loop count */
typedef struct {
    int thread_num;
    int loops;
} ThreadArg;

static void *threadFunc(void *arg)
{
    ThreadArg *targ = (ThreadArg *) arg;
    int j, loc;

    for (j = 0; j < targ->loops; j++) {
        loc = glob;
        loc++;
        glob = loc;
        /* Print every 100 iterations to see interleaving */
        if (j % 100 == 0)
            printf("Thread %d: glob = %d\n", targ->thread_num, glob);
    }
    return NULL;
}

int main(void)
{
    pthread_t t1, t2;
    ThreadArg arg1 = {1, 500};
    ThreadArg arg2 = {2, 500};

    pthread_create(&t1, NULL, threadFunc, &arg1);
    pthread_create(&t2, NULL, threadFunc, &arg2);
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);

    printf("Final glob = %d (expected 1000)\n", glob);
    return 0;
}

/*
 * Compile: gcc -o thread_observe thread_observe.c -lpthread
 *
 * Redirect output to file and observe interleaving:
 *   ./thread_observe > output.txt
 *
 * You will see Thread 1 and Thread 2 printing interleaved,
 * and the final glob will be less than 1000.
 */

The Solution: Mutex

The fix is to use a mutex (mutual exclusion lock). A mutex ensures that only one thread can execute the critical section at a time. The other thread must wait until the first releases the mutex.

We cover this in the next files: static mutex (30.1.1), locking/unlocking (30.1.2), and the corrected program.

Interview Questions — Critical Sections & Race Conditions

Q1. What is a race condition? Give a simple example. A race condition occurs when two or more threads access shared data concurrently, and the final result depends on the exact timing/order of their execution. Example: Two threads both read a counter as 5, both increment their local copy to 6, and both write 6 back. The counter should be 7 but ends up as 6.
Q2. What is a critical section? A critical section is a segment of code that accesses a shared resource (variable, file, etc.) and must execute atomically — without any other thread accessing the same resource at the same time.
Q3. What does “atomic” mean in the context of threading? Atomic means “all or nothing” — an operation that completes entirely without being interrupted midway by another thread. For example, a mutex lock/unlock protects a block of code so it runs atomically with respect to other threads using the same mutex.
Q4. Is glob++ thread-safe? Why or why not? No. On most architectures, glob++ compiles to three machine instructions: load, increment, store. The OS can preempt the thread between any two of these instructions, causing a race condition. Only truly atomic hardware instructions (like x86 LOCK prefix) are thread-safe without a mutex.
Q5. Why does the same broken program sometimes give the correct result? When the loop count is small, Thread 1 may complete all its iterations before Thread 2 starts (the time slice never expires during Thread 1’s work). This is luck, not correctness. With larger loop counts, the race condition is exposed.
Q6. What makes race conditions difficult to debug? Race conditions are timing-dependent and nondeterministic. They may appear only under heavy load, with specific CPU speeds, or specific OS scheduling decisions. They are hard to reproduce consistently, and the symptom (wrong value) may be far from the cause (missing synchronization).
Q7. What are the two main synchronization primitives discussed in Chapter 30? (1) Mutex — for mutual exclusion, ensuring only one thread accesses a shared resource at a time. (2) Condition Variable — for signaling between threads that a shared state has changed, allowing threads to sleep and wake up efficiently.
Q8. What is the relationship between a mutex and “advisory locking”? Mutex locking in pthreads is advisory (not mandatory). A thread is free to access shared data without holding the mutex — the system does not prevent it. All threads must cooperate and follow the locking convention. If any thread ignores the mutex, the protection breaks.

Leave a Reply

Your email address will not be published. Required fields are marked *