C++ Linkage and Storage Duration: extern and static

Key takeaways

Linkage decides whether a name is shared across translation units, storage duration decides how long an object lives, and the static keyword touches both depending on where it appears. The post separates the two, explains why namespace-scope const has internal linkage, whether local statics are thread-safe, and builds a per-thread RNG with thread_local.

What is Linkage?

When you build a C++ program, it is compiled as multiple translation units (roughly, one .cpp file each). Linkage determines which names are visible across these translation units.

There are three kinds of linkage:

  • External linkage: visible to other translation units — the default for non-static functions and variables at namespace scope
  • Internal linkage: visible only within the current translation unit — static at file scope, or anonymous namespaces
  • No linkage: local variables inside functions

This article is about names and lifetimes in general: which declarations the linker joins, and how long each kind of object lives. It does not go deep on any single keyword. For the three meanings of static on functions (class statics, file-scope statics, static locals) see static Functions in C++, and if you arrived here from a multiple definition link error, Fixing “multiple definition” Linker Errors walks through the causes one by one.


External Linkage with extern

Variables and functions with external linkage are shared across translation units. Declare them in a header, define them in exactly one .cpp. This section covers only what you need alongside storage duration; extern "C" for calling C code, extern template and the one-definition rule are covered in C++ extern linkage:

// globals.h — declarations (no definition, no storage allocated)
extern int requestCount;  // declared, not defined
void processRequest(int id);

// globals.cpp — definition (storage allocated here)
int requestCount = 0;

void processRequest(int id) {
    ++requestCount;
    // ...
}

// main.cpp — uses the extern declaration
#include "globals.h"
#include <iostream>

int main() {
    processRequest(1);
    processRequest(2);
    std::cout << "Requests: " << requestCount << '\n';  // 2
}

Rule: one definition, many declarations. If you define the same variable in two .cpp files, you get a linker error: “multiple definition of requestCount” (GCC/Clang) or LNK2005: "int requestCount" already defined in globals.obj (MSVC). The opposite mistake, declaring with extern everywhere and defining nowhere, gives undefined reference to 'requestCount' / LNK2001: unresolved external symbol.

Both errors come from the linker, not the compiler, because each .cpp file is compiled separately and a declaration is all the compiler needs. The compiler happily emits “somebody else will provide requestCount”, and only the linker, which sees every object file at once, can notice that nobody did, or that two files did. The most common way to get “multiple definition” by accident is writing int requestCount = 0; in a header without extern: every .cpp that includes the header then defines its own copy.

extern for const

const variables at namespace scope have internal linkage by default (unlike non-const). To share a const across translation units, you need extern explicitly:

// constants.h
extern const double PI;  // declare

// constants.cpp
extern const double PI = 3.14159265358979;  // define (extern required for const)

// any.cpp
#include "constants.h"
double circumference(double r) { return 2.0 * PI * r; }

Without extern const, each .cpp that const double PI = ... defines gets its own copy — technically OK (internal linkage), but wastes space and requires separate definitions.

This rule exists so that const int size = 10; can live in a header like a C #define replacement: because each translation unit gets its own private copy, including the header everywhere does not cause “multiple definition” errors. For simple constants the compiler usually folds the value into the code and the copies never materialize. The cost shows up with non-trivial constants, for example a const std::string in a header, which is constructed once per translation unit at startup, and whose address differs from file to file.

In modern C++, use inline constexpr in headers instead:

// constants.h — C++17
inline constexpr double PI = 3.14159265358979;  // one entity shared by every TU

Internal Linkage

Use static at file scope (or an anonymous namespace) to give a name internal linkage — it’s only visible within the current .cpp file:

// helpers.cpp

// These are invisible to other .cpp files — no linker conflicts
static int helperCounter = 0;

static void resetCounter() {
    helperCounter = 0;
}

// Modern C++ prefers anonymous namespace
namespace {
    double scaleFactor = 1.5;  // file-local — internal linkage

    bool validate(int x) {     // file-local function
        return x > 0 && x < 1000;
    }
}

// Public API — external linkage
void processData(int x) {
    if (!validate(x)) return;  // can use file-local validate
    resetCounter();
    // ...
}

Internal linkage is worth using by default for anything a .cpp file does not export. It prevents accidental clashes: two files that each define a helper called validate with external linkage produce a “multiple definition” error, or worse, if both are inline (for example member functions of two unrelated classes that happen to share a name, defined inside the class bodies), the linker silently keeps one of them and the other file calls the wrong function. It also lets the compiler inline or remove functions freely, because it knows nobody outside the file can call them, and it can warn about unused functions.

Internal linkage in a header is a different story. A static int counter; or a namespace { int counter; } in a header gives every including file its own separate counter, which is almost never what the author meant: one file increments its copy and another file reads a different one that is still zero. That bug compiles and links cleanly, which makes it confusing to track down.

Anonymous namespaces are preferred over static in modern C++ because they work for types and classes too:

namespace {
    struct ParseState {  // file-local type
        int pos;
        bool error;
    };
    
    class InternalParser {  // file-local class
        // ...
    };
}

Storage Duration

Linkage is about visibility. Storage duration is about lifetime.

Automatic Storage (the most common)

Local variables live on the stack. They’re created when the block is entered and destroyed when it exits:

void compute() {
    int local = 0;          // created here
    double buffer[1024];    // allocated on stack
    // ...
}                           // local and buffer destroyed here

Static Storage

Globals, static locals, and static class members have static storage — they live for the entire program duration:

int globalCounter = 0;  // static storage — lives until main() returns

void increment() {
    static int callCount = 0;  // static local — initialized once, persists between calls
    ++callCount;
    std::cout << "Called " << callCount << " times\n";
}

int main() {
    increment();  // Called 1 times
    increment();  // Called 2 times
    increment();  // Called 3 times
}

Static local initialization is thread-safe since C++11 — the first thread to reach the initialization point initializes it; other threads wait.

Thread-Local Storage

thread_local gives each thread its own instance:

#include <thread>
#include <iostream>
#include <random>

thread_local int threadId = 0;  // each thread has its own copy
thread_local std::mt19937 rng;  // each thread has its own RNG

void worker(int id) {
    threadId = id;              // sets this thread's copy
    std::cout << "Thread " << threadId << " started\n";
}

int main() {
    threadId = 0;  // main thread's copy
    
    std::thread t1(worker, 1);
    std::thread t2(worker, 2);
    t1.join();
    t2.join();
    
    std::cout << "Main thread id: " << threadId << '\n';  // still 0
}

thread_local is initialized once per thread on first use. It’s ideal for:

  • Per-thread random number generators (avoids locking shared mt19937)
  • Thread-local caches (avoids cache line ping-pong between threads)
  • Per-thread buffers (formatting, string building)

Dynamic Storage

Objects allocated with new (or smart pointers) have dynamic storage. Their lifetime is manual — they live until delete (or the last shared_ptr is destroyed):

// Dynamic — lifetime controlled explicitly
auto p = std::make_unique<Widget>();  // created now
// ...
// destroyed when p goes out of scope (unique_ptr)

The Static Initialization Order Fiasco

When two global variables depend on each other across .cpp files, their initialization order is unspecified:

// logger.cpp
Logger globalLogger("app.log");  // may initialize before or after...

// config.cpp
Config globalConfig("config.ini");  // ... this
// If Logger reads from Config in its constructor — Config might not be ready

Fix: use function-local static for lazy initialization:

// config.h
Config& getConfig();

// config.cpp
Config& getConfig() {
    static Config config("config.ini");  // initialized on first call
    return config;
}

// logger.h
Logger& getLogger();

// logger.cpp — Logger needs Config
#include "config.h"
Logger& getLogger() {
    static Logger logger(getConfig().logPath());  // getConfig() constructs Config first if needed
    return logger;
}

Function-local statics initialize on first call, so the dependency order is decided by the call chain instead of by the linker. Whichever code calls getLogger() first, including another global’s constructor, triggers getConfig(), which constructs the Config before returning it. The functions are declared in headers and defined once in .cpp files; defining a non-inline function body in a header that several files include is itself a “multiple definition” error. (Marking them inline in the header also works, and C++17 guarantees there is still only one static object.)

The fix covers construction but not destruction. Function-local statics are destroyed in reverse order of construction at exit, so if the Logger was constructed after the Config and a destructor of some other global logs something after Logger has been destroyed, you are back to undefined behavior. Keeping destructors of such objects trivial, or deliberately leaking the object (static Logger& logger = *new Logger(...);), are the usual workarounds.

How the fiasco shows up at runtime (crashes before main, values that are zero on one build and correct on another) and the other fixes, such as constinit and explicit init functions, are covered in Static Initialization Order Fiasco.


Summary Table

Keyword/ContextLinkageStorage Duration
int x; at namespace scopeExternalStatic (program)
static int x; at namespace scopeInternal (file-only)Static (program)
Anonymous namespace variableInternal (file-only)Static (program)
int x; inside a functionNoneAutomatic (block)
static int x; inside a functionNoneStatic (program)
thread_local int x;Depends on scopeThread lifetime
extern int x;ExternalDefined elsewhere
Class static memberClass scope (external)Static (program)

Practical: Per-Thread RNG

#include <random>
#include <thread>
#include <iostream>
#include <vector>

// Each thread has its own engine — no contention, no locks
thread_local std::mt19937 tl_rng{std::random_device{}()};

int randomInt(int lo, int hi) {
    return std::uniform_int_distribution<int>{lo, hi}(tl_rng);
}

void worker(int threadNum, int count) {
    int sum = 0;
    for (int i = 0; i < count; ++i) {
        sum += randomInt(1, 100);
    }
    std::cout << "Thread " << threadNum << " sum: " << sum << '\n';
}

int main() {
    std::vector<std::thread> threads;
    for (int i = 0; i < 4; ++i) {
        threads.emplace_back(worker, i, 1000);
    }
    for (auto& t : threads) t.join();
}

Each thread initializes tl_rng independently when it first calls randomInt. No mutex, no contention — each thread gets its own seeded Mersenne Twister.

Two caveats. A std::mt19937 holds about 2.5 KB of state, and each thread pays for that plus a random_device read on first use, which is fine for long-lived worker threads and wasteful for many short-lived ones. And on some older MinGW toolchains std::random_device was deterministic, which would give every thread the same sequence; if reproducibility or quality matters, seed from a known source such as a base seed plus the thread’s index. Also note that thread_local objects are destroyed when their thread exits, so a pointer to a thread’s thread_local must never be handed to another thread that outlives it.


Frequently Asked Questions (FAQ)

Q. Is initialization of a function-local static variable thread-safe?

A. Yes, since C++11. If several threads reach the declaration at the same time, one of them performs the initialization and the others wait until it finishes, which is why a function-local static is a common fix for the static initialization order fiasco. The guarantee covers only the initialization; later reads and writes from multiple threads still need a mutex or atomics.