A concurrency bug can be maddening because adding a debug print sometimes makes it disappear. The mistake is assuming that counter += delta is one indivisible event. It is a read, a calculation, and a write—and two threads can overlap those steps unless the program establishes synchronization.

The rule in one sentence

Lock the mutex, inspect or update the entire shared invariant, unlock on every path, and never access that invariant outside the protocol. A successful unlock followed by a successful lock in another thread supplies the synchronization needed for protected writes to become visible in the intended order.

A complete checked example

mutex_counter.cc
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
 
struct shared_counter {
    pthread_mutex_t mutex;
    long value;
};
 
struct worker_args {
    struct shared_counter *counter;
    long delta;
    unsigned long iterations;
};
 
static void check_pthread(int error, const char *operation)
{
    if (error != 0) {
        fprintf(stderr, "%s: %s\n", operation, strerror(error));
        exit(EXIT_FAILURE);
    }
}
 
static void *worker(void *opaque)
{
    struct worker_args *args = opaque;
 
    for (unsigned long i = 0; i < args->iterations; ++i) {
        check_pthread(
            pthread_mutex_lock(&args->counter->mutex),
            "pthread_mutex_lock"
        );
 
        args->counter->value += args->delta;
 
        check_pthread(
            pthread_mutex_unlock(&args->counter->mutex),
            "pthread_mutex_unlock"
        );
    }
 
    return NULL;
}
 
int main(void)
{
    enum { THREAD_COUNT = 2 };
    const unsigned long iterations = 100000UL;
    struct shared_counter counter = {
        .mutex = PTHREAD_MUTEX_INITIALIZER,
        .value = 0,
    };
    pthread_t threads[THREAD_COUNT];
    struct worker_args args[THREAD_COUNT] = {
        { .counter = &counter, .delta = 5, .iterations = iterations },
        { .counter = &counter, .delta = -2, .iterations = iterations },
    };
 
    for (size_t i = 0; i < THREAD_COUNT; ++i) {
        check_pthread(
            pthread_create(&threads[i], NULL, worker, &args[i]),
            "pthread_create"
        );
    }
 
    for (size_t i = 0; i < THREAD_COUNT; ++i) {
        check_pthread(pthread_join(threads[i], NULL), "pthread_join");
    }
 
    const long expected =
        (args[0].delta + args[1].delta) * (long)iterations;
    printf("counter=%ld expected=%ld\n", counter.value, expected);
 
    check_pthread(pthread_mutex_destroy(&counter.mutex),
                  "pthread_mutex_destroy");
    return counter.value == expected ? EXIT_SUCCESS : EXIT_FAILURE;
}

The invariant lives beside its mutex

  • shared_counter groups the value and the mutex that governs it, making ownership visible.

  • Each worker gets a distinct argument object whose lifetime extends until both joins finish.

  • PTHREAD_MUTEX_INITIALIZER gives the ordinary mutex static-style initialization within this block-scope aggregate.

  • POSIX thread functions return an error number directly; they do not generally report failure through errno, so strerror(error) receives the returned code.

  • The critical section contains only the read–modify–write operation.

  • pthread_join() waits for each joinable worker to terminate before main reads the final value or destroys the mutex.

  • The result is schedule-independent: (5 + -2) × 100000 = 300000.

Compile and run with the thread option

Directory containing mutex_counter.cbash
cc -std=c17 -Wall -Wextra -Wpedantic -Wconversion \
   -pthread mutex_counter.c -o mutex_counter
./mutex_counter
counter=300000 expected=300000

Use -pthread, not only -lpthread

  • -pthread enables the compiler and linker settings required by the platform’s POSIX thread model.

  • The exact executable and library implementation vary across Unix-like systems; pthreads are not native Windows threads.

  • Strict warnings catch signature, conversion, and prototype mistakes before race analysis.

  • A correct final value in one run is useful verification but not a proof that every access is synchronized.

  • Run the binary repeatedly and under instrumentation in CI-supported environments.

What the mutex guarantees

  • At most one thread owns a normal mutex at a time.

  • A contender waits until the owner unlocks, subject to scheduling and mutex semantics.

  • The protected operations are ordered through POSIX synchronization, so threads observe the invariant consistently when every access uses the mutex.

  • The mutex protects all data and relationships assigned to its critical section—not just one machine word.

  • A normal mutex does not promise fairness or a particular acquisition order.

  • Recursive relocking of an ordinary mutex by its owner is erroneous/undefined or may deadlock depending on the mutex type and implementation; do not rely on it.

Why the unlocked version is undefined

Incorrect worker fragmentc
/* Incorrect: multiple threads execute this without synchronization. */
long snapshot = args->counter->value;
long updated = snapshot + args->delta;
args->counter->value = updated;

Lost updates are only one possible symptom

  • Two threads may read the same old value and each overwrite the other’s update.

  • A non-atomic conflicting read/write without synchronization is a data race under the C memory model.

  • A data-raced program has undefined behavior; the compiler is not required to preserve an intuitive interleaving.

  • volatile does not make this compound operation atomic and does not establish inter-thread synchronization.

  • Sleeping, printing, lowering optimization, or observing the expected result does not repair the race.

Make ThreadSanitizer part of the investigation

Directory containing mutex_counter.cbash
cc -std=c17 -g -O1 -fno-omit-frame-pointer \
   -fsanitize=thread -pthread mutex_counter.c -o mutex_counter_tsan
./mutex_counter_tsan
counter=300000 expected=300000

Instrumentation observes executed paths

  • -fsanitize=thread instruments memory accesses and links the ThreadSanitizer runtime on supported toolchains/targets.

  • -g and frame pointers improve diagnostic stack traces.

  • The synchronized program should run without a race report.

  • Removing both lock and unlock calls should produce a race report, but do that only in a disposable teaching copy.

  • ThreadSanitizer cannot prove the absence of races in paths the test never executes.

  • GCC documents that ThreadSanitizer cannot be combined with AddressSanitizer or LeakSanitizer in the same build.

Keep critical sections small—but complete

  • Compute thread-local work before locking when it does not depend on protected state.

  • Hold the lock across every read and write required to preserve one invariant.

  • Avoid blocking network calls, disk I/O, sleeps, callbacks, and slow logging while holding a general mutex.

  • Copy a stable snapshot under the lock, unlock, then format or transmit it when that preserves semantics.

  • Do not split “check then act” into two separately locked regions if another thread can invalidate the condition between them.

  • Measure contention before replacing simple correct locking with a complex scheme.

Unlock on every control-flow path

Checked critical-section patternc
int update_if_positive(struct shared_counter *counter, long delta)
{
    int error = pthread_mutex_lock(&counter->mutex);
    if (error != 0) {
        return error;
    }
 
    if (counter->value + delta >= 0) {
        counter->value += delta;
    }
 
    error = pthread_mutex_unlock(&counter->mutex);
    return error;
}

One exit point reduces accidental lock leaks

  • The function does not return from the protected branch before unlocking.

  • The check and update occur under one acquisition, preserving the non-negative invariant against other cooperating writers.

  • Arithmetic overflow must be checked separately when untrusted values can exceed the long range.

  • A production error path must define what an unlock failure means for subsequent state use.

  • Cancellation and cleanup handlers require additional design when a thread can be canceled while owning resources.

Prevent deadlock with a lock order

  1. Assign a global order to mutex classes or object identities.

  2. Acquire multiple locks only in that order everywhere.

  3. Release them in reverse order where practical.

  4. Never call unknown code while holding a lock unless its locking contract is explicit.

  5. Use pthread_mutex_trylock() only when the fallback can make real progress; spinning blindly moves the problem.

  6. Capture thread dumps, lock ownership, and wait graphs when diagnosing a hang.

Choose the right synchronization primitive

  • Use a mutex for multi-field invariants, compound check/update sequences, containers, and code that needs exclusive ownership.

  • Use a condition variable with a mutex when threads must sleep until a state predicate changes; always recheck the predicate in a loop.

  • Use a read–write lock only after measurement shows a read-heavy workload and the platform behavior suits it.

  • Use semaphores for counted resources or signaling patterns—not as an automatic mutex replacement.

  • Use C atomics for simple independent values when the required memory ordering is understood.

  • Use thread-local data when sharing is unnecessary; avoiding shared mutable state is often the cleanest optimization.

When an atomic counter is enough

atomic_counter_fragment.cc
#include <stdatomic.h>
 
_Atomic long counter = 0;
 
void add_to_counter(long delta)
{
    atomic_fetch_add_explicit(&counter, delta, memory_order_relaxed);
}

Atomicity is narrower than an invariant

  • atomic_fetch_add performs one indivisible read–modify–write on the atomic object.

  • memory_order_relaxed preserves that object’s atomic modification order but does not publish unrelated data.

  • A statistics counter that has no relationship with other fields can often use relaxed ordering.

  • If correctness depends on multiple variables changing together, one atomic counter is insufficient; use a suitable mutex or a carefully proven lock-free design.

  • Confirm the atomic type and operations are supported efficiently on target platforms if performance is the reason for choosing them.

Mutex lifecycle and ownership mistakes

  • Initialize a mutex exactly once before any thread can use it.

  • Do not copy a live pthread_mutex_t with assignment or memcpy.

  • Only the owning thread may unlock an error-checking/recursive/robust mutex; unlocking an ordinary mutex incorrectly is not a recovery strategy.

  • Do not destroy a locked mutex or one that another thread may still access.

  • Join or otherwise coordinate all users before freeing the protected object.

  • Robust mutexes can report EOWNERDEAD; the new owner must repair the invariant and call the consistency API or declare it unrecoverable.

  • Process-shared mutexes require storage and attributes designed for interprocess synchronization.

Troubleshooting map

  • Final value varies: audit every access, not only writes, and run ThreadSanitizer on realistic paths.

  • Program hangs: inspect self-locking, inconsistent multi-lock order, callbacks under locks, and forgotten unlock paths.

  • Compilation succeeds but pthread symbols fail at link: use -pthread during the final link invocation.

  • Performance collapses: profile lock wait/hold time, shrink expensive work outside the critical section, and reduce false sharing or unnecessary sharing.

  • Mutex destroy returns busy/error: some thread still owns or can access it; fix lifecycle ordering rather than retrying destruction.

  • Adding `volatile` changes symptoms: the race remains; replace it with synchronization or a suitable atomic design.

  • A worker uses corrupted arguments: ensure each argument object remains alive and is not concurrently overwritten before the worker reads it.

  • Only optimized builds fail: undefined behavior frequently changes with optimization; trust the memory model and sanitizer evidence, not debug-build luck.

A practical review checklist

  • Each shared invariant names its owning mutex.

  • Every read and write follows the same locking rule.

  • Return codes from create, lock, unlock, join, and destroy are handled.

  • No slow or reentrant operation runs under the lock without a documented reason.

  • Multiple mutexes follow one order.

  • Worker arguments and protected state outlive all users.

  • All joinable threads are joined exactly once or deliberately detached.

  • Sanitizer-enabled tests exercise high-contention and error paths.

  • Performance changes follow measurement, not fear of mutexes.

Authoritative references