41.4 std::stacktrace és futásidejű diagnosztika

A termelési hibajelentés értékét nagyrészt az dönti el, hogy a hiba pillanatában mennyi kontextust sikerült megőrizni. Egy ritkán előforduló, nehezen reprodukálható hiba esetén a legfontosabb kérdés nem az, hogy mi történt, hanem hogy hol: melyik hívási láncon keresztül jutott a vezérlés a hibás pontra. A C++23 std::stacktrace (P0881) ezt a pillanatképet szabványos, hordozható formában teszi elérhetővé. Ez a rész a rögzítés szerződését, a symbolizáció implementációs feltételeit, a hibamodellbe illesztést és a költség oldalát tárgyalja. A release/debug symbol kérdéskörét itt nem tanítjuk újra; a release, debug symbol és reprodukálhatóság rész eredményeire építünk. A hibamodell oldalán a std::expected monadikus hibaláncai jelentik az előzményt, a kimenet oldalán pedig az iostream és pufferelés alapjaira támaszkodunk. A fejezet nem tekinti ellenőrzöttnek a konkrét fordító- és könyvtártámogatást: minden új képességet feature-test makróval és compile probe-bal kell igazolni.

41.4.1 A basic_stacktrace szerződése és a current() szemantikája

A std::basic_stacktrace<Allocator> olyan értéktípus, amely std::stacktrace_entry elemek sorozatát tárolja. A std::stacktrace ennek a std::allocator<std::stacktrace_entry>-ral paraméterezett aliasza. A tárolt elemek egy rögzítési esemény pillanatképét írják le: a current() hívásakor a könyvtár megpróbálja felderíteni a hívási láncot, és a kereteket stacktrace_entry objektumokként eltárolja. A rögzítés után a stacktrace független a további végrehajtástól; a hívási lánc későbbi változása nem hat rá vissza.

A current() három túlterheléssel érhető el: allokátorral, skip paraméterrel, illetve skip és max_depth paraméterrel. A skip a legfelső keretek kihagyására szolgál, a max_depth pedig a rögzítendő keretek felső korlátja. A skip pontos jelentése implementációfüggő mértékben eltérhet, ezért a gyakorlatban a current() hívókeretének kihagyására használjuk, és a viselkedést a konkrét toolchainen ellenőrizzük. A current() nem noexcept: a basic_stacktrace allokálhat, így allokációs hiba esetén std::bad_alloc terjedhet. Ez a tény a későbbi, noexcept határokra vonatkozó döntéseknél fontos lesz.

A stacktrace_entry értéktípus, másolható és összehasonlítható. A description() ember által olvasható szimbólumnevet ad, a source_file() és source_line() a forráshelyet, a native_handle() pedig egy implementációfüggő nyers azonosítót. A description(), source_file() és source_line() egyaránt visszaadhat üres értéket, ha a symbolizációhoz szükséges információ nem elérhető. A basic_stacktrace size(), empty(), operator[], at(), begin()/end() tagfüggvényekkel és operator<<-val rendelkezik; a std::to_string túlterhelése teljes szöveges formát ad. A szöveges forma pontos alakja implementációfüggő, ezért arra nem szabad gépi parsert építeni.

A következő példa a minimális rögzítést mutatja. A kód nem futtatott, a kimenet formátuma illusztráció.

// st_basic.cpp
#include <stacktrace>
#include <iostream>

void fail() {
    const std::stacktrace st = std::stacktrace::current(0, 16);
    std::cerr << "hiba helye:\n" << st << '\n';
}

int main() { fail(); }
g++ -std=c++23 -g -O1 -fno-omit-frame-pointer st_basic.cpp -lstdc++exp -o st_basic
./st_basic

A -lstdc++exp itt feltételes: a GCC/libstdc++ egyes kiadásaiban a <stacktrace> implementációja a libbacktrace-re épül, és a linkeléshez ez a kísérleti könyvtár (korábbi nevén -lstdc++_libbacktrace) szükséges lehet. A pontos könyvtárnév és annak szükségessége implementáció- és verziófüggő; a linker hibaüzenete és a toolchain dokumentációja együtt adja a választ. A libc++ oldalán a C++23 státuszfüggvényt kell ellenőrizni; a támogatás verziófüggő, és ezt a fejezet nem tekinti igazoltnak.

A használat előtt érdemes compile probe-bal ellenőrizni a képességet:

#include <version>
#if !defined(__cpp_lib_stacktrace)
#error "a toolchain nem hirdeti a std::stacktrace-t"
#endif
#include <stacktrace>
int main() {
    auto st = std::stacktrace::current(0, 8);
    return st.empty() ? 1 : 0;
}

A __cpp_lib_stacktrace ismert értéke a szabványos <version> fejlécben szerepel; azt nem hardcode-oljuk, hanem a fejlécből olvassuk ki, és a konkrét értéket a toolchain verziójához kötjük.

41.4.2 Symbolizáció: linkelés, debug info és PIE

A rögzítés és a symbolizáció két külön lépés. A current() a keretek azonosítóit rögzíti; a description(), source_file() és source_line() hívásakor történhet a szimbolizáció, ami lényegesen drágább lehet, mint a puszta rögzítés. Ezért a hibajelentésben érdemes a stacktrace-t nyers formában eltárolni, és csak a feldolgozás, a megjelenítés vagy a naplózás során szöveggé alakítani.

A symbolizáció feltételei: a megfelelő bináris, a hozzá tartozó debug info és a betöltési környezet ismerete. A -g fordítói kapcsoló debug szekciókat generál; a -fno-omit-frame-pointer a frame-pointer alapú visszafejtést teszi megbízhatóbbá optimalizált kód mellett is. A modern disztribúciók jellemzően PIE binárist linkelnek, így a címek futásidőben az ASLR miatt változnak. A description() a futó folyamatban a betöltött modulokból symbolizál; a native_handle() nyers címét addr2line-nal csak a betöltési bázis ismeretében lehet feloldani.

A strip --strip-all eltávolítja a .symtab-ot és a debug szekciókat; a .dynsym megmarad, de csak az exportált szimbólumokat tartalmazza. Ekkor a statikus függvények neve és a sorinformáció elveszik, a description() üres lehet, a source_file() és source_line() pedig üresen marad. A strip --strip-debug csak a debug szekciókat távolítja el, a szimbólumtáblát megtartja. A release buildben is érdemes lehet a debug infót külön fájlban megőrizni (objcopy --only-keep-debug, majd --add-gnu-debuglink), és a binárisból eltávolítani; a build-id (readelf -n) alapján a debug csomag később is megtalálható.

41.4.3 Stacktrace a hibamodellben: expected és kivétel

A stacktrace-t a hiba keletkezésének pontján kell rögzíteni, nem a hibaút végén. Ha a hibát std::expected-tel adjuk tovább, a hibaérték tartalmazhatja a stacktrace-t; a monadikus lánc (and_then, transform, or_else) ezt az értéket továbbviszi, de a rögzítés helye továbbra is a hiba keletkezése marad.

// diag_expected.cpp
#include <expected>
#include <stacktrace>
#include <string>
#include <string_view>
#include <charconv>
#include <cstdint>
#include <system_error>
#include <utility>

struct DiagError {
    std::string message;
    std::stacktrace trace;
};

std::expected<std::uint16_t, DiagError> parse_port(std::string_view s) {
    std::uint16_t port{};
    const auto* first = s.data();
    const auto* last = s.data() + s.size();
    auto [ptr, ec] = std::from_chars(first, last, port);
    if (ec != std::errc{} || ptr != last)
        return std::unexpected(DiagError{
            "érvénytelen port: " + std::string(s),
            std::stacktrace::current(0, 32)});
    return port;
}

A skip értéke itt 0, hogy a parse_port kerete biztosan benne legyen a pillanatképben; a current() belső implementációs kereteit a könyvtár eltávolítja. A max_depth = 32 felső korlátot ad a költségnek.

Kivétel esetén a stacktrace-t a kivétel objektumába építhetjük:

#include <stdexcept>
#include <stacktrace>
#include <string>
#include <utility>

class AppError : public std::runtime_error {
public:
    explicit AppError(std::string msg)
        : std::runtime_error(msg), trace_(std::stacktrace::current(0, 32)) {}
    const std::stacktrace& trace() const noexcept { return trace_; }
private:
    std::stacktrace trace_;
};

A current() allokálhat, ezért a kivétel konstruktorában a std::bad_alloc kockázata fennáll; noexcept destruktorban vagy kivételkezelő rutinban csak akkor hívjuk, ha ezt a kockázatot kezeljük. A trace_ tagot érdemes mozgatással átadni, mert a basic_stacktrace másolása a teljes kerettömböt másolja.

41.4.4 Fix pufferes formázás spanstreammel

A std::spanstream (P0448) a <spanstream> fejlécben érhető el; a std::basic_spanstream<charT, Traits> egy std::span<charT> által adott fix pufferen dolgozik, allokáció nélkül. A std::spanbuf a mögöttes puffer, a span() tagfüggvény kimeneti módban a ténylegesen kiírt tartományt adja vissza std::span<char>-ként. (view() tagfüggvény nincs: az a std::stringstream-é, és std::string_view-t ad; a két API-t nem szabad felcserélni.) A puffer megtelésekor a stream badbit vagy failbit állapotba kerül; ezt ellenőrizni kell, különben csendben csonkolt üzenetet írunk ki.

// diag_line.cpp — C++23: fix pufferes naplósor spanstreammel
#include <array>
#include <cstdio>
#include <span>
#include <spanstream>
#include <string>
#include <string_view>

// Hibapolitika: ha az üzenet nem fér el, a csonkolt sor helyett egy fix,
// rövid jelzés kerül ki; a visszatérési érték jelzi a kiírás sikerét.
bool diag_line(std::string_view what) {
    std::array<char, 64> buf{};
    std::spanstream ss(buf);
    ss << "[diag] " << what << '\n';
    if (!ss) {
        static constexpr std::string_view overflow =
            "[diag] (az uzenet nem fert el a pufferben)\n";
        return std::fwrite(overflow.data(), 1, overflow.size(), stderr)
               == overflow.size();
    }
    const std::span<char> written = ss.span();   // a ténylegesen kiírt rész
    return std::fwrite(written.data(), 1, written.size(), stderr)
           == written.size();
}

int main() {
    const bool a = diag_line("short message");
    const bool b = diag_line(std::string(200, 'x'));   // túl hosszú
    return a && b ? 0 : 1;
}

A hibapolitika dokumentált: ha az üzenet nem fér el, a csonkolt sor helyett egy rövid, fix jelzés kerül ki; a függvény visszatérési értéke a fwrite sikerét jelzi, így a hívó dönthet a naplózási hiba kezeléséről.

Buildjegyzőkönyv (lab-gcc profil, g++ -std=c++23 -Wall -Wextra -Wpedantic). A program figyelmeztetés nélkül fordult; a standard hibakimenetre [diag] short message, majd a túl hosszú üzenetnél [diag] (az uzenet nem fert el a pufferben) került, a kilépési kód 0. A ss.view() hívás has no member named 'view' hibával nem fordul.

A spanstream előnye a determinisztikus memóriahasználat és az allokáció elkerülése ott, ahol a naplózás szűkös környezetben történik. Hátránya, hogy a puffer méretét előre ismerni kell, és a formázás nem std::format-alapú; a std::format és a std::print allokálhat, ezért nem helyettesíti a fix pufferes utat. A spanstream nem async-signal-safe, signal handlerben nem használható. A feature-test __cpp_lib_spanstream; a támogatást itt is compile probe-bal ellenőrizzük.

41.4.5 Költség és a hot path védelme

A rögzítés költsége a keretek számával és a visszafejtési módszerrel nő; a symbolizáció külön, jellemzően nagyobb költség. A current() allokálhat, ezért a hot pathban különösen kerülendő. A védelem eszközei: csak hibaágon rögzíteni; a max_depth szigorú korlátozása; a skip beállítása; a symbolizáció késleltetése; rate limit vagy mintavétel ott, ahol a hiba nagy gyakoriságú. A mérést nem helyettesíti a becslés: a költséget a saját toolchainen, a saját optimalizálási profilon kell megmérni.

Egy mérési terv: rögzítsük a max_depth 8, 32 és 128 értékeit; hasonlítsuk össze a -O0 -g, -O2 -g és -O2 (stripped) buildet; a -fno-omit-frame-pointer legyen külön változó; a mérést warmup előzze meg, és a mediánt és a p99-et jegyezzük fel. Az elfogadási feltétel a szolgáltatás SLO-jából származik, nem univerzális szám.

#include <stacktrace>
#include <chrono>
#include <cstdio>
#include <cstddef>

int main() {
    constexpr int N = 10000;
    std::size_t total = 0;
    const auto t0 = std::chrono::steady_clock::now();
    for (int i = 0; i < N; ++i) {
        const auto st = std::stacktrace::current(0, 32);
        total += st.size();
    }
    const auto t1 = std::chrono::steady_clock::now();
    const auto ns = std::chrono::duration_cast<std::chrono::nanoseconds>(t1 - t0).count();
    std::printf("N=%d, keretek=%zu, atlag=%.1f ns/hivas\n",
                N, total, static_cast<double>(ns) / N);
}

A kód nem futtatott; a mérés eredménye a konkrét gépen, fordítón és könyvtáron múlik.

41.4.6 Gyakori hibák és javításuk

A leggyakoribb hiba a current() hívása minden kérés kiszolgálásakor, a hot pathban; javítás: csak a hibaágon rögzíteni. A második, hogy a stacktrace-t a legfelső hibakezelőben rögzítik, ahol már elveszett a hiba helye; javítás: a hiba keletkezésénél rögzíteni, és az értéket továbbadni. A harmadik a -lstdc++exp hiánya vagy a feature-test elmaradása; javítás: compile probe és a linker beállítása. A negyedik a stripped release bináris, amelyből üres description() származik; javítás: debug info külön fájlban, build-id alapján. Az ötödik a basic_stacktrace felesleges másolása; javítás: mozgatás. A hatodik a spanstream túlcsordulásának figyelmen kívül hagyása; javítás: a stream állapotának ellenőrzése. A hetedik a current() hívása noexcept destruktorban; javítás: a kivétel kockázatának kezelése vagy elkerülése.

41.4.7 Feladatok

  1. Magyarázó feladat. Készíts döntési táblázatot arról, hogy egy szolgáltatás mely pontjain rögzítenél stacktrace-t (váratlan hiba, validációs hiba, hot path, indulás, leállás, aszinkron callback), és minden sorhoz indokold a döntést a költség és a diagnosztikai érték szempontjából.

  2. Kódolási feladat. Egészítsd ki a parse_port példát egy with_report függvénnyel, amely or_else-sel kiegészíti a hibaüzenetet a stacktrace szöveges formájával, majd írd ki az eredményt. Ügyelj arra, hogy a trace a hiba keletkezésének helyén rögzüljön.

  3. Diagnosztikai feladat. Egy stripped release binárisban a description() üres, a source_file() és source_line() szintén üres. Sorolj fel legalább három lehetséges okot, és mindegyikhez adj egy ellenőrzési lépést.

  4. Mérési feladat. Tervezz mérést a current() költségére: add meg a független változókat, a mintaszámot, a warmupot, a mért statisztikákat és az elfogadási feltételt.

41.4.8 Megoldások

1. megoldás. A döntési táblázat lehetséges tartalma:

Kontextus Rögzítés Indok
Váratlan hiba igen a hibaút ritka, a diagnosztikai érték nagy
Validációs hiba nem nagy gyakoriságú, a hiba helye a hibaüzenetből ismert
Hot path nem a költség és az allokáció nem fér bele a budgetbe
Indulás opcionális a kontextus ismert, de a konfigurációs hiba diagnosztizálható
Leállás igen a leállítás ritka, a hívási lánc fontos
Aszinkron callback igen, rate limittel a hiba forrása nehezen követhető, de a gyakoriság korlátozandó

Az indoklás minden sorban a költség és a diagnosztikai érték arányán alapul; az ellenőrzési feltétel az, hogy a hot pathra eső rögzítések száma nulla legyen, és a hibaágon a trace ne legyen üres.

2. megoldás.

std::expected<std::uint16_t, DiagError> with_report(std::string_view s) {
    return parse_port(s).or_else(
        [](DiagError e) -> std::expected<std::uint16_t, DiagError> {
            e.message += "\n--- stacktrace ---\n";
            e.message += std::to_string(e.trace);
            return std::unexpected(std::move(e));
        });
}

Az or_else csak a hibaágon hívódik, így a sikeres útvonalon nincs többletköltség. A trace a parse_port-ban, a hiba keletkezésénél rögzült; a with_report csak a megjelenítést egészíti ki. Ellenőrzési feltétel: érvénytelen bemenetre a message tartalmazza a „stacktrace” jelölőt, és a trace.size() nullánál nagyobb, feltéve, hogy a toolchain támogatja a rögzítést.

3. megoldás. Lehetséges okok és ellenőrzések: (a) a binárist strip --strip-all-lal csökkentették, így a .symtab és a debug szekciók eltűntek — ellenőrzés: file, readelf -S; (b) a build nem kapott -g kapcsolót — ellenőrzés: a buildparancs és a readelf -S debug szekcióinak hiánya; (c) a debug info külön fájlban van, de a build-id vagy a debuglink nem található — ellenőrzés: readelf -n, readelf --string-dump=.gnu_debuglink; (d) a szimbólumok nem exportáltak a dinamikus táblába — ellenőrzés: nm -C, nm -D. A native_handle() nyers címét addr2line -e és gdb segítségével lehet feloldani, a PIE betöltési bázisát figyelembe véve.

4. megoldás. Független változók: max_depth (8, 32, 128), optimalizálási szint (-O0, -O2), debug info (-g be/ki), frame pointer (-fno-omit-frame-pointer be/ki). Mintaszám: legalább 10 000 hívás, 1 000 warmup hívással. Mért statisztikák: medián, p99, szórás. Elfogadási feltétel: a mérés variációs koefficiense 5% alatt legyen, és a hibaágon mért rögzítési költség a szolgáltatás SLO-jából levezetett budgeten belül maradjon. A mérési kódot úgy kell kialakítani, hogy a fordító ne ejtse ki a hívást (a keretszám összegzését ki kell írni).

41.4.9 Összefoglaló

A std::stacktrace a hiba pillanatának hívási láncát rögzíti; a current() nem noexcept, mert allokálhat, és a skip/max_depth paraméterekkel a költség korlátozható. A rögzítés és a symbolizáció külön lépés: a description(), source_file() és source_line() implementációfüggően üres lehet, ha a debug info nem elérhető, a bináris stripped, vagy a PIE betöltési bázisa nem ismert. A linkelés feltétele implementációfüggő (GCC/libstdc++ esetén például -lstdc++exp), ezért feature-test és compile probe szükséges. A stacktrace-t a hiba keletkezésénél kell rögzíteni, std::expected hibaértékébe vagy kivétel objektumába építve. A fix pufferes megjelenítésre a std::spanstream allokációmentes alternatívát ad, de a túlcsordulást ellenőrizni kell. A hot path védelme a költségmérésen és a hibaágon történő rögzítésen alapul.