Practical Multithreading Best Practices: C Edition — Writing Safely the Win32 API Way

· · Windows, Multithreading, C, Win32 API, Business Applications, Bug Investigation, Design

“I’m writing a resident process for equipment control in C.” “We ended up adding threads to a twenty-year-old C application.” “We stop threads with TerminateThread, but the whole process occasionally locks up.” — Multithreading in C is the world where the language gives you the least help. There are no exceptions, no RAII, no templates; the correctness of synchronization rests entirely on which APIs you choose and how disciplined you are about calling them.

This article is the C edition of the practical multithreading series. Aimed at developers writing C against the Win32 API, it takes the principles of multithreading design — stop mass-producing threads, reduce shared mutable state, keep lock discipline, and design how to stop before you design how to start — and maps them onto Win32’s toolset: how to create threads (_beginthreadex), how to choose synchronization objects, how to design a stop path without TerminateThread, and the constraints of DllMain, all organised around primary sources as of August 2026. It is written to be read on its own. The same principles, worked out for other languages, are also covered in the .NET edition, the C++ edition, and the Java edition.

1. The Bottom Line First

  • Create threads with _beginthreadex, not CreateThread. If a thread that calls the CRT is created with CreateThread, the CRT can terminate the process when memory is low.12
  • The default lock within a process is an SRW lock; use a CRITICAL_SECTION only when recursive acquisition is needed. Using a Mutex for exclusion within a process is a “common mistake” that always involves a transition into kernel mode.3
  • Update single variables with the Interlocked family of functions. volatile guarantees neither atomicity nor ordering. Most Interlocked functions carry a full memory barrier.4
  • Wait using a condition variable (the SleepConditionVariableCS family) or an event plus a wait function. A Sleep-based polling loop wastes both CPU and responsiveness.5
  • Never use TerminateThread. It is a dangerous function that corrupts locks, the heap, and DLL state, and it’s the target of code-analysis warning C6258. Design stopping as cooperative shutdown: a stop event plus WaitForMultipleObjects.67
  • Hand short jobs off to the Windows thread pool (CreateThreadpoolWork) instead of parallelizing them with your own threads. Never end pool threads with ExitThread / TerminateThread.89
  • Don’t create threads, synchronize, or wait for threads to finish inside DllMain. It’s called while the loader lock is held, making it a breeding ground for deadlocks.10
  • C11’s <threads.h> is usable from VS 2022 17.8 onward, but <stdatomic.h> is still experimental. For a Windows-only codebase, the Win32 approach is the realistic choice.11

2. Why Is Multithreading Hard? — Race Conditions and Deadlocks

Boil down the problems multithreading introduces, regardless of language, and there are two kinds.

A race condition is a bug where the outcome changes depending on the order in which multiple threads reach a particular piece of code. The classic example is a shared counter: the single expression count++ breaks down at the machine-code level into three steps — read, add, write back. If two threads enter these three steps at the same time, one thread’s addition gets overwritten and lost when the other writes back.4 The result changes from run to run, and which result you get is unpredictable.

Thread BShared variable countThread AThread BShared variable countThread Acount = 10count = 11 despite two incrementsThread A's increment was lostRead (10)Read (10)Add locally (11)Add locally (11)Write back (11)Write back (11)

Figure 1: The classic race condition where a shared counter loses an increment. If another thread interleaves within count++’s three steps, whichever write-back happens last overwrites the other

A deadlock is a state where two threads each wait on a lock the other one holds, and neither can proceed. Thread A holds lock 1 and waits for lock 2; thread B holds lock 2 and waits for lock 1 — that alone is enough for both to stop forever.

waiting for lock 2 to be releasedwaiting for lock 1 to be releasedThread Aholding lock 1Thread Bholding lock 2

Figure 2: The circular wait of a deadlock. The moment the waiting arrows form a ring, every thread in that ring stops forever

Both are timing-dependent: an execution-order combination that only shows up once in tens of thousands of runs on a development machine can happen every day on a customer machine with a different core count and different timing. “It doesn’t reproduce with a debugger attached” happens because observation itself changes the timing — a typical behaviour of race bugs. So every principle in this article points in one direction: reduce the places that need synchronization, before you worry about synchronizing correctly.

2.1. C-Specific Assumptions — The Language Protects You From Nothing

In C, because the language has no mechanism to enforce these principles, they need to be written down explicitly as discipline.

First, build the guarantee of release into the structure. With nothing equivalent to C++’s RAII, releasing locks and calling CloseHandle on handles has to be protected by a goto cleanup pattern that funnels every function exit through one place, or by a coding convention that pairs every acquire with a release. Adding an early return and leaking a lock as a result is a classic C accident.

Second, treat data races the same way C++ does. A simple read or write of a properly aligned 32-bit variable is atomic on Windows, but nothing beyond that — 64-bit variables on 32-bit Windows, compound operations, or consistency across multiple variables — is guaranteed at all.12 Code that “happens to work” breaks the moment the compiler or the optimization level changes.

Third, decide ownership. A culture of spelling out, in a function comment, “which thread writes this buffer, and from when does it belong to whom” pays off in C multithreading just as much as the choice of synchronization primitive does.

3. How to Create Threads — _beginthreadex, and Nothing Else

3.1. Why CreateThread Is the Wrong Choice

Win32’s native API is CreateThread, but the official guidance is that any thread that calls CRT (C runtime) functions must be created with _beginthreadex. _beginthreadex initializes the per-thread internal data the CRT needs before starting the thread. If a thread created with CreateThread calls a CRT function, the CRT can terminate the process under low-memory conditions.12 Since printf, malloc, and strtok are all CRT functions, the practical rule is: “threads written in C always use _beginthreadex”.

Avoid _beginthread (without the ex) too. It has a trap: if the thread it creates finishes early, the returned handle can already be invalid — or even point at a different thread — whereas _beginthreadex, whose handle can safely be passed to synchronization APIs, is the safer choice. The caller closes the handle _beginthreadex returns with CloseHandle.13

#include <process.h>

static unsigned __stdcall WorkerMain(void* arg)
{
    WorkerContext* ctx = (WorkerContext*)arg;
    /* ... the wait loop from Sections 5 and 6 ... */
    return 0;
}

HANDLE hThread = (HANDLE)_beginthreadex(
    NULL, 0, WorkerMain, &ctx, 0, NULL);
if (hThread == NULL) { /* failure handling */ }
/* ...after the stop request... */
WaitForSingleObject(hThread, INFINITE);  /* join */
CloseHandle(hThread);

3.2. Short Jobs Go to the Windows Thread Pool

If you want to “throw a large number of small jobs at something” or find yourself “creating and destroying short-lived threads over and over”, use the Windows thread pool (the thread pool API available from Vista onward) instead of your own threads. Create a work object with CreateThreadpoolWork and submit it with SubmitThreadpoolWork, and the pool’s worker threads execute the callback in parallel.8 Managing the thread count is left to the OS, and the cost of creating and destroying threads disappears. This is C’s answer to the principle of “don’t create your own threads”.

The discipline for using the pool is also officially documented: never end a pool thread with TerminateThread / ExitThread; restore any state you changed inside a callback (TLS, thread priority, and so on) before returning; and keep wait handles alive until the pool is done with them.9 One more practical note: the pool only limits the number of worker threads — the number of un-executed callbacks queued via SubmitThreadpoolWork can pile up without bound. In a long-running configuration where submissions keep outpacing processing, put an entry limiter such as a semaphore, or a bounded queue, on the application side, so that the submitting side waits or is rejected once it is full (this is back-pressure to keep overload from turning into a memory problem, and it’s the same principle as the queue design in Section 5).

4. Minimize Shared Mutable State — Partition, Read-Only, and Handoff

A race only happens when “multiple threads” and “shared mutable data” are both present. Before choosing a synchronization primitive (next chapter), think about whether you can reduce the sharing in the first place. There are three families of technique.

Partition. For parallel aggregation, rather than having each thread write to a shared counter, build a subtotal in a per-thread local variable (or a buffer allocated per thread), and merge them just once at the end with something like InterlockedAdd. Writes to shared state drop from “every iteration” to “once per thread”, and both the synchronization cost and the window for contention shrink by orders of magnitude. The ownership discipline from Section 2.1 — “which thread does this buffer belong to” — becomes the blueprint for how you partition.

Make it read-only. Configuration and tables that are built at startup and never modified afterward are safe to read from any number of threads once initialization has finished. Either finish all initialization before any threads start, or, if lazy initialization is required, use Win32’s one-time initialization (InitOnceExecuteOnce), and make the boundary — “from when does this become read-only” — explicit in the code.3

Hand it off. Rather than having both sides touch a shared variable, route the flow of data between threads through a producer/consumer queue. In C, the implementation is exactly the condition-variable pattern from Section 5 (a bounded circular buffer plus SleepConditionVariableCS), which is the official worked example; a buffer with a capacity limit also gives you natural back-pressure — production waits once it outruns consumption.5

5. Choosing Synchronization Objects and Lock Discipline

Win32 has many kinds of synchronization primitives, and choosing the wrong one costs you both performance and correctness. Here is the official guidance summarised in one picture.3

YesMutual exclusionLimiting concurrent access countEvent notificationNo - process-internalYesNoYesNoCross-processsynchronization needed?What is it for?Named MutexNamed semaphoreNamed eventRecursive acquisitionby same thread needed?CRITICAL_SECTIONPortable C++ codea priority?std::mutex /std::shared_mutexSRW lock - the default choice

Figure 3: How to choose a Win32 synchronization primitive. The first branch is “does it cross processes” — the key point being not to reach for a kernel object (Mutex) when it doesn’t

Primitive Scope Characteristics Where to use it
SRW lock Process-internal Fast (usually stays entirely in user mode), pointer-sized, non-recursive The default for new code. AcquireSRWLockShared also allows shared read access
CRITICAL_SECTION Process-internal Fast (spins, then falls back to a kernel wait), recursive When the same thread needs recursive acquisition
Mutex Process-internal / cross-process Always a kernel object, so slower Cross-process exclusion (named), or combined with WaitForMultipleObjects
Semaphore Process-internal / cross-process Kernel object Limiting concurrent access to a resource pool
Event Process-internal / cross-process Kernel object Notifying that “something happened” (not for protecting data)
Interlocked functions Process-internal (cross-process too, over shared memory) Lock-free atomic operations Counters, flags, pointer swaps4

One footnote to the table and flowchart. Kernel objects such as events, semaphores, and mutexes work perfectly well for process-internal synchronization when created unnamed (the stop event in Section 6 is exactly an unnamed event). Kernel object does not mean cross-process-only. Nor, conversely, does “unnamed” strictly mean “confined to one process” — if you let a child process inherit the handle, or duplicate it into another process with DuplicateHandle, the same kernel object can be used from multiple processes even without a name. The accurate way to put it is that naming is one representative way to let processes reopen the same object. The branch in Figure 3 captures the point of the choice as “don’t pick a kernel object for a process-internal lock” — for process-internal notification (events) or concurrency limiting (semaphores), an unnamed kernel object is still the right answer.

The Interlocked family corresponds to the Interlocked class in the .NET edition and std::atomic in the C++ edition. InterlockedIncrement / InterlockedExchange / InterlockedCompareExchange perform operations on a single variable indivisibly, and since most of these functions carry a full memory barrier, you also get an ordering guarantee.4 “It’s fine because I marked it volatile” is a misconception — volatile guarantees neither atomicity nor ordering (see the FAQ). There is one more prerequisite: alignment. The variable targeted by an Interlocked function must be aligned on a natural boundary (a 4-byte boundary for a 32-bit value, an 8-byte boundary for a 64-bit value); if it isn’t, behaviour is unpredictable.12 Never target a field inside a #pragma pack-ed struct, or a field in a buffer mapped directly onto a wire format, with an Interlocked function. Restrict counters and flags to ordinarily declared variables — ones the compiler aligns for you. There is also a specific caveat around swapping pointers with something like InterlockedExchangePointer: only the swap itself is indivisible, and nobody guarantees the lifetime of the old block after the swap. If a reader loads the old pointer just as a writer swaps it out and calls free, that’s an access to freed memory. A design that updates shared data by swapping pointers only works when it is paired with a reclamation protocol — a lock, reference counting, or similar (if in doubt, protecting it with an SRW lock is the safe default).

For waiting on something, there are condition variables. Create one with InitializeConditionVariable; the consumer sleeps on SleepConditionVariableCS (paired with a CRITICAL_SECTION), and the producer wakes it with WakeConditionVariable — this is exactly the shape of the official producer/consumer queue example over a bounded buffer.5 The important discipline: on waking, always re-check the condition (whether the queue is non-empty) inside the lock, and loop back to waiting if it’s false. Condition variables can suffer spurious wakeups with no notification at all, and by the time you wake up another consumer may already have taken the item first — so “I was woken” does not necessarily mean “the condition holds”. This is C’s tool for building the same shape as the channel in .NET edition Section 4.3 and the BlockingQueue in C++ edition Section 4. When pairing with an SRW lock, use SleepConditionVariableSRW.

5.1. Lock Discipline — Three Principles That Hold Regardless of Which Primitive You Choose

Choosing the right primitive isn’t enough by itself — without disciplined use, you still won’t prevent races.

  • Decide, one-to-one, which lock protects which data. Assign exactly one lock (an SRW lock or a CRITICAL_SECTION) to each set of mutable data you want protected, and take that same lock at every place that touches that data. In C in particular, it pays off to spell this out explicitly in a header comment — “this struct is protected by g_lockFoo”.
  • Don’t do anything slow or external while holding a lock. The only thing that’s fine to do while holding a lock is read or write the data it protects. File I/O, network calls, or callback invocations made while still holding the lock both lengthen how long you hold it and risk the callee trying to take another lock, creating the circular wait from Figure 2.
  • Fix the acquisition order for multiple locks. Anywhere two or more locks are taken, make it a rule that every thread takes them in the same order (a lock hierarchy). The DLL best-practices documentation states explicitly that reversing that order (lock order inversion) produces deadlocks that are hard to debug, and that you should define a hierarchy and follow it consistently.10

6. Designing How to Stop — Never TerminateThread

6.1. What TerminateThread Breaks

TerminateThread erases the target thread without letting it execute any user-mode code at all. The consequences the official documentation lists are severe. If the target thread was holding a critical section, it is never released; if it was in the middle of a heap operation, the heap lock stays held (and every subsequent thread that calls malloc hangs); and if it was manipulating a DLL’s global state, that state is left corrupted. The official position is that it is “a dangerous function that should only be used in the most extreme cases”, and code analysis flags it as warning C6258.67

Finding TerminateThread while investigating an app that “occasionally locks up entirely” really is a common sight in practice. If you find it, treat it as something that needs fixing.

6.2. The Correct Pattern: a Stop Event Plus WaitForMultipleObjects

The established pattern for cooperative shutdown in C is to create a single manual-reset stop event, and have each worker thread wait on the “work signal” and the “stop signal” at the same time. The C6258 warning documentation itself points to exactly this pattern — create an event, have each thread watch it with WaitForSingleObject, and let the thread end itself — as the correct way to terminate.7

HANDLE hStopEvent;   /* CreateEvent(NULL, TRUE, FALSE, NULL): manual-reset */
HANDLE hWorkEvent;   /* CreateEvent(NULL, FALSE, FALSE, NULL): auto-reset.
                        Automatically returns to non-signaled on being received
                        (a manual-reset event would let waits pass straight through
                        after being signaled once, turning into a busy loop that
                        keeps spinning on an empty queue) */

static unsigned __stdcall WorkerMain(void* arg)
{
    HANDLE waits[2] = { hStopEvent, hWorkEvent };
    for (;;) {
        DWORD r = WaitForMultipleObjects(2, waits, FALSE, INFINITE);
        if (r == WAIT_FAILED) {          /* e.g. an invalid handle. Left unhandled, this spins flat out */
            LogLastError();              /* Record GetLastError() and bail out */
            break;
        }
        if (r == WAIT_OBJECT_0)          /* Stop requested */
            break;
        if (r == WAIT_OBJECT_0 + 1) {    /* Work available */
            /* Also pass the stop event into ProcessNextItem: if it waits a long time
               internally for one item, and can't observe the stop there too,
               shutdown ends up held hostage by that one item */
            while (ProcessNextItem(hStopEvent)) {  /* Process one item from the queue; FALSE if empty */
                /* Check for the stop request during draining too. Skip this and you
                   can't stop as long as work keeps piling up (stop starvation) */
                if (WaitForSingleObject(hStopEvent, 0) == WAIT_OBJECT_0)
                    break;
            }
        }
    }
    Cleanup();                           /* Do your own cleanup yourself */
    return 0;                            /* End yourself */
}

BOOL StopWorkers(HANDLE* threads, DWORD count)
{
    BOOL ok = TRUE;
    if (!SetEvent(hStopEvent)) {             /* If the stop request didn't get through, */
        LogLastError();                      /* don't go on to an unbounded join */
        return FALSE;
    }
    /* Everyone now has the stop request raised at once */
    for (DWORD i = 0; i < count; i++) {
        if (WaitForSingleObject(threads[i], INFINITE) == WAIT_OBJECT_0) {
            CloseHandle(threads[i]);         /* Only close handles we confirmed joined */
        } else {
            LogLastError();                  /* WAIT_FAILED: e.g. an invalid handle */
            ok = FALSE;                      /* Don't report "everyone stopped" */
        }
    }
    return ok;   /* If FALSE, don't proceed to release shared resources */
}

There is a reason the stopper joins one thread at a time with WaitForSingleObject. WaitForMultipleObjects can wait on at most MAXIMUM_WAIT_OBJECTS (64) handles at once; hand it a larger array and the wait itself fails with WAIT_FAILED, leaving you closing handles while believing you’d waited for everyone when in fact you’d waited for no one. If all you need is to wait for everyone to finish, a one-at-a-time loop with no upper bound is the safe choice.

SetEvent(hStopEvent)StopperStop eventmanual-reset - visible to everyone at onceWorker 1 - waits for stop and worksimultaneously via WaitForMultipleObjectsWorker 2 - waits for stop and worksimultaneously via WaitForMultipleObjectsCleans up and returns on its ownCleans up and returns on its ownStopper waits on the thread handles and joinsonly now can we call it stopped

Figure 4: The stop-event pattern. Using a manual-reset event for the stop signal means a single SetEvent wakes every waiting worker at once. Each thread decides for itself how it finishes, and stopping is considered complete only once the join has finished

There are three key points. Make the stop event manual-reset (so a single SetEvent is visible to every worker); put the stop event first in the wait array (so that if both are signaled at once, the stop takes priority); and the stopper must always join the thread handles before closing them.

Two caveats about scope. First, this “event plus drain-everything” pattern is for a single-worker configuration. However many times you call SetEvent on an auto-reset event, it can only express “there is one signaled state” (successive signals coalesce), so with multiple workers only one of them wakes and ends up serially working through the whole burst. If multiple workers share a queue, switch the work signal to a semaphore, incrementing the count with ReleaseSemaphore(hSem, 1, NULL) each time an item is queued. A successful semaphore wait consumes one count, giving the correct correspondence: exactly as many waiting workers wake, one at a time, as there are items queued (this use is squarely within a semaphore’s remit, the same as “limiting concurrent access to a resource pool” in the Figure 3 table). But when you switch to a semaphore, also change the consumer side so that one successful wait equals processing exactly one item from the queue. Leave the drain-everything loop from the sample above as is, and a single wait — which only consumes one permit — will empty the whole queue, throwing the accounting off: other workers wake to an empty queue on the leftover permits, and the producer’s ReleaseSemaphore starts failing from exceeding the maximum count. Keeping the correspondence “one permit equals one job” is the precondition for the semaphore approach. Second, route the stop path through the processing of a single item as well. If ProcessNextItem does a long blocking wait internally, either pass the stop event in there too and wait on both together, or attach a finite timeout. Checking only between items leaves a hole where “shutdown waits forever because one item never finishes”. This says exactly the same thing as StopAsync in the .NET edition and jthread plus join in the C++ edition.

A thread waiting on blocking I/O (a pipe, a socket, a serial port) cannot come back and check the event, so the I/O side needs its own design — either OVERLAPPED I/O combined with an event you wait on together, or waking the I/O with CancelIoEx (for a concrete serial-communication example, see “Serial Communication App Pitfalls”).

7. DllMain and the Loader Lock — a Minefield for DLL Authors

Shared components written in C often end up as DLLs, and DLLs come with a constraint of their own: the loader lock. The OS loader calls DllMain while holding the loader lock, so doing any of the following inside it becomes a source of deadlocks or crashes.10

  • Synchronizing with other threads (acquiring locks, waiting for a thread to finish)
  • Calling LoadLibrary / FreeLibrary, directly or indirectly
  • Creating threads (dangerous if it involves synchronization), or calling ExitThread

“Waiting inside DllMain for a worker thread to finish when the DLL is unloaded” looks perfectly reasonable but is a classic deadlock: the thread that’s ending tries to take the loader lock to deliver DLL_THREAD_DETACH, and the two sides end up waiting on each other. A DLL with threads of its own should expose explicit initialization and shutdown functions — something like MyLib_Init / MyLib_Shutdown — and do thread startup and joining there. The ideal DllMain is close to an empty stub.10

8. The C11 Threads Option — Where Things Stand

If you want to write portable C that doesn’t depend on Win32, the option is C11’s <threads.h> (thrd_create / mtx_lock / cnd_wait) and <stdatomic.h>. According to the official conformance table, MSVC’s support stands as follows: <threads.h> is supported from Visual Studio 2022 17.8 (requires /std:c11 and a matching Windows SDK), while <stdatomic.h> is still experimental, at the stage of requiring the /experimental:c11atomics option.11

If sharing code with Linux is a requirement, C11 threads (or a pthread wrapper) have real value, but for a Windows-only codebase, the Win32 approach this article covers has the advantage in the volume of available information, track record, and ease of debugging. Whichever you choose, the design principles covered so far — reducing sharing, the correspondence between locks and data, and cooperative shutdown — don’t change.

9. Verification and Debugging — Preparing for “It Doesn’t Reproduce”

You cannot expect testing to catch race bugs. Ordinary tests count a run where the race “just happened not to trigger” as a pass. Think about your defences in three layers.

The first line of defence is design. In review, confirm with a table: which mutable data is shared, which lock protects each piece (the correspondence from Section 5.1), whether the lock acquisition order is unambiguous, and whether the stop event reaches every worker. A design that cannot fill in this table isn’t finished yet, even if it currently works.

Second, make anomalies observable. Rather than waiting unconditionally with INFINITE, attach a timeout at key points and log the timeout when it fires, turning a hang that would otherwise last forever into a detectable failure. When a hang occurs in the field, capture a dump, check every thread’s stack, and look for a cycle in who’s waiting on whose lock. Checking with Application Verifier is officially recommended for errors around DLLs.10 Building out dumps and logging is covered in “Designing Windows Apps to Leave Logs and Dumps When They Crash”.

Third, shake things up with load. Stress tests — running with more threads than you have cores, randomizing processing order, inserting artificial delays — are a practical way to make it more likely you’ll hit a race “jackpot” on a development machine. Don’t forget to also test reproduction on an optimized release build under heavy load.

10. Summary — the C Edition Checklist

  1. Is every thread created with _beginthreadex (with no CreateThread / _beginthread mixed in)?
  2. Do you join thread handles (WaitForSingleObject) before calling CloseHandle?
  3. Are you mass-producing your own threads for short-lived jobs (could they be handed to the thread pool API instead)?
  4. Is process-internal exclusion using an SRW lock / CRITICAL_SECTION (rather than misusing a Mutex)?
  5. Are shared counters and flags updated with Interlocked functions rather than relying on volatile?
  6. Is any Sleep polling left over (has it been replaced with a condition variable or an event wait)?
  7. Is TerminateThread (forcibly killing another thread) absent everywhere? Do workers end via return from the thread function rather than calling ExitThread (so CRT cleanup runs correctly through _endthreadex)?
  8. Does every worker have a stop path via a stop event plus WaitForMultipleObjects, and can you also wake threads that are blocked on I/O?
  9. Is releasing locks and handles guaranteed on every return path (the goto cleanup discipline)?
  10. Does DllMain avoid creating threads, synchronizing, or waiting for threads to finish?

In exchange for having no help from the language, multithreading quality in C is exactly what your API choices and discipline make it. Make _beginthreadex, SRW locks, Interlocked functions, and the stop event your default four-piece set, and even in C you can design your way clear of “it occasionally locks up”.

KomuraSoft LLC handles multithreading design review for resident processes, equipment-control applications, and DLLs written in C; root-cause investigation (dump analysis) of hangs and crashes caused by TerminateThread or leaked locks; and technical consulting on adding threads to legacy C code.

References

  1. Microsoft Learn, CreateThread function. On threads within an executable that calls the CRT needing to be managed with _beginthreadex / _endthreadex rather than CreateThread / ExitThread, and on the CRT being able to terminate the process under low-memory conditions when a thread created with CreateThread calls the CRT.  2

  2. Microsoft Learn, Multithreading with C and Win32. On programs that call the CRT library needing to start threads with _beginthread / _beginthreadex rather than the Win32 CreateThread / ExitThread; on the _beginthread family initializing the CRT’s per-thread variables; and on SuspendThread being able to stop a thread while it is accessing internal CRT data structures, which can lead to deadlock.  2

  3. Microsoft Learn, About Synchronization. On the guidance for choosing Win32 synchronization primitives: SRW locks as the default for new code, being pointer-sized and normally staying in user mode; CRITICAL_SECTION for cases needing recursive acquisition; Mutex always being a kernel object, used for named cross-process synchronization and in combination with WaitForMultipleObjects; using a Mutex for process-internal synchronization being a “common mistake” that is far slower under frequent operations; and semaphores being used to limit concurrent access to a resource pool, events for notification.  2 3

  4. Microsoft Learn, Interlocked Variable Access. On Interlocked functions synchronizing access to a variable shared across multiple threads and performing the operation indivisibly; on InterlockedIncrement / Decrement bundling the read, add, and write-back into a single atomic operation, since without synchronization a simultaneous increment from two threads can lose one of the increments; on the InterlockedExchange / InterlockedCompareExchange family of functions; on being usable between threads in different processes when the variable is in shared memory; and on most Interlocked functions providing a full memory barrier, with Acquire / Release variants available to select ordering semantics.  2 3 4

  5. Microsoft Learn, Using Condition Variables. On the worked example of a producer/consumer queue implemented over a bounded circular buffer protected by a CRITICAL_SECTION; on the structure where InitializeConditionVariable creates a condition variable, the consumer waits with SleepConditionVariableCS, and the producer wakes it with WakeConditionVariable; and on condition variables being supported from Windows Vista onward.  2 3

  6. Microsoft Learn, TerminateThread function. On TerminateThread ending the target thread without letting it execute any user-mode code; on the target’s critical section not being released if it held one; on the heap lock not being released if the thread was allocating memory from the heap; on kernel32’s state or a DLL’s global state potentially being corrupted; and on it being “a dangerous function that should be used only in the most extreme cases”, not to be called unless you fully know and control every code path the target thread could be executing.  2

  7. Microsoft Learn, Warning C6258. On code analysis warning C6258 detecting use of TerminateThread; on TerminateThread being unable to perform proper thread cleanup; and on the correct termination procedure being shown as creating an event with CreateEvent, having each thread monitor the event’s state with WaitForSingleObject, and having the thread end its own execution once the event becomes signaled.  2 3

  8. Microsoft Learn, CreateThreadpoolWork function. On creating a work object with CreateThreadpoolWork and having a pool worker thread execute the callback each time SubmitThreadpoolWork is called; on being able to specify the execution environment via a callback environment (TP_CALLBACK_ENVIRON); and on availability from Windows Vista onward.  2

  9. Microsoft Learn, Thread Pools. On the thread pool being suited to applications that execute large numbers of short asynchronous jobs, or that frequently create short-lived threads; on the components of the new thread pool API redesigned in Vista; on the best practices of never ending a pool thread with TerminateThread or calling ExitThread from within a callback, cleaning up any state created in a callback before returning, and keeping wait handles alive until the pool has finished using them.  2

  10. Microsoft Learn, Dynamic-Link Library Best Practices. On DllMain being called while the loader lock is held, which places serious restrictions on which APIs can safely be called; on synchronizing with other threads inside DllMain leading to deadlock; on calling LoadLibrary being on the list of prohibited actions; on the pattern where waiting for a thread to finish inside DllMain during DLL unload deadlocks against that thread’s own attempt to acquire the loader lock to deliver DLL_THREAD_DETACH; on the ideal DllMain being close to an empty stub, with initialization deferred as much as possible; and on defining a lock hierarchy with the loader lock at the top.  2 3 4 5

  11. Microsoft Learn, Microsoft C/C++ language conformance by Visual Studio version. On the C standard library conformance table, showing C11 threads (threads.h) supported from Visual Studio 2022 17.8; stdatomic.h being treated as experimental (behind the /experimental:c11atomics option); and C11 / C17 compiler support requiring Visual Studio 2019 16.8 or later along with a matching Windows SDK.  2

  12. Microsoft Learn, Interlocked Variable Access. On a simple read or write of a properly aligned 32-bit variable being atomic, but the synchronization (ordering) of the access not being guaranteed; on a simple read or write of a 64-bit variable being atomic on 64-bit Windows but not guaranteed on 32-bit Windows; and on variables of other sizes not being guaranteed atomic on any platform.  2

  13. Microsoft Learn, _beginthread, _beginthreadex. On why _beginthreadex is safer than _beginthread: a thread created with _beginthread can leave the returned handle invalid (or pointing at a different thread) if it finishes early; the handle from _beginthreadex must be closed by the caller with CloseHandle and its validity is guaranteed; _beginthreadex lets you pass the handle to synchronization APIs; the thread function returns a thread exit code under the __stdcall calling convention; and linking against the multithreaded CRT is required. 

Recent articles sharing the same tags. Deepen your understanding with closely related topics.

These topic pages place the article in a broader service and decision context.

This article connects naturally to the following service pages.

Frequently Asked Questions

Common questions about the topic of this article.

Should I use CreateThread or _beginthreadex?
Use _beginthreadex for any thread that calls functions in the C runtime library (CRT). _beginthreadex initializes the internal per-thread data the CRT needs before starting the thread. The official documentation states plainly that if a thread created with CreateThread calls a CRT function, the CRT can terminate the process when memory is low. In practice, threads in a C application almost always call a CRT function somewhere (printf, malloc, strtok, and so on), so it does no harm to remember the rule as "always _beginthreadex". Also avoid _beginthread (without the ex) — it has a trap where the returned handle can become invalid if the created thread finishes early, so _beginthreadex, whose handle can be passed to synchronization APIs, is the one to choose.
Am I not allowed to stop a thread with TerminateThread?
No, you shouldn't. TerminateThread wipes out the target thread without letting it execute any user-mode code at all, so if that thread was holding a critical section it never gets released, if it was in the middle of allocating memory from the heap the heap lock stays held, and if it was manipulating a DLL's global state that state gets corrupted. The official documentation states explicitly that it is "a dangerous function that should only be used in the most extreme cases", and code analysis also flags it as warning C6258. The correct way to stop is cooperative shutdown: create a stop event, have each thread watch that event with WaitForSingleObject / WaitForMultipleObjects, and let each thread clean up after itself and end on its own.
I was using a Mutex for exclusion within a process. What's wrong with that?
It works, but it costs you a lot of performance. A Win32 Mutex is always a kernel object, so every acquire and release triggers a transition into kernel mode. For exclusion within a single process, an SRW lock or a CRITICAL_SECTION — which stays in user mode and only falls back to a kernel wait under contention — is dramatically faster, and the official documentation explicitly calls using a Mutex for process-internal synchronization a "common mistake". A Mutex earns its keep when you need exclusion across processes as a named object, or when you want to wait on it alongside other kernel objects with WaitForMultipleObjects.
Can I use C11's threads.h and stdatomic.h on Windows?
In MSVC, C11 threads (threads.h) have been supported since Visual Studio 2022 17.8 (requires /std:c11 and a matching Windows SDK). stdatomic.h, on the other hand, is still treated as experimental, requiring the /experimental:c11atomics option (per the official conformance table as of August 2026). It's a viable option if portability is your top priority, but for a Windows-only codebase, writing to the Win32 API (_beginthreadex, SRW locks, condition variables, Interlocked functions) is the realistic choice given the track record and volume of information available.
Does adding volatile make a shared flag safe?
No, it doesn't. C's volatile only suppresses compiler optimizations such as caching a value in a register — it guarantees neither the atomicity of an operation nor memory ordering across processors. A simple read or write of a properly aligned 32-bit variable is itself atomic on Windows, but "read, add, and write back" gets split into separate steps, and there's no guarantee about its ordering relative to surrounding memory operations either. Use the Interlocked family of functions to update a shared counter or flag. Most Interlocked functions carry a full memory barrier, so you get an ordering guarantee at the same time. When you need to protect several variables together, use an SRW lock or a CRITICAL_SECTION.

Author Profile

Profile page for the article author.

Go Komura

Representative of KomuraSoft LLC

Focused on Windows software development, technical consulting, and investigations into failures that are difficult to reproduce.

Back to the Blog