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.
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:
static minősítésű függvény- és namespace-hatókörű változó,
valamint az osztály statikus adattagja;thread_local
változó tárolója szálanként külön van, és a szál futása alatt áll
rendelkezésre;static és nem thread_local változó tárolója a
blokk végéig él;new kifejezéssel (vagy
allokátorral) kért tároló a felszabadításig él.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_staticBuildjegyző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.
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]):
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.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ű konstansAz 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ó.
A sorrendgarancia a változó fajtájától függ ([basic.start.dynamic]):
inline, nem sablonból példányosított namespace-hatókörű
statikus változó. Ezek egy fordítási egységen belül a definíciók
sorrendjében, felülről lefelé inicializálódnak.inline változó (C++17): egy W változó előtt
akkor inicializálódik garantáltan, ha W nem rendezetlen, és
W minden definícióját megelőzi az
inline változó valamelyik definíciója (például mert a
header minden érintett fordítási egységben W előtt
szerepel).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őttFordí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-baBuildjegyző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.
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-baItt 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.
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:
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.
thread_local
élettartamA 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
./tlsBuildjegyző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]):
thread_local objektumai az inicializálás
befejezésének fordított sorrendjében destruálódnak.main visszatérése vagy std::exit
hívása: a hívó (fő)szál thread_local objektumai
destruálódnak, és ez a szabvány szerint minden statikus
tárolású objektum destruálása előtt történik meg (strongly happens
before). Ez garantált sorrend, nem implementációfüggő.std::abort,
std::quick_exit, std::_Exit,
std::terminate, kezeletlen szignál): sem a
thread_local, sem a statikus objektumok destruktora nem fut
(a quick_exit csak az at_quick_exit kezelőket
hívja).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.
.cpp fájlok sorrendje a linker parancssorban nem szerződés;
a build rendszer átalakítása csendben elrontja. Javítás: function-local
static.constinit/constexpr, vagy lusta
inicializálás.constinit és const
összekeverése. A constinit csak az inicializálás
módját rögzíti; a változó írható marad. Ha const kell,
mindkettőt ki kell írni.shutting_down
flaggel.thread_local nagy példányok minden
szálon. Memóriafogyasztás; javasolt a méretkorlát és a
szál-specifikus újrafelhasználás.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.
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ó.
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.