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.
|
|
#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 |
|---|---|
|
Starts |
|
Waits for |
|
Gives up the right to join; resources are released automatically at exit. |
|
This thread’s identifier. |
|
Compares identifiers — |
|
Sleeps for a |
|
Hints that the scheduler may run something else. |
|
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 |
|---|---|
|
A simple non-recursive mutex. Re-locking it in the same thread is undefined. |
|
Supports |
|
May be locked repeatedly by the owning thread; needs one unlock per lock. |
|
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
returninside a critical section is the classic deadlock. Keep critical sections small enough to see both ends at once, or use a single-exitgotoas 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_plainis 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.
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:
-
Hold the mutex when testing the predicate, when waiting, and when changing the state the predicate reads.
-
Wait in a
whileloop, never anif—cnd_waitis permitted to return spuriously, and between the signal and the wakeup another thread may have taken the item. -
cnd_signalwakes one waiter,cnd_broadcastwakes all. Use broadcast when waiters are waiting for different predicates (like the shutdown above) or when more than one can proceed. -
cnd_waitatomically 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
ifinstead ofwhile. -
Killing work at exit — returning from
mainterminates 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
-
Atomics and Memory Consistency — lock-free counters and flags, and the memory model these guarantees come from.
-
Storage Duration, Scope and Linkage — thread storage duration and
thread_local. -
Error Handling and Program Failure — races and deadlocks in the failure catalogue.
-
Advanced Control Flow — why signals and threads interact badly.
-
C++: Threads and Synchronization — C++ adds RAII lock guards,
std::jthreadandstop_tokencancellation.
References
-
WG14 N3220 — the C23 working draft (§7.28 "Threads `<threads.h>`", §5.1.2.5 "Multi-threaded executions and data races").