Skip to content
Interview Pilot Logo

Interview Pilot

AI
Interview Pilot
Interview AssistantHow to UseReviewsPricing
LoginStart for Free
Resources

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

Multithreading Programming C: A Practical Guide

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.

A six-step infographic explaining the lifecycle of a POSIX thread from setup to completion in C programming.

The Lifecycle in Order

  1. 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.
  2. Create: Call pthread_create(&tid, &attr, worker, &ctx) and check the returned integer. Pthread functions return error numbers directly. They don't set errno, so testing errno after a failed create tells you nothing.
  3. Run: The worker runs until it returns or calls pthread_exit. Around sections that own a mutex, use pthread_setcancelstate to disable cancellation temporarily, so a cancelled thread can't abandon a locked mutex for everyone else.
  4. Collect: Call pthread_join(tid, &result) in the parent to wait, collect the exit status, and reclaim the thread's resources. Or call pthread_detach(tid) if nobody will ever join it.
  5. 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.

Two programmers working on computers, illustrating a multithreading race condition concept with atomic integers in C.

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.

A diagram illustrating a producer-consumer synchronization model with a fixed size queue for multithreading programming in C.

Build It in Six Steps

  1. Define the queue struct: Give it a fixed array, head, tail, and count, one pthread_mutex_t, two condition variables named not_empty and not_full, and a done shutdown flag. Everything shared lives in one place with one mutex.
  2. Initialize: Call pthread_mutex_init and pthread_cond_init for both conditions, and start count at 0. Check each return value before creating any thread.
  3. Producer loop: Lock the mutex. While count equals capacity, call pthread_cond_wait(&not_full, &mutex). Write the item at tail, advance tail, increment count, then signal not_empty and release the mutex.
  4. Consumer loop: Lock the mutex. While count equals 0 and done is false, wait on not_empty. Read the item at head, advance head, decrement count, then signal not_full and release the mutex. Process the item after releasing the mutex, not while holding the lock.
  5. Shut down cleanly: When the producer finishes, it locks, sets done, broadcasts on not_empty, and releases the mutex. The consumer wakes, sees done with an empty queue, and exits instead of waiting forever.
  6. Join and destroy: Pass the same struct address to both pthread_create calls. Join both threads, and only then call pthread_mutex_destroy and pthread_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.

An infographic explaining why multithreading can slow down programs when mutex overhead outweighs the actual work performed.

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

  1. Stress under varied scheduling: Run the sanitizer build repeatedly with different thread counts and randomized delays, so different interleavings get exercised.
  2. 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.
  3. 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.
  4. 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

Phone Interview Preparation Checklist for 2026

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

Sales Interview Pitch: How to Prepare and Deliver It

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

Interview Questions About C: 10 Topics with Sample Answers

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