资讯动态

C++计时器类设计:从chrono到RAII作用域计时

发布时间:2026/10/21 23:53:27 来源:尧图企业网站定制
写C的人总有一天会给自己的代码计时。算法评测、渲染耗时、接口延迟、超时判断……这些需求最后都会落到一个C计时器类上。有人临时写三行clock()有人每次手打steady_clock::now()有人干脆只在测试用例里看一眼运行时间。我经历过这几个阶段之后沉淀了一个不到一百行、能满足绝大多数场景的计时器类。这篇文章就把这个类的设计思路、完整实现、接入方式以及后来踩过的坑一起讲透。适用面挺广要做性能对比的同学、要统计每帧耗时的客户端开发、需要控制请求超时的服务端同学基本都能直接用或者按自己的场景改一改。1. 为什么自己封装一个C计时器类标准库的痛点1.1 直接用chrono写会遇到的重复劳动C11 之后标准库提供了chrono设计上其实很严谨区分了时钟、时间点、时长这些概念。但真正在业务代码里用起来会发现几个非常实际的不顺手第一类型名太长。写一行“记录开始时间”就要敲std::chrono::steady_clock::time_point start std::chrono::steady_clock::now();如果在一段代码里出现三次类似的调用代码瞬间变得啰嗦。很多项目里根本没人愿意写这么长的类型名于是退回去用clock()或者 C 风格的time()精度和语义又不对。第二计算耗时还要每次都做duration_cast。两个时间点相减拿到的是时长但要转成毫秒或者微秒需要再套一层模板。写多了就会发现这个转换特别容易漏而且代码读起来很费劲auto ms std::chrono::duration_caststd::chrono::milliseconds(end - start).count();第三想“暂停一段再继续”就麻烦了。比如测一段循环里某个阶段的耗时这个阶段中间有个等待操作不希望把等待时间也算进去那就得手动维护变量记录累计时长、记录当前开始点、记录是否在计时中。这种状态只要出现在两个函数里就特别容易出 bug。与其每次写一遍不如把“开始、停止、暂停、恢复、查询”这些行为统一收进一个类里。这就是我认为 C 计时器类最核心的价值把计时逻辑做成一个状态机业务代码只需要负责调用start()和stop()不需要关心中间那些时间点怎么存、时长怎么累加。1.2 一个计时器类能适配的四大典型场景我总结下来一个完整的计时器类至少能覆盖四类常见场景性能基准测试对比两个算法、两个版本的耗时判断哪一版更快。实时渲染与动画循环统计每帧耗时算平均帧时间、百位线耗时。网络与异步超时控制轮询等待一个条件超过阈值就放弃。音视频或数据流处理中的同步与缓冲统计比如统计数据攒够多少个包、处理一帧音频用了多久。这四类场景对接口的诉求其实不完全一样。性能测试看重“一次性计时”帧耗时统计看重“周期性重启计时”超时控制看重“反复读取已经经过的时间”而异步场景往往需要“暂停/累计”语义。所以接口设计不能只做一个“记录开始和结束”的玩具。这也是我下面要重点讲的设计接口时先把语义定清楚再考虑具体实现。2. 计时器类的接口设计先定语义再写代码2.1 核心方法start、stop、reset、restart我最终的接口很简单就五个方法加一个查询函数start()、stop()、reset()、restart()、elapsed()。先说状态。这个类内部只维护三个字段一个累计时长accumulated_、一个当前起点start_、一个布尔标记running_。整个对象的状态可以分成三个空闲从未开始或者累计时长已经被清零。此时accumulated_为 0running_为 false。运行中running_为 truestart_记录的是最后一次开始或者继续的瞬间。暂停running_为 falseaccumulated_中已经保住了暂停前累计的时间。start()的语义是“使计时器进入运行状态”。如果当前已经在运行调用start()应该被忽略不能把start_直接覆盖掉否则会把之前累计的时间丢掉。这个对“重复调用不产生副作用”的约定能在代码里减少很多不必要的判断。stop()的语义是“暂停累计”。运行中调用stop()就把now() - start_加进accumulated_然后把running_置为 false。如果本来就没在运行调用stop()应该什么都不做。注意这里的语义不是“清零重来”而是“暂存当前成绩”。reset()的语义是“清零累计时间但保持当前状态”。如果正在运行清零后继续从当前时刻重新累计如果没有运行就只是把累计值归零。这个语义在实际写代码时很有用比如渲染循环里希望统计的是“这 60 帧的平均耗时”清零后不用重新start()下一帧自动从新的起点开始累计。restart()是“清零并立即开始”。它等于reset()加隐式的start()。这个接口在性能测试里用得多测完一段代码想立刻测下一段一行restart()就完成了。2.2 时钟选型为什么锁死steady_clock作为计时器类最不该偷懒的地方就是时钟选择。很多人直接写system_clock这是最容易埋雷的选择。std::chrono::system_clock对应的是“墙上的钟”它表示的是真实世界的日期时间。问题在于墙钟是可以被人为调整的。操作系统校时、手动修改系统时间都会让system_clock往前跳或者往后跳。如果代码正在用system_clock测量一段函数的耗时中途系统时间跳了一下测出来的结果甚至可能是负数。std::chrono::steady_clock对应的概念是“单调时钟”它保证只会向前走永远不会回退。它不关心当前是几点几分只负责提供一个稳定的时间增量。这就非常适合“测量两段代码之间过去了多久”这种需求。用生活的话说system_clock是挂钟steady_clock是秒表。你要计量一个人跑 100 米用了几秒肯定不会因为旁边挂钟被人拨快了就认为他跑完了。所以我在类里直接把Clock类型通过using锁死为steady_clock并且不开放模板参数目的就是防止调用方误用。2.3 返回值设计强类型duration比裸double更安全很多简易计时器的接口长这样double getMilliseconds();单看这个函数签名其实看不出单位到底是什么。返回值是毫秒还是秒内部实现是count() * 1000还是count() / 1000调用方只能去读实现或者猜。一旦有人在两个单位之间传错排查起来非非常痛苦。我更推荐让接口返回std::chrono::duration或者模板化的计数结果。单位作为类型的一部分存在换算交给标准库调用方自己决定要毫秒还是要微秒// 想要整数毫秒 auto ms timer.elapsedstd::chrono::milliseconds(); // 想要浮点秒 double sec timer.elapsed_seconds();这样设计有另一个好处elapsed()内部可以做统一的duration_cast调用方就不必每次都写那一长串转换代码。但我不建议只提供一种返回形式。实际工程里既需要“整数毫秒”也需要“浮点秒”。比如计算帧率的时候需要1.0 / frame_seconds这种浮点除法而判断是否超时的时候整数毫秒完全够用。所以一个项目里两种形态可以同时存在模板函数返回整数计数浮点秒函数返回double。后面我会给出具体代码。3. 完整实现基础Timer类与逐段注解3.1 完整头文件代码下面这个类我用了很久代码量不大依赖只有chronoC11 之后都能编译#pragma once #include chrono class Timer { public: using Clock std::chrono::steady_clock; using Duration Clock::duration; using TimePoint Clock::time_point; Timer() default; explicit Timer(bool auto_start) { if (auto_start) { start(); } } void start() { if (!running_) { running_ true; start_ Clock::now(); } } void stop() { if (running_) { accumulated_ Clock::now() - start_; running_ false; } } void reset() { accumulated_ Duration::zero(); start_ Clock::now(); } void restart() { reset(); running_ true; } template typename Unit std::chrono::milliseconds auto elapsed() const - typename Unit::rep { return std::chrono::duration_castUnit(elapsed_duration()).count(); } std::chrono::durationdouble elapsed_duration() const { if (running_) { return std::chrono::durationdouble( accumulated_ (Clock::now() - start_)); } return std::chrono::durationdouble(accumulated_); } double elapsed_seconds() const { return elapsed_duration().count(); } bool is_running() const { return running_; } private: TimePoint start_{}; Duration accumulated_{Duration::zero()}; bool running_{false}; };整体逻辑很直观。start_是当前时间点accumulated_是已经累计的时长running_标记计时器是否处于运行状态。3.2 每个关键函数的设计理由先看start()。这里我用if (!running_)挡住了重复调用。假如没有这个判断某段逻辑不小心调用了两次start()第一次的时间点就会被第二次覆盖导致这段时间白白丢掉了。生产中“重复调用”比想象中常见尤其当start()放在循环里而循环内多个分支都会触发它时。再看stop()。这里做的是“累加”而不是“清零”。为什么为了让计时器支持暂停后继续。比如一个任务要处理三批数据批次之间有等待我只想统计实际处理时间就可以在处理前start()、处理后stop()等待的时候不累计。到下一批时再start()最终elapsed()拿到的就是三批处理时间的总和。elapsed_duration()返回的是std::chrono::durationdouble单位是秒的浮点时长。之所以用浮点不用整型是因为很多场景需要亚毫秒精度而duration_castmilliseconds会把不足 1 毫秒的部分截掉。浮点秒可以把微秒、纳秒差距也体现出来。这个函数是elapsed()和elapsed_seconds()的共同基础。reset()是目前这套接口里最容易被误解的。它的设计是“清零累计值但不清除运行状态”。比如计时器正在运行调reset()相当于把秒表拨回零然后秒表继续走。这在渲染循环里很方便每 60 帧清零一次累计耗时但不用管计时器当前是不是在运行。如果希望“清零并且停下来”应该是stop()之后再reset()。restart()则完全面向“一段代码测完立刻测下一段”的场景。它的调用频率高所以写得很短彻底清零然后立刻把running_置为 true 并把起点设为当前时间。3.3 RAII扩展作用域计时器ScopedTimer有了基础Timer我通常还会配套写一个极简的作用域计时器。它的作用是用 RAII 的方式自动计时进入作用域时开始离开作用域时结束并输出耗时。#include cstdio class ScopedTimer { public: explicit ScopedTimer(const char* name scoped) : name_(name) { timer_.start(); } ~ScopedTimer() { double sec timer_.elapsed_seconds(); if (sec 1.0) { std::printf([%s] %.3f s\n, name_, sec); } else if (sec 1e-3) { std::printf([%s] %.3f ms\n, name_, sec * 1e3); } else { std::printf([%s] %.3f us\n, name_, sec * 1e6); } } private: const char* name_; Timer timer_; };用法非常简单void ProcessFrame() { ScopedTimer t(ProcessFrame); // 这里写你的业务逻辑 }函数不管从哪个分支 return甚至抛了异常析构函数都会照常执行并打印耗时。这一点是手动start()/stop()很难保证的一旦中间提前返回很容易忘记调stop()。注意一个细节析构函数里不要放可能抛异常的操作比如打开文件、加锁、调用用户的回调函数。析构函数默认是noexcept的如果有异常从析构函数里跑出去会直接触发std::terminate。用printf这种不会抛异常的函数就足够安全了。name_存const char*也是有意为之避免额外构造std::string保持极致的轻量。3.4 扩展可暂停累计计时的Stopwatch语义有一些场景真正需要的是“秒表模式”暂停后时间不累计继续后时间接着累计。基础Timer其实已经天然支持这种语义不需要再写一个专门的类。比如套接字接收数据中间有一段 IO 等待你只想统计非等待的处理时间Timer work_timer; work_timer.start(); while (has_more) { ProcessPacket(); // 等待下一批数据这段时间不计入工作耗时 work_timer.stop(); WaitForNextPacket(); work_timer.start(); } double work_seconds work_timer.elapsed_seconds();这里的关键是stop()不会清零start()也不会重置起点以外的时间于是两次开始之间的等待时间被自然排除在外。这种写法比维护last_time、total_elapsed两个变量简单太多了。4. 三种实际接入场景从性能测试到超时控制4.1 性能基准测试循环放大减少计时器误差很多刚开始做性能测试的开发者会犯一个错误拿计时器直接包裹一小段代码然后发现结果不稳定甚至测出 0 毫秒。问题在于绝大多数被测试代码的执行时间在微秒级而steady_clock::now()本身也不是零开销再加上操作系统调度、CPU 频率调整单次测量的噪声可能比真实耗时还大。正确做法是把被测代码放在循环里重复成千上万次测总耗时再除以循环次数。Timer t; constexpr int kLoops 100000; t.restart(); for (int i 0; i kLoops; i) { FunctionUnderTest(); } t.stop(); double avg_ns t.elapsedstd::chrono::nanoseconds() / static_castdouble(kLoops); printf(avg: %.2f ns\n, avg_ns);有一个 C 编译器优化问题必须提醒如果被测试函数没有副作用优化器在开了-O2之后可能直接把整个循环优化掉。比如函数内部只是算了一个数没写出去编译器会认为循环是死的。为了避免这种情况最简单的方式是把每次结果累加到一个变量最后把这个变量打印或者传给一个不内联的“黑盒”函数。这一点在实际测试中非常重要不处理的话你会得到一个极其漂亮的耗时数字但那不是你代码的真实耗时。4.2 渲染循环里的帧耗时统计渲染循环无论游戏还是 UI 动画是计时器类最典型的应用场景。每帧开始时restart()帧结束时用elapsed_seconds()拿到这一帧的实际耗时再换算成 FPS 或者直接喂给历史统计数组Timer frame_timer; std::vectordouble frame_seconds; while (IsRunning()) { frame_timer.restart(); Update(); Render(); double frame_sec frame_timer.elapsed_seconds(); frame_seconds.push_back(frame_sec); double fps 1.0 / frame_sec; UpdateFpsDisplay(fps); }这里为什么用restart()而不是start()因为每帧都是一个全新周期帧与帧之间不需要累计时间必须清零重来。如果要做更细致的性能分析还可以用多个 Timer 分别测量Update()和Render()的耗时最后只把关键数据上报。再配合历史数组就能算平均帧耗时和 99 分位耗时。99 分位比平均值更能反映卡顿问题因为平均会把极端卡顿抹平。4.3 网络调用与异步任务中的超时管理网络请求里经常碰到“最多等 2 秒”这种需求。此时计时器用来记录“已经等了多久”配合轮询模型使用Timer wait_timer; wait_timer.start(); const double timeout_sec 2.0; while (NeedKeepWaiting()) { double elapsed wait_timer.elapsed_seconds(); if (elapsed timeout_sec) { MarkTimeout(); break; } TryReceiveOnce(); SleepMilliseconds(50); // 短暂让出避免忙等 }这里有一个概念要分清楚TryReceiveOnce()如果不是阻塞操作计时器判断超时是有效的如果它是一个会阻塞几十秒的系统调用那循环里的超时判断根本没法执行。真正的超时控制应该用带超时参数的系统 API比如非阻塞 socket 配合轮询而计时器用于确定“剩余时间还剩多少”。更好的做法是每次循环重新计算剩余时间而不是反复拿“总经过时间”和“总超时时间”比较。这两者本质一样但“剩余时间”这种写法在接口设计上更接近真实意图double remaining timeout_sec - wait_timer.elapsed_seconds(); if (remaining 0.0) { break; }总之计时器在这里不是“阻塞控制”的工具而是“进度记录”的工具。理解这一点接口才不会用错。5. 新手最容易踩的坑常见问题与排查手册5.1 时间倒流system_clock被修改导致的异常我见过不止一次有人报 bug程序跑得好好的突然一段计时的结果变成负数。第一反应是查代码逻辑查了半天没问题最后发现用的是std::chrono::system_clock。系统时间被自动校时或者手动改了一下墙上时钟直接往后跳导致减法结果变成负值。这个问题的解决办法早就写在前面了一律使用steady_clock。但实际接手项目时有些人会为了“拿到当前时间戳”顺手把system_clock::now()用在计时器里计时器类要做的就是在类型层面把它挡住。5.2 duration_cast直接截断导致全部变成0duration_cast的转换是向零取整不是四舍五入。如果一段代码实际耗时 0.4 毫秒duration_castmilliseconds得到的结果是 0。这个 0 一旦参与除法、平均值、超时判断整组数据就废了。排查手段很简单打印原始 tick 数或者先用微秒和浮点秒观测一轮确认量级之后再去选单位。我在计时器类里提供elapsedstd::chrono::microseconds()和elapsed_seconds()就是希望调用方在不同需求之间能自由切换不要让毫秒成为唯一选项。5.3 线程安全误区加锁只会让计时更失真这个类没有做线程安全也不建议通过加互斥锁让它变成“线程安全”。两个线程同时start()和stop()确实会有数据竞争但如果只是加上锁问题并没有解决锁本身会让线程阻塞在等待上于是最终测出来的耗时包含了大量锁竞争时间性能数据完全失真。更合理的使用方式是一个计时器对象只属于一个逻辑线程。真到了需要统计多线程耗时的时候要么给每个线程一个局部 Timer汇总时再合并要么用thread_local方案。性能分析工具本身不能成为被测系统的瓶颈这一点需要谨记。5.4 计时结果忽大忽小频率、预热与调度器影响计时数据抖动是常态不是 bug。常见原因有CPU 动态调频第一次循环跑的是低频后面跑的是高频。操作系统的其他进程抢占核心导致某一段被切出去。第一次调用触发了缓存加载之后的数据都在缓存里。Debug 模式下容器类做了大量边界检查耗时被严重放大。所以我的建议是测量之前先预热几轮让缓存、频率进入稳定状态统计上不要只取一次结果至少取 5 到 10 次看中位数或者 p90而不是看平均值对比两个方案时必须在同一状态、同一负载环境下交替测量才能避免“时间先后不同”造成的误判。下面给一个速查表方便排障现象常见原因正确处理计时结果出现负数误用 system_clock换成 steady_clock毫秒单位全是 0duration_cast 截断取整用微秒或浮点秒多线程调用结果错乱Timer 不支持并发每个线程独立计时器数值越测越小缓存预热/编译器优化预热循环 防止优化额外代码数值偶尔特别大系统负载或调度抢占取多次结果的中位数/p906. 经验沉淀计时器类在真实项目里的优化体感6.1 计时器本身的调用开销steady_clock::now()本身有成本虽然通常也就几十纳秒到一两百纳秒的量级不同平台差异很大。大部分业务代码完全不用关心这一点但对那种本身就只耗时几纳秒的函数计时器开销就成了不可忽略的干扰。我常用的策略是“循环放大测量”把小函数放进一个十万次循环里测完总耗时除以十万这样单次调用开销会被平摊到很小。另一个策略是“只测相对性能”不追求绝对精确比较两个算法时让它们在相同的循环次数下各自测一遍直接对比总耗时这样计时器开销对两个方案的影响基本一致对比结论仍然可靠。6.2 实用的使用习惯与扩展方向有几个使用习惯是在项目里反复验证过的值得直接抄走函数级统计优先用ScopedTimer。不改业务逻辑只插一行构造声明函数结束时自动打点。单位要显式标注。日志输出统一用“2.34 ms”“800.2 us”这种带单位的字符串不要只输出一个裸数字。裸数字在阅读时完全无法判断量级。计时器只负责记录不负责统计。把每次的耗时丢进一个环形缓冲区或者直方图后续再算平均数、分位数。这样能避免在一个类里塞入太多职责。不要用计时器做线程同步。等待一个条件变量、睡眠固定时间这些需求应该用标准库的sleep_for、condition_variable以及带超时的同步原语。计时器只回答“过去了多久”不保证“到点精确唤醒”。如果需要还可以在stop()之外增加get_accumulated_duration()这样更细粒度的接口或者把耗时记录改造成支持多个时间段的数组。核心类保持轻量扩展放在外层是我一贯的偏好。我后来把不少项目里的手写计时都统一换成了这套工具类最明显的感受不是代码变少而是团队里再也没有人把system_clock当秒表用了。一个顺手的小类能省下未来排查时间的成本往往远远大于写它时的几分钟。希望这份实现和踩坑记录能帮你少走一段弯路。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑