20.5 Statikus és thread-local objektumok életciklusa

A statikus élettartamú globális objektumok a C++ egyik legkényelmesebb, ugyanakkor legkönnyebben félreértett eszközei. Egy démon, egy mellékprogram, egy plugin-registry vagy egy diagnosztikai szolgáltatás tipikus indulási pontja: „legyen egy globális konfiguráció, egy logger, egy regisztrációs tábla”. A kényelem ára, hogy az objektum életciklusa összekapcsolódik a folyamat indulásával és leállásával, és éppen ezen a határon keletkeznek azok a hibák, amelyek fejlesztés közben nem, éles környezetben viszont annál látványosabban jönnek elő. Ez az alfejezet a statikus és thread_local objektumok életciklusát a folyamat startuptól a leállásig vezeti végig, a korábban rögzített destruktor-szabályokra építve (Destruktor és részleges konstrukció (20.3)), a header- és névfeloldási ismereteket előfeltételezve (Namespace, header és névfeloldás (19.2)), és a startup-sorrendet a folyamatindulás leírására támasztva (main, startup, argumentumok és környezet (3.4)).

A tárgyalás a C++20 profilt követi, C++23 kiegészítésekkel; a szabványhelyek a forrásjegyzék [C++20] tétele szerint: tárolási idők [basic.stc], élettartam [basic.life], statikus és dinamikus inicializálás [basic.start.static] és [basic.start.dynamic], blokk-hatókörű static és thread_local változó [stmt.dcl]. A fejezet programjait a lab-gcc profilban lefordítottuk és lefuttattuk; az eredményeket a buildjegyzőkönyvek rögzítik.

20.5.1 Tárolási idő, élettartam és statikus inicializálás

Két fogalmat kell szétválasztani. A tárolási idő (storage duration) azt mondja meg, meddig áll rendelkezésre az objektum tárolója. Az élettartam (lifetime) azt, hogy mikor létezik maga az objektum: osztálytípusnál nem triviális konstruktor esetén az élettartam az inicializálás befejezésével kezdődik, és a destruktorhívás megkezdésével ér véget ([basic.life]). A tároló tehát létezhet úgy is, hogy benne még, vagy már nincs objektum.

Definíció. A C++ négy tárolási időt különböztet meg:

A statikus tárolási idő nem jelenti, hogy az objektum a program indulásától létezik. Egy függvény lokális static változójának tárolója végig rendelkezésre áll, de az objektum élettartama csak akkor kezdődik el, amikor a vezérlés először áthalad a deklarációján, és a dinamikus inicializálás befejeződik ([stmt.dcl]). Ha a függvényt soha nem hívjuk meg, a konstruktor és a destruktor sem fut le. Ugyanez igaz a blokk-hatókörű thread_local változóra szálanként. A következő program ezt mutatja meg:

// lazy_static.cpp — C++20: a tárolási idő nem azonos az élettartammal
#include <cstdio>
#include <thread>

struct Noisy {
    const char* name;
    explicit Noisy(const char* n) : name(n) { std::printf("ctor %s\n", name); }
    ~Noisy() { std::printf("dtor %s\n", name); }
};

Noisy& used()   { static Noisy n{"used"}; return n; }
Noisy& unused() { static Noisy n{"unused"}; return n; }   // sosem hívjuk

Noisy& per_thread() { thread_local Noisy n{"per_thread"}; return n; }

int main(int argc, char**) {
    std::puts("main");
    if (argc > 100) unused();         // futás közben nem teljesül
    used();
    std::thread quiet([] {});          // nem éri el a per_thread()-et
    quiet.join();
    std::thread busy([] { per_thread(); });
    busy.join();
    std::puts("end of main");
}
g++ -std=c++20 -Wall -Wextra -Wpedantic -O2 -pthread lazy_static.cpp -o lazy_static
./lazy_static

Buildjegyzőkönyv (lab-gcc profil). A kimenet: main, ctor used, ctor per_thread, dtor per_thread, end of main, dtor used. Az unused objektum konstruktora és destruktora nem fut, a quiet szálon nem jön létre per_thread példány, a busy szálé pedig a szál kilépésekor szűnik meg.

20.5.1.1 A statikus tárolási idejű változók inicializálása

A statikus vagy szál tárolási idejű változó inicializálása nem három egymást követő fázis, hanem egy döntési folyamat ([basic.start.static], [basic.start.dynamic]):

  1. Statikus inicializálás. Ha a változó konstans inicializálást kaphat (az inicializáló konstans kifejezés, és a kiválasztott konstruktor is constexpr), akkor azt kapja. Ha nem, a változó zero-inicializálást kap. Ez értékszabály, nem bájtszabály: aritmetikai típus a 0 értéket, pointer és tagpointer a saját típusának null értékét kapja, osztálynál és tömbnél a részobjektumok rekurzívan zero-inicializálódnak, a paddingbitek nullák. Hogy ez a memóriában csupa nulla bájt-e, az ABI kérdése: a null adattag-pointer például az Itanium C++ ABI-ban (x86-64 Linux) -1 reprezentációjú, a csupa nulla bájt pedig a 0 eltolású tagra mutat. Ezért a memset(&x, 0, sizeof x) nem hordozható módja egy null pointer vagy tagpointer előállításának. A kettő közül pontosan az egyik történik, és mindkettő felhasználói kód futtatása nélkül.
  2. Dinamikus inicializálás. Ha a változó nem konstans inicializálást kapott, de van inicializálója, akkor a zero-inicializálás után futásidőben is lefut: konstruktorhívás, nem constexpr függvényhívás. Namespace-hatókörű változónál ez a main előtt (vagy legkésőbb az első használat előtt) történik, blokk-hatókörűnél az első áthaladáskor.
// memberptr_zero.cpp — C++20: a null tagpointer nem feltétlenül csupa nulla bájt
#include <cstdio>
#include <cstring>

struct S { int a; int b; };

int S::*g_mp;          // statikus tárolás: zero-inicializálás -> null tagpointer

int main() {
    int S::*bytes;
    std::memset(&bytes, 0, sizeof bytes);   // csupa nulla bájt
    unsigned char rep[sizeof g_mp];
    std::memcpy(rep, &g_mp, sizeof rep);
    std::printf("g_mp == nullptr: %d\n", g_mp == nullptr);
    std::printf("bytes == nullptr: %d, bytes == &S::a: %d\n",
                bytes == nullptr, bytes == &S::a);
    std::printf("g_mp elso bajtja: 0x%02x\n", rep[0]);
}

Buildjegyzőkönyv (lab-gcc profil, x86-64). A kimenet: g_mp == nullptr: 1, bytes == nullptr: 0, bytes == &S::a: 1, g_mp elso bajtja: 0xff. Ez ABI-tény, nem nyelvi garancia; egy más ABI-jú célon más eredmény is helyes lehet.

A statikus inicializálás minden dinamikus inicializálás előtt megtörténik (strongly happens before). Ezért a konstans vagy zero-inicializált objektumokat bármely más statikus objektum konstruktora olvashatja, és a konstans inicializált érték már ilyenkor a végleges. A szabvány megengedi, hogy az implementáció egy dinamikus inicializálást is előre, statikusan végezzen el, ha az eredmény ugyanaz lenne; erre a programozó nem építhet. Az sem következik a kategóriából, hogy a változó hol helyezkedik el a binárisban: ez az implementáció dolga.

Három példa besorolása:

Deklaráció Inicializálás
constexpr int kMax = 64; (namespace-hatókör) konstans inicializálás
int g_port = read_port(); ahol read_port nem constexpr zero-inicializálás (0), majd dinamikus inicializálás a main előtt
int& counter() { static int c = read_port(); return c; } zero-inicializálás, majd dinamikus inicializálás az első hívásnál

A dinamikus inicializálás a fordítási egységek között nincs sorrendbe állítva; ez a következő szakasz problémája.

A C++20 constinit kulcsszó pontosan ezt a kötelezettséget fejezi ki: a jelölt változónak statikus (konstans vagy zero) inicializálást kell kapnia, különben a fordító diagnosztikát ad. Fontos, hogy a constinit nem tesz const-tá; a változó futásidőben írható marad. Ez teszi alkalmassá olyan globális állapotra, amelyet main előtt is el kell érni, de később módosul, például egy std::atomic<bool> indulásjelzőre:

#include <atomic>
constinit std::atomic<int> g_initialized{0};  // konstans inicializált, futásidőben írható
inline int g_schema_version = 3;              // C++17 inline változó, egyetlen példány
constexpr int kMaxConnections = 64;           // fordítási idejű konstans

Az inline változó (C++17) a headerben definiálható, egyetlen program-szintű példánnyal; ez kiváltja az extern + .cpp-definíció mintát, de nem adja meg az inicializálási sorrendet, és nem tesz const-tá. A constexpr változó implicit módon const, így futásidőben nem írható.

20.5.2 Fordítási egységek közti inicializálási sorrend

A sorrendgarancia a változó fajtájától függ ([basic.start.dynamic]):

Két különböző .cpp fájl között a szabvány a rendezett változókra sem állít fel sorrendet: a sorrend attól függ, milyen sorrendben kerülnek a fordítási egységek a linkelésbe, és hogy a fordító milyen inicializálási rutint generál. Ez a híres static initialization order problem (statikus inicializálási sorrend-probléma).

Az alábbi negatív labor szándékosan hibás: a viselkedése a linkelési sorrendtől függ. Fájlleltár: config.cpp, bootstrap.cpp, main.cpp.

// config.cpp
#include <string>
std::string make_name() { return "diagnostics"; }  // nem constexpr
std::string g_service_name = make_name();           // dinamikus inicializálás
// bootstrap.cpp
#include <iostream>
#include <string>
extern std::string g_service_name;

struct Banner {
    Banner() { std::cout << "starting: " << g_service_name << '\n'; }
};
Banner g_banner;  // sorrendje a g_service_name-hez képest nem meghatározott
// main.cpp
int main() {}  // a kimenetet a g_banner konstruktora adja, main előtt

Fordítás és futtatás mindkét objektumfájl-sorrendben:

g++ -std=c++20 -Wall -Wextra -O2 config.cpp bootstrap.cpp main.cpp -o demo-ab
g++ -std=c++20 -Wall -Wextra -O2 bootstrap.cpp config.cpp main.cpp -o demo-ba
./demo-ab
./demo-ba

Buildjegyzőkönyv (lab-gcc profil). A demo-ab kimenete starting: diagnostics, a demo-ba kimenete starting: (üres név), mindkettő 0 kóddal lép ki; ASan/UBSan builddel a hiba nem jelentkezik diagnosztikaként. A make_name() szándékos: ha a változót közvetlenül a "diagnostics" literállal inicializálnánk, C++20-ban a std::string constexpr konstruktora miatt az inicializálás konstans inicializálás lehet (a lab-gcc profilban az is lett, mindkét sorrend helyesen futott), és a példa nem mutatná a hibát. C++17 módban ugyanez a literálos változat a demo-ba sorrendben üres nevet írt ki.

Két külön kérdést kell szétválasztani. (1) A sorrend. Hogy a két fordítási egység közül melyik dinamikus inicializálása fut előbb, azt a szabvány nem határozza meg; ez önmagában nem hiba. (2) A hozzáférés. Ha a g_banner konstruktora fut előbb, akkor egy olyan std::string objektumot használ, amelynek az élettartama még nem kezdődött el — ez nem definiált viselkedés. A fenti „üres név” tehát nem a program „másik helyes kimenete”, hanem egy UB-s futás egyetlen megfigyelt eredménye; a nem definiált viselkedésre semmilyen kimenetlista nem adható. Ez a hibakategória nem tesztelhető megbízhatóan: ugyanaz a forrás a linkelési sorrendtől vagy a fordító verziójától függően másképp viselkedhet.

Ugyanabban a fordítási egységben a rendezett változók közti függőség helyes, ha a függő változó később van definiálva:

// same_tu_order.cpp — C++20: ugyanabban a fordítási egységben rendezett függőség
#include <iostream>
#include <string>

std::string make_name() { return "diagnostics"; }      // nem constexpr

std::string g_name = make_name();                      // (1) ordered, előbb definiálva
std::string g_banner = "starting: " + g_name;          // (2) később: g_name már kész

int main() { std::cout << g_banner << '\n'; }

Buildjegyzőkönyv (lab-gcc profil). g++ -std=c++20 -Wall -Wextra -Wpedantic -O2 same_tu_order.cpp kimenete starting: diagnostics. Ha viszont a két definíció sorrendjét felcseréljük, a g_banner inicializálója egy még el nem kezdődött élettartamú g_name-et olvas: ez egyetlen fordítási egységen belül is ugyanaz a túl korai hozzáférés, tehát UB, nem „nem specifikált sorrend”.

A megoldás nem az, hogy „megpróbáljuk jól sorrendezni a .cpp fájlokat”, mert a sorrend törékeny és dokumentálatlan. A megoldás a lusta, első használatkor történő inicializálás.

20.5.3 Function-local static és szálbiztos inicializálás

Definíció. A function-local static (függvény-hatókörű statikus változó) statikus tárolási idejű objektum, amelynek dinamikus inicializálása akkor történik meg, amikor a vezérlés először halad át a deklarációján.

// config.hpp
#pragma once
#include <string>
struct Config { std::string service_name; };
const Config& config();
// config.cpp
#include "config.hpp"
const Config& config() {
    static const Config c{"diagnostics"};
    return c;
}
// bootstrap.cpp
#include "config.hpp"
#include <iostream>
struct Banner {
    Banner() { std::cout << "starting: " << config().service_name << '\n'; }
};
Banner g_banner;

A garanciák, amelyekre a minta épül: (1) az inicializálás pontosan egyszer fejeződik be sikeresen; ha az inicializáló kivétellel lép ki, az objektum nem számít inicializáltnak, és a vezérlés következő áthaladása újra megkísérli; (2) a C++11 óta a konkurens belépés is biztonságos — ha két szál egyszerre ér el a deklarációhoz, az egyik inicializál, a másik megvárja a befejezést („magic statics”, guaranteed initialization); (3) az inicializálás befejeződése után az objektum élettartama a folyamat végéig tart; (4) a destruálás az inicializálás befejezésének fordított sorrendjében történik.

Az (1) pontot érdemes kipróbálni, mert a „pontosan egyszer” könnyen „pontosan egy kísérletnek” olvasható. A Service első konstrukciója dob, a második sikerül; a már felépült Buffer tag a dobáskor felszabadul, így a részleges erőforrásszerzés visszagörgetődik:

// retry_static.cpp — C++20: kivétellel megszakadt function-local static újrapróbálása
#include <cstdio>
#include <memory>
#include <stdexcept>

int g_attempts = 0;

struct Buffer {                       // részleges erőforrás: RAII-tag
    std::unique_ptr<char[]> data{new char[64]};
    Buffer()  { std::puts("Buffer acquired"); }
    ~Buffer() { std::puts("Buffer released"); }
};

struct Service {
    Buffer buf;                       // előbb ez épül fel
    Service() {
        ++g_attempts;
        std::printf("Service() attempt %d\n", g_attempts);
        if (g_attempts == 1) throw std::runtime_error("backend not ready");
    }
    ~Service() { std::puts("~Service()"); }
};

Service& service() {
    static Service s;                 // dobás esetén nem számít inicializáltnak
    return s;
}

int main() {
    try { service(); }
    catch (const std::exception& e) { std::printf("caught: %s\n", e.what()); }
    service();                        // új kísérlet, most sikerül
    service();                        // már inicializált: nem fut újra
    std::printf("attempts = %d\n", g_attempts);
}

Buildjegyzőkönyv (lab-gcc profil). g++ -std=c++20 -Wall -Wextra -Wpedantic -O2 retry_static.cpp kimenete: Buffer acquired, Service() attempt 1, Buffer released, caught: backend not ready, Buffer acquired, Service() attempt 2, attempts = 2, majd a program végén ~Service(), Buffer released. Az inicializáló tehát kétszer futott, a Service egyszer jött létre és egyszer destruálódott. Ha a konstruktor törzse nem adattagban szerez erőforrást, a visszagörgetés a konstruktor felelőssége (lásd Destruktor és részleges konstrukció (20.3)).

A javított változat fájlleltára: config.hpp, config.cpp, bootstrap.cpp és a fenti main.cpp.

g++ -std=c++20 -Wall -Wextra -O2 config.cpp bootstrap.cpp main.cpp -o demo-ab
g++ -std=c++20 -Wall -Wextra -O2 bootstrap.cpp config.cpp main.cpp -o demo-ba
./demo-ab
./demo-ba

Itt a kimenet determinisztikusan starting: diagnostics, tekintet nélkül a linkelési sorrendre. Buildjegyzőkönyv (lab-gcc profil): mindkét sorrend ezt a kimenetet adta.

Két fontos korlátot kell ismerni. Egyrészt rekurzív inicializálás: ha a config() konstruktorából közvetlenül vagy közvetetten újra belépünk a config()-ba, miközben az inicializálás még nem fejeződött be, a viselkedés nem definiált. Másrészt a function-local static nem oldja meg automatikusan a destruálási sorrendet — csak az inicializálásét. Egyébként a kezdeti hívás költsége egy szálankénti ellenőrzés (guard variable), ami forró ciklusban mérhető overhead lehet; ilyenkor a mutatót egyszer, main elején vagy a szál belépésekor érdemes cache-elni.

20.5.4 Destrukciós sorrend és leálláskori függőségek

A statikus tárolási idejű objektumok destruálása a folyamat rendes leállásakor történik, az inicializálás befejezésének fordított sorrendjében. A main visszatérése után, illetve std::exit hívásakor. Ez a szabály a fordítottja az inicializálásénak, de ugyanazt a sebezhetőséget termeli ki a másik irányban: a static destruction order fiasco.

Tipikus eset: egy globális Logger objektum és egy globális Metrics objektum, ahol a Metrics destruktora még naplózni szeretne. Ha a Logger destruálódik előbb, a Metrics::~Metrics() egy élettartama végét ért objektum tagfüggvényét hívja — nem definiált viselkedés, gyakorlatban összeomlás vagy elveszett naplóbejegyzés. A jelenség különösen zavaró, mert a hiba csak a leállás utolsó pillanataiban jelentkezik, ahol a diagnosztika már nem működik.

Két bevett javítás:

  1. Hozzáférési függvény function-local static-kal. A Logger-t nem globális objektumként, hanem Logger& logger() { static Logger l; return l; } formában érjük el. A destruálás így az inicializálás befejezésének sorrendjéhez kötődik. Ha a Metrics konstruktora maga is hívja a logger()-t, akkor a Logger inicializálása a Metrics konstruktorán belül fejeződik be, tehát előbb, mint a Metrics konstrukciója; a fordított sorrend miatt a Metrics destruálódik előbb, és a destruktora még élő naplózót lát. A veszélyes eset az, amikor egy statikus objektum konstrukciója a naplózó első használata előtt fejeződik be (a konstruktora nem hívja a logger()-t), a destruktora viszont naplózna: ekkor a Logger később lett kész, ezért előbb szűnik meg.
// shutdown_order.cpp — C++20: ki használja a loggert a konstruktorában?
#include <cstdio>

struct Logger {
    Logger()  { std::puts("L kész"); }
    ~Logger() { std::puts("L dtor"); }
    void log(const char* m) { std::printf("  log: %s\n", m); }
};
Logger& logger() { static Logger l; return l; }

struct Metrics {                     // biztonságos: a konstruktor már használja
    Metrics()  { logger().log("Metrics indul"); std::puts("M kész"); }
    ~Metrics() { logger().log("Metrics leáll"); std::puts("M dtor"); }
};

struct Late {                        // veszélyes: a konstruktor NEM használja
    Late()  { std::puts("X kész"); }
    ~Late() { std::puts("X dtor (itt loggert hívna: már destruált lenne)"); }
};

Late    g_late;                      // X előbb kész, mint L
Metrics g_metrics;                   // L kész a Metrics konstruktorán belül

int main() { logger().log("main"); }

Buildjegyzőkönyv (lab-gcc profil). g++ -std=c++20 -Wall -Wextra -Wpedantic -O2 shutdown_order.cpp kimenete: X kész, L kész, log: Metrics indul, M kész, log: main, log: Metrics leáll, M dtor, L dtor, X dtor .... A biztonságos idővonal: L kész → M kész → M dtor → L dtor. A veszélyes idővonal: X kész → L kész → L dtor → X dtor; ha az X destruktora naplózna, egy már destruált Logger-t hívna. A szabály tehát: ha egy statikus objektum destruktora használ egy másikat, a konstruktorában is érintse meg (az accessor hívásával), így a sorrend garantált. 2. Szándékos „soha nem törlődő” példány. static Logger* l = new Logger(); return *l; — a példány a folyamat végéig él, nem destruálódik. Ez szivárgásnak tűnik, de a folyamat leállásakor a free már úgyis megtörténne; a szándékos elhagyás dokumentált és gyakori minta olyan eszközöknél, amelyeket a leállási sorrendben bármikor hívhatnak. Az ára, hogy a Logger destruálási mellékhatásai (puffer kiírása, fájl lezárása) elmaradnak — ezt külön flush-mechanizmussal kell pótolni.

Statikus destruktorból std::exit-et hívni nem definiált viselkedés, mert az újra belép a leállási folyamatba; kivételt dobni belőle a noexcept destruktor miatt std::terminate-hez vezet. Ezekre a destruktor-szabályoknál (Destruktor és részleges konstrukció (20.3)) már kitérünk; itt csak a leállási sorrend szempontjából relevánsak.

20.5.5 thread_local élettartam

A thread_local kulcsszó szál tárolási időt (thread storage duration) ad: minden szálnak saját tárolója van a változóhoz, amely a szál futása alatt áll rendelkezésre. Az objektum élettartama ettől külön kérdés: csak akkor kezdődik el, amikor az adott szálon az inicializálás megtörténik (lásd lent), és a szál kilépésekor ér véget. Egy szál, amely a változót soha nem használja, nem feltétlenül hoz létre belőle objektumot, ahogy a fenti lazy_static.cpp is mutatja.

#include <iostream>
#include <thread>

thread_local int t_request_count = 0;

void handle() { ++t_request_count; }

int main() {
    handle();
    handle();
    std::thread worker([] {
        handle();
        std::cout << "worker: " << t_request_count << '\n';  // 1
    });
    worker.join();
    std::cout << "main: " << t_request_count << '\n';        // 2
}
g++ -std=c++20 -Wall -Wextra -O2 -pthread tls.cpp -o tls
./tls

Buildjegyzőkönyv (lab-gcc profil): a kimenet worker: 1, majd main: 2 — a szálak egymástól független példányokat látnak. Ez a tulajdonság teszi a thread_local-t kiváló eszközzé szálankénti puffer, konverziós buffer vagy diagnosztikai számláló tárolására.

Az élettartam-szabályok: namespace-hatókörű thread_local esetén a dinamikus inicializálás az adott szálon az első használat előtt történik meg; blokk-hatókörű thread_local esetén az első áthaladáskor. A destruálás a leállás módjától függ ([basic.start.term]):

A még futó más szálak thread_local objektumaira ez nem ad garanciát: ha egy mellékszál a main visszatérésekor még fut, az már önmagában hibás leállás.

// tls_vs_static.cpp — C++20: a főszál thread_local-ja a statikusak előtt szűnik meg
#include <cstdio>
#include <cstdlib>

struct Noisy {
    const char* n;
    explicit Noisy(const char* s) : n(s) { std::printf("ctor %s\n", n); }
    ~Noisy() { std::printf("dtor %s\n", n); }
};

Noisy g_static{"static"};                       // namespace-szintű statikus
Noisy& tls() { thread_local Noisy t{"thread_local"}; return t; }

int main(int argc, char**) {
    tls();                                      // a statikus UTÁN jön létre
    if (argc > 1) std::exit(0);                 // std::exit: ugyanaz a sorrend
    return 0;
}

Buildjegyzőkönyv (lab-gcc profil). g++ -std=c++20 -Wall -Wextra -Wpedantic -O2 tls_vs_static.cpp mindkét futása (./tls_vs_static a return-ös, ./tls_vs_static x az std::exit-es úton) ugyanazt adta: ctor static, ctor thread_local, dtor thread_local, dtor static — bár a thread_local később jött létre, a statikus destruálása előtt szűnt meg, ahogy a szabály előírja.

Két gyakorlati csapda. Egyrészt a thread_local nagy objektum szálankénti példányosítása memóriarobbanást okozhat sok szál esetén; érdemes a méretet korlátozni, vagy szálonkénti arena allokátorral dolgozni. Másrészt a dinamikus thread_local (amikor a kezdeti érték nem konstans) drágább lehet, mint a konstans inicializálású változat, és dlopen-elt könyvtárakkal való interakciója platformfüggő; a laborban érdemes mérni, nem feltételezni.

20.5.6 Tipikus hibák

20.5.7 Feladatok

1. feladat (definíció-azonosítás). Az alábbi deklarációk közül melyiknél garantált, hogy a dinamikus inicializálás nem fut, és miért? constexpr int a = 4; — constinit std::atomic<int> b{0}; — std::string c = "x"; — inline std::vector<int> d{1,2,3}; — const char* const e = "x";

2. feladat (kódolás). Adott egy kétfájlos program, amelyben egy Registry globális objektum konstruktorában egy másik globális std::string-et olvas. Alakítsd át a programot úgy, hogy a viselkedés determinisztikus legyen, és a Registry konstruktora mindenképpen a helyes értéket lássa. Indokold, miért garantált az új sorrend.

3. feladat (diagnosztika). Egy szolgáltatás leállításkor néha nem írja ki az utolsó metrikákat. A naplózó logger() accessoron keresztül érhető el, a metrikagyűjtő viszont egy globális objektum, amelynek destruktora hívja a naplózót. Magyarázd el, milyen sorrendprobléma léphet fel, milyen eszközzel lehetne igazolni, és milyen két javítást javasolsz, milyen trade-offal.

20.5.8 Megoldások

1. megoldás. Hordozhatóan csak az a, b és e esetén garantált, hogy nincs dinamikus inicializálás: a konstans kifejezés, b a constinit miatt kötelezően konstans inicializált, e pedig konstans mutató egy literálra, ami konstans kifejezés. A c és a d besorolása az implementációtól függ. C++20-ban a std::string és a std::vector konstruktora constexpr, de egy konstans kifejezés nem hagyhat maga után dinamikus allokációt. A d három eleme heapet igényel, ezért dinamikus inicializálást kap. A c rövid literáljánál a kisstring-optimalizáció (SSO) miatt nincs allokáció, így az inicializálás konstans lehet; ezt a szabvány nem írja elő, és nem is tiltja. Ezért a constinit std::string képességpróba, nem negatív teszt: hogy a fordító elfogadja-e, az a könyvtár SSO-méretén és constexpr támogatásán múlik, és az elfogadás nem hiba. A lab-gcc profilban a constinit std::string g_short = "x"; lefordul (a Clang 22 + libc++ is elfogadta), a hosszú, SSO-n túli literál pedig does not have a constant initializer hibát ad. Biztosan elutasított negatív példához futásidejű bemenet kell: constinit int g_port = read_port();, ahol a read_port() a PORT környezeti változót olvassa. Ezt minden megfelelő fordító diagnosztizálja; a lab-gcc profilban a hiba: 'constinit' variable 'g_port' does not have a constant initializer, illetve call to non-'constexpr' function 'int read_port()'.

2. megoldás. A Registry-t és a konfigurációs sztringet egyaránt function-local static-ká alakítjuk: const std::string& registry_name() { static const std::string n = load(); return n; }, és a Registry konstruktora ezt hívja. Az első hívás inicializálja a sztringet (a load() lefut), a második hívás már a kész objektumot adja vissza; a C++11-es garancia miatt a konkurens belépés is biztonságos, és a Registry mindig a már inicializált sztringet látja. Az eredeti globális Registry objektumot is érdemes accessorrá alakítani, hogy a destruálási sorrend is a használathoz kötődjön. Ellenőrzési feltétel: a két main-hívás közötti kimenet determinisztikus, és -fsanitize=address mellett sem jelez a futás.

3. megoldás. A hiba forrása a statikus destruálási sorrend: ha a metrikagyűjtő konstruktora nem hívja a logger()-t, a Logger inicializálása csak a metrikagyűjtő konstrukciója után fejeződik be, ezért a fordított sorrendben előbb destruálódik; a metrikagyűjtő destruktora így már destruált naplózót hív, ami nem definiált viselkedés. (Ha a konstruktor is használja a logger()-t, a sorrend helyes.) Igazolás: -fsanitize=address a leállási fázisban „use-after-destruction” jellegű hibát jelezhet, illetve a naplóbejegyzés elmaradása reprodukálható, ha a linkelési sorrendet megváltoztatjuk. Két javítás: (a) a logger soha nem törlődő példányként (static Logger* l = new Logger();) éljen, dokumentált flush-mechanizmussal; (b) a metrikagyűjtő destruktora egy shutting_down flag alapján ne naplózzon, hanem a metrikákat std::atexit-tel regisztrált, később futó függvényben írja ki. Az (a) előnye az egyszerűség, hátránya a destruálási mellékhatások elmaradása; a (b) előnye a rendezett leállás, hátránya a bonyolultabb, könnyen elfelejthető regisztráció.

20.5.9 Összefoglaló

A tárolási idő és az élettartam két külön fogalom: a statikus tárolójú objektum is csak az inicializálásakor kezd élni. Az inicializálás döntési folyamat: konstans inicializálás, ha lehetséges, egyébként zero-inicializálás, majd szükség esetén dinamikus inicializálás. A statikus inicializálás garantáltan minden dinamikus inicializálás előtt megtörténik, ezért az ilyen objektumok globálisan biztonságosan olvashatók; a constinit és constexpr ezt a garanciát teszi kifejezhetővé. A dinamikus inicializálás sorrendje fordítási egységek között nem meghatározott, amit a function-local static lusta inicializálása old meg, a C++11 óta szálbiztosan. A destruálás az inicializálás befejezésének fordított sorrendjében történik, és ez a leállási fázisban újabb függőségi csapdákat okoz; a legrobusztusabb minta a hozzáférési függvény vagy a szándékosan soha nem törlődő példány. A thread_local szálankénti tárolót ad; az objektum az adott szálon az inicializáláskor kezd élni, és a szál kilépésekor szűnik meg; a fő szál thread_local objektumai a main visszatérésekor vagy std::exit-kor garantáltan a statikus objektumok előtt destruálódnak. Mindhárom terület közös tanulsága: az életciklust nem a véletlen linkelési sorrendre, hanem explicit szerződésre kell építeni.