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.
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:
s. A duration::count() a tickek számát adja vissza
Rep típusban, tehát a duration értéke
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
a hours száma,
a nanoszekundumok száma óránként. A
szorzat pontosan akkor ábrázolható int64_t-ben, ha
és
.
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
,
tehát a konverzió nem hajtható végre ellenőrzés nélkül.
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.
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_demoA 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 monotonicA 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.
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.
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
millió év tartományt fed le, a nanoszekundum
é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.
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.high_resolution_clock steady
feltételezése. Hordozhatatlan. Javítás:
steady_clock, vagy a laborprofilban ellenőrzött
is_steady.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.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.get_info-val.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.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.
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
ms, azaz mintegy
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.
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.