Practical Multithreading Best Practices: C Edition — Writing Safely the Win32 API Way
· Updated: · Go Komura · Windows, Multithreading, C, Win32 API, Business Applications, Bug Investigation, Design
Revision history (first version, published Aug 2, 2026)
- First published
Cite this article(DOI (registered archive): 10.5281/zenodo.22170849)
The DOIs below refer to previously archived versions and may not match the current text. Use this page’s URL to reference the current text.
Go Komura (2026). Practical Multithreading Best Practices: C Edition — Writing Safely the Win32 API Way. KomuraSoft LLC. https://comcomponent.com/en/blog/multithreading-best-practices-c/
- DOI (registered archive)
- 10.5281/zenodo.22170849
- DOI (last registered version)
- 10.5281/zenodo.22170850
“A resident process written in C hangs only when it shuts down.” “We want to add threads to an old equipment-control application.” “We stop threads with TerminateThread, but every so often the whole process stops responding.” When multithreading is written in C, the problem is less about running work in parallel than about who protects the shared data, how threads wait, and how they end.
C has no mechanism like C++’s RAII that releases a lock or a handle automatically when a scope is exited. With no help from exceptions or templates either, choosing the right APIs is not enough: the discipline of acquiring, releasing, and waiting for exit has to be built into the structure of the code.
This article is the C edition of the practical multithreading series. Aimed at developers who use C and the Win32 API, it maps the principles of multithreading design onto concrete APIs: avoid mass-producing threads, reduce shared mutable state, set lock discipline, and design how to stop first.
It covers creating threads with _beginthreadex, choosing synchronization objects, cooperative stopping that does not rely on TerminateThread, and the constraints of DllMain. The information is current as of August 2026. The article can be read on its own, but the same principles are worked out for other languages in the “.NET Edition”, the “C++ Edition”, and the “Java Edition”.
Start From Your Problem
| Your problem | What to check first | Where to read |
|---|---|---|
| I want to add threads / run a large number of short jobs | When to use your own threads versus the thread pool | Creating threads |
| Counters come out wrong / shared data gets corrupted | Designs that reduce sharing, and matching locks and atomic operations to data | Reducing shared state, Choosing synchronization |
| Adding a lock made it hang | What the lock protects, what runs while it is held, and the acquisition order | Lock discipline |
| It hangs at shutdown / we use forced termination | Separating the stop request from confirming exit, and whether a waiting thread can still stop | Cooperative stopping |
| Workers are woken by an event, but the work is unevenly distributed | The difference between single-worker and multi-worker notification | Scope of the stop sample |
| It hangs when the DLL unloads | Whether threads are started, stopped, and joined outside DllMain | DllMain constraints |
| I want to share code with Linux / I cannot reproduce a bug | Portability requirements, plus design review, logging, and load testing | The C11 option, Verification and debugging |
If this is your first such design, cover the C prerequisites in Chapter 2, decide the units of execution and the shared data in Chapters 3 to 5, and check the stop path in Chapter 6. For reviewing existing code, the checklist at the end also works.
1. The Bottom Line First
Choose Where Work Runs According to the Nature of the Work
Threads of your own that call the CRT are created with _beginthreadex. If a thread created with CreateThread calls the CRT, the CRT can terminate the process when memory is low. To parallelize a large number of short jobs, use CreateThreadpoolWork from the Windows thread pool instead of creating your own threads over and over.123
Never end a pool thread with ExitThread or TerminateThread. A pool thread is an execution slot borrowed as a callback: clean up and hand it back.4
Separate Protecting Shared Data From Waiting
Within a process, the default lock is an SRW lock; choose a CRITICAL_SECTION when recursive acquisition is needed. Using a Mutex for frequent in-process exclusion pays the cost of kernel transitions.5
Update single variables with the Interlocked family of functions. Adding volatile alone does not secure atomicity or the necessary synchronization. Most Interlocked functions carry a full memory barrier. For waiting, use a condition variable, or an event with a wait function, so that Sleep polling does not waste CPU and responsiveness.67
Decide How to Stop and the Order of Release Before Starting
Do not use TerminateThread; design cooperative stopping with a stop event and WaitForMultipleObjects. Forced termination can corrupt the state of locks, the heap, and DLLs, and it is the target of code analysis warning C6258. Do not move on to releasing resources just because a stop was requested; close handles only after confirming that the thread has ended.89
Do not create threads, synchronize, or wait for threads to finish in DllMain. Provide explicit initialization and shutdown functions outside the loader lock.10
If portability is required, C11 is also an option. As of August 2026, <threads.h> is usable from VS 2022 17.8 onward and <stdatomic.h> is experimental. For Windows-only code, the Win32 API approach in this article is the baseline.11
In the diagram a solid line marks a relation that always holds and a dashed line marks a conditional one (the conditions are given per relation on the detail page). The full list of relations (23 in total, with evidence and certainty) and the definitions of the main concepts are collected on the knowledge map detail page (in Japanese). Data: JSON-LD / Turtle
2. Why Multithreading Is Hard — Race Conditions and Deadlocks
The first things to grasp are the race condition, where the result depends on the execution order, and the deadlock, where a cycle of waits stops all progress. Distinguish these two before learning the APIs.
A race condition is a bug in which the result changes depending on the order in which multiple threads reach a particular piece of code.
Take count++ on a shared counter. Even though it is a single expression, without synchronization there is no guarantee that “read, add, write back” is executed indivisibly. If two threads read the same value, each adds to it, and each writes it back, one of the additions is lost.6 Figure 1 shows an execution order in which the counter was supposed to be incremented twice from 10 and yet ends up at 11.
sequenceDiagram
participant A as Thread A
participant M as Shared variable count
participant B as Thread B
Note over M: count = 10
A->>M: Read (10)
B->>M: Read (10)
A->>A: Add locally (11)
B->>B: Add locally (11)
A->>M: Write back (11)
B->>M: Write back (11)
Note over M: count = 11 after two increments<br/>Thread A's increment was lost
Figure 1: The classic race condition in which a shared counter loses an increment. If another thread cuts in between the three steps of count++, whichever writes back later overwrites the other
A deadlock is a state in which two threads each wait for the lock the other holds, and neither can move forward. Thread A holds lock 1 and waits for lock 2, thread B holds lock 2 and waits for lock 1 — that alone stops both forever.
flowchart LR
A["Thread A<br/>holding lock 1"] -->|"waiting for lock 2 to be released"| B["Thread B<br/>holding lock 2"]
B -->|"waiting for lock 1 to be released"| A
Figure 2: The circular wait of a deadlock. The moment the wait arrows form a ring, every thread in the ring stops forever
These defects depend on timing. An execution order that rarely appears on a development machine can appear repeatedly at a customer site with a different core count and load. The reason a bug stops reproducing when a debugger is attached is that observation changes the timing.
So every principle that follows starts from “reduce the places that need synchronization” before “synchronize correctly”.
2.1. What C Assumes — The Language Protects You From Nothing
In C, because the language has no mechanism to enforce these principles, they must be written down explicitly as discipline.
Design Release to Cover Every Function Exit
There is no equivalent of C++’s RAII, so releasing locks and calling CloseHandle on handles is something you guarantee yourself. Use the goto cleanup pattern that funnels every exit of a function through one place, or a convention of writing each acquire paired with its release. The point is a structure in which adding an early return later cannot skip the release.
Even When Simple Reads and Writes Are Atomic, Synchronization Is Still Needed
The need to avoid data races is the same as in C++. On Windows, a simple read or write of a properly aligned 32-bit variable is atomic, but that alone guarantees neither synchronization of the access nor the ordering of surrounding memory operations. A 64-bit variable on 32-bit Windows, a compound read-and-add operation, and consistency across multiple variables cannot be treated like a simple read or write either.12
Do not use “it happens to work” as evidence; state the necessary synchronization explicitly with a lock or Interlocked. This discipline is still needed when the compiler or the optimization level changes.
Write Down Ownership Up to the Point of Handoff
State in the function comment “which thread writes this buffer” and “from when it belongs to whom”. Ownership discipline is as important as the choice of synchronization primitive. The partitioning and the queues described later can be used safely only once this boundary is decided.
3. Creating Threads — _beginthreadex, and Nothing Else
3.1. Why CreateThread Is Wrong
Win32’s native API is CreateThread, but the official guidance is that a thread that calls CRT (C runtime) functions is created with _beginthreadex. _beginthreadex initializes the internal per-thread data the CRT uses before starting execution.2
If a thread created with CreateThread calls a CRT function, the CRT can terminate the process when memory is low.1 printf, malloc, and strtok are CRT functions too, so in practice the baseline rule can be “threads of your own written in C use _beginthreadex”.
The Creator Is Responsible Until the Handle Has Been Waited On and Closed
Avoid _beginthread (without the ex) as well. If the thread ends early, the returned handle becomes invalid and may refer to a different thread. Choose _beginthreadex, whose handle can be passed to the synchronization APIs, and have the caller wait for exit and then CloseHandle.13
The next excerpt shows the flow of creation and joining. It assumes the application provides the definition of WorkerContext, the initialization of ctx, and the failure handling; it is not a complete program that compiles on its own. If creation fails, do not proceed to the wait, and when it succeeds keep the ctx the worker uses valid until exit has been confirmed.
#include <process.h>
static unsigned __stdcall WorkerMain(void* arg)
{
WorkerContext* ctx = (WorkerContext*)arg;
/* ... the wait loop from Chapters 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
When you want to “submit a large number of small jobs” or are “creating and destroying short-lived threads over and over”, use the Windows thread pool. In the API available from Windows Vista onward, create a work object with CreateThreadpoolWork and submit it with SubmitThreadpoolWork. The pool’s workers run the callback, so managing the thread count is left to the OS and there is no need to create and destroy a thread of your own for each job.3
Restore the Borrowed Thread’s State Before Returning
The official rules are: do not end a pool thread with TerminateThread / ExitThread; restore anything the callback changed, such as TLS or the thread priority, before returning; and keep wait handles valid until the pool has finished using them.4
Managing the Thread Count Alone Does Not Limit Submissions
Even if the pool manages the number of threads, that does not prevent a design in which unexecuted callbacks pile up without bound. In a resident process where submissions keep outpacing processing, add an admission limit such as a semaphore, or a bounded queue, on the application side.
Also decide whether the submitting side waits or the submission is rejected when the queue is full. This is backpressure that keeps overload from being converted into memory consumption, and it is the same idea as the bounded buffer in Chapter 5.
4. Minimize Shared Mutable State — Partition, Read-Only, Handoff
Before choosing a synchronization primitive, ask whether the places where multiple threads touch the same mutable data can be reduced. There are three means: partition, make it read-only, and hand it off through a queue.
Partition per Thread, Merge at the End
In parallel aggregation, rather than having each thread write to a shared counter every time, build subtotals in local variables or dedicated buffers. Merging just once at the end with something like InterlockedAdd reduces writes to shared state from “every iteration” to “once per thread”.
This reduces both the synchronization cost and the opportunities for contention. The decision from Section 2.1, “which thread owns this buffer”, becomes the partitioning design as it stands.
Make It Read-Only Once Initialization Is Complete
Configuration and tables that are built at startup and never modified afterward can be read from multiple threads once initialization is complete. Do not leave the boundary vague: finish initialization before any thread starts, or, for lazy initialization, use InitOnceExecuteOnce.5
What matters is not “meant to be read-only” but making when initialization ends and from when nothing is modified explicit in the code.
Turn a Shared Variable Touched From Both Sides Into a Queue Handoff
Route the flow of data between threads through a producer/consumer queue rather than operating on a shared variable from both sides. For C and Win32 there is an official implementation example that combines a bounded circular buffer with SleepConditionVariableCS.7
Capping the capacity also gives natural backpressure: the producer waits when production outruns consumption.
5. Choosing Synchronization Objects and Lock Discipline
Win32 has many kinds of synchronization primitives, and the wrong choice costs both performance and correctness. The official guidance is summarized in one diagram.5
flowchart TB
S{"Synchronize<br/>across processes?"} -->|"Yes"| Q2{"For what?"}
Q2 -->|"Mutual exclusion"| MTX["Named Mutex"]
Q2 -->|"Limiting concurrent access"| SEM["Named semaphore"]
Q2 -->|"Notifying an event"| EVT["Named event"]
S -->|"No (in-process)"| Q3{"Recursive acquisition<br/>by the same thread needed?"}
Q3 -->|"Yes"| CS["CRITICAL_SECTION"]
Q3 -->|"No"| Q4{"Portability-focused<br/>C++ code?"}
Q4 -->|"Yes"| STD["std::mutex /<br/>std::shared_mutex"]
Q4 -->|"No"| SRW["SRW lock (the default choice)"]
Figure 3: How to choose a Win32 synchronization primitive. The first branch is “does it cross processes”, and the key point is not to choose a kernel object (Mutex) when it does not
| Primitive | Scope | Characteristics | Where to use it |
|---|---|---|---|
| SRW lock | In-process | Fast (normally completes in user mode), pointer-sized, non-recursive | The default for new code. AcquireSRWLockShared also allows shared reads |
| CRITICAL_SECTION | In-process | Fast (spins, then waits in the kernel), recursive | When the same thread needs recursive acquisition |
| Mutex | In-process / cross-process | Always a kernel object, slow | Cross-process exclusion (named), and use together with WaitForMultipleObjects |
| Semaphore | In-process / cross-process | Kernel object | Limiting concurrent access to a resource pool |
| Event | In-process / cross-process | Kernel object | Notifying that “something happened” (not for protecting data) |
| Interlocked functions | In-process (cross-process too, over shared memory) | Lock-free atomic operations | Counters, flags, pointer swaps6 |
Kernel Objects Are Not Only for Cross-Process Use
What Figure 3 shows is the point that you do not choose a Mutex for an in-process lock without a reason. Events can be used for in-process notification, and semaphores for limiting concurrency. The stop event in Chapter 6 is also an unnamed kernel object.
Nor does the absence of a name necessarily confine an object to one process. Through handle inheritance or duplication with DuplicateHandle, the same object can be used from multiple processes. Naming is one representative means of letting another process reopen the same object.
Think About Interlocked in Terms of Operation, Alignment, and Lifetime Separately
Make the Operation on a Single Variable Indivisible
InterlockedIncrement / InterlockedExchange / InterlockedCompareExchange perform an operation on a single variable indivisibly. They are the counterpart of the Interlocked class in the .NET Edition and std::atomic in the C++ Edition, and most of the functions also carry a full memory barrier.6
Do not assume that adding volatile alone gives atomicity or the necessary synchronization. To keep several variables consistent together, protect them with an SRW lock or a CRITICAL_SECTION.
Respect the Alignment of the Target Variable
The target of an Interlocked function must be aligned on its natural boundary: a 4-byte boundary for a 32-bit value, an 8-byte boundary for a 64-bit value. Behavior on an unaligned target is unpredictable.12
Do not target a field of a #pragma pack-ed struct or of a buffer mapped directly onto a wire format; declare counters and flags as ordinary variables that the compiler aligns properly.
Swapping a Pointer Does Not Protect the Old Data’s Lifetime
What InterlockedExchangePointer and similar functions make indivisible is the pointer swap itself. If the writer swaps the pointer and calls free on the old block right after a reader has obtained the old pointer, the reader accesses freed memory.
Swapping and reclaiming are separate problems. A procedure is needed, such as a lock or safe reference counting, that does not reclaim the old block until the readers are done with it. When in doubt, protect it with an SRW lock.
Condition Variables: Check the Condition After Waking
A bounded-buffer producer/consumer queue uses a condition variable. Initialize it with InitializeConditionVariable; the consumer waits with SleepConditionVariableCS combined with a CRITICAL_SECTION, and the producer wakes it with WakeConditionVariable. That is the form of the official implementation example.7 When combining with an SRW lock, use SleepConditionVariableSRW.
What matters is a loop that re-checks the condition inside the lock after waking and waits again if the condition is false. There are spurious wakeups that occur without any notification, and by the time a thread wakes another consumer may already have taken the item. “Woken” and “the queue is not empty” are not the same thing.
This is the tool for building in C the same structure as the channel in Section 4.3 of the .NET Edition and the BlockingQueue in Chapter 4 of the C++ Edition.
5.1. Lock Discipline — Three Principles That Hold Whichever Primitive You Choose
Choosing the primitive correctly does not prevent races unless there is discipline in how it is used.
1. Fix the Correspondence Between Data and Lock
Assign one SRW lock or CRITICAL_SECTION to each set of mutable data you protect. Take that same lock at every place that touches the data.
In C, the practice of writing “this struct is protected by g_lockFoo” in the header is especially useful. In review, check this correspondence as a table.
2. Do Not Run Slow Work or External Calls While Holding a Lock
Restrict the inside of a lock to reading and writing the data it protects. File I/O, network communication, and callback invocations while still holding it extend the hold time. If the callee acquires another lock, it can also create the circular wait of Figure 2.
3. Fix the Acquisition Order for Multiple Locks
When two or more locks are taken, define a lock hierarchy so that every thread follows the same order. The DLL best-practices document also explains that reversing the acquisition order invites deadlock and that the hierarchy must be followed consistently.10
6. Designing How to Stop — Do Not Use TerminateThread
6.1. What TerminateThread Breaks
TerminateThread ends the target thread without letting it run any user-mode cleanup. The state it was holding is not necessarily cleaned up safely.8
| When the thread was terminated | What can happen |
|---|---|
| While holding a critical section | The lock is never released, and other threads keep waiting |
| While allocating memory from the heap | The heap lock remains held, and subsequent memory allocations stop |
| While manipulating a DLL’s global state | The DLL’s internal state is corrupted |
Officially it is positioned as “a dangerous function that should only be used in the most extreme cases”, and code analysis also detects it as warning C6258.89
Finding TerminateThread while investigating the cause of an app that “occasionally hangs as a whole process” is a genuinely common sight in practice. If you find it, it is something to repair.
6.2. The Correct Form: A Stop Event Plus WaitForMultipleObjects
In cooperative stopping, the stopping side does not erase the thread; it requests a stop and lets the worker itself clean up and exit. The established form is to create one manual-reset stop event and have the worker wait for the work signal and the stop signal at the same time. The official C6258 material also describes watching an event and ending on one’s own.9
Treat stop request → worker cleanup → confirmation of thread exit → release of handles and shared resources as separate stages. Setting the stop event to the signaled state does not mean the thread has ended.
The next code is also an explanatory excerpt. Creating the events and checking for failure, the mutual exclusion of the queue, and the implementations of ProcessNextItem, LogLastError, and Cleanup are omitted. The events and the queue are not destroyed while waiting or processing, and read it on the premise of work notification for a single worker, which is explained below.
HANDLE hStopEvent; /* CreateEvent(NULL, TRUE, FALSE, NULL): manual-reset */
HANDLE hWorkEvent; /* CreateEvent(NULL, FALSE, FALSE, NULL): auto-reset.
Returns to non-signaled automatically on receipt (with manual
reset, once signaled the wait would pass straight through and
spin in a busy loop over 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 alone, 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 */
/* Pass the stop event into ProcessNextItem too: if it waits a long time
inside one item and cannot observe the stop there, shutdown is held hostage by that item */
while (ProcessNextItem(hStopEvent)) { /* Process one item from the queue. FALSE if empty */
/* Check for the stop request while draining too. Skip this and you
cannot 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 on your own */
}
BOOL StopWorkers(HANDLE* threads, DWORD count)
{
BOOL ok = TRUE;
if (!SetEvent(hStopEvent)) { /* If the stop request did not get through, */
LogLastError(); /* do not enter an unbounded join */
return FALSE;
}
/* The stop request is now raised for everyone at once */
for (DWORD i = 0; i < count; i++) {
if (WaitForSingleObject(threads[i], INFINITE) == WAIT_OBJECT_0) {
CloseHandle(threads[i]); /* Close only the handles whose join was confirmed */
} else {
LogLastError(); /* WAIT_FAILED: e.g. an invalid handle */
ok = FALSE; /* Do not report "everyone stopped" */
}
}
return ok; /* If FALSE, do not proceed to release shared resources */
}
Confirm That the Wait Succeeded Before Moving On From the Join
StopWorkers first checks that SetEvent succeeded. If the stop request could not be delivered, it must not proceed to an unbounded join. For each thread, too, it closes only the handles for which WaitForSingleObject returned WAIT_OBJECT_0, and if a wait fails it does not report “everyone has stopped”. If the return value is FALSE, do not proceed to release shared resources.
The reason the exit wait is done one thread at a time is that WaitForMultipleObjects has an upper limit of MAXIMUM_WAIT_OBJECTS (64) on the number of handles it can wait on at once. With an array beyond that limit, the wait can return WAIT_FAILED. If all you need is to wait for everyone to finish, a loop that waits one at a time has no such constraint on the array length.
flowchart TB
OWNER["Stopper"] -->|"SetEvent(hStopEvent)"| SE["Stop event<br/>(manual-reset, visible to everyone)"]
SE --> W1["Worker 1<br/>waits for stop and work at once<br/>with WaitForMultipleObjects"]
SE --> W2["Worker 2<br/>waits for stop and work at once<br/>with WaitForMultipleObjects"]
W1 --> C1["Cleans up and returns on its own"]
W2 --> C2["Cleans up and returns on its own"]
C1 --> J["Stopper waits on the thread handles and joins<br/>only now can it be called stopped"]
C2 --> J
Figure 4: The stop-event pattern. Using a manual-reset event for stopping means one SetEvent wakes every waiting worker at once. Each thread decides how it ends, and completion of the join is what counts as stopped
Keep the Stop Event’s Settings and Order
Make the stop event manual-reset, so that one SetEvent is visible to every worker. Its role differs from that of work notification.
Also, put the stop event first in the WaitForMultipleObjects wait array. In a wait with bWaitAll set to FALSE, as in this sample, when several objects are signaled the handle with the lower index takes priority. Finally, the stopping side closes a thread handle only after confirming the join on it.
Single Worker: Wake With an Event and Drain the Queue
The form above, “wake with an auto-reset event and drain the whole queue”, is for a single-worker configuration. An auto-reset event does not count the number of jobs no matter how many times SetEvent is called. There is only one signaled state, so consecutive signals merge.
If this form is used as it is with multiple workers, only one of them may wake and process a batch of work serially.
Multiple Workers: Match One Semaphore Permit to One Job
If several workers share the queue, switch the work signal to a semaphore. The producer increases the count with ReleaseSemaphore(hSem, 1, NULL) for each item queued, and the consumer takes exactly one item per successful wait. Because a successful wait consumes one permit, the correspondence with the item count is preserved.
In this case, do not keep the sample’s drain-everything loop. If a worker empties the queue while consuming only one permit, another worker wakes on the leftover permit to an empty queue, or the producer’s ReleaseSemaphore fails by exceeding the maximum count. “One permit = one job” is the premise.
Make the Stop Observable Even When One Item Takes a Long Time
Checking for the stop only between items is not enough. If ProcessNextItem does a long blocking wait internally, pass the stop event in there too and wait on both together, or set a finite timeout.
Otherwise shutdown waits indefinitely because one item never finishes. StopAsync in the .NET Edition and jthread with join in the C++ Edition follow the same idea.
Provide a Stop Path on the I/O Side for Blocking I/O
A thread waiting on pipe, socket, or serial port I/O cannot come back to check the stop event as it is. The I/O side also needs a path that ends the wait, including waiting on OVERLAPPED together with an event, and canceling the I/O with CancelIoEx.
A concrete example is covered in “Serial Communication App Pitfalls”.
7. DllMain and the Loader Lock — A Minefield When Writing DLLs
Shared components written in C often become DLLs, and DLLs carry 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 causes deadlocks or crashes.10
- Synchronizing with other threads (acquiring locks, waiting for threads to finish)
- Calling
LoadLibrary/FreeLibrary, directly or indirectly - Creating threads (dangerous when synchronization is involved) or calling
ExitThread
Provide an Explicit Shutdown Function Outside DllMain
“Wait for the workers to finish in DllMain when the DLL unloads” looks right at first sight but is a classic deadlock. The exiting thread also needs the loader lock to deliver DLL_THREAD_DETACH, so it and DllMain wait on each other.10
A DLL that owns threads exposes initialization and shutdown functions such as MyLib_Init / MyLib_Shutdown and starts and joins its threads outside DllMain. The caller unloads the DLL after confirming the stop through the shutdown function. DllMain itself is designed as a stub that is as close to empty as possible.10
8. The C11 Threads Option — Where Things Stand
To share code with other operating systems without depending on Win32, C11’s <threads.h> and <stdatomic.h> are an option. Looking at MSVC support separately, as of August 2026, the picture is as follows.11
| Feature | Status in MSVC | Conditions to check |
|---|---|---|
<threads.h> (thrd_create / mtx_lock / cnd_wait) |
Supported in Visual Studio 2022 17.8 | /std:c11 and a matching Windows SDK |
<stdatomic.h> |
experimental | The /experimental:c11atomics option |
It is important not to assume that threads and atomic operations are at the same stage of support.
If sharing code with Linux is a requirement, C11 threads (or a pthread wrapper) have value, but for a Windows-only codebase the Win32 approach in this article has the advantage in the volume of information, the track record, and the ease of debugging. Whichever you choose, the design principles so far (reduce sharing, match locks to data, stop cooperatively) do not change.
9. Verification and Debugging — Prepare on the Assumption That It Will Not Reproduce
Even when ordinary tests pass, you cannot say there is no race, because the possibility remains that “the problematic execution order just did not happen”. Prepare in three layers: check the design, make anomalies observable, and shake the execution order with load.
Design Review: Match Data, Locks, and Stop Paths
Check, as a table, the shared mutable data, the locks that protect it, the acquisition order for multiple locks, and the workers the stop event reaches. This is the work of seeing whether the discipline of Section 5.1 corresponds to the actual code.
If this correspondence cannot be written down, “it works” cannot be the basis for calling the design complete.
Observation: Record Where Threads Wait With Timeouts, Logs, and Dumps
Rather than making every wait an unconditional INFINITE, set timeouts at key points and log it when time runs out. That turns a state that only kept waiting into a failure that can be investigated. A timeout, however, is not confirmation that a thread has exited, and it alone does not make it safe to release shared resources.
On a hang, capture a dump, look at every thread’s stack, and check whether the lock waits form a cycle. For mistakes around DLLs, also use Application Verifier, which is officially recommended.10 For setting up logs and dumps, see “Designing Windows Apps to Leave Logs and Dumps When They Crash”.
Load Testing: Try Execution Orders That Rarely Appear on a Development Machine
Stress tests such as running for a long time with more threads than cores, randomizing the processing order, and inserting artificial delays increase the chances that a defect appears. Also check the combination of an optimized release build and high load.
Load testing is not a substitute for design review. Combine the three layers to get closer to a state where the cause can be traced when a bug reproduces.
10. Summary — The C Edition Checklist
- Is every thread created with
_beginthreadex(with noCreateThread/_beginthreadmixed in)? - Are thread handles joined (
WaitForSingleObject) beforeCloseHandle? - Are you mass-producing threads of your own for short-lived jobs (could they be submitted to the thread pool API)?
- Is in-process exclusion done with an SRW lock / CRITICAL_SECTION (rather than misusing a Mutex)?
- Are shared counters and flags updated with Interlocked functions rather than relying on
volatile? - Is any
Sleeppolling left (has it been replaced with a condition variable or an event wait)? - Is
TerminateThread(forced termination of another thread) absent everywhere? Do workers end with areturnfrom the thread function rather than a call toExitThread(so that CRT cleanup runs correctly through_endthreadex)? - Does every worker have a stop path of a stop event plus
WaitForMultipleObjects, and can threads blocked in I/O be woken too? - Is the release of locks and handles guaranteed on every return path (the
goto cleanupdiscipline)? - Does
DllMainavoid creating threads, synchronizing, and waiting for threads to finish?
In place of help from the language, in C multithreading the choice of APIs and the discipline become the quality as they are. _beginthreadex, SRW locks, Interlocked, and the stop event — make this set of four the default, and even in C you can design your way clear of “it occasionally hangs”.
Related Articles
- Practical Multithreading Best Practices: .NET Edition
- Practical Multithreading Best Practices: C++ Edition
- Practical Multithreading Best Practices: Java Edition
- Shared Memory Pitfalls and Practical Best Practices
- Why You Should Prefer Event Waits over Sleep(1) on Windows
- Serial Communication App Pitfalls - Through Reconnection and Log Design
- Designing Windows Apps to Leave Logs and Dumps When They Crash
Related Consulting Areas
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.
- Technical Consulting and Design Review
- Bug Investigation and Root-Cause Analysis
- Windows Application Development
- Contact Us
References
-
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
-
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
-
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 with a callback environment (TP_CALLBACK_ENVIRON); and on availability from Windows Vista onward. ↩ ↩2
-
Microsoft Learn, Thread Pools. On the thread pool being suited to applications that execute large numbers of short jobs asynchronously or that frequently create short-lived threads; on the components of the new thread pool API redesigned in Vista; and on the best practices of never ending a pool thread with TerminateThread or calling ExitThread from 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
-
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 completing in user mode; CRITICAL_SECTION for cases needing recursive acquisition; Mutex always being a kernel object, used for named cross-process synchronization and together with WaitForMultipleObjects; using a Mutex for in-process synchronization being a “common mistake” that is far slower under frequent operations; and semaphores being used to limit concurrent access to a resource pool and events for notification. ↩ ↩2 ↩3
-
Microsoft Learn, Interlocked Variable Access. On Interlocked functions synchronizing access to a variable shared by 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
-
Microsoft Learn, Using Condition Variables. On the implementation example of a producer/consumer queue using a bounded circular buffer protected by a CRITICAL_SECTION; on the structure in which InitializeConditionVariable creates the condition variable, the consumer waits with SleepConditionVariableCS, and the other side is woken with WakeConditionVariable; and on condition variables being supported from Windows Vista onward. ↩ ↩2 ↩3
-
Microsoft Learn, TerminateThread function. On TerminateThread ending the target thread without letting it execute any user-mode code; on the critical section not being released if the target held one; on the heap lock not being released if the thread was allocating memory from the heap; on the state of kernel32 or a DLL’s global state potentially being corrupted; and on it being “a dangerous function that should only be used in the most extreme cases”, not to be called unless you know exactly and control what code the target thread could be running. ↩ ↩2 ↩3
-
Microsoft Learn, Warning C6258. On code analysis warning C6258 detecting the use of TerminateThread; on TerminateThread not allowing 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
-
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 be called; on synchronizing with other threads inside DllMain leading to deadlock; on calling LoadLibrary being prohibited; on the pattern in which waiting for a thread to finish inside DllMain during DLL unload deadlocks against that thread’s delivery of 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 ↩6
-
Microsoft Learn, Microsoft C/C++ language conformance by Visual Studio version. On the C standard library conformance table showing C11 threads (threads.h) supported in Visual Studio 2022 17.8; on stdatomic.h being treated as experimental (the /experimental:c11atomics option); and on C11 / C17 compiler support requiring Visual Studio 2019 16.8 or later along with a matching Windows SDK. ↩ ↩2
-
Microsoft Learn, Interlocked Variable Access. On a simple read or write of a properly aligned 32-bit variable being atomic while the synchronization (ordering) of the access is not 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
-
Microsoft Learn, _beginthread, _beginthreadex. On why _beginthreadex is safer than _beginthread: a thread created with _beginthread can leave the returned handle invalid, possibly referring to a different thread, if it ends 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. ↩
Related Articles
Recent articles sharing the same tags. Deepen your understanding with closely related topics.
Practical Multithreading Best Practices: C++ Edition — Designing Failures Out with RAII and jthread
C++ data races are undefined behavior. Learn the std::thread destructor trap, stopping with jthread and stop_token, scoped_lock, atomic's...
Practical Multithreading Best Practices: .NET Edition — What to Decide Before You Add More Threads
Keep .NET/C# threads from crashing or hanging. Ride on Task, cut shared mutable state, lock with discipline, stop with CancellationToken,...
Apps That Break on Resume from Sleep — How Power Events Work and How to Build Business Apps That Survive Resume
Why business apps break after a laptop resumes from sleep: WM_POWERBROADCAST notifications, Modern Standby, reconnect design, sleep suppr...
DllMain and the Loader Lock — The Real Reason You Are Told to "Do Nothing in DLL Initialization"
Why DllMain must not call LoadLibrary or wait on threads: the loader lock serializes DLL notifications, typical deadlocks, deferred initi...
What "Not Responding" Really Is — How Windows Decides an App Has Hung, and How to Design Apps That Don't
Windows marks a window Not Responding after 5 seconds without message retrieval and shows a ghost window: the check, hang causes, UI-thre...
Related Topics
These topic pages place the article in a broader service and decision context.
Windows Technical Topics
Topic hub for KomuraSoft LLC's Windows development, investigation, and legacy-asset articles.
Bug Investigation & Long-Run Failures
Topic page for intermittent failures, communication diagnosis, long-run crashes, and failure-path test foundations.
Where This Topic Connects
This article connects naturally to the following service pages.
Windows App Development
We support Windows desktop applications that involve resident processing, device integration, operational logging, and maintainable structure.
Bug Investigation & Root Cause Analysis
We investigate difficult production issues such as intermittent failures, long-run crashes, leaks, and communication stoppages.
Frequently Asked Questions
Common questions about the topic of this article.
- Should I use CreateThread or _beginthreadex?
- For a thread that calls C runtime library (CRT) functions, _beginthreadex. _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, a thread in an application written in C almost certainly calls a CRT function somewhere (printf, malloc, strtok, and so on), so it is safe to remember the rule as "always _beginthreadex". _beginthread (without the ex) has a trap where the handle becomes invalid if the thread ends early, so choose _beginthreadex over it as well.
- Must I not stop a thread with TerminateThread?
- You must not. TerminateThread wipes out the target thread without letting it run any user-mode code, so if that thread holds a critical section it is never released, if it is allocating memory from the heap the heap lock stays held, and if it is manipulating a DLL's global state that state is 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 detects it as warning C6258. The correct way to stop is cooperative stopping: create a stop event, have each thread watch it with WaitForSingleObject / WaitForMultipleObjects, and let each thread clean up after itself and end.
- I was using a Mutex for exclusion within a process. What is wrong with that?
- It works, but it costs a lot of performance. A Win32 Mutex is always a kernel object, so every acquire and release causes a transition into kernel mode. For exclusion within a process, an SRW lock or a CRITICAL_SECTION, which completes in user mode and falls back to a kernel wait only under contention, is dramatically faster, and the official documentation explicitly calls using a Mutex for in-process synchronization a "common mistake". A Mutex is for exclusion across processes as a named object, and for waiting on it together with 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 (/std:c11 and a matching Windows SDK are required). stdatomic.h, on the other hand, is treated as experimental and is at the stage of requiring the /experimental:c11atomics option (per the official conformance table as of August 2026). They are an option when portability is the top priority, but for a Windows-only codebase, writing to the Win32 API (_beginthreadex, SRW locks, condition variables, Interlocked) is the realistic choice given the track record and the volume of information available.
- Does adding volatile make a shared flag safe?
- No. 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 between processors. A simple read or write of a properly aligned 32-bit variable is itself atomic on Windows, but "read, add, and write back" is split into separate steps, and its ordering relative to surrounding memory operations is not defined 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 the ordering guarantee at the same time. To protect several variables together, use an SRW lock or a CRITICAL_SECTION.