前言
「高精度计时器」在 C++ 里通常指两类东西:一是测量一段代码耗时的秒表(stopwatch),二是让线程精确等待到时或按固定间隔触发的定时器(timer)。两者都依赖时钟,但关注点不同:秒表关心单调性和分辨率,定时器还关心调度延迟和唤醒精度。
初学者最常见的误解是「std::chrono::high_resolution_clock一定是最好的时钟」。标准只规定它拥有「最短的 tick 周期」,并没有要求它单调。在很多实现上,它只是system_clock的别名,会随系统时间调整而跳变,用它测耗时可能得到负数。测量时间间隔应当用std::chrono::steady_clock,它的is_steady为真,保证不会倒退。
第二个误解是「分辨率高就等于精度高」。时钟的 tick 周期再小,线程被调度器延迟唤醒、CPU 频率变化、缓存预热、测量代码本身的开销,都会让结果偏离真实值。本文讲清时钟选择、duration换算、RAII 计时器写法、定时等待的正确姿势,以及如何评估测量噪声;不给出任何没有实际测量的性能数字。
一、标准库的三种时钟
<chrono>提供三个时钟类型,都是TrivialClock,提供now()、time_point、duration和is_steady:
| 时钟 | 纪元(epoch) | 是否单调 | 适用场景 |
|---|
system_clock | 未指定,通常对应 Unix 时间 | 否 | 记录墙上时间、与日历互转 |
steady_clock | 未指定,通常为开机时刻 | 是 | 测量时间间隔、超时控制 |
high_resolution_clock | 由实现决定 | 由实现决定 | 需要最短 tick 时,但须先检查is_steady |
system_clock可以映射到time_t,适合打日志时间戳;但系统对时(NTP 调整、用户改时间)会让它向前或向后跳,绝不能用来算耗时。steady_clock的纪元没有现实含义,两个now()相减才有意义。high_resolution_clock可能是前两者的别名,标准允许,所以不要假设它单调,也不要假设它的 tick 一定比steady_clock细。
可以在运行时打印时钟的属性来确认实现:
// 适用于 C++17
#include <chrono>
#include <iostream>
#include <ratio>
template <typename Clock>
void print_clock_info(const char* name) {
using period = typename Clock::period;
std::cout << name
<< " | steady=" << (Clock::is_steady ? "true" : "false")
<< " | tick=" << period::num << '/' << period::den
<< " s\n";
}
int main() {
print_clock_info<std::chrono::system_clock>("system_clock");
print_clock_info<std::chrono::steady_clock>("steady_clock");
print_clock_info<std::chrono::high_resolution_clock>("high_resolution_clock");
return 0;
}period::num / period::den表示一个 tick 有多少秒。例如1/1000000000表示纳秒级 tick。注意:tick 周期小只说明表示精度细,不代表now()的实际更新频率有这么高,后者取决于操作系统和硬件。
二、duration 与安全换算
std::chrono::duration<Rep, Period>由数值类型和单位两部分组成。常见的别名有nanoseconds、microseconds、milliseconds、seconds,以及 C++20 才加入的days、weeks、months、years(需要 C++20 及以上)。
整数duration之间的转换用std::chrono::duration_cast,它是截断而不是四舍五入:把 1500 微秒转成毫秒得到 1。需要保留小数时,转成浮点duration:
// 适用于 C++17
#include <chrono>
#include <iostream>
#include <ratio>
int main() {
auto start = std::chrono::steady_clock::now();
// 这里放被测代码
volatile int sink = 0;
for (int i = 0; i < 1000; ++i) {
sink += i;
}
auto end = std::chrono::steady_clock::now();
// 整数毫秒:截断
auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(end - start);
// 浮点毫秒:保留小数
std::chrono::duration<double, std::milli> ms_f = end - start;
std::cout << ms.count() << " ms (truncated)\n";
std::cout << ms_f.count() << " ms (floating)\n";
return 0;
}duration<double, std::milli>的count()返回double,可以直接输出小数。从steady_clock::duration转换到它是隐式的,因为目标表示类型是浮点;反过来从浮点转整数必须显式duration_cast,避免悄悄丢精度。
把时间点格式化成「时:分:秒.毫秒」时,不要手工做除法链,先用duration_cast<milliseconds>得到总毫秒,再拆分:
// 适用于 C++17
#include <chrono>
#include <cstdint>
#include <iostream>
void print_elapsed(std::chrono::steady_clock::duration elapsed) {
using namespace std::chrono;
auto total_ms = duration_cast<milliseconds>(elapsed).count();
if (total_ms < 0) {
total_ms = 0;
}
auto ms_part = total_ms % 1000;
auto total_s = total_ms / 1000;
auto s_part = total_s % 60;
auto total_min = total_s / 60;
auto min_part = total_min % 60;
auto hour_part = total_min / 60;
std::cout << hour_part << ':'
<< (min_part < 10 ? "0" : "") << min_part << ':'
<< (s_part < 10 ? "0" : "") << s_part << '.'
<< ms_part << '\n';
}三、Stopwatch 与 RAII 作用域计时
最实用的封装是一个只记录起点、随时可查询经过时间的类。它应当在构造时取一次steady_clock::now(),reset()重新取起点,查询函数标记const且不修改起点。
// 适用于 C++17
#include <chrono>
class Stopwatch {
public:
using Clock = std::chrono::steady_clock;
Stopwatch() : start_(Clock::now()) {}
void reset() {
start_ = Clock::now();
}
// 经过的秒数,保留小数
double elapsed_seconds() const {
const auto now = Clock::now();
return std::chrono::duration<double>(now - start_).count();
}
// 经过的纳秒数,整数
std::chrono::nanoseconds elapsed_ns() const {
const auto now = Clock::now();
return std::chrono::duration_cast<std::chrono::nanoseconds>(now - start_);
}
private:
Clock::time_point start_;
};如果希望函数退出时自动打印耗时(包括异常退出的路径),用 RAII 作用域计时器:
// 适用于 C++17
#include <chrono>
#include <iostream>
class ScopedTimer {
public:
using Clock = std::chrono::steady_clock;
explicit ScopedTimer(const char* label)
: label_(label), start_(Clock::now()) {}
~ScopedTimer() {
const auto end = Clock::now();
const auto us =
std::chrono::duration_cast<std::chrono::microseconds>(end - start_);
// 析构函数不应抛出异常;输出失败时 iostream 默认只置错误位
std::cout << label_ << ": " << us.count() << " us\n";
}
ScopedTimer(const ScopedTimer&) = delete;
ScopedTimer& operator=(const ScopedTimer&) = delete;
private:
const char* label_;
Clock::time_point start_;
};
void work() {
ScopedTimer timer("work");
// 无论正常返回还是抛异常,析构都会输出耗时
}析构函数里做 I/O 有争议:如果std::cout抛异常(仅在开启了异常掩码时),析构函数抛出会导致std::terminate。工程中可改为把结果写入成员或在析构中try/catch吞掉异常。
四、高精度等待与误差来源
定时等待用std::this_thread::sleep_until配合steady_clock,它接收一个时间点,语义是「至少睡到该时间点之后」:
// 适用于 C++17
#include <chrono>
#include <thread>
void sleep_for_precise(std::chrono::microseconds duration) {
const auto deadline = std::chrono::steady_clock::now() + duration;
std::this_thread::sleep_until(deadline);
}标准保证的是「不早于截止时间」,不保证「正好在截止时间醒来」。操作系统调度、其他线程抢占、电源管理都会让实际唤醒晚于截止时间。对毫秒级需求,这通常够用;对微秒级需求,单靠sleep_until无法保证,需要实时调度策略、CPU 隔离等系统级配置,而且这些配置因平台而异。
一种常见的折中是「粗睡 + 自旋」:先睡到截止时间前一小段,再忙等剩余时间。忙等会占满一个核心,必须谨慎使用:
// 适用于 C++17
#include <chrono>
#include <thread>
void hybrid_wait(std::chrono::microseconds total) {
using clock = std::chrono::steady_clock;
const auto deadline = clock::now() + total;
const auto spin_margin = std::chrono::microseconds(200);
if (total > spin_margin) {
std::this_thread::sleep_until(deadline - spin_margin);
}
while (clock::now() < deadline) {
// 忙等,占满当前核心
}
}spin_margin取多大取决于平台,没有通用最优值。忙等期间 CPU 无法做其他工作,在笔记本上还会增加功耗,因此只适合确实需要低延迟且核心数充足的场景。
测量误差的来源可以归为几类:
| 来源 | 影响 | 缓解方式 |
|---|
| 时钟 tick 粒度 | 小于一个 tick 的间隔无法区分 | 增大被测工作量或重复多次 |
| 测量调用本身开销 | 每次now()有固定成本 | 减少调用次数,测量空操作基线 |
| 缓存与分支预测预热 | 第一次运行偏慢 | 先跑预热轮次,再统计 |
| 调度与中断 | 偶发离群值 | 取中位数/最小值,报告分布 |
| 编译器优化 | 被测代码被消除或提升 | 用volatile或 benchmark 的防优化手段 |
| CPU 频率变化 | 同样代码耗时漂移 | 固定频率、多次测量、报告环境 |
测量时应至少报告样本数、最小值、中位数和最大值,而不是只给一个平均值。单次测量几乎无法说明问题;用steady_clock连续采样并统计分布,才能判断差异是否超过噪声。
常见坑点
❌ 用system_clock测量函数耗时。 ✅ 用steady_clock;系统对时会让system_clock跳变,导致耗时为零甚至负数。
❌ 认为high_resolution_clock一定单调、一定最精确。 ✅ 它可能只是system_clock的别名;测间隔统一用steady_clock,需要时先检查is_steady。
❌ 用整数duration_cast把微秒转毫秒后比较快慢,结果大量为 0。 ✅ 用duration<double, std::milli>或纳秒整数,避免截断到零。
❌ 只测一次就下结论「A 比 B 快」。 ✅ 预热、重复多次、取分布统计;单次结果受调度和缓存影响极大。
❌ 被测代码结果没有被使用,编译器把整个循环优化掉。 ✅ 把结果写入volatile变量或使用 benchmark 框架的防优化工具,并检查生成的汇编(如果环境允许)。
❌ 认为sleep_until会精确在截止时刻唤醒。 ✅ 标准只保证不早于截止时间;实际唤醒受调度影响,可能明显偏晚。
❌ 在高精度定时循环里无条件忙等,占满所有 CPU 核心。 ✅ 粗睡加短自旋,或使用操作系统提供的定时器/事件机制;评估功耗与延迟需求。
❌ 在析构函数里做可能抛异常的输出。 ✅ 析构函数默认noexcept,内部捕获异常或改为显式stop()输出。
总结
| 需求 | 正确工具 | 关键注意点 |
|---|
| 测量代码耗时 | std::chrono::steady_clock | 单调,不受系统时间调整影响 |
| 记录日历时间 | std::chrono::system_clock | 可与time_t互转,不适合测间隔 |
| 小数秒表示 | duration<double, std::milli> | 避免整数截断 |
| 自动打印耗时 | RAII 作用域计时器 | 析构不抛异常 |
| 线程等待 | sleep_until+steady_clock | 只保证不早于截止时间 |
| 评估精度 | 多次采样 + 分布统计 | 区分分辨率与准确度 |
高精度计时的第一原则是选对时钟:测间隔只认单调时钟。第二原则是承认误差:分辨率、调度、缓存和测量开销都会叠加,任何结论都应当附带测量方法和样本分布。把Stopwatch、RAII 计时器和duration换算这三块用熟,就足以覆盖绝大多数性能分析与超时控制需求。