Windows Thread Message Queues: Posting Work Without Breaking the Pump
Use PostThreadMessage only with a live queue and compatible message loop, and avoid lost notifications, modal-loop surprises, and synchronous-send deadlocks.
Windows message queues are part of the execution model for GUI threads. A thread that owns windows retrieves queued input and dispatches it to window procedures; the same queue can also carry application-defined notifications. PostThreadMessage posts a message to a thread’s queue without waiting for the thread to process it. The call is asynchronous, but its success depends on the target queue already existing and the target loop being able to retrieve and interpret a message with no associated window.
Those caveats are why posting a message is not a complete work-queue design. A thread message can be lost from a modal-loop path, a bounded queue can reject posts, a target thread can exit after the post, and a pointer in LPARAM does not acquire ownership by being copied into the message. For durable work, pair the notification with a synchronized queue or use a dedicated task/event mechanism. Reserve messages for coordinating with the thread’s message-processing model.
A thread queue is created lazily
Windows does not create a message queue for every thread at thread startup. A queue is created when the thread first makes certain User or GDI calls. If another thread calls PostThreadMessage too early, the post fails because no message queue exists yet. Establish readiness explicitly: have the target create its queue, then signal a startup event that the sender waits on before posting.
struct UiWorkerStartup {
HANDLE queueReady;
DWORD threadId;
};
DWORD WINAPI MessageWorker(void* raw)
{
auto* startup = static_cast<UiWorkerStartup*>(raw);
MSG message{};
// Force creation of this thread's message queue before reporting ready.
PeekMessageW(&message, nullptr, WM_USER, WM_USER, PM_NOREMOVE);
startup->threadId = GetCurrentThreadId();
SetEvent(startup->queueReady);
BOOL result = FALSE;
while ((result = GetMessageW(&message, nullptr, 0, 0)) > 0) {
if (message.hwnd == nullptr && message.message == WM_APP + 1) {
ProcessControlNotification(message.wParam, message.lParam);
continue;
}
TranslateMessage(&message);
DispatchMessageW(&message);
}
if (result == -1) {
ReportMessageLoopError(GetLastError());
}
return 0;
}
The example uses PeekMessageW to force queue creation and an event to publish readiness. The receiver treats a thread message explicitly because DispatchMessage has no window procedure to call when MSG.hwnd is null. A real GUI loop may also need accelerators, modeless-dialog handling, COM apartment dispatch, or framework-specific hooks. Do not replace those required message-loop steps with a generic loop copied from a small sample.
The sender should wait for queueReady, check the target thread is still alive, call PostThreadMessageW, and check the Boolean result. A successful post means the message was enqueued, not that the worker handled it. If handling must be acknowledged, include a request identifier and signal a separate completion object once the worker reaches a defined terminal state.
Thread messages are not window messages
A message posted with PostThreadMessage has no HWND; it is retrieved from the thread’s queue but is not associated with a window. DispatchMessage cannot route it to a window procedure. The message loop must inspect it directly, as in the example, and handle it before or alongside ordinary window messages.
Modal loops complicate delivery. Dialog boxes and message boxes can run nested message loops that retrieve window messages but do not necessarily process application thread messages the way the outer loop does. Microsoft warns that thread messages can be lost while the target is in such a modal loop. If a notification must reach a GUI thread regardless of which nested loop is active, consider posting to a dedicated hidden/message-only window and handling a registered window message, or use a framework-supported dispatch mechanism.
Window-targeted messages have a clear destination and can be dispatched through that window’s procedure. They still need careful lifetime rules, but they integrate more naturally with nested loops. Use PostMessage to a window when the operation belongs to that window; use PostThreadMessage only for thread-wide control that the target loop intentionally handles.
Messages should be small notifications
Message parameters are fixed-width values, not an automatically managed object transport. Within one process, an application sometimes places a pointer in LPARAM, but the pointer’s lifetime and thread safety remain entirely application-owned. The sender cannot free it until the receiver has taken ownership or acknowledged completion. If the post fails because the queue is full or the target is no longer valid, the sender must reclaim it. If the thread exits after the message is posted, no receiver may run at all.
For robust cross-thread work, put the payload in a synchronized queue and post a small “queue changed” notification. The receiver drains the queue under the queue’s locking contract. This supports batching, explicit backpressure, and recovery if a notification is coalesced or a transient post fails. It also decouples the message count from the number of work items. For cross-process messages, Windows marshals only the system-defined range; custom messages require explicit marshalling and validation. Never treat a numeric pointer from another process as a valid local address.
PostThreadMessage is also subject to User Interface Privilege Isolation (UIPI). A process generally cannot post to a thread in a process at a higher integrity level. A failure with access denied is a boundary condition to diagnose, not something to “fix” by weakening system policy. Check the target identity, desktop, integrity level, thread ID, and last error.
Posting versus sending
PostThreadMessage returns without waiting for processing. SendMessage instead calls a window procedure and waits for it to return. Sending synchronously across threads can deadlock if the receiver waits for the sender or yields while processing. Never hold a lock that the target needs while making a synchronous send. When synchronous communication is required, define a timeout and reentrancy policy; otherwise prefer posting or a request/response queue.
The message queue has a finite limit. Check every PostMessage or PostThreadMessage result and treat quota errors as backpressure. Raising the system-wide limit should not be the first response to a producer that can overwhelm a consumer. Coalesce replaceable state updates, bound your application queue, and make overload visible through counters and logs.
Shutdown protocol
To stop a message worker, stop accepting new work, enqueue or post a quit/control signal, and wait for the worker thread to exit before destroying its queue state. PostQuitMessage posts a quit message to the calling thread’s queue; it does not target an arbitrary thread by thread ID. To request another thread to stop, post an application control message to its existing queue or signal a separate event that its loop waits on.
If using a thread message as the stop signal, it must be handled in every relevant nested loop or shutdown can hang. A message-only window, event-based wait integrated with MsgWaitForMultipleObjectsEx, or framework dispatcher may provide stronger semantics. Close readiness and completion events only after all senders and receivers are done. Keep the target thread handle long enough to observe exit and distinguish a completed stop from a failed post.
Diagnostics and stress cases
Log queue creation readiness, thread ID, message identifier, post result, and acknowledgement time. Do not log arbitrary pointer payloads. Test posting before and after queue creation, posting while the receiver enters a modal dialog, queue saturation, UIPI denial, receiver shutdown, and two threads issuing synchronous sends while holding locks. Verify that an event or queue-based fallback behaves correctly when the message cannot be delivered.
The message system is designed to route UI and thread notifications, not to own application work. Its strongest use is a lightweight signal into a message pump that is already alive and prepared to receive it. Explicit readiness, a durable payload queue, checked return values, and a shutdown contract turn that signal into a dependable part of a production Windows application.
Related:
- How to Configure Windows Terminal and PowerShell Profiles
- Windows Wait Chain Traversal: Diagnosing Hangs Without Guessing
Sources: