资讯动态

C/C++程序耗时测量:四大计时方案原理与踩坑指南

发布时间:2026/10/5 1:17:22 来源:尧图企业网站定制
做 C/C 的基本都绕不开一个问题程序跑得够不够快运行时间是多少。算法竞赛里它有 1 秒、2 秒的时限多跑 100 毫秒就是 TLE业务代码里它决定一个请求是 5 毫秒还是 500 毫秒就算你只是交个数据结构课程设计老师也会问一句你这套植物百科的增删改查一万条数据要多久。算程序运行的时间就是这些问题的起点。网上讲 C/C 计时方案的文章挺多但大多数只贴一段 clock() 的代码就完了真正踩过的坑一个没提。我写代码这些年从最早在 Windows 上用 GetTickCount到后来切到 C11 的 chrono再到给老代码库补性能测试把能踩的坑基本都踩了一遍。这篇文章不整虚的直接把四套主流计时方案、各自原理、完整封装代码以及我实测中遇到的坑全部摊开讲。先说结论省得你翻到最后要跨平台、要稳定用 C11 的 chrono时钟选 steady_clock。要在 Windows 上追求微秒级高精度用 QueryPerformanceCounter。要写算法题、搞竞赛clock() 也够用但注意它在多线程下不靠谱。POSIX/Linux 环境下clock_gettime 配合 CLOCK_MONOTONIC 是精细测量的靠谱选择。下面一个个拆。1. 先搞清楚你测的是墙上时间还是CPU 时间1.1 两种时间有什么区别很多新手拿到 clock() 就开始用结果发现测出来的数跟自己掐表对不上。比如一段空转 5 秒的循环clock() 给出的结果可能是 5 秒看起来没毛病但同样的循环放线程池里跑任务里再 sleep 两秒clock() 的结果反而缩水了怎么想都想不通。这里的关键是时间分两类。第一类叫墙上时间英文叫 wall-clock time就是你拿块表从代码开始走到代码结束表上走了多少秒这是真实世界的流逝时间。第二类叫 CPU 时间指 CPU 真正花在你这段代码上的时间。一个程序里如果发生了 sleep、等待 I/O、线程切换后被晾在一边墙钟时间会一直走CPU 时间却可能停在原地不动。clock() 统计的就是进程消耗的 CPU 时间不是墙钟时间。这一点几乎每个 C/C 开发者都被坑过因为它的名字太有迷惑性了。在主流实现里clock() 返回的是 clock_t 类型你把它除以 CLOCKS_PER_SEC 才是秒数而且这个秒是 CPU 时间不是自然时间。1.2 场景决定工具别一把尺子量所有东西搞清楚两类时间的区别之后你会发现选哪套方案完全取决于你要回答什么问题如果你想回答我这个函数整体要跑多久比如一个图像处理算法处理一张 4K 图从调用到返回共耗时多少那你关心的是墙钟时间用 chrono 或者 clock_gettime 那一类。如果你想回答我的 CPU 密集计算部分到底烧了多少算力比如一个排序算法在 CPU 上实际跑了多久的 CPU 时间那你要看的是 CPU 时间clock() 反而合适。如果你在做基准测试想把两个算法放在公平环境里比快慢那你更应该在排除干扰的条件下看墙钟时间而且要多轮采样取中位数或最小值。换句话说先想清楚目的再选工具。目的不清测出来的数字没有任何意义。我见过不少人拿着一个被系统调度、后台更新、散热降频污染过的墙钟时间去判断两个算法谁快结论自然完全不可信。2. 四套主流计时方案原理、代码与适用边界老规矩先把方案列清楚再一个个讲。按推荐程度和使用频率排一下C11 的 chrono 是现在写新代码的首选跨平台、语义清晰、精度足够Windows 上做性能采集绕不开 QPCLinux/Unix 下 clock_gettime 精度能到纳秒级clock() 是 C 标准库里最经典的但限制也最多。除了这四套还有 time()、GetTickCount()、rdtsc 之类由于精度或稳定性问题日常做性能分析基本用不上后面我会在踩坑环节提一句为什么别碰 rdtsc。2.1 clock()C 标准库里的老资格但别指望它高精度clock() 是 C 标准库time.h里的函数C 里用ctime也可以调用。它返回程序运行开始到现在所消耗的 CPU 时钟计数除以 CLOCKS_PER_SEC 就能得到秒数。#include cstdio #include ctime int main() { clock_t start clock(); // 要测量的代码段 volatile long long sum 0; for (long long i 0; i 500000000LL; i) { sum i; } clock_t end clock(); double seconds static_castdouble(end - start) / CLOCKS_PER_SEC; std::printf(CPU 时间: %.4f 秒, sum %lld\n, seconds, sum); return 0; }为什么这里加了 volatile因为如果不加编译器会看出来这个累加结果根本没被用到直接把你整个循环优化没了最后测出一个接近 0 的耗时。这是计时测试里最经典的坑后面排查部分会展开讲。clock() 的粒度在 Windows 上通常比较粗糙很多实现里最小单位是 1/CLOCKS_PER_SEC在 MSVC 下 CLOCKS_PER_SEC 是 1000也就是说最小只能分辨到 1 毫秒在 Linux 的 glibc 实现里精度也没好到哪儿去。更重要的是clock() 统计的是整个进程的 CPU 时间在 C 多线程程序里这个值会把所有线程烧掉的 CPU 时间加在一起可能比你掐表的墙钟时间大很多也可能因为线程等待而偏小非常容易误导人。我的建议是写算法题、做课程设计、粗略估算完全可以用它因为操作最省事不用引入任何额外依赖但你要是准备做一个正式的基准测试或者程序里有线程、有休眠、有 I/O请直接往下看 chrono。2.2 chronoC11 起跨平台计时的第一选择chrono 是 C11 引入的时间库头文件chrono从设计上就把时间点、时间段和时钟分开了。它有三个自带时钟system_clock、steady_clock 和 high_resolution_clock。system_clock对应系统实时时钟也就是墙上时间它可能被用户改时间、NTP 校时影响不适合用来测耗时。steady_clock单调时钟保证只会往前走不会被系统时间调整影响这才是测耗时该用的。high_resolution_clock名字听起来最牛标准上说是最短 tick 周期的时钟但不同编译器实现不同有的就是 steady_clock 的别名有的在部分平台上稳定性还不如 steady_clock 好评估。实操层面一句话测量耗时别碰 system_clock优先 steady_clock。如果你想写个简单 demo用 high_resolution_clock 问题也不大但别把它当成什么特权工具。来看代码#include iostream #include chrono int main() { auto start std::chrono::steady_clock::now(); // 要测量的代码段 volatile long long sum 0; for (long long i 0; i 500000000LL; i) { sum i; } auto end std::chrono::steady_clock::now(); auto duration end - start; // 三种常用单位 auto ns std::chrono::duration_caststd::chrono::nanoseconds(duration).count(); auto ms std::chrono::duration_caststd::chrono::milliseconds(duration).count(); double ms_f std::chrono::durationdouble, std::milli(duration).count(); std::cout ns: ns std::endl; std::cout ms: ms std::endl; std::cout ms_f: ms_f std::endl; return 0; }这里有个细节duration_cast 是向最近的整数做截断转换的比如 999 微秒转成毫秒就是 0所以你要是测的代码只有几十微秒却用毫秒去输出看到的就全是一堆 0。正确的做法是直接构造双精度时间间隔std::chrono::durationdouble, std::milli这样得到的毫秒数是带小数的精度不会丢。chrono 的底层也值得说一句在 Linux 上libstdc 的 steady_clock 通常基于 clock_gettime(CLOCK_MONOTONIC) 实现在 Windows 上MSVC 的 steady_clock 基于 QueryPerformanceCounter 实现。也就是说chrono 其实是在系统层的高精度接口外面包了一层统一 API跨平台写代码用它是最高性价比的。2.3 clock_gettimeLinux/POSIX 环境的纳秒级方案如果你常年混 Linux 服务器你可能更习惯直接调 clock_gettime。它定义在time.h里可以指定不同的时钟源最常见的是 CLOCK_MONOTONIC。#include time.h #include cstdio double now_ms() { struct timespec ts; clock_gettime(CLOCK_MONOTONIC, ts); return ts.tv_sec * 1000.0 ts.tv_nsec / 1000000.0; } int main() { double start now_ms(); volatile long long sum 0; for (long long i 0; i 500000000LL; i) { sum i; } double end now_ms(); std::printf(耗时: %.4f ms\n, end - start); return 0; }CLOCK_MONOTONIC 是单调时钟不会受系统改时间影响而且它在 Linux 上的分辨率是纳秒级号称支持到 1 纳秒的 tick。当然分辨率是一回事实际能稳定区分的最小间隔还要看硬件时钟源和内核配置常见 x86 机器上用 TSC 做时钟源时实际精度非常可观。用 clock_gettime 要注意一个编译链接问题早期 glibc 版本需要链接 rt 库也就是编译时加-lrt后来 glibc 2.17 之后把 clock_gettime 直接并入了 libc不用再手动加。如果你的生产环境比较老编译报未定义的引用这类错误先想想是不是缺-lrt。另外clock_gettime 家族里还有 CLOCK_PROCESS_CPUTIME_ID 和 CLOCK_THREAD_CPUTIME_ID前者可以拿到整个进程的 CPU 时间后者可以拿到当前线程的 CPU 时间。这两个在分析多线程程序时非常有用比 clock() 细粒度多了。想要线程级别的 CPU 时间用这个是最准的。2.4 QueryPerformanceCounterWindows 平台的高精度选择在 Windows 上开发最常见的坑就是标准 C/C 计时粒度不够。CLOCKS_PER_SEC 是 1000也就是 1 毫秒粒度这对很多高频的算法分析完全不够用。Windows 提供的正经高精度方案是 QueryPerformanceCounter 和 QueryPerformanceFrequency这一对函数从 Windows 2000 时代就有了至今依然是 Windows 上基准测试的标配。#include windows.h #include cstdio double measure_ms() { LARGE_INTEGER freq, start, end; QueryPerformanceFrequency(freq); QueryPerformanceCounter(start); // 要测量的代码段 volatile long long sum 0; for (long long i 0; i 500000000LL; i) { sum i; } QueryPerformanceCounter(end); return static_castdouble(end.QuadPart - start.QuadPart) * 1000.0 / freq.QuadPart; }注意 QPC 的用法先调用 QueryPerformanceFrequency 拿到计数器频率也就是每秒多少个 tick然后两次 QueryPerformanceCounter 的差值除以频率就得到秒数。这套 API 在大多数 Windows 硬件上走的是高精度节拍器精度远高于毫秒级能达到微秒甚至更细具体看硬件。用 QPC 时有一件事要特别留意不要把它返回的计数器值直接理解成 CPU 主频的周期数。在老的奔腾时代QPC 确实跟 CPU 主频挂钩过所以有人拿它去做 CPU 主频检测但现代 Windows 上 QPC 已经由平台抽象层管理和 CPU 频率没有必然关系它的频率在系统运行期基本固定。用的时候就记住一句话QPC 只是一个高精度单调递增的 tick 计数器取差值、除以频率、得到时间别想太多。另外QPC 在多核系统上早年还有核间计数器不同步的问题现代 Windows 通过平台层已经处理得比较好了。真遇到计数异常优先怀疑驱动或虚拟化环境而不是计数器本身。2.5 四套方案放一张表里看聊完四套方案整理一张对照表方便选型时一眼定位方案适用平台时间类型典型精度核心头文件推荐场景clock()全平台C 标准CPU 时间毫秒级ctime算法题、粗略估算chronoC11 跨平台墙钟/单调微秒到纳秒级chrono新代码默认选择clock_gettimePOSIX/Linux可指定纳秒级time.hLinux 性能分析QueryPerformanceCounterWindows墙上单调微秒级windows.hWindows 基准测试这张表我给过几个组里的新人基本都能照着选对。选型并不复杂复杂的是选完之后怎么测才准那就是第三节和第四节的内容。3. 实操封装一个真正好用的跨平台计时工具方案讲完了直接贴一段我自己在项目里常用的封装。这段代码说不上多高级但它解决了计时场景里几个非常实际的痛点一是不用每次写一大坨类型申明二是自动输出耗时到日志三是利用 RAII 保证哪怕代码段里有异常、有提前 return也能测到耗时。3.1 完整代码// scope_timer.hpp #pragma once #include chrono #include string #include iostream class ScopeTimer { public: explicit ScopeTimer(std::string name) : name_(std::move(name)), start_(std::chrono::steady_clock::now()) {} ~ScopeTimer() { auto end std::chrono::steady_clock::now(); double ms std::chrono::durationdouble, std::milli(end - start_).count(); std::cout [Timer] name_ 耗时: ms ms std::endl; } void restart(std::string new_name ) { if (!new_name.empty()) { name_ std::move(new_name); } start_ std::chrono::steady_clock::now(); } private: std::string name_; std::chrono::steady_clock::time_point start_; };用法很简单#include scope_timer.hpp void process_image() { ScopeTimer t(process_image); // 这里写你的图像处理逻辑 volatile int dummy 0; for (int i 0; i 100000000; i) { dummy i; } } // 出了作用域析构函数自动打印耗时这个类的核心思路就是 RAII。你在函数开头声明一个局部对象等函数执行完无论是正常 return 还是因为异常走栈展开对象析构都会执行耗时必然被打印出来。这个模式在需要多处插桩测量时特别省心不用记着在每个 return 前手动算时间。除了作用域计时器我还会再写一个匿名函数版本的方便在那种不想动函数结构、只想临时测一段代码的地方用template typename Func void bench(const char* name, Func func) { auto start std::chrono::steady_clock::now(); func(); auto end std::chrono::steady_clock::now(); double ms std::chrono::durationdouble, std::milli(end - start).count(); std::cout [Bench] name : ms ms std::endl; } // 用法 bench(快速排序, [] { quick_sort(arr.data(), arr.size()); });这个小工具用起来非常顺手尤其适合做同一个函数不同输入的对比测试。我测排序算法在不同数据规模下的表现时就是写一个循环数据集从小到大跑一遍每个规模调用一次 bench输出的结果直接就能画性能曲线。3.2 封装到底解决了什么问题可能有人觉得直接用 chrono 写个 begin/end 不就行了何必封一层我自己以前也这么想直到有一次给一个老的 C 工程补性能日志函数里有七八个提前 return 的分支用裸的 chrono 写法就意味着在每一个 return 之前都要插入一行 end now() 和 cout漏一个就是灾难。换成 ScopeTimer声明一个对象放在函数开头就完事了脏活全交给析构函数。另外封装之后测试代码可读性会高很多。性能测试这种东西过三个月自己再看都未必记得当时的场景一个清晰的名字比如 [Bench] 批量插入 unordered_map: 3.2 ms比一堆裸的 auto start 和 end 好懂多了。还有一个小细节封装里我用的是 steady_clock 而不是 high_resolution_clock。原因前面说过steady_clock 语义上就是单调时钟专门给测耗时设计的high_resolution_clock 在标准里只保证它是分辨率最高的时钟但具体是不是单调、是不是稳定实现说了算。为了避免在不同编译器上出现诡异行为稳态测量直接锁死 steady_clock 是最稳的。3.3 灰度实操用封装测一个真实的算法光说不练假把式。给你一个具体例子用 ScopeTimer 对比 std::vector 的 push_back 不预分配和预分配在 1000 万次插入下的耗时差别。#include scope_timer.hpp #include vector int main() { const int N 10000000; { ScopeTimer t(vector 不预分配(reserve)); std::vectorint v; for (int i 0; i N; i) { v.push_back(i); } } { ScopeTimer t(vector 预分配(reserve)); std::vectorint v; v.reserve(N); for (int i 0; i N; i) { v.push_back(i); } } return 0; }这个例子我跑过很多次在默认的 Debug 配置下不预分配比预分配慢很多倍原因就是扩容触发的内存重新分配和元素搬移在 Release 下差距会缩小但依然存在。这本身就是一个很好的性能分析教材你不去量永远不知道 reserve 到底值不值得写你量了才有底气去优化。跑这种对比实验时我强烈建议用 Release 配置而且别测一次两次就下结论。CPU 频率、后台进程、系统负载都会有波动正确做法是每个 case 跑三轮以上取最小值或者中位数千万不要用第一轮的数字就发结论。第一轮往往包含缺页、冷缓存这些额外成本数值会偏高。4. 实测踩过的坑那些让数据失去意义的事故现场4.1 clock() 在多线程里会算出令人迷惑的数字前面提过 clock() 统计的是进程 CPU 时间。你现在开一个主线程再开四个工作线程每个线程各自计算 3 秒五个线程并发跑墙钟时间大概是 3 秒多但 clock() 算出来的 CPU 时间很可能是 12 到 15 秒因为每个线程烧掉的 CPU 时间都累加到了一起。反过来如果线程之间大量时间在互斥等待或者 condvar 等待CPU 时间又会远小于墙钟时间。这两种失真都会让数据完全没法看。所以只要程序里出现多线程、锁、sleep、文件或网络 I/O就不要用 clock() 去测耗时。要用就用 steady_clock 测墙钟时间要格外区分线程的话用 clock_gettime(CLOCK_THREAD_CPUTIME_ID, ...) 拿单线程 CPU 时间。4.2 编译器优化把你的被测代码优化没了这是所有计时测试里最防不胜防的一个坑。你写一段循环想测它的耗时for (int i 0; i 100000000; i) { sum i; }然后 Release 模式一跑耗时 0.000001 毫秒。你的第一反应是编译器开挂了其实不是是编译器发现 sum 算完之后根本没被用到属于 dead code整段循环被直接删除了。你要测的东西压根没执行计时自然约等于零。解决办法就是让结果看起来被用到了又不改变计算语义。常用技巧有几个用 volatile 修饰累加变量告诉编译器这个变量可能在外部被修改别乱优化。把累加结果打印出来sum 只要被 printf 或者 cout 读走编译器就得老老实实把循环算完。用内嵌汇编做一个编译器没办法优化的消费点比如在循环末尾把 sum 写到一个 volatile 内存地址。第一条最简单也够绝大多数场景用了。但注意 volatile 只阻止针对该变量的优化如果你的对象本身是复杂的 class内部成员可能还是会被优化掉所以最保险的还是把最终结果打印出来。4.3 测量工具本身的高精度开销与热调用还有一个坑是离得太近看不清。当你用 QPC 或者 clock_gettime 去测一个只有几百纳秒的操作时测量函数自身调用带来的开销已经不可忽略了你测出来的是操作耗时 计时函数耗时甚至计时函数调度噪声远大于操作本身。这种情况怎么处理一个办法是批量测量把同样的操作循环执行 10000 次测总时间再除以 10000得到单次平均。另一个办法是基线扣除先单独测 10000 次空测量函数的耗时再从总耗时里减去。关于 rdtsc 我要专门说一句网上有不少人推崇用 rdtsc 指令直接读 CPU 周期计数器说精度高、开销小。但 rdtsc 有几个致命问题第一它读的是 TSC现代 CPU 的 TSC 频率和实际 CPU 频率并不一定一致尤其在有睿频、降频的机器上用周期数换算时间是错的第二它不保证在多核间同步线程被调度到不同核心可能读到不同偏移。所以 rdtsc 只适合做那种我很清楚我在测什么的微基准不适合做通用计时。尤其是新手我劝你直接远离。4.4 顺带说一句VSCode 配置 C/C 环境时的计时测试问题不知道看这篇文章的人有多少是在 VSCode 里写 C/C 的。如果是你会发现有时候计时测出来离谱不是代码的问题是环境压根没配好。热搜里那些vscode配置c/c环境、已检测到匹配的 visual c redistributable跳过安装之类的内容说的都是同一件事编译器、调试器、运行环境没对齐。我自己也踩过这类坑最常见的情况是你装了 Visual Studio 但没装 C 桌面开发相关组件然后 VSCode 装了 C/C 插件tasks.json 里配的编译器路径不对导致编译出来的二进制是旧的跑的还是老代码或者是 Windows 下缺 Visual C Redistributable程序能编译但一跑就弹窗或者秒退。碰到计时数据忽高忽低、甚至根本跑不起来先别怀疑代码把 minGW 或者 MSVC 的路径、运行库、tasks.json 的 command 一项项对一遍。用 VSCode 做性能测试还有个小建议默认的调试模式是按 F5 跑的通常是 Debug 配置你测出来的性能和 Release 可能差出几倍甚至几十倍。真的要做基准测试务必在 tasks.json 里加上-O2或/O2编译选项单独配一个 release 任务来跑。这个坑我见过太多次了调试模式下 vector 扩容慢得离谱一开优化全好了然后你以为自己调出了什么不得了的效果其实只是把编译器优化开关拨对了。5. 从测出慢到知道慢在哪性能分析的正确姿势5.1 多次测量学会看稳态而不是第一拍测耗时最忌讳一次定生死。程序第一次跑的时候数据可能还没进缓存内存页面也未必映射完启动时还要加载各种库这些都会让第一次耗时偏高。正确做法是热身加多轮采样先让被测代码跑几轮热身让缓存、分支预测器都热起来。正式采样的轮次用稳态值通常取后续几轮的最小值因为最小值最能代表不受系统干扰时的真实性能。如果多次采样波动特别大说明系统环境干扰严重先看看有没有别的大进程在抢 CPU或者是不是在轻薄本上跑导致散热降频。我自己的习惯是把同一函数跑 5 轮记下每一轮耗时然后取中位数。中位数比平均值抗噪比最小值又能反映部分随机干扰是各方面比较平衡的统计量。5.2 用时间测量定位热点先分段再优化很多时候你并不需要高精度只需要把问题切出来。比如一个图像处理流程里面经历了缩放、灰度化、滤波、阈值分割四个阶段某一帧总耗时 120 毫秒你觉得慢但不知道慢在哪。这时候就在每个阶段外面包一层 ScopeTimer跑一遍数据自然告诉你瓶颈在哪儿。九成情况下你会惊讶地发现慢的往往不是你想的那一段。这种分段计时找热点虽然土但非常有效。比一上来就上 perf 这种大杀器要容易理解得多尤其是在你不想花一整个周末去研究火焰图的时候。5.3 时间测量只是第一步复杂度分析跟上最后想强调一点计时是手段不是目的。你测出某段代码慢接下来更重要的是理解它为什么慢。比如你测出一个嵌套循环处理一万个元素要 3 秒O(n^2) 的时间复杂度摆在那里你再怎么调参数也只是把 3 变成 2.5真正的解法是换成 O(n log n) 的算法。测量给了你方向数据结构和算法给了你路径。我见过太多人把大量时间花在微调循环上用一个又一个微观优化给一个宏观上就应该推翻的算法续命。与其这样不如先花时间把时间测清楚再花时间把复杂度想明白。这两个动作加起来比任何花哨的编译器优化都值钱。最后再分享一个实操小技巧写代码的时候把计时工具和打印日志做成默认关闭、开关开启的模式。平时正常跑业务不输出任何耗时信息一旦环境变量或者配置项打开立即输出关键函数的耗时日志。这样你在生产环境或者同学部署的程序里想复现为什么慢的时候不需要重新编译一遍代码翻一下日志就全清楚了。这个习惯我坚持了四年受益很大。

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

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

免费获取报价 →
↑