32.4 chrono, duration és időpont

A C++20 chrono könyvtárának legfontosabb hozadéka nem az, hogy „van benne idő”, hanem hogy az idő típusokba kódolt szerződés. A C világában a time_t, a struct timespec és a clockid_t együtt él, és a programozó fejében kell nyilvántartani, hogy melyik érték melyik órához és milyen egységhez tartozik. A chrono ezt a megkülönböztetést a típusrendszerbe emeli: a steady_clock és a system_clock időpontja nem keverhető össze véletlenül, a másodperc és a milliszekundum külön típus, és a konverzió csak ott implicit, ahol nem veszik el információ.

Ez az alfejezet két dolgot jár körül: a mért időt (elapsed, timeout, latency — monoton óra kell hozzá) és a naptári időt (timestamp, ütemezés, napló — naptárhoz kötött óra kell hozzá). A kettő szétválasztása nem esztétikai kérdés: a naptári óra ugrik (NTP, kézi állítás, nyári időszámítás), a monoton óra nem korrelál a naptárral. Aki ezt összekeveri, annak a timeoutja negatív lesz, a mérése pedig negatív latenciát mutat. Az alfejezet épít a filesystem, path és OS-határ részben tárgyalt fájlrendszer-időbélyegekre, a C időkezelésre és az értéktípus-szemléletre; ezeket itt nem ismétlem meg.

32.4.1 Duration, ratio és a mértékegység típusa

A std::chrono::duration sablon két paramétere a tároló típus és a tick hossza:

template<class Rep, class Period = std::ratio<1>>
class duration;

A Period egy std::ratio<Num, Den> specializáció, amely azt mondja meg, hogy egy tick hány másodperc: tick=NumDen\text{tick} = \frac{Num}{Den} s. A duration::count() a tickek számát adja vissza Rep típusban, tehát a duration értéke count()⋅NumDen\text{count}() \cdot \frac{Num}{Den} másodperc. A Rep nemcsak egész lehet: duration<double> másodpercet jelent double tickekkel.

A szerződés feltételei: Rep aritmetikai típus (legalábbis a szokásos műveletekhez), a Period nevezője pozitív, és a duration értéktípus — másolható, összehasonlítható, és a műveletek a két operandus common_type rep-típusával számolnak. Ebből következik a gyakori meglepetés: seconds és milliseconds összeadásának eredménye common_type<seconds, milliseconds>, azaz jellemzően milliseconds, nem seconds.

Az implicit konverzió feltétele ([C++20] [time.duration.cons]) a cél rep-típusától függ. A duration<Rep1, P1> implicit módon duration<Rep2, P2>-vé konvertálható, ha a periódusarány számítása nem csordul túl, és (a) a cél Rep2 lebegőpontos — ekkor bármely periódus megengedett; vagy (b) a cél Rep2 egész, a forrás Rep1 nem lebegőpontos, és a forrás tickje egész számú cél tick (P1/P2 nevezője 1). Ezért implicit a seconds → milliseconds és a milliseconds → duration<double>, de nem implicit a milliseconds → seconds és a duration<double> → seconds. A szabály a periódusról és a lebegőpontosságról szól, nem a rep szűküléséről: a duration<std::int64_t, std::milli> → duration<std::int16_t, std::milli> és a duration<double> → duration<float> is implicit, és az engedélyezett konstruktor semmilyen értéktartomány-védelmet nem ad:

// duration_implicit.cpp — C++20: mely duration-konverzió implicit?
#include <chrono>
#include <cstdio>
#include <cstdint>
#include <type_traits>
using namespace std::chrono;

// Egész cél-rep: a periódusarány egész, és a forrás rep nem lebegőpontos —
// a rep szűkülése (int64 -> int16) NEM akadály:
using ms16 = duration<std::int16_t, std::milli>;
static_assert(std::is_convertible_v<duration<std::int64_t, std::milli>, ms16>);
static_assert(std::is_convertible_v<seconds, milliseconds>);
// Lebegő cél-rep: bármely arány megengedett, a float pontosságvesztése sem akadály:
static_assert(std::is_convertible_v<duration<double>, duration<float>>);
static_assert(std::is_convertible_v<milliseconds, duration<double>>);
// Nem implicit: törtrészt veszítő arány egész célba, vagy lebegő forrás egész célba
static_assert(!std::is_convertible_v<milliseconds, seconds>);
static_assert(!std::is_convertible_v<duration<double>, seconds>);

int main() {
    ms16 small = duration<std::int64_t, std::milli>{100000};   // implicit, de 100000 nem fér el
    std::printf("%d\n", static_cast<int>(small.count()));     // nem 100000
}

Buildjegyzőkönyv (lab-gcc profil). g++ -std=c++20 -Wall -Wextra -Wpedantic duration_implicit.cpp figyelmeztetés nélkül fordul, minden static_assert teljesül, a kimenet -31072: a 100000 ms csendben túlcsordult a 16 bites rep-ben. Utóbbira a duration_cast való, amely explicit, és a dokumentált szemantikája szerint csonkít, nem kerekít: a végeredmény a cél rep-típusba static_cast-olva áll elő.

using namespace std::chrono;
static_assert(duration_cast<seconds>(milliseconds(1500)).count() == 1);
static_assert(duration_cast<seconds>(milliseconds(-1500)).count() == -1);

A C++17-es floor, ceil, round a szándékot teszi explicitté. A floor lefelé (a negatív végtelen felé), a ceil felfelé kerekít. A round a legközelebbi tickre kerekít, félúton a páros tick felé ([C++20] [time.duration.cast]): round<seconds>(1500ms) és round<seconds>(2500ms) is 2 s, round<seconds>(-2500ms) pedig −2 s. Pozitív és negatív, páros és páratlan félutas esetekkel:

// chrono_round_even.cpp — C++20: floor, ceil, round, duration_cast
#include <chrono>

using namespace std::chrono;
using namespace std::chrono_literals;

// duration_cast: nulla felé csonkít
static_assert(duration_cast<seconds>(1500ms) == 1s);
static_assert(duration_cast<seconds>(-1500ms) == -1s);
// floor: lefelé, ceil: felfelé
static_assert(floor<seconds>(1500ms) == 1s);
static_assert(floor<seconds>(-1500ms) == -2s);
static_assert(ceil<seconds>(1500ms) == 2s);
static_assert(ceil<seconds>(-1500ms) == -1s);
// round: legközelebbi; félúton a PÁROS tick felé
static_assert(round<seconds>(1500ms) == 2s);    // 1 és 2 közül a páros
static_assert(round<seconds>(2500ms) == 2s);    // 2 és 3 közül a páros
static_assert(round<seconds>(-1500ms) == -2s);
static_assert(round<seconds>(-2500ms) == -2s);
static_assert(round<seconds>(2600ms) == 3s);    // nem félút: legközelebbi

int main() {}

Buildjegyzőkönyv (lab-gcc profil): a g++ -std=c++20 -Wall -Wextra -Wpedantic chrono_round_even.cpp fordítás sikeres, minden static_assert teljesül. Timeout-számításnál ez nem kozmetika: egy lejárati időpont kiszámításánál a csonkítás rendre korábbi, a ceil későbbi határt ad, és a különbség éles rendszerben másodpercekben mérhető.

Levezetés (overflow-ellenőrzés). A duration_cast nem nyújt túlcsordulás-védelmet: ha a szorzat nem ábrázolható a cél rep-ben, az eredményre nincs hordozható garancia. Legyen HH a hours száma, N=3600000000000N = 3\,600\,000\,000\,000 a nanoszekundumok száma óránként. A H⋅NH \cdot N szorzat pontosan akkor ábrázolható int64_t-ben, ha H≤⌊INT64_MAX/N⌋H \le \lfloor \text{INT64\_MAX}/N \rfloor és H≥⌈INT64_MIN/N⌉H \ge \lceil \text{INT64\_MIN}/N \rceil. Mivel a C++ egész osztása null felé csonkít, a negatív oldalon az INT64_MIN / N kifejezés éppen a felső korlátot adja, így a feltétel H >= INT64_MIN / N alakban is helyes. Például hours(3'000'000) esetén H⋅N=1,08⋅1019>9,22⋅1018H \cdot N = 1{,}08 \cdot 10^{19} > 9{,}22 \cdot 10^{18}, tehát a konverzió nem hajtható végre ellenőrzés nélkül.

32.4.2 time_point, clock és a clock domain

A time_point<Clock, Duration> egy Clock epochjától mért Duration. A Clock nem tetszőleges osztály: teljesítenie kell a TrivialClock követelményeit — rep, period, duration, time_point, is_steady és now(). A now() adja az aktuális időpontot; a time_point kivonása durationt ad, a time_point + duration új time_point-ot. Két különböző clock time_point-ja nem vonható ki egymásból: ez fordítási hiba, és ez a típusrendszer egyik leghasznosabb védelme.

clock szemantika tipikus Linux-implementáció mire való
system_clock naptári, ugrálhat, nem garantáltan monoton jellemzően CLOCK_REALTIME timestamp, napló, naptári számítás
steady_clock monoton, nem korrelál a naptárral jellemzően CLOCK_MONOTONIC mérés, timeout, backoff
high_resolution_clock implementációfüggő, lehet a kettő aliasa változó csak ellenőrzött laborprofilban

A táblázat „tipikus” oszlopa implementációs megfigyelés, nem szabványos garancia: a szabvány a system_clock és a steady_clock szemantikáját rögzíti, a mögöttes clockid_t leképezést nem. A high_resolution_clock különösen csapdás: sok implementációban steady_clock alias, de nem kötelező, hogy az legyen, és nem kötelező, hogy is_steady igaz legyen rá. Ha a laborprofilban nem ellenőrizted, ne használd mérésre.

A clock domain fogalma azt jelenti, hogy egy időérték csak a saját órájához viszonyítva értelmezhető. A steady_clock epochja tetszőleges (jellemzően a boot pillanata), ezért a belőle készített számot nem szabad szerializálni, fájlba írni vagy más folyamatnak átadni: újraindítás után más epochhoz tartozik. A system_clock C++20 óta a naptári típusokkal összekapcsolt, Unix epochú óra (a sys_time 1970-01-01 00:00:00 UTC-től számol, szökőmásodperc nélkül); korábbi szabványokban az epoch nem volt rögzítve, ezért régi kódon a to_time_t/from_time_t pár ma is a hordozható átjáró. A C++20-as chrono kiegészítés ezt a kapcsolatot rögzíti, de a system_clock monotonitását nem.

Timeoutot mindig monoton órával kell számolni. A helyes minta: a határidőt egyszer, a ciklus előtt rögzítjük, majd a ciklusban a monoton órához hasonlítunk.

const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(500);
while (std::chrono::steady_clock::now() < deadline) {
    // próbálkozás; a ciklus feltétele a valódi eltelt időt méri
}

A std::this_thread::sleep_for és a condition_variable::wait_for a szabvány szerint steady_clock-hoz kötött, tehát ezek is a monoton domainben maradnak. A system_clock-kal számolt deadline viszont NTP-ugrás után vagy azonnal lejár, vagy soha.

32.4.3 Kidolgozott példa: mérés, timeout, timestamp

A következő fordítási egység önállóan fordítható C++20-ban. Nem állítom, hogy lefuttattam; a viselkedés a laborprofil fordítási beállításaitól és a könyvtárverziótól függ, a buildparancsot a tanuló laborjában kell ellenőrizni.

#include <chrono>
#include <cstdint>
#include <iostream>
#include <limits>
#include <thread>

namespace chr = std::chrono;

// Mért idő: kizárólag steady_clock.
chr::nanoseconds measure_once()
{
    const auto t0 = chr::steady_clock::now();
    std::this_thread::sleep_for(chr::milliseconds(10));
    const auto t1 = chr::steady_clock::now();
    return t1 - t0;
}

// Overflow-tudatos konverziós feltétel: hours -> nanoseconds.
bool hours_fit_in_nanoseconds(chr::hours h)
{
    constexpr std::int64_t ns_per_hour = 3'600'000'000'000LL;
    constexpr std::int64_t max_v = std::numeric_limits<std::int64_t>::max();
    constexpr std::int64_t min_v = std::numeric_limits<std::int64_t>::min();
    return h.count() <= max_v / ns_per_hour && h.count() >= min_v / ns_per_hour;
}

// Naptári idő: system_clock, fix egységben, UTC-ben.
std::int64_t now_unix_millis()
{
    const auto since_epoch = chr::system_clock::now().time_since_epoch();
    return chr::duration_cast<chr::milliseconds>(since_epoch).count();
}

int main()
{
    const auto elapsed = measure_once();
    std::cout << "elapsed_us="
              << chr::duration_cast<chr::microseconds>(elapsed).count() << '\n';

    const auto deadline = chr::steady_clock::now() + chr::milliseconds(50);
    int spins = 0;
    while (chr::steady_clock::now() < deadline) { ++spins; }
    std::cout << "spins=" << spins << '\n';

    std::cout << "unix_ms=" << now_unix_millis() << '\n';

    const auto big = chr::hours(3'000'000);
    std::cout << "3e6h fits in nanos: " << std::boolalpha
              << hours_fit_in_nanoseconds(big) << '\n';
    if (hours_fit_in_nanoseconds(big)) {
        std::cout << chr::duration_cast<chr::nanoseconds>(big).count() << '\n';
    }
}
g++ -std=c++20 -O2 -Wall -Wextra -Wconversion -Wsign-conversion \
    chrono_demo.cpp -o chrono_demo

A példa három szerződést mutat. A measure_once csak steady_clock-ot használ, és nanoseconds-t ad vissza, hogy a hívó dönthesse el a megjelenítési egységet. A timeout-határidő a ciklus előtt rögzített steady_clock időpont. A now_unix_millis a naptári domaint fix, egész egységre képezi le, ami szerializálható. Az hours_fit_in_nanoseconds pedig azt demonstrálja, hogy a duration_cast előtt a hívónak kell ellenőriznie az ábrázolhatóságot.

A C-oldali kontraszt rávilágít a különbségre: ott a clock domain a függvény argumentuma, nem a típus része.

#define _POSIX_C_SOURCE 200809L
#include <time.h>
#include <stdint.h>

/* Mért idő: CLOCK_MONOTONIC; a clock id kiválasztása a hívó felelőssége. */
int64_t monotonic_ns(void)
{
    struct timespec ts;
    if (clock_gettime(CLOCK_MONOTONIC, &ts) != 0) return -1;
    return (int64_t)ts.tv_sec * 1000000000LL + ts.tv_nsec;
}
cc -std=c17 -Wall -Wextra monotonic.c -o monotonic

A C változatban semmi nem akadályozza meg, hogy ugyanabba a változóba CLOCK_REALTIME értéket tegyen a programozó; C++-ban ez a hiba típushibává válik, amíg valaki time_since_epoch()-en keresztül nem „lapítja” el a típusokat.

32.4.4 Naptár, timezone és DST C++20-ban

A C++20-as chrono naptári típusai (year_month_day, sys_days, hh_mm_ss, weekday) és a timezone-réteg (time_zone, zoned_time, current_zone, locate_zone, tzdb) a naptári domainre épülnek. A sys_time<Duration> a system_clock időpontja, a local_time<Duration> egy időzóna nélküli helyi idő, a zoned_time pedig a kettőt köti össze egy konkrét zónával.

A helyi idő és a sys_time közötti átjárás nem függvény, mert nem is lehet az: a nyári időszámítás miatt egy helyi időpont lehet egyértelmű, nem létező vagy kétértelmű. A time_zone::get_info(local_time) local_info-t ad vissza, amelynek result mezője unique, nonexistent vagy ambiguous. A zoned_time konstruktora kétértelmű esetben choose::earliest vagy choose::latest paramétert vár; a nem létező helyi időt a programnak kell kezelnie, nem szabad „valahogy” átfordítani.

// C++20 timezone-részlet. A <chrono> tzdb-támogatása könyvtárverzió-függő:
// fordítási próbával és a __cpp_lib_chrono feature-test makróval ellenőrizd.
#include <chrono>

void describe_local_time(const std::chrono::local_time<std::chrono::seconds>& lt)
{
    const std::chrono::time_zone* zone = std::chrono::current_zone();
    const std::chrono::local_info info = zone->get_info(lt);
    switch (info.result) {
    case std::chrono::local_info::unique:      /* egyértelmű */ break;
    case std::chrono::local_info::nonexistent: /* DST-ugrás miatt nem létezik */ break;
    case std::chrono::local_info::ambiguous:   /* óra visszaállítása miatt kétértelmű */ break;
    }
}

Két korlátot itt is hangsúlyozni kell. Egyrészt a timezone-adatbázis (tzdb) nem a szabvány része, hanem a rendszer/könyvtár szolgáltatja; tartalma és frissítési ritmusa változik, és a reload_tzdb() csak akkor segít, ha a folyamat hajlandó újratölteni. Másrészt a szökőmásodpercek külön órán élnek: a utc_clock, tai_clock és gps_clock, valamint a clock_cast kezeli őket, a system_clock nem számol szökőmásodperccel. A timezone-adatbázis vagy a teljes Unicode-kezelés saját implementációja kívül esik ezen az alfejezeten; a szövegkódolás és locale kérdéseit az előző rész tárgyalja.

32.4.5 Timestamp-szerializálás és clock domain

A szerializálás szabálya egyszerű, de következménye van: a tartósan tárolt és továbbított időérték naptári idő, fix egységben, UTC-ben. A steady_clock értékét tilos kiírni. A system_clock::time_point-ból time_since_epoch()-tel duration lesz, duration_cast-tal fix egységre váltunk, count()-tal egész számot kapunk; visszafelé a sys_time<milliseconds>{milliseconds{value}} rekonstruál. Az int64_t milliszekundum ±292\pm 292 millió év tartományt fed le, a nanoszekundum ±292\pm 292 évet — ez a tartomány a gyakorlati rendszerekben elég, de a határt dokumentálni kell.

A protokollnak rögzítenie kell a clock domaint is: melyik óra, melyik egység, mi történik óraugráskor, és hogy a sorrendiséghez használt idő melyik domainből jön. Egy naplóban a system_clock időbélyeg és a steady_clock-ból számolt relatív eltelt idő együtt értelmes: az előbbi megmondja, mikor, az utóbbi megmondja, mennyi ideig. Önmagában egyik sem elég.

32.4.6 Tipikus hibák és javításuk

  1. Mérés system_clock-kal. NTP-ugrás vagy kézi állítás után negatív vagy hihetetlen latencia. Javítás: mérésre és timeoutra steady_clock.
  2. high_resolution_clock steady feltételezése. Hordozhatatlan. Javítás: steady_clock, vagy a laborprofilban ellenőrzött is_steady.
  3. duration_cast kerekítésnek gondolása. A csonkítás rendre korábbi határt ad. Javítás: floor/ceil/round a szándék szerint.
  4. Ellenőrizetlen duration_cast nagy egységekből. Csendes túlcsordulás. Javítás: a szorzat ábrázolhatóságának ellenőrzése a hívónál.
  5. Helyi idő tárolása. DST-átmenetnél kétértelmű vagy nem létező érték. Javítás: UTC-ben tárolás, lokalizálás csak megjelenítéskor, get_info-val.
  6. Két clock domain összekeverése time_since_epoch()-en keresztül. A típusrendszer megkerülése; a hiba csak futásidőben látszik. Javítás: a time_point típusát a végpontig megőrizni.

32.4.7 Feladatok

1. feladat (magyarázó). Indokold, melyik clockot használnád: (a) egy HTTP-kérés időbélyegzője, (b) egy adatbázis-lekérdezés latenciája, (c) egy retry backoff határideje, (d) egy naplósor és a hozzá tartozó „eltelt 120 ms” mező.

2. feladat (kódolási). Írj to_seconds_ceil(std::chrono::milliseconds) függvényt, amely felfelé kerekítve adja vissza a másodpercek számát, és negatív értékre is helyes. Add meg az ellenőrzési feltételt.

3. feladat (diagnosztikai). Adott a következő kódrészlet. Mi a hiba, milyen tünetet okoz NTP-ugrás után, és hogyan javítod?

const auto start = std::chrono::system_clock::now();
do_work();
const auto elapsed = std::chrono::system_clock::now() - start;
log("elapsed_ms", std::chrono::duration_cast<std::chrono::milliseconds>(elapsed).count());

4. feladat (kódolási). Szerializálj egy system_clock::time_point-ot int64_t Unix-milliszekundumba, majd olvasd vissza. Add meg a tartományt, és azt, hogy mi történik, ha a bemenet a tartományon kívül esik.

32.4.8 Megoldások

1. megoldás. (a) system_clock: naptári idő, más rendszerekkel összevethető. (b) steady_clock: csak az eltelt idő számít, a naptári ugrás nem. (c) steady_clock: a határidő a folyamat élettartamán belül értelmezett, monoton kell. (d) kettő: a naplósor időbélyegzője system_clock, az eltelt idő steady_clock; a kettőt külön mezőben kell tartani, nem összeadni. Ellenőrzés: minden válaszhoz indokold, hogy az adott értéket tárolják-e, összehasonlítják-e egy másik folyamat értékével, és átél-e újraindítást.

2. megoldás. A ceil a duration_cast csonkítását ellensúlyozza, de a negatív irányt külön kell kezelni, mert a ceil a matematikai mennyezet, nem a nullától elfelé kerekítés.

#include <chrono>

std::chrono::seconds to_seconds_ceil(std::chrono::milliseconds ms)
{
    using namespace std::chrono;
    const auto s = duration_cast<seconds>(ms);
    if (s < ms) { return s + seconds{1}; }  // volt csonkolt maradék
    return s;
}

Ellenőrzési feltétel: to_seconds_ceil(1500ms) == 2s, to_seconds_ceil(2000ms) == 2s, to_seconds_ceil(-1500ms) == -1s, to_seconds_ceil(0ms) == 0s. Az utolsó három eset mutatja, hogy a „maradék” feltétel a negatív oldalon is a matematikai mennyezetet adja, mert a csonkítás null felé történt.

3. megoldás. A hiba, hogy mérésre system_clock-ot használ. Ha az NTP a mérés közben előre állítja az órát, az elapsed negatív lesz, és a duration_cast negatív milliszekundumot ad; ha hátraállítja, az eltelt idő irreálisan nagy lesz. A javítás: steady_clock a két now() hívásra, a naplózott időbélyeg pedig külön system_clock érték. Ellenőrzési feltétel: a javított kódban a mérés két steady_clock::now() különbsége, és a naplóban a két clock értéke külön mezőben szerepel.

4. megoldás.

#include <chrono>
#include <cstdint>
#include <limits>

std::int64_t to_unix_millis(std::chrono::system_clock::time_point tp)
{
    return std::chrono::duration_cast<std::chrono::milliseconds>(
               tp.time_since_epoch()).count();
}

bool from_unix_millis(std::int64_t v, std::chrono::system_clock::time_point& out)
{
    using namespace std::chrono;
    const auto ms = milliseconds{v};
    const auto tp = system_clock::time_point{duration_cast<system_clock::duration>(ms)};
    if (duration_cast<milliseconds>(tp.time_since_epoch()).count() != v) {
        return false;  // a rendszer duration felbontása nem tudta visszaadni
    }
    out = tp;
    return true;
}

A tartomány int64_t milliszekundumban ±9,22⋅1018\pm 9{,}22 \cdot 10^{18} ms, azaz mintegy ±292\pm 292 millió év; a gyakorlati korlátot a system_clock::duration felbontása és a naptári típusok tartománya szabja meg. Tartományon kívüli bemenetre a milliseconds{v} konstrukció túlcsordul, ezért a bemenetet a hívónak kell ellenőriznie; a visszaolvasás utáni összehasonlítás a system_clock::duration felbontásából eredő veszteséget is kiszűri. Ellenőrzési feltétel: kerek értékre (0, 1'700'000'000'000) a visszaolvasás egyenlő; olyan értékre, amelyet a rendszer duration felbontása nem ábrázol, a függvény false-t ad.

32.4.9 Összefoglaló

A chrono lényege a domainek és egységek típusba kódolása. A duration tickekből és ratio-ból áll; az implicit konverzió csak veszteségmentes esetben engedett, a duration_cast explicit és csonkít, a floor/ceil/round a szándékot nevezi meg. A time_point a clockhoz kötött, és két clock időpontja nem keverhető. Mérésre és timeoutra steady_clock, naptári időre system_clock való; a high_resolution_clock csak ellenőrzött laborprofilban. A C++20 naptári és timezone-rétege a sys_time/local_time/zoned_time hármassal dolgozik, a DST-kétértelműséget get_info-val és choose-szal kell kezelni; a tzdb külső adatfüggőség, a szökőmásodperc külön órán él. Tartós tárolásra UTC-alapú, fix egységű, egész típusú timestamp való, a steady_clock értéke soha nem szerializálható, és a protokollnak rögzítenie kell a clock domaint.