Skip to content
WindowsDeep Dive Published Updated 6 min readViews unavailable

Windows Thread-Local Storage: TLS Slots, Ownership, and Fiber Boundaries

Use Windows TLS for per-thread state with explicit slot and value cleanup, and choose FLS or C++ thread_local when the execution model requires it.

Windows thread-local storage (TLS) gives each thread in a process a private value slot at a process-wide index. It is useful when a library needs state associated with the calling thread without passing a context argument through every API, such as a per-thread parser buffer, tracing context, or legacy runtime state. The index is shared by the process; the value stored at that index is specific to the current thread.

The API is small, but its lifetime contract is easy to get wrong. TlsAlloc allocates an index, not a heap object for every thread. TlsSetValue stores a pointer-sized value in the current thread’s slot, and TlsFree releases the index. The application remains responsible for allocating and freeing the objects represented by those per-thread values. In particular, freeing the index does not walk all threads and destroy their pointed-to data.

Index lifetime and per-thread values

Allocate the TLS index once during a controlled process or module initialization phase. Check for TLS_OUT_OF_INDEXES; do not assume an allocation always succeeds. Before using the index, each thread should initialize its own value. Newly created threads begin with null values for allocated slots, so a getter must distinguish “not initialized yet” from a valid initialized context.

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

DWORD g_tlsIndex = TLS_OUT_OF_INDEXES;

struct ThreadContext {
    std::vector<std::byte> scratch;
    unsigned nestingDepth = 0;
};

bool InitializeTls()
{
    g_tlsIndex = TlsAlloc();
    return g_tlsIndex != TLS_OUT_OF_INDEXES;
}

ThreadContext* GetOrCreateThreadContext()
{
    if (g_tlsIndex == TLS_OUT_OF_INDEXES) {
        return nullptr;
    }

    auto* context = static_cast<ThreadContext*>(TlsGetValue(g_tlsIndex));
    if (context != nullptr) {
        return context;
    }

    auto* created = new (std::nothrow) ThreadContext{};
    if (created == nullptr || !TlsSetValue(g_tlsIndex, created)) {
        delete created;
        return nullptr;
    }
    return created;
}

void ReleaseCurrentThreadContext()
{
    auto* context = static_cast<ThreadContext*>(TlsGetValue(g_tlsIndex));
    if (context != nullptr) {
        TlsSetValue(g_tlsIndex, nullptr);
        delete context;
    }
}

This fragment omits error reporting and synchronization around module initialization. It assumes each thread releases its own context before the process frees the TLS index. A production library should make cleanup idempotent, handle TlsSetValue failure, and define who calls ReleaseCurrentThreadContext for every thread that may have used the API. If worker threads are created by a pool, associate cleanup with the pool’s task lifecycle carefully: a pool thread can outlive a logical task and may execute unrelated work later.

The TLS slot stores only one pointer-sized value. If a thread needs several fields, group them in one context object rather than allocating many indexes. This makes ownership and per-thread cleanup visible. Avoid storing a pointer to a short-lived stack object in a slot; every later call on that thread could retrieve a dangling address.

TLS is not automatic object cleanup

Unlike a higher-level language’s thread-local object facility, the raw Win32 TLS API does not give TlsAlloc a callback that automatically frees an arbitrary heap value when every thread exits. The application must arrange cleanup on every thread that used the slot, including worker threads whose lifecycle is managed by another subsystem. If a thread terminates without releasing its context, that allocation can leak until process termination.

This is one reason per-thread state should be small and bounded. For a short-lived context, explicit initialization and a guaranteed worker-exit cleanup path may be straightforward. For plug-ins or libraries used by arbitrary host threads, automatic cleanup is harder: the DLL cannot assume it owns those threads or can safely run destructors at a convenient point. Document cleanup requirements, offer an explicit detach function, and ensure the host can call it before unloading the module.

TLS, FLS, and C++ thread_local

Use TLS when the code needs a dynamic Windows slot and controls its initialization and cleanup. For ordinary C++ objects in a modern toolchain, thread_local is often clearer and gives language-level initialization and destruction semantics. Those semantics still need to be considered with thread pools: a thread-local object belongs to the worker thread, not to each submitted task, so its state can survive across tasks and become visible to later work scheduled on that worker.

Fibers introduce another execution identity. A fiber runs within a thread and can be switched cooperatively to another fiber on the same thread. Ordinary TLS remains associated with the underlying thread, so two fibers can see the same TLS value while alternating on that thread. Windows Fiber Local Storage (FLS) provides a slot whose value changes with the currently running fiber. Use FLS only when the program actually uses fibers and per-fiber data is required; it is not a performance upgrade for normal threads.

FLS also supports an optional callback at index allocation time, which is designed for cleanup of fiber-local data. The callback and stored value must follow the FLS API’s ownership rules, and the code providing the callback must remain loaded for as long as the runtime can invoke it. FlsAlloc and its related functions are process-local; an index has no meaning in another process.

Dynamic libraries and module unload

TLS indexes are process resources shared by code in the same process, so libraries need a well-defined owner for allocation and release. A DLL that allocates an index should not free it while another thread may still call into the DLL or use that slot. Before FreeLibrary, stop new entry, wait for active calls to drain, clean up values on participating threads, and only then release the index and callback code.

Static compiler-supported thread-local storage and dynamic TLS APIs have different loader considerations. If a DLL may be loaded dynamically, consult Microsoft’s documented platform and compiler constraints for its static TLS declarations. The dynamic TLS API exists partly for cases where a module needs to obtain a slot at runtime. Do not copy old operating-system caveats into current deployment guidance without verifying the actual minimum Windows version and toolchain target.

Thread pools make a common ownership trap more visible. A logical request can set a TLS value and return; the same worker may later run a different request and inherit that value because thread-local state lasts for the thread’s lifetime. Always clear or replace request-scoped context at task boundaries. If callbacks can nest, save and restore the previous value rather than unconditionally clearing a value owned by an outer call.

Error handling and tests

Check TlsAlloc and TlsSetValue results. A null value can be a valid empty slot, so the getter’s contract should say whether null means “not initialized” or a legitimate stored state. If the code uses a null slot to indicate absence, construct the object and publish the pointer only after construction succeeds. Keep the index value synchronized if module initialization and shutdown can occur concurrently.

Test with several threads that repeatedly create, read, clear, and destroy their contexts. Add a worker-pool test where one task leaves an error marker in TLS and a second task runs on the same worker; assert that the second task cannot observe request-scoped residue. For FLS, switch between two fibers and verify each sees its own value. Exercise library unload only after all callers have drained and confirm that no cleanup callback points into unmapped code.

Instrument allocations by thread ID and logical task ID, but do not log raw secrets stored in context objects. TLS is a storage mechanism, not a security boundary: other code running in the same process can access relevant memory. Keep the stored context minimal, protect mutable shared fields separately, and make thread, fiber, task, and module lifetimes explicit.

Related:

Sources:

Comments