自顶向下地聊聊C++的时间处理和chrono库

在上一篇文章中,我们讨论了C++处理字符串的100种方法(划掉)如何与Python协作,这篇文章我们来讨论一个不一样的话题——时间处理。说到这里,我想有些读者可能会有一些陌生感。这也难怪,毕竟C++更多作为Leetcode刷题专用系统开发语言,有些时候并不会使用到时间处理功能。但真正让我惊讶的是,直到C++11,STL标准库都只提供了从C继承来的ctime头文件,另外的可选项就是Boost库等提供的时间相关函数(果然Boost才是C++真正的标准库)。不过C++的时间处理库chrono虽然姗姗来迟,但其中一些有趣的设计还是值得我们了解一些的。当然,本文除了介绍一下chrono库的一些用法,还会探讨一个更为小众的话题,即时间库的计时精度问题。文章的四个部分会从应用层直到更为底层的实现,读者可根据自己的需要选择性地阅读。另外本文不会涉及太多ctime库的使用,对此感兴趣的读者可以参考相关文章[1]

目录

  1. 入门 —— chrono的常规用法
  2. 刨根问底 —— 涉及的一些C++语言特性
  3. 掘地三尺 —— 从标准库的实现看计时与计时精度
  4. 带上她的眼睛 —— 计时功能如何从OS、硬件能力封装而来

入门 —— chrono的常规用法

时间点与时间长度

说到时间处理,首先要理清两个概念,时间点和时间长度。熟悉Python的同学会知道datetime和time_delta两个类型,其他语言我想应当同样会有这种非常自然的设计,chrono也并不例外。考虑到叙述的连贯性,这里先介绍C++中的时钟源开始。

第一个要介绍的就是std::chrono::system_clock,这个代表着系统时间。C++20前,并没有对这个时间点的起始做出规定,从C++20开始,这个时间被确定为Unix Time。不过在20之前,大部分编译器的实现都使用Unix时间(Unix Time)作为这个时钟源的纪元(epoch)起点,因此一般来说可以将其视为Unix时间戳进行使用(对于20版本以前能确认一下当然也是更好的)。获取当前系统时间的代码就是

using namespace std::chrono; // 后文的代码默认包含此命名空间
const time_point<system_clock> now = system_clock::now();

time_point表明这是一个时间点类型,模板参数使用system_clock表明这个时间点是基于system_clock进行计时的

时间长度的模板参数代表时间长度的存储类型和计时单位

seconds s3(3); // 3秒, 使用整型存储。seconds 类型等价于duration<int64>
duration<double, std::milli> ms3k(3000); // 3000毫秒,使用浮点数存储

chrono库中提供了从纳秒到年的各种时间长度类型ref

熟悉了这两个概念后,我们就可以真正拿到可以使用的时间戳了

auto timestamp = now.time_since_epoch().count();

这里time_since_epoch()的返回值是类型是时间长度(duration类型),即从纪元起点到now对应时间点间的时间长度。时间长度类型可以通过count()转化为数值类型,方便进一步在其他代码中使用

类型转换与时间计算

在介绍类型转换之前,我们先进一步明确time_point这一类型,完整定义是

// time_point<Clock, Duration>, 例如
using unix_time_seconds = time_point<system_clock, seconds>;

也就是说,时间点是由起点(Clock部分)和计时单位(Duration部分,实际也就是一个duration<>类型)共同定义,可以理解为时间点类型内部保存了从Clock纪元开始到当前使用的时间点,以Duration为单位的时间长度。因此对于时间点的类型转换,就是对这一特殊时间长度的单位进行转换的过程

与普通类型、智能指针以及对象的类型转换类似,时间和时间点也有专门的类型转换函数。对于时间点类型,使用time_point_cast,对于时长类型,使用duration_cast

auto now_in_seconds = time_point_cast<seconds>(now); // 时间戳的单位为秒

注意,system_clock::now()时间点的单位是与平台相关的,使用时最好进行适当的转型

auto s3_ = duration_cast<seconds>(ms3k); // s3_ 与前文 s3 是相等的

使用时,时长变量可以直接加减;时间点之间可以做差求间隔;时间点与时长的加减可以得到新的时间点。在这些计算中,能够自由使用不同的时间长度类型。最终的运算结果,会使用精度更高的单位进行存储

duration<double, std::milli> ms1(1.1);
auto t = s3 + ms1; // t.count() == 3001.1;

auto tp = now_in_seconds + ms1;
// now_in_seconds.time_since_epoch().count() == 1632117633
// tp.time_since_epoch().count() == 1.63212e+12
// tp - ms1 == time_point_cast<milliseconds>(now_in_seconds)

至于比较操作,时间点和时长自然都对相应的运算符进行了重载,直接使用即可

日期

保存日期的基本类型为std::chrono::year_month_day,定义方式为

year_month_day ymd{year(2021), month(9), day(20)};

日期与时间点的转换也很方便

year_month_day ymd_now{std::chrono::floor<std::chrono::days>(now)}; // 这里floor是相当于time_point_cast,区别在于会向下取整,即舍弃精度高于day的部分
auto tp = sys_days{ymd_now}; // 转换为 system_clock 时间点,精度为 day

同样日期也可以进行加减运算

auto ymd2 = ymd + months(12); // 2022-09-20

时间点与字符串的相互转换

理论上,C++20提供了std::parse和format标准库完成日期时间字符串的相互转换,但现实是,这些特性还没有被当前的主流编译器所支持,我们也就不得不使用更传统的方式实现这一功能了

解析包含日期的字符串时,可以使用

#include <iomanip>
#include <sstream>

std::tm tm = {};
std::stringstream ss("2017-06-08 09:00:05");
ss >> std::get_time(&tm, "%Y-%m-%d %H:%M:%S");
time_point<system_clock> tp2 = std::chrono::system_clock::from_time_t(std::mktime(&tm));

将时间点转换为字符串的流程为

std::time_t tt = system_clock::to_time_t(tp2);
std::tm tm = *std::gmtime(&tt); //UTC 时间
//std::tm tm = *std::localtime(&tt); //使用本地时区
std::stringstream ss;
ss << std::put_time( &tm, "%Y-%m-%d %H:%M:%S");
std::string result =  ss.str();

看到上面这个复杂的流程,还是期待C++20的特性能够早日全部得到支持吧

语法糖

考虑到时间长度,日期等类型使用的复杂性,chrono库大量使用了自定义字面量,简化了时间相关的操作

using std::chrono::literals; // 时间相关字面量的命名空间
auto one_second = 1s;
auto two_minutes = 2min;
auto dur_2m1s = two_minutes + one_second; // dur_2m1s.count() == 121

目前(C++20)支持的字面量包括y(年)d(日)hminsmsusns

还有一个用法就是定义日期

auto ymd2 = 2021y / September / 20; // 2021-09-20

这真是一种看起来十分平凡的用法(并不)

限于篇幅,chrono库的用法就介绍到这里,更多的用法请参考文档以及一些很棒的文章。

刨根问底 —— 涉及的一些C++语言特性

C++复杂的语言特性向来都是褒贬不一的,但我想对于库的作者来说,丰富的语言特性也给了他们许多发挥的空间(尤其是上面那个离谱的日期定义方式),本节会介绍几个值得聊一聊的语言特性

模板与ratio

模板这一特性可以称得上是C++的黑魔法,许多令人震惊的用法都来源于此。ratio这个库也基于模板,关于模板的更多用法,这里就不作展开了,今天我们就专注于ratio提供的编译期计算功能,例如

#include <iostream>
#include <ratio>

int main()
{
    using two_third = std::ratio<2, 3>;
    using one_sixth = std::ratio<1, 6>;

    using sum = std::ratio_add<two_third, one_sixth>; // 注意two_third,one_sixth和sum都是类型,不是变量
    std::cout << "2/3 + 1/6 = " << sum::num << '/' << sum::den << '\n';
    return 0;
}

在编译期,sum::num就会被计算为5sum::den会计算为6。

在chrono库中,时间长度类型就是基于ratio定义的,例如分钟的定义方式就是

using std::chrono::minutes = duration</*signed integer type of at least 29 bits*/, std::ratio<60>>; // 用于说明,不代表具体实现

利用ratio,我们就可以自己定义时长单位,比如说。我们要定义一刻钟类型one_quarter_of_an_hour

using one_quarter_of_an_hour = duration<long long, ratio<60 * 15>>;
auto t = duration_cast<one_quarter_of_an_hour>(1h).count() // t == 4;

自定义字面量

前文中的语法糖部分已经介绍了chrono库中字面量的使用,相比于seconds乃至于duration<*, *>这样指定类型,字面量降低了手指关节和键盘的损耗。自定义字面量的定义也非常简单,例如,我们想要表达长度,我们就可以以米为基准,并定义其他长度

constexpr long double operator ""_m(long double v) // 下划线是自定义字面量的习惯用法,用于与标准库提供的字面量做区分
{
    return v;
}

constexpr long double operator ""_cm(long double v)
{
    return v / 100;
}

constexpr long double operator ""_mm(long double v)
{
    return v / 1000;
}

这样

long double l1 = 1_m;
long double l2 = 100_cm;
long double l3 = 1000_mm; // l1 == l2 && l2 == l3;

更近一步,我们也可以类似时间,结合ratio定义出长度类型

操作符重载

操作符重载应该是最广为人知的特性了,在许多语言中也有类似的设计。但我在见识到chrono中日期定义的方法之前,从未想到结合字面量可以产生如此有趣的效果。chrono中为了实现这一效果,定义了year_month_dayyear_monthmonth_day等多种类型,并定义了它们之间使用 /操作符的返回结果,最终实现了auto ymd = 2021y / September / 20;这种自然而又不那么自然的用法

掘地三尺 —— 从标准库的实现看计时与计时精度

既然chrono中有system_clock,那是不是还有别的表呢?没错!不仅如此,这个库里一共有7种表!通常情况下,我们会使用到的时钟有两种,system_clocksteady_clock。为了更好地理解这些时钟的用法,本节会结合文档与相关代码进行介绍

system_clock

在了解用法前,我们先讨论一下我们平常使用的时间是怎么来的。人们对时间最直观的感受就是太阳的东升西落,这就引出了世界时(UT1)简单理解这个就是基于地球自转所定义的时间。除了基于天文观测确定的世界时,还有一个时间就是原子时(TAI),以铯原子跃迁周期作为基准定义时间长度。既然选择了TAI作为时间定义的基准,不直接在日常中使用它呢?问题就在于地球自转的周期实际上并没有那么稳定,如果使用TAI,就会出现太阳在12点零几秒才到达了当天的最高点,对于日常使用实际是非常不便的。这种误差还会随着时间推移不断积累。为了综合TAI计时精度和UT实际应用的便利性,就有了协调世界时,即UTC时间。UTC时间以原子时作为计时基础,同时通过添加或减少闰秒(leap second)的方式与UT进行同步。我们日常使用的时间,实际就是UTC时间。chrono库中utc_clock提供的就是UTC时间,而system_clock提供的时间并不包含闰秒。因此在打印秒数的时候,二者会有一定的差距

cout << "utc_clock:\t" << duration_cast<seconds>(utc_clock::now().time_since_epoch()).count() << endl;
cout << "system_clock:\t" << duration_cast<seconds>(system_clock::now().time_since_epoch()).count() << endl;
// 上述代码在ms visual studio 16.11.3 开启 /std:c++latest 后测试通过,gcc尚未支持

输出

utc_clock:      1632225520
system_clock:   1632225493

另外需要注意的是,为了便于使用,utc_clock使用了Unix时间的起始时间点,上述两者之差就是Unix时间起点到代码运行时所增加的闰秒数

不过接下来会有一个有趣的事情发生,如果我们将这两个表的时间打印出来

cout << system_clock::now() << endl;
cout << utc_clock::now() << endl;
// 上述代码在ms visual studio 16.11.3 开启 /std:c++latest 后测试通过

我们会发现,他们的输出结果是一样的!(忽略高于秒的精度)

2021-09-21 12:07:27.1916279
2021-09-21 12:07:27.1945413

这就进一步引出了一个问题,这两个表的秒数不一样,但格式化后却是一样的,为什么?这就涉及到闰秒实际插入时的操作了。闰秒由IERS(International Earth Rotation and Reference Systems Service)决定。os的时间服务(如linux中的NTP)会根据该机构公布的信息,完成相关操作。对于UTC时间,在插入闰秒时,会出现23:59:59->23:59:60->00:00:00这样的操作,但对于系统时间,出现类似于23:59:60的时间会造成很大的麻烦。因此os的时间服务会用某种机制避免这种情况,例如,可以在UTC出现23:59:60时,让系统时间仍然保持在23:59:59,在这两秒中内,系统时间都是23:59:59。一个更好的方案是在插入闰秒的时间前后,略微增加系统时间秒的长度,实现平滑的过度。

现在我们将目光朝向system_clockutc_clock的具体实现。事实上,system_clock恰如其名,这个时间是通过系统调用从OS获取的,具体涉及的系统调用如下表[2]

平台系统调用
WindowsGetSystemTimePreciseAsFileTime(&ft) GetSystemTimeAsFileTime(&ft)
其他clock_gettime(CLOCK_MONOTONIC, &tp) gettimeofday(&tv, 0)

对于utc_clock目前仅MSVC提供了相关支持。通过代码可以看出[3],UTC时间是系统时间经过闰秒调整后获得的。不过如果恰好处于闰秒调整阶段,这个处理就会更为复杂一些,也欢迎有兴趣的同学进一步探究

最后需要强调的是system_clock返回的是系统时间,而系统时间是会发生调整的,例如NTP时间同步,或者用户手动设置(突然想到以前用变速齿轮或者变速精灵等软件加速游戏速度的方法)。因此要注意,这个时间是有可能发生时间回退等情况的,在使用时务必做出充分的考量

steady_clock

相比于system_clocksteady_clock最为重要的特性就是它一定是单调增加的,而且通常来说,它能够提供系统所支持的最高精度时间。另一个要注意的点是它的起始时间是不一定的,例如,它的起点可能是系统启动的时间,因此,它非常适合于测试时间区间,典型的应用就是测试程序运行时长。从代码中可以看出,steady_clock同样是由操作系统的系统调用完成,关键的系统调用如下表[2]

平台系统调用
z/OSgettimeofdayMonotonic(&ts)
Windows__QueryPerformanceFrequency() QueryPerformanceCounter(&counter)
Linuxclock_gettime(CLOCK_MONOTONIC, &tp)
macOSmach_timebase_info(&MachInfo);

另外请注意,chrono库中还提供了high_resolution_clock,顾名思义,大家可能会认为这个可以用于高精度计时,但目前在大多数平台上它仅仅是system_clocksteady_clock的别名,并没有提供更高精度的时间度量。这种不确定的实现也带来了最大的问题,high_resolution_clock若基于system_clock实现,那么潜在的时间回退风险,也使得其高精度变得缺乏意义,因而标准库并不建议用户使用

带上她的眼睛 —— 计时功能如何从OS、硬件能力封装而来

在看完了标准库中的实现后,实际上关于C++本身的部分已经结束了,但时间处理这个问题并没有结束。在前一节中,我们在标准库之外讨论了UTC时间的渊源,而这一节我们要聊一聊steady_clock的精确性和稳定性是如何达到的。

在我们向内核进发之前,先抛出几个问题

  1. CPU是如何感知时间的?特别是现代CPU可以根据负载改变频率的情况下,如何稳定计时。
  2. 在多核、多CPU硬件上运行的OS,又如何提供一个可靠的稳定时钟?
  3. 既然高精度计时依赖于系统调用,那怎么避免或者减少系统调用本身带来的时间误差?

考虑到这些问题与OS和硬件的实现非常相关,因此本文仅讨论Linux和Intel的CPU。下面的部分参考了Intel的手册和一篇非常好的博文(欢迎感兴趣的同学阅读原文[4]或者我翻译的版本[5]

如果我们百度“如何高精度测量程序运行时间”,可能会得到几个答案:使用标准库,使用clock_gettime,以及时间戳计数器(rdtsc指令)。当然我们已经知道,至少在Linux平台上,使用标准库就相当于使用了clock_gettime。因此,目前我们的比较对象就是clock_gettime和rdtsc。

从直觉上来看,使用rdtsc指令直达CPU,不论精确度和延迟都是最好的选择,也有不少人持有这一观点。要考察这一观点的正确性,还是要落实到CPU的设计中。

早期的Intel CPU,其时间戳计数器(即rdtsc读取的寄存器)增加速率是会受到CPU频率的影响,因此只能保证这个值是单调上升而不能确保稳定上升,随后的改进克服了这一不足,但仍然带来了一个问题,就是CPU休眠时这个计数器会停止增加。显然,这种设计仍然会带来一些麻烦。最终在Nehalem架构后,TSC成为了一个稳定可靠的时钟源,在CPU改变运行频率或是休眠时仍然能够稳定计时。

接下来的一个挑战是乱序执行,现代CPU为了提高处理速度,并不会完全按照代码顺序执行程序,而是会根据对指令的分析提前或者并行地执行某些代码。这就意味着,在执行rdtsc指令时,待测量的代码有可能已经执行完毕了,测量的结果自然也就失去了意义。不过这个问题还是有解决办法的,即换用rdtscp指令。rdtscp指令会在执行前增加一个内存屏障,从而解决这一问题。

但这个故事并没有结束,CPU执行TSC指令时,TSC在哪里?是多个CPU核心共享一个TSC寄存器,还是每个核心都有自己的TSC呢?答案是,每个核心都有自己的TSC。熟悉操作系统原理的同学都知道,OS会根据系统的负载对线程进行调度,同一个线程被切换到不同的CPU核上运行是家常便饭。在这种情况下,rdtsc可用的前提就是所有的TSC是同步的,否则时间就完全不可比了。更具有挑战性的情况是对称多处理器系统,只有所有CPU的所有CPU核的TSC得到同步,我们才能够用rdtsc获得可靠的时间度量。在Nehalem架构后,CPU支持跨核的同步,对于多处理器系统,则由具体的设计决定

你以为这就是全部了吗,很遗憾并不是,前方还有一个大BOSS:虚拟化。在使用云服务器、容器化技术已经是常规选择的现在,这并不是一件容易的事情。虚拟化软件最简单的方式是不对rdtsc指令进行处理,直接由物理机执行。但这也会引起与多线程场景下类似的问题,特别是虚拟机在不同宿主机上发生迁移时,这个问题就更为严重了。如果采用模拟的方法,也会引起一系列的问题。对于这种情况下的不同虚拟化软件处理方法的详细比较,请参考[5]

我们也可以使用下面的命令查看系统使用的时钟源

cat /sys/devices/system/clocksource/clocksource0/current_clocksource

我使用的阿里云服务器给出的结果是

kvm-clock

既然直接使用rdtsc会有许多潜在问题,那linux的clock_gettime是如何实现的呢?毕竟任何由软件所提供的功能,最终都要落实到硬件上去执行。目前clock_gettime的首选时钟源,其实就是TSC。但考虑到上述的一系列问题,Linux会采用专门的流程去验证当前的TSC是否是一个可靠的时钟源,并且还提供了相关的标志位,从而使用户了解当前系统TSC所支持的特性。

$ cat  /proc/cpuinfo | grep -E "constant_tsc|nonstop_tsc"

constant_tsc表明tsc以恒定速率增加而不受CPU频率变动影响,nonstop_tsc表明休眠时TSC也正常增长。如果Linux认为TSC是不可靠的,则会采用其他时钟源。

最后,是关于系统调用的问题。众所周知,系统调用由于会发生用户态和内核态的切换,是一个昂贵的操作,gettimeofdayclock_gettime也不例外。但显然系统调用的开销会影响到时间相关函数的使用。为了缓解这一问题,Linux引入了一种称之为虚拟动态共享对象(virtual dynamic shared object,vDSO)的机制[6]。直观理解就是系统把一些本属于内核态的代码映射到了用户态代码的地址空间中,在使用时就不需要经过标准的中断流程,从而大大减少调用开销。在x86平台上,使用了vDSO机制的函数有

函数名称版本
__vdso_clock_gettimeLINUX 2.6
__vdso_getcpuLINUX 2.6
__vdso_gettimeofdayLINUX 2.6
__vdso_timeLINUX 2.6

gettimeofdayclock_gettime都有着其对应的vDSO版本。对于日常撸码,只需要正常使用标准库中的函数即可,系统会优先使用vSDO版本

至于如何测量时间这个问题,结论就是,优先使用clock_gettime,当然这也就意味着优先使用标准库。除非你知道自己在干什么,否则不要直接使用rdtsc。

总结

关于代码中的时间处理,看起来是一个并不起眼的部分,但实际上,这是一个综合了语言、标准库、OS和硬件设计的一个综合性的问题。限于篇幅原因,本文对这个问题的讨论也就到此为止了,也希望各位读者能够有所收获。对此问题感兴趣的同学,欢迎进一步阅读参考文献中的相关内容

参考

  1. ^C++日期与时间编程(C++11-C++17) C++日期与时间编程(C++11-C++17) - 知乎
  2. ^abllvm-project/libcxx/src/chrono.cpp https://github.com/llvm/llvm-project/blob/ceee35e3e4bf9729e1aae9bfadd6d25bfe3769ee/libcxx/src/chrono.cpp
  3. ^STL/stl/inc/chrono https://github.com/microsoft/STL/blob/6d2f8b0ed88ea6cba26cc2151f47f678442c1663/stl/inc/chrono#L3196
  4. ^Pitfalls of TSC usage Pitfalls of TSC usage | Oliver Yang
  5. ^ab【译文】【中英对照】使用TSC的潜在风险 Pitfalls of TSC usage 【译文】【中英对照】使用TSC的潜在风险 Pitfalls of TSC usage - 知乎
  6. ^vdso(7) — Linux manual page vdso(7) - Linux manual page

自顶向下地聊聊C++的时间处理和chrono库 - 知乎 (zhihu.com)

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值