Windows Threads and Synchronization: Win32 API and NT Primitives
A deep dive into Windows multithreading. Learn how to manage threads with CreateThread, synchronize using SRWLocks, and build high-performance blocking mechanisms with WaitOnAddress (the Windows futex).
When developing high-performance systems software on Windows, understanding how the OS manages concurrency is critical. While modern C++ (std::thread, std::mutex) abstracts much of this away, peering under the hood reveals a robust and evolving set of primitives provided by the Win32 API and the NT kernel.
In this chapter, we will explore how to create threads natively, why legacy synchronization primitives are often best avoided, and how to leverage modern, lightweight NT locking mechanisms like SRWLOCK and WaitOnAddress.
Creating Threads with the Win32 API
At the core of Windows concurrency is the thread. A process in Windows is merely a container for resources (memory space, handles), but threads are the actual entities scheduled by the NT kernel for execution.
To create a thread, we use the CreateThread API. It requires a starting function (often called the thread routine), an optional parameter, and gives us back a HANDLE. (Note: if your thread calls C Runtime (CRT) functions like printf, Microsoft’s official guidance is to use _beginthreadex instead of CreateThread to avoid memory leaks in low-memory conditions, though the modern UCRT makes this far less dangerous.)
#include <windows.h>
#include <stdio.h>
// Thread routine signature MUST match this prototype
DWORD WINAPI WorkerThread(LPVOID lpParam) {
int* value = (int*)lpParam;
printf("Hello from worker thread! The answer is %d\n", *value);
return 0;
}
int main() {
int data = 42;
HANDLE hThread = CreateThread(
NULL, // Default security attributes
0, // Default stack size (usually 1MB)
WorkerThread, // Thread function entry point
&data, // Parameter passed to the thread
0, // Default creation flags (start immediately)
NULL // Optional out-parameter for Thread ID
);
if (hThread == NULL) {
printf("Failed to create thread. Error: %lu\n", GetLastError());
return 1;
}
// Block the main thread until the worker thread exits
WaitForSingleObject(hThread, INFINITE);
// Always clean up your handles!
CloseHandle(hThread);
printf("Worker thread completed.\n");
return 0;
} The critical piece here is WaitForSingleObject. Windows handles are “waitable” objects. Waiting on a thread handle blocks execution until the thread terminates, effectively joining it.
The Evolution of Synchronization
When multiple threads mutate shared memory, we need synchronization to prevent data races. Windows has historically offered several primitives:
- Mutexes (
CreateMutex): Heavyweight kernel objects. Making a kernel transition just to grab an uncontended lock is extremely slow, but a named kernel mutex is the only primitive listed here that can easily span across different processes (asSRWLOCKandWaitOnAddressrequire shared memory to cross process boundaries). - Critical Sections (
CRITICAL_SECTION): User-mode constructs that fall back to a kernel wait only when contended. Faster, but historically bulky and recursive (allowing the same thread to acquire it multiple times), which adds overhead.
If you are building a modern application, you should prefer Slim Reader/Writer (SRW) Locks.
Slim Reader/Writer Locks (SRWLOCK)
Introduced in Windows Vista, SRWLOCK is the modern standard for fast, non-recursive synchronization in user mode. It is the size of a single pointer, extremely fast when uncontended, and distinguishes between shared (read) and exclusive (write) access.
Let’s look at how we protect a shared counter using an SRWLock.
#include <windows.h>
#include <stdio.h>
// Initialize the lock statically
SRWLOCK lock = SRWLOCK_INIT;
int shared_counter = 0;
DWORD WINAPI CounterThread(LPVOID lpParam) {
for (int i = 0; i < 100000; i++) {
// Acquire exclusive access (Write mode)
AcquireSRWLockExclusive(&lock);
shared_counter++; // Critical section
// Release the lock
ReleaseSRWLockExclusive(&lock);
}
return 0;
}
int main() {
HANDLE threads[4];
// Spawn 4 threads
for (int i = 0; i < 4; i++) {
threads[i] = CreateThread(NULL, 0, CounterThread, NULL, 0, NULL);
}
// Wait for all threads to finish.
// Note: WaitForMultipleObjects is capped at MAXIMUM_WAIT_OBJECTS (64 handles).
WaitForMultipleObjects(4, threads, TRUE, INFINITE);
for (int i = 0; i < 4; i++) {
CloseHandle(threads[i]);
}
printf("Final counter value: %d (Expected: 400000)\n", shared_counter);
return 0;
} If we only needed to read the value without modifying it, we could use AcquireSRWLockShared, allowing multiple reader threads to execute concurrently.
(Note: In real code, protecting a simple shared_counter++ with a lock is overkill; you would use InterlockedIncrement or std::atomic. The lock is used here simply to demonstrate the SRWLOCK API.)
WaitOnAddress: The Windows Futex
Sometimes, locks aren’t enough. You might need a thread to sleep until a specific condition becomes true—for example, waiting for a queue to become non-empty, or a flag to be set.
Linux solves this with futex (Fast Userspace Mutex). Windows 8 introduced an elegant equivalent: WaitOnAddress.
WaitOnAddress allows a thread to tell the kernel: “Check that the value at this memory address still equals X, and if so, sleep until woken.” It avoids heavy kernel objects (like Win32 Events) and spins briefly in user mode before sleeping, making it extremely fast. Since wakes are advisory and may be spurious, the condition must always be re-checked in a loop (similar to futex(FUTEX_WAIT) on Linux).
Here is a barebones example of building a custom waitable flag.
#include <windows.h>
#include <stdio.h>
// Note: You must link against synchronization.lib
#pragma comment(lib, "synchronization.lib")
volatile LONG g_ReadyFlag = 0;
DWORD WINAPI WaitingThread(LPVOID lpParam) {
printf("[Worker] Thread waiting for flag...\n");
LONG UndesiredValue = 0;
LONG Captured = g_ReadyFlag;
// Block until g_ReadyFlag is no longer equal to 0
// The kernel handles the sleep mechanism efficiently.
while (Captured == UndesiredValue) {
WaitOnAddress(
(volatile VOID *)&g_ReadyFlag,
&UndesiredValue,
sizeof(LONG),
INFINITE
);
Captured = g_ReadyFlag; // re-check: the wake is advisory, not a guarantee
}
printf("[Worker] Thread woke up! Flag is now %ld\n", g_ReadyFlag);
return 0;
}
int main() {
HANDLE hThread = CreateThread(NULL, 0, WaitingThread, NULL, 0, NULL);
printf("[Main] Simulating work...\n");
Sleep(2000);
printf("[Main] Setting flag and waking waiter...\n");
// Atomically set the flag to 1
InterlockedExchange(&g_ReadyFlag, 1);
// Notify the kernel that the value at this address has changed
// This will wake up one thread waiting on this address
WakeByAddressSingle((PVOID)&g_ReadyFlag);
WaitForSingleObject(hThread, INFINITE);
CloseHandle(hThread);
return 0;
} Why is WaitOnAddress important?
When you build lock-free data structures or custom synchronization primitives, you often encounter a scenario where a thread cannot proceed. Rather than writing a purely busy-wait loop (while(flag == 0) {}) which drains battery and monopolizes CPU cores, WaitOnAddress lets the NT kernel put the thread to sleep efficiently. When another thread changes the value and calls WakeByAddressSingle (or WakeByAddressAll), the kernel wakes up the waiting thread.
This is exactly how modern standard library primitives (like C++ std::atomic::wait or Rust’s standard library locks on Windows) are implemented under the hood.
Conclusion
Windows systems programming provides a deep well of synchronization capabilities. While standard C/C++ abstractions are great for cross-platform code, knowing the Win32 API natively gives you complete control over performance.
By using CreateThread (or _beginthreadex when the thread touches the CRT) for raw thread management, SRWLOCK for state protection, and WaitOnAddress for conditional blocking, we can write incredibly fast, battery-efficient multi-threaded code native to the NT kernel.