Interviews
Multithreading Programming C: A Practical Guide
Learn multithreading programming c with pthreads, C11 threads, mutexes, memory orders, and the races that make extra threads slower.
Interview Pilot Editorial Team
Updated November 2, 2026
18 min read

You've added a second thread to a C program that worked perfectly as a single thread, and now it prints the right answer nine times out of ten. The tenth run drops a value, hangs, or crashes on data you're sure you wrote. Nothing in the code changed between runs. That's the normal entry point to multithreading programming in C, and it's frustrating because the bug isn't in one line. It's in the agreement between threads about who owns shared data, when they can touch it, and how one thread knows another thread's writes are ready to read.
Which C Thread API to Use
A thread is a second flow of execution inside one process. Both flows share the same address space, heap, and globals, but each keeps its own stack. Think of one kitchen ticket worked by several cooks at one shared pass. Coordination is fast because everyone reaches the same bench, and collisions are easy for the same reason.
| API | Standard | Header | Best fit | Limit |
|---|---|---|---|---|
| POSIX pthreads | IEEE 1003.1c-1995, published July 12, 1996 | pthread.h | Unix servers, embedded, scientific shared memory | OS interface, not original ISO C |
| C11 threads | ISO/IEC 9899:2011, December 8, 2011 | threads.h | Language-level threads when the library exists | Optional; many production toolchains omit it |
| C11 atomics | Same C11 standard | stdatomic.h | Lock-free flags, counters, publication | Not a thread lifecycle API |
| C17 and C23 | C17 June 2018; C23 is ISO/IEC 9899:2024 | threads.h unchanged in role | Clarified C11, not a new model | Thread support remains conditional |
Why Two Standards Exist
POSIX came first. The IEEE listing for 1003.1c records the threads extension as IEEE 1003.1c-1995, ANSI-approved on June 7, 1996 and published on July 12, 1996. It covered creation, management, execution, termination, and asynchronous cancellation, plus mutexes, reader-writer locks, and condition variables. It's an operating-system interface, not part of original ISO C, which is why it integrates so well with Unix file descriptors, signals, and scheduling.
ISO C caught up later. C11, published as ISO/IEC 9899:2011 on December 8, 2011, roughly 16 years after POSIX, added <threads.h> and <stdatomic.h>, thread-specific and thread-local storage, and a formal memory model that C90 and C99 lacked. C17 in June 2018 mainly corrected defects, and C23, ISO/IEC 9899:2024, supersedes C17 without inventing a new threading model. The catch is that C11 threads are conditionally implemented, so your compiler may accept the language standard and still not ship threads.h.
The Choice in Practice
Practical rule: Treat pthreads as the production default on Unix-like systems, and treat C11 as the language model that explains atomics and visibility. Reach for C11 threads only after you've confirmed your toolchain actually implements them.
A minimal C11 example shows the shape. You include <threads.h>, call thrd_create(&t, worker, &arg) with a worker function and an argument pointer, call thrd_join(t, &res), and check the result against thrd_success. It reads cleanly, but it only compiles where the library provides those symbols.
For pthreads, compile and link with -pthread, which sets both preprocessor and linker flags. Don't link -lpthread alone and hope. And don't use volatile as a substitute for either API. volatile tells the compiler not to cache a value in a register. It doesn't create mutual exclusion, atomicity, or ordering between threads.
How a POSIX Thread Lives and Dies
Say you're writing a small log processor. The main thread reads lines, and one worker counts them. That worker needs the filename and a place to put the count, so you package both in a struct and hand the worker a pointer to it. That single pattern, a shared context struct passed as a void *, covers most pthread programs you'll write.

The Lifecycle in Order
- Include and define: Include
<pthread.h>and define a struct holding everything the worker needs. Don't pass the address of a loop variable that changes before the worker reads it. - Create: Call
pthread_create(&tid, &attr, worker, &ctx)and check the returned integer. Pthread functions return error numbers directly. They don't seterrno, so testingerrnoafter a failed create tells you nothing. - Run: The worker runs until it returns or calls
pthread_exit. Around sections that own a mutex, usepthread_setcancelstateto disable cancellation temporarily, so a cancelled thread can't abandon a locked mutex for everyone else. - Collect: Call
pthread_join(tid, &result)in the parent to wait, collect the exit status, and reclaim the thread's resources. Or callpthread_detach(tid)if nobody will ever join it. - Finish before exit: Join every joinable thread before the process exits, or the program may end while workers still hold pointers into
main's stack frame.
Join is the cook returning the plate to the pass so you can check it. Detach is the cook leaving without a receipt. Detached threads clean up after themselves, but you lose the exit status and any guarantee they've finished before shared data goes away.
Shared and Private State
Threads share code, the heap, globals, and open file descriptors. Each thread keeps its own stack, registers, and errno. That split explains two common surprises. A local variable in the worker is private and safe, while a static buffer inside a function both threads call is shared and isn't.
Two attributes are worth setting on purpose. Use pthread_attr_setstacksize when a worker recurses to significant depth or allocates large arrays on its stack, since thread stacks are smaller than the main stack. Use pthread_attr_setdetachstate to create a thread detached from the start when no one will join it, instead of detaching later and racing with a fast-finishing worker.
This lifecycle comes from the model described in the Illinois Grainger notes on POSIX threads, which present IEEE POSIX 1003.1c, produced in 1995, as defining creation, management, execution, termination, and asynchronous cancellation together with mutexes, reader-writer locks, and condition variables. Lifecycle control alone is only half of that model. If you want the process and scheduling background behind it, work through these operating system interview questions alongside the code.
Mutexes Condition Variables and Atomics Compared
A data race is two conflicting non-atomic accesses to the same memory location, at least one of them a write, with no happens-before relationship between them. In C that makes the whole program's behavior undefined, not just the value of that variable. Each tool below prevents that outcome in a different situation, and picking the wrong one creates a new bug while fixing the old one.
| Primitive | Protects | Typical calls | Use when | Failure if misused |
|---|---|---|---|---|
| Mutex | Compound shared state | pthread_mutex_lock and unlock | One owner mutates a struct | Deadlock from opposite lock order |
| Reader-writer lock | Read-mostly data | pthread_rwlock_rdlock and wrlock | Many readers, rare writers | Writer starvation under constant readers |
| Condition variable | Waiting for a state change | pthread_cond_wait and signal | Queue not empty or not full | Missed wakeup or spurious wakeup without a predicate loop |
| Atomic or _Atomic | One flag or counter | atomic_store and atomic_load | Lock-free state bit | Flag set without publishing the related data |
Mutex and Reader-Writer Lock
A pthread_mutex_t gives one thread exclusive ownership of a compound structure. Initialize it with pthread_mutex_init or PTHREAD_MUTEX_INITIALIZER, then lock, mutate, and release on every path, including error paths. The classic failure is deadlock from inconsistent ordering. Thread one locks mutex A then B, thread two locks B then A, and both wait forever. Fix it by defining one global lock order and never deviating. Releasing a mutex you don't own, or locking a non-recursive mutex twice in the same thread, is undefined or a self-deadlock depending on the mutex type.
A reader-writer lock lets many readers hold the lock at once while a writer holds it alone. It fits a configuration table that's read on every request and rewritten once an hour. It doesn't fit a hot counter, since every increment is a write and you pay rwlock overhead for no sharing. Under a constant stream of readers, a writer can also starve if the implementation keeps preferring readers.
Condition Variable and Atomic
A condition variable doesn't protect data. It puts a thread to sleep until some predicate about shared data might have changed. It must be paired with a mutex, and the wait must sit in a while loop that rechecks the predicate, because wakeups can be spurious and because another thread may consume the condition first. If you set the flag and signal before the waiter starts waiting, the signal is lost, which is exactly why the predicate lives in shared state under the mutex rather than in the signal itself. Waiting without holding the mutex is a bug, full stop.
An atomic fits one flag, counter, or pointer-sized state transition. It doesn't fit a struct of related fields, because another thread can observe the flag updated and the fields not yet updated. Before you write any of these calls, name the owner of each shared object and which primitive guards it. If you can't say that in one sentence, the code isn't ready to write.
Memory Orders That Publish Shared Data
Atomicity and synchronization are different promises. Atomicity means one operation on one object can't be observed half done. Synchronization means other writes become visible in a defined order. The picture below captures the situation most C programmers meet first, two threads racing on shared state.

One Pattern Worth Memorizing
A producer fills a plain buffer, then stores 1 to an _Atomic int ready with memory_order_release. A consumer loads ready with memory_order_acquire, and only if it sees 1 does it read the buffer. The cppreference page on the C memory model describes exactly this pairing, where the release store publishes preceding ordinary writes and the acquire load prevents subsequent reads from being reordered before that publication. It also states the hard rule underneath: conflicting non-atomic accesses with neither happening before the other are a data race with undefined behavior, and volatile doesn't fix that.
| Memory order | Publishes other writes | Use when |
|---|---|---|
| memory_order_relaxed | No | Approximate counters and statistics |
| memory_order_acquire | Consumer observes prior writes | Load of a ready flag before reading payload |
| memory_order_release | Producer publishes prior writes | Store of a ready flag after filling payload |
| memory_order_acq_rel | Both directions on one RMW | Atomic update that also publishes |
| memory_order_seq_cst | One global order | Default when the cost is acceptable |
Reading the Other Orders
memory_order_relaxed guarantees only atomicity and per-object modification order, so it's fine for an approximate statistics counter where a lost moment of ordering costs nothing. memory_order_acq_rel is for read-modify-write operations like atomic_fetch_add on a reference count that must both observe prior state and publish new state. memory_order_seq_cst is the default and puts participating operations in one global order, which is easiest to reason about and sometimes stricter than your algorithm needs.
A single atomic flag is not enough unless its memory order also correctly publishes the associated data. A relaxed flag can be visible while the payload behind it still looks stale.
The video below walks through that visibility problem visually, which helps if the acquire and release pairing still feels abstract.
When the shared state is more than one word, reach for a mutex or a proper acquire-release pair rather than stacking atomics field by field. Reserve _Atomic for the lock-free transition itself. Write the owner, the flag, and the memory order in a comment next to the shared object, since the next reader can't infer your ordering intent from types alone. These are also the distinctions that separate a memorized answer from a real one in interview questions about C.
A Producer and Consumer You Can Compile
One bounded queue teaches more than five disconnected snippets. A producer inserts items into a fixed-size buffer and blocks when it's full. A consumer removes items and blocks when it's empty. Every synchronization idea so far appears once, in a place where removing it breaks something visible.

Build It in Six Steps
- Define the queue struct: Give it a fixed array,
head,tail, andcount, onepthread_mutex_t, two condition variables namednot_emptyandnot_full, and adoneshutdown flag. Everything shared lives in one place with one mutex. - Initialize: Call
pthread_mutex_initandpthread_cond_initfor both conditions, and startcountat 0. Check each return value before creating any thread. - Producer loop: Lock the mutex. While
countequals capacity, callpthread_cond_wait(¬_full, &mutex). Write the item attail, advancetail, incrementcount, then signalnot_emptyand release the mutex. - Consumer loop: Lock the mutex. While
countequals 0 anddoneis false, wait onnot_empty. Read the item athead, advancehead, decrementcount, then signalnot_fulland release the mutex. Process the item after releasing the mutex, not while holding the lock. - Shut down cleanly: When the producer finishes, it locks, sets
done, broadcasts onnot_empty, and releases the mutex. The consumer wakes, seesdonewith an empty queue, and exits instead of waiting forever. - Join and destroy: Pass the same struct address to both
pthread_createcalls. Join both threads, and only then callpthread_mutex_destroyandpthread_cond_destroy.
Compile it with cc -Wall -Wextra -pthread queue.c -o queue and run it with a few hundred items first, not millions.
Why the Details Are Shaped This Way
The wait sits in a while loop, not an if, because pthread_cond_wait can return without a signal, and because a woken consumer can find the queue already drained by another consumer. Rechecking the predicate under the mutex is what makes both cases safe. Skipping the loop produces a program that passes small tests and fails under load.
The signal goes after the state change, either just before or just after releasing the mutex. Both work because the waiter must reacquire the mutex and recheck the predicate before proceeding. Signalling before changing count doesn't work, since the waiter wakes, finds nothing changed, and you've wasted the wakeup. Use pthread_cond_signal when one waiter can make progress, and pthread_cond_broadcast for shutdown, when every waiter must recheck.
Destroying the mutex or condition variables before joining is a lifetime bug, not a style issue. A worker might still be inside pthread_cond_wait on memory you've just invalidated. The join is your proof that no thread can touch the struct again, which is what makes destruction safe.
Success looks boring. Every item the producer writes comes out exactly once, no thread waits on a destroyed condition variable, and both pthread_join calls return zero. If items go missing, suspect the predicate loop first. If the program hangs at the end, suspect the shutdown broadcast.
Why More Threads Can Run Slower
A correct mutex around almost no work can cost more than the work itself. The published POSIX benchmark from the University of Bologna course notes measured 20 million mutex lock and release operations at approximately 1.65 seconds, compared with about 0.15 seconds for a path that skipped mutex synchronization, and it notes the protected critical section did almost no useful work. That fast path is a data race, not a technique. The number matters because it shows where time goes when you lock per item.

| Case | Elapsed time | What it shows |
|---|---|---|
| 20 million mutex lock and unlock pairs | about 1.65 seconds | Synchronization dominates an empty critical section |
| Same volume without the mutex | about 0.15 seconds | The gap is overhead, but the fast path races |
| OpenMP loop with false sharing | 0.0131083 seconds | Independent variables still share a cache line |
| Same loop after the sharing is removed | 0.0030481 seconds | About 4.3 times faster |
Amortize, Then Look at the Cache
The fix for lock overhead is fewer trips to the lock, not a cleverer lock. Process items in batches between lock acquisitions. Give each worker its own counter or accumulator, keep the hot loop free of shared writes, and merge the per-thread totals once after the parallel work finishes, with that final reduction synchronized. A globally locked counter incremented per element turns your extra threads into a queue for one mutex.
False sharing is the second trap, and it survives perfect locking. When two independent per-thread counters sit in the same cache line, each write invalidates the other core's copy, so the line ping-pongs between cores despite zero logical sharing. In the measured OpenMP example in those same course materials, elapsed time fell from 0.0131083 seconds to 0.0030481 seconds, about 4.3 times faster, after the access pattern stopped that sharing. In C, pad or align hot per-thread structs to a typical 64-byte cache line boundary, and partition contiguous arrays so each worker owns a separate region.
Measure Honestly
An empty critical section exaggerates lock cost, so a microbenchmark can mislead you in the opposite direction too. Time your real batch size and real contention level. Record thread count, critical-section size, and elapsed time for each run before you claim a speedup, since adding threads past the point where they contend on one lock or one cache line makes the program slower while looking more parallel.
Check Races Then Rehearse the Explanation
ThreadSanitizer is a test, not a proof. It reports races only on executions it ran, so an untested interleaving stays invisible no matter how clean the report looks. Mozilla's engineering report on eliminating data races in Firefox notes that TSan can avoid false positives when properly deployed, but it requires instrumenting all loaded code and avoiding primitives it doesn't understand, such as certain atomic-fence patterns. An uninstrumented library in your build is a blind spot, not a clean bill of health.
A Verification Checklist
- Stress under varied scheduling: Run the sanitizer build repeatedly with different thread counts and randomized delays, so different interleavings get exercised.
- Review ownership and lifetime: For every shared object, write down who owns it, what guards it, and when it's freed relative to the last join.
- Check what TSan can't certify: Deadlock, missed wakeups, starvation, and performance regressions need separate reasoning and tests. A race detector says nothing about them.
- Feed failures back: If production fails after a clean report, the failing interleaving was never exercised. Reproduce it, then add it to the stress test so it stays covered.
That last point answers the question teams ask, why TSan reported nothing when production still failed. The tool observed your test runs faithfully. It never saw the schedule that broke.
For a live round, a strong explanation names the shared object, the mutex or acquire-release pair guarding it, and what the tool cannot prove. That's the difference between tool familiarity and engineering judgment, and it's worth rehearsing out loud. Our software engineer technical interview questions and answers can help you structure that practice, since the software engineer question list covers programming, data structures, algorithms, system design, debugging, testing, production and collaboration. Start today with one shared structure in your current code. Write its owner and memory order next to it, run a sanitizer build, and practice explaining why a silent report isn't a correctness proof.
If you can name the owner, guard, and memory order for every shared object in your queue, you're ready to defend that reasoning under interview pressure. Interview Pilot gives you guided mock practice and a large searchable question bank so you can rehearse exactly these concurrency explanations before a live round, with one subscription that works across all platforms. Visit Interview Pilot to practice saying your bounded queue design out loud before someone asks you to.
Topics
multithreading programming c
POSIX threads
C11 threads
thread synchronization
data races
Continue reading

Interviews
Phone Interview Preparation Checklist for 2026
Phone interview preparation checklist with room and line setup, timed STAR stories, and scripts for a 15–30 minute screen.
November 2, 2026
19 min read

Interviews
Sales Interview Pitch: How to Prepare and Deliver It
Prepare a sales interview pitch or presentation that wins the room: a five-slide structure, discovery questions, objection handling and practice tips.
November 1, 2026

Interviews
Interview Questions About C: 10 Topics with Sample Answers
Practice interview questions about C, from pointers and memory to volatile and macros. Each of 10 topics has sample answers, pitfalls, and tips.
November 1, 2026
20 min read