Skip to content
WindowsDeep Dive Published Updated 7 min readViews unavailable

Windows One-Time Initialization: Safe Lazy Setup with InitOnceExecuteOnce

Use INIT_ONCE to initialize shared Windows state exactly once, handle retries and context ownership, and avoid loader-lock, reentrancy, and teardown races.

Lazy initialization is attractive when a process has expensive state that most execution paths never need. It also creates a concurrency boundary: two threads can discover the uninitialized state at the same time, both allocate resources, or one can publish a partially constructed object while another begins using it. Windows provides the one-time initialization API around an opaque INIT_ONCE object so callers do not have to invent their own publication protocol for this case.

InitOnceExecuteOnce is the synchronous, callback-based path. It serializes callers until initialization either succeeds or fails. A successful callback publishes optional context for later callers; a failed callback leaves the one-time object retryable. That is a compact mechanism, not an application-wide lifetime manager. The program must still decide what the context owns, when it can be destroyed, and whether initialization is safe to run on the calling thread.

The guarantee and its boundaries

An INIT_ONCE object represents a state machine whose internal representation is private. Initialize it statically with INIT_ONCE_STATIC_INIT or dynamically with InitOnceInitialize. The structure is process-local and cannot coordinate initialization across separate processes. The API supplies the synchronization needed to publish successful initialization; it does not automatically free the published resource or make arbitrary operations on that resource thread-safe.

With synchronous initialization, one caller executes the callback while concurrent callers wait. If the callback succeeds, subsequent callers observe completion and receive the stored context. If it reports failure, another call may attempt initialization again. Therefore the callback must be safe to retry: close any partial handles, roll back temporary allocations, and avoid externally visible side effects that cannot be repeated. A failed attempt does not mean that the system has reversed what the callback already did.

The initializer runs in the context of whichever application thread first reaches the API. That detail affects latency and lock ordering. A UI thread can block while another thread initializes; a service callback may unexpectedly perform disk I/O; and a thread holding a lock needed by initialization can deadlock with another initializer. Keep initialization bounded, avoid calling arbitrary plugin code, and document which thread may pay the first-use cost.

A small context-owning example

The context should point to stable storage whose lifetime is at least as long as every use of the returned pointer. This example deliberately models process-lifetime configuration and has no teardown path; a library with unload or reload requirements needs an explicit owner and a quiescence protocol.

#include <windows.h>
#include <new>

struct RuntimeConfig {
    DWORD workerLimit;
    HANDLE shutdownEvent;
};

static INIT_ONCE g_configOnce = INIT_ONCE_STATIC_INIT;

static BOOL CALLBACK InitializeRuntimeConfig(
    PINIT_ONCE once,
    PVOID parameter,
    PVOID* context)
{
    UNREFERENCED_PARAMETER(once);
    UNREFERENCED_PARAMETER(parameter);

    auto* config = new (std::nothrow) RuntimeConfig{};
    if (config == nullptr) {
        return FALSE;
    }

    config->workerLimit = 8; // Example policy; choose from measured workload data.
    config->shutdownEvent = CreateEventW(nullptr, TRUE, FALSE, nullptr);
    if (config->shutdownEvent == nullptr) {
        delete config;
        return FALSE;
    }

    *context = config;
    return TRUE;
}

RuntimeConfig* GetRuntimeConfig()
{
    PVOID context = nullptr;
    if (!InitOnceExecuteOnce(
            &g_configOnce, InitializeRuntimeConfig, nullptr, &context)) {
        return nullptr;
    }
    return static_cast<RuntimeConfig*>(context);
}

The callback assigns *context only after all required initialization has succeeded. If CreateEventW fails, it releases the partially allocated object and returns FALSE, allowing a later caller to retry. A production implementation should capture and report the specific failure cause using an error channel owned by the application; callers should not assume that every callback failure maps to a useful GetLastError() value.

The returned pointer is not a reference count. If one thread frees RuntimeConfig while another still uses it, INIT_ONCE cannot prevent a use-after-free. For a process-lifetime singleton, a deliberate process-exit cleanup policy may be enough. For unloadable components, use an owning object, reference counting, reader/writer synchronization, or an explicit stop-and-drain phase before releasing its state. Do not store a pointer to a stack local in the one-time context.

Failure, retry, and publication policy

Choose whether initialization failure should be transient or permanent. The basic synchronous API allows another call to retry after the callback returns failure. That behavior is useful for a temporary resource shortage, but can turn a deterministic configuration error into repeated expensive work. The caller may need to cache a terminal error in a separate, synchronized state if retry is not appropriate. Avoid writing an unrelated global error variable from the callback without synchronization: multiple failed attempts can race to replace it.

The initialization routine should create a complete object privately, validate all required fields, and only then publish its address through the callback context. Do not publish the pointer early and finish populating its members afterward. The API’s successful completion is the publication boundary; consumers should see an immutable or separately synchronized object. If later mutation is required, protect those operations with their own lock or atomics.

One-time initialization is particularly useful for immutable lookup tables, process-wide API discovery, a lazily created event, or a read-mostly configuration snapshot. It is usually the wrong tool for a cache that must refresh, a resource that can be replaced, per-request state, or an object that has several independent shutdown/restart cycles. Model those as a lifecycle with generations rather than trying to reset the INIT_ONCE object after users may have cached its context.

Reentrancy and loader-lock hazards

The callback should not recursively call a public accessor that uses the same INIT_ONCE. That re-enters initialization before the first callback has completed and can block or deadlock. Pass the partially constructed dependencies directly to helper routines instead. Also inspect indirect call paths: a logging, tracing, or allocation hook might call back into the subsystem during first use.

Do not perform complex lazy initialization from DllMain. Windows calls DLL entry points under the loader lock, and initialization that loads another DLL, waits for another thread, or enters code that needs the loader lock can deadlock the process. A one-time primitive does not make loader-lock work safe. Prefer an explicit exported initialization function or initialize on the first normal API call after module loading, with documented ordering.

Synchronous and asynchronous forms are not interchangeable

InitOnceExecuteOnce is appropriate when callers can wait for a single initializer and the work is synchronous. The lower-level InitOnceBeginInitialize and InitOnceComplete functions also support asynchronous initialization, where multiple contenders may begin work and only one ultimately publishes success. This can suit work that cannot be performed while other callers are blocked, but it transfers more coordination and cleanup responsibility to the application.

Do not mix synchronous and asynchronous modes for the same INIT_ONCE object. Pick one model at construction time and test it under concurrent success and failure. In the asynchronous form, losing contenders must release their privately created resources when InitOnceComplete indicates another thread won. Never assume that because a caller began initialization, its result will be the published one.

Testing and operational checks

Exercise the initializer from many threads at once, with a counter proving the successful path ran once. Inject failures at every allocation or handle-creation step and verify that the next call either retries as designed or returns a stable failure. Add a test in which the callback re-enters nearby logging and diagnostics, since instrumentation frequently creates hidden cycles. Run shutdown tests while other threads are retrieving or using the context; the once object does not replace the component’s stop protocol.

Record initialization duration, attempt count, and the final error category. A slow first call may otherwise appear as an unexplained cold-start pause. Never log secrets from the context, and do not emit a success record until the object is fully initialized and safely published. The most maintainable use of INIT_ONCE is a short, deterministic callback, a stable context pointer, a clear retry policy, and an independent lifetime rule for the object it creates.

Related:

Sources:

Comments