Threads

This section documents C23 (ISO/IEC 9899:2024), per ISO/IEC JTC1/SC22/WG14’s freely available working draft N3220, which WG14 documents as differing from the published standard only editorially — the reference these pages are written and verified against.

This content was generated with the assistance of AI and should be verified against the WG14 draft and cppreference.com’s C reference before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

C11 added a portable threading API in <threads.h>: threads, mutexes, condition variables, thread-local storage and one-time initialization. It is deliberately minimal — no thread pools, no futures, no cancellation — and it is optional, so a conforming implementation may define __STDC_NO_THREADS__ instead of providing it.

<threads.h> was optional in C11 and remains optional in C23. glibc has provided it since 2.28 and musl since 1.1.x, but MSVC does not implement it at all (use Win32 threads or the C++ <thread> there). Always guard portable code with __STDC_NO_THREADS__, and link with -pthread on Unix.

#include <stdio.h>

#ifdef __STDC_NO_THREADS__
int main(void)
{
    puts("this implementation has no <threads.h>");
    return 0;
}
#else
#include <threads.h>

static int worker(void *arg)
{
    (void)arg;
    return 0;
}

int main(void)
{
    thrd_t t;
    if (thrd_create(&t, worker, nullptr) != thrd_success) {
        return 1;
    }
    thrd_join(t, nullptr);
    puts("threads are available");
    return 0;
}
#endif

Creating and Joining Threads

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

struct WorkItem {
    int id;
    int input;
};

// A thread function has exactly this signature: int (*)(void *).
static int square_worker(void *arg)
{
    const struct WorkItem *item = arg;

    printf("thread %d computing %d^2\n", item->id, item->input);
    return item->input * item->input;       // the return value reaches thrd_join
}

int main(void)
{
    enum { THREAD_COUNT = 4 };

    thrd_t threads[THREAD_COUNT];
    struct WorkItem items[THREAD_COUNT];    // one per thread: NEVER share a loop variable

    for (int i = 0; i < THREAD_COUNT; ++i) {
        items[i].id = i;
        items[i].input = i + 2;

        int status = thrd_create(&threads[i], square_worker, &items[i]);
        if (status != thrd_success) {
            // thrd_nomem or thrd_error: join what was already started, then fail.
            fprintf(stderr, "thrd_create failed for %d\n", i);
            for (int j = 0; j < i; ++j) {
                thrd_join(threads[j], nullptr);
            }
            return EXIT_FAILURE;
        }
    }

    int total = 0;
    for (int i = 0; i < THREAD_COUNT; ++i) {
        int result = 0;
        if (thrd_join(threads[i], &result) == thrd_success) {
            total += result;
        }
    }

    printf("total = %d\n", total);          // 4 + 9 + 16 + 25 = 54
    return 0;
}

The API surface is small:

Function Behavior

thrd_create(&t, fn, arg)

Starts fn(arg). Returns thrd_success, thrd_nomem or thrd_error.

thrd_join(t, &result)

Waits for t and collects its return value. Each thread may be joined once.

thrd_detach(t)

Gives up the right to join; resources are released automatically at exit.

thrd_current()

This thread’s identifier.

thrd_equal(a, b)

Compares identifiers — == on thrd_t is not portable.

thrd_sleep(&duration, &remaining)

Sleeps for a struct timespec. Returns -1 if interrupted.

thrd_yield()

Hints that the scheduler may run something else.

thrd_exit(result)

Terminates this thread with a result.

The two hazards visible in the example above: give each thread its own argument object (passing &i from a loop is a data race on i), and make sure the argument outlives the thread — a pointer to a local of a function that returns is dangling.

Data Races

Unsynchronized concurrent access to the same object, where at least one access is a write, is a data race and undefined behavior. Not "produces a wrong number" — undefined:

#include <stdio.h>
#include <threads.h>

static int unsynchronized = 0;      // shared, unprotected -- a data race
static mtx_t lock;
static int protected_by_mutex = 0;  // shared, protected

static int racer(void *arg)
{
    (void)arg;
    for (int i = 0; i < 100000; ++i) {
        // ++unsynchronized;        // DATA RACE: undefined behavior, and
                                    // ThreadSanitizer reports it immediately.

        mtx_lock(&lock);
        ++protected_by_mutex;       // correct: the increment is atomic w.r.t. other threads
        mtx_unlock(&lock);
    }
    return 0;
}

int main(void)
{
    if (mtx_init(&lock, mtx_plain) != thrd_success) {
        return 1;
    }

    thrd_t a, b;
    if (thrd_create(&a, racer, nullptr) != thrd_success) {
        mtx_destroy(&lock);
        return 1;
    }
    if (thrd_create(&b, racer, nullptr) != thrd_success) {
        thrd_join(a, nullptr);
        mtx_destroy(&lock);
        return 1;
    }

    thrd_join(a, nullptr);
    thrd_join(b, nullptr);

    printf("protected = %d (expected 200000), unsynchronized = %d\n",
           protected_by_mutex, unsynchronized);

    mtx_destroy(&lock);
    return 0;
}

++x is a read, an add and a write — three steps, interleavable. The fixes are a mutex (above) or an atomic (see Atomics and Memory Consistency). Build with -fsanitize=thread and let ThreadSanitizer find the ones you missed.

Critical Sections — mtx_t

Mutex type Behavior

mtx_plain

A simple non-recursive mutex. Re-locking it in the same thread is undefined.

mtx_timed

Supports mtx_timedlock with a deadline.

mtx_recursive

May be locked repeatedly by the owning thread; needs one unlock per lock.

mtx_plain | mtx_recursive

The types combine as flags.

#include <stdio.h>
#include <threads.h>
#include <time.h>

struct Counter {
    mtx_t lock;
    int value;
};

static bool counter_init(struct Counter *c)
{
    c->value = 0;
    return mtx_init(&c->lock, mtx_timed) == thrd_success;
}

static void counter_increment(struct Counter *c)
{
    mtx_lock(&c->lock);
    ++c->value;                     // the critical section: as short as possible
    mtx_unlock(&c->lock);
}

static bool counter_try_increment(struct Counter *c)
{
    // Non-blocking: returns thrd_busy rather than waiting.
    if (mtx_trylock(&c->lock) != thrd_success) {
        return false;
    }
    ++c->value;
    mtx_unlock(&c->lock);
    return true;
}

static bool counter_increment_before(struct Counter *c, int milliseconds)
{
    struct timespec deadline;
    if (timespec_get(&deadline, TIME_UTC) != TIME_UTC) {
        return false;
    }
    deadline.tv_nsec += (long)milliseconds * 1000000L;
    deadline.tv_sec += deadline.tv_nsec / 1000000000L;
    deadline.tv_nsec %= 1000000000L;

    if (mtx_timedlock(&c->lock, &deadline) != thrd_success) {
        return false;               // thrd_timedout
    }
    ++c->value;
    mtx_unlock(&c->lock);
    return true;
}

int main(void)
{
    struct Counter c;
    if (!counter_init(&c)) {
        return 1;
    }

    counter_increment(&c);
    counter_try_increment(&c);
    counter_increment_before(&c, 100);

    printf("value = %d\n", c.value);
    mtx_destroy(&c.lock);
    return 0;
}

The discipline that keeps mutex code correct:

  • Every unlock on every path. An early return inside a critical section is the classic deadlock. Keep critical sections small enough to see both ends at once, or use a single-exit goto as in Error Handling and Program Failure.

  • Never call unknown code while holding a lock — a callback that takes another lock inverts your order.

  • Take multiple locks in one globally agreed order. Thread A taking L1 then L2 while B takes L2 then L1 is a deadlock, and ThreadSanitizer detects the inversion even when it does not actually hang.

  • mtx_plain is not recursive: locking it twice in one thread is undefined, not a wait.

  • Destroy it once, when no thread can still use it, and never destroy a locked mutex.

Race-Free Initialization — call_once

The correct way to initialize shared state exactly once, replacing the broken double-checked-locking idiom:

#include <stdio.h>
#include <threads.h>

static once_flag initialized = ONCE_FLAG_INIT;
static int shared_table[16];

static void initialize_table(void)
{
    for (int i = 0; i < 16; ++i) {
        shared_table[i] = i * i;
    }
    puts("initialized exactly once");
}

static int worker(void *arg)
{
    (void)arg;

    // Every thread calls this; exactly one runs the function, and every other
    // thread blocks until it has finished.
    call_once(&initialized, initialize_table);

    return shared_table[4];         // safely 16 in every thread
}

int main(void)
{
    thrd_t a, b;

    if (thrd_create(&a, worker, nullptr) != thrd_success) {
        return 1;
    }
    if (thrd_create(&b, worker, nullptr) != thrd_success) {
        thrd_join(a, nullptr);
        return 1;
    }

    int ra = 0, rb = 0;
    thrd_join(a, &ra);
    thrd_join(b, &rb);

    printf("%d %d\n", ra, rb);
    return 0;
}

The initialization function takes no arguments and returns nothing — pass what it needs through file-scope state. Note that call_once gives you a happens-before guarantee: everything the initializer wrote is visible to every thread that returns from call_once.

Thread-Local Storage

Two mechanisms, for two different needs:

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

// 1. thread_local: static/compile-time per-thread state. Zero-initialized.
static thread_local int call_count = 0;

// 2. tss_t: dynamically created per-thread storage WITH a destructor, for
//    per-thread heap allocations that must be released when the thread ends.
static tss_t buffer_key;

static void free_buffer(void *buffer)
{
    printf("releasing this thread's buffer\n");
    free(buffer);
}

static int worker(void *arg)
{
    (void)arg;

    ++call_count;                           // no synchronization needed

    char *buffer = tss_get(buffer_key);
    if (buffer == nullptr) {
        buffer = malloc(64);
        if (buffer == nullptr) {
            return 1;
        }
        if (tss_set(buffer_key, buffer) != thrd_success) {
            free(buffer);
            return 1;
        }
    }

    snprintf(buffer, 64, "count=%d", call_count);
    puts(buffer);
    return 0;                               // free_buffer runs as the thread exits
}

int main(void)
{
    if (tss_create(&buffer_key, free_buffer) != thrd_success) {
        return 1;
    }

    thrd_t a, b;
    if (thrd_create(&a, worker, nullptr) != thrd_success) {
        tss_delete(buffer_key);
        return 1;
    }
    if (thrd_create(&b, worker, nullptr) != thrd_success) {
        thrd_join(a, nullptr);
        tss_delete(buffer_key);
        return 1;
    }

    thrd_join(a, nullptr);
    thrd_join(b, nullptr);

    printf("main's own call_count is still %d\n", call_count);       // 0
    tss_delete(buffer_key);
    return 0;
}

Prefer thread_local — it is simpler and faster. Reach for tss_t only when the per-thread object is heap-allocated and needs a destructor. See Storage Duration, Scope and Linkage.

Condition Variables — cnd_t

A condition variable lets a thread wait for a predicate to become true without spinning. It is always used with a mutex and always inside a loop.

sequenceDiagram participant P as producer participant Q as queue + mutex participant C as consumer C->>Q: mtx_lock Note over C: predicate false (queue empty) C->>Q: cnd_wait on ready, releasing lock Note over C: atomically unlocks and sleeps P->>Q: mtx_lock P->>Q: push item P->>C: cnd_signal on ready P->>Q: mtx_unlock Note over C: wakes, RE-ACQUIRES the mutex,
re-tests the predicate in a loop C->>Q: pop item C->>Q: mtx_unlock
#include <stdio.h>
#include <threads.h>

#define QUEUE_CAPACITY 4
#define ITEM_COUNT 10

struct Queue {
    mtx_t lock;
    cnd_t not_empty;
    cnd_t not_full;
    int items[QUEUE_CAPACITY];
    size_t count;
    size_t head;
    bool closed;
};

static bool queue_init(struct Queue *q)
{
    q->count = 0;
    q->head = 0;
    q->closed = false;

    if (mtx_init(&q->lock, mtx_plain) != thrd_success) {
        return false;
    }
    if (cnd_init(&q->not_empty) != thrd_success) {
        mtx_destroy(&q->lock);
        return false;
    }
    if (cnd_init(&q->not_full) != thrd_success) {
        cnd_destroy(&q->not_empty);
        mtx_destroy(&q->lock);
        return false;
    }
    return true;
}

static void queue_destroy(struct Queue *q)
{
    cnd_destroy(&q->not_full);
    cnd_destroy(&q->not_empty);
    mtx_destroy(&q->lock);
}

static void queue_push(struct Queue *q, int value)
{
    mtx_lock(&q->lock);

    // ALWAYS wait in a while loop, never an if: cnd_wait may wake spuriously,
    // and another thread may have consumed the condition before this one runs.
    while (q->count == QUEUE_CAPACITY) {
        cnd_wait(&q->not_full, &q->lock);
    }

    q->items[(q->head + q->count) % QUEUE_CAPACITY] = value;
    ++q->count;

    cnd_signal(&q->not_empty);      // wake one waiting consumer
    mtx_unlock(&q->lock);
}

static void queue_close(struct Queue *q)
{
    mtx_lock(&q->lock);
    q->closed = true;
    cnd_broadcast(&q->not_empty);   // wake EVERY consumer so they can all exit
    mtx_unlock(&q->lock);
}

// Returns false when the queue is closed and drained.
static bool queue_pop(struct Queue *q, int *out)
{
    mtx_lock(&q->lock);

    while (q->count == 0 && !q->closed) {
        cnd_wait(&q->not_empty, &q->lock);
    }

    if (q->count == 0) {            // closed and empty
        mtx_unlock(&q->lock);
        return false;
    }

    *out = q->items[q->head];
    q->head = (q->head + 1) % QUEUE_CAPACITY;
    --q->count;

    cnd_signal(&q->not_full);       // wake one waiting producer
    mtx_unlock(&q->lock);
    return true;
}

static int consumer(void *arg)
{
    struct Queue *q = arg;
    int value = 0;
    int consumed = 0;

    while (queue_pop(q, &value)) {
        ++consumed;
    }
    return consumed;
}

int main(void)
{
    struct Queue q;
    if (!queue_init(&q)) {
        return 1;
    }

    thrd_t c;
    if (thrd_create(&c, consumer, &q) != thrd_success) {
        queue_destroy(&q);
        return 1;
    }

    for (int i = 0; i < ITEM_COUNT; ++i) {
        queue_push(&q, i);          // blocks while the queue is full
    }
    queue_close(&q);

    int consumed = 0;
    thrd_join(c, &consumed);

    printf("produced %d, consumed %d\n", ITEM_COUNT, consumed);
    queue_destroy(&q);
    return 0;
}

The four rules of condition variables, all visible above:

  1. Hold the mutex when testing the predicate, when waiting, and when changing the state the predicate reads.

  2. Wait in a while loop, never an if — cnd_wait is permitted to return spuriously, and between the signal and the wakeup another thread may have taken the item.

  3. cnd_signal wakes one waiter, cnd_broadcast wakes all. Use broadcast when waiters are waiting for different predicates (like the shutdown above) or when more than one can proceed.

  4. cnd_wait atomically releases the mutex and re-acquires it before returning. That atomicity is the whole point — it is what makes the "test, then wait" sequence race-free.

cnd_timedwait adds a deadline, returning thrd_timedout.

Detaching, and Liveness

#include <stdio.h>
#include <threads.h>
#include <time.h>

static int background_task(void *arg)
{
    (void)arg;

    struct timespec nap = { .tv_sec = 0, .tv_nsec = 10000000 };  // 10 ms
    thrd_sleep(&nap, nullptr);
    return 0;
}

int main(void)
{
    thrd_t t;
    if (thrd_create(&t, background_task, nullptr) != thrd_success) {
        return 1;
    }

    // Detach: we will never join, so resources are reclaimed automatically.
    // After this, t must NOT be used again -- no join, no detach.
    if (thrd_detach(t) != thrd_success) {
        return 1;
    }

    // But note: when main returns, the whole process exits and every detached
    // thread is killed mid-work. If a detached thread must finish, you need your
    // own completion signal -- a condition variable or an atomic counter.
    struct timespec wait_a_moment = { .tv_sec = 0, .tv_nsec = 50000000 };
    thrd_sleep(&wait_a_moment, nullptr);

    puts("main exiting");
    return 0;
}

The liveness failures to keep in mind:

  • Deadlock — two threads each hold what the other needs. Prevent with a lock order; detect with ThreadSanitizer.

  • Livelock — threads keep running but make no progress (mutual mtx_trylock/back-off loops).

  • Starvation — C makes no fairness guarantee; a thread may never win a contended mutex. Do not rely on scheduling order for correctness.

  • Lost wakeup — signalling without holding the mutex, or waiting with if instead of while.

  • Killing work at exit — returning from main terminates the process. Join what must finish.

For CPU-bound work, one thread per core plus a work queue (the pattern above) is the usual design; C provides no thread pool, so that queue is yours to write.

See Also