TICTOC: Header Only C++ Timer
感觉最近的更新频率略高啊~哈哈~
这次的带来的是一个十分简单便利的C++计时库。
项目地址:https://github.com/miaoerduo/tictoc 欢迎Start和提MR。
项目中有详细的说明和Demo,可以很直观的体验到这个库的易用性。
先看一下效果,如果我们正确使用的话,大致会出现类似下面的信息:
demo.cpp @ main [ 8, 13] elapsed: 0.025 s 24.786 ms 24786 us
demo.cpp @ main [ 8, 18] elapsed: 0.049 s 48.709 ms 48709 us
demo.cpp @ main [ 8, 23] elapsed: 0.072 s 72.211 ms 72211 us
demo.cpp @ main [ 8, 24] elapsed: 0.072 s 72.225 ms 72225 us
demo.cpp @ main [ 30, 36] elapsed: 0.022 s 21.747 ms 21747 us
demo.cpp @ main [ 36, 41] elapsed: 0.021 s 21.463 ms 21463 us
可以显示,我们的每个区域的代码(包括行号)的消耗时间。精确到微秒。
起因是这样的,之前有很长时间的工作内容是优化一些特定的函数,保证新旧的SDK的速度的对齐。然后C++虽然有一些工具可以分析运行状态,但通常还是简单的打印时间来的方便 /* Print大法好 */ 。之后,和工程的小伙伴一起Debug的时候,就发现他写了一个头文件,然后用绝对路径的方式去include,而头文件里面就是各种常用的小工具,而最常用到的就是时间的打印。
之后,我专门要到了他的百宝箱,仔细分析了一下,发现计时器模块仍然存在一些问题:
- 在Debug的时候,如果加上工具代码,在Release的时候,还得一点点删掉,很麻烦。
- 修改时间精度的话,需要修改源码,略麻烦。
- 打印的时间戳的信息不完整,看不出来该段时间具体的代码的范围。
- 计时器如果在多个文件中都用到,会有各种奇怪的错误,重复定义变量啊,或者找不到变量啥的。
- 对更复杂的程序,比如各种库的编译,多个库的链接调用不支持。
上面说的问题,说大不大,说小不小。如果能有个工具能解决上面5个问题,那也是一件十分惬意的事情。所以,也就有了本文和TICTOC这个库。接下来,我们会从上面的5个问题开始,一点一点介绍C++的小技巧。
〇、设计思路
其实计时器的思路很简单,就是定义两个宏TIC和TOC,如果插入TIC,则记录为起始时间,当插入TOC的时候,则计算与上一次TIC之间的时间,并打印出来。
比较麻烦的是,如果我在使用TIC的时候,生成一个变量,那连续使用两次TIC的话,就会出现变量的重复定义。另一个方案就是在全局定义一个时间的变量,但这样会带来另一个问题,就是所有函数都共享这个变量,如果函数内部再运行一次TIC,会覆盖掉这个时间戳,但是其他的TOC的结果不直观。
所以,这里就使用了一个字典,来存放TIC的时间戳。这个字典本身是使用单例模式去生成和维护的。每次TIC的时候都会初始化一次它,但是由于是单例,所以只有第一次会耗时。而字典的键是个字符串,由文件名+函数名联合构成。这样针对每个函数,都会有自己的一个计时器,就不用担心冲突了。之后运行TOC的时候,也会检查当前的文件名和函数名,从而与对应的TIC时间戳相减。是不是听起来很简单!
当然还会碰到很多奇怪的问题,其中最无语的是,当动态库使用这个库,而主程序也使用这个库的时候,所谓的单例模式就失效了,两段程序里面都会有这个字典,然后就冲突了,出现double free的情况。查了半天,才发现是动态库只在静态表导出这个单例,动态连接器默认查询动态表,没找到,从而主程序自己又重复构建了这个实例,导致了存在两个实例。最终用-rdynamic的方式编译就可以解决。但是用这种方式的话,又会显得很麻烦。我采用的解决方法是匿名命名空间,在每个文件中生成自己的单例。细节我们在后面会谈到。
一、Debug or Release?
因为我们不希望在Deliver的时候,再修改代码,所以有没有办法,使用不同的宏来控制我们的程序呢?当然是可以的。C/C++最常用到的预处理语句:#define, #ifdef, #ifndef,#else, #endif。采用下面的方式来进行就可以。
#ifndef TICTOC_HPP
#define TICTOC_HPP
#ifdef WITH_TICTOC
// 一些计时器的逻辑单元
// 函数啥的
#else
// 一些假的信息
// 比如宏函数,内容空的,免得编译不过
#endif
#endif
首先,这个TICTOC_HPP的宏定义,是为了防止头文件的多次包含。不然在多处include这个头文件的时候,会出现函数重复定义的问题。是一个良好的编程习惯。
WITH_TICTOC这个宏才是用来控制我们的Debug/Release的关键。在Debug的时候,编译加入一个宏定义,用g++直接编译的话,就是编译的时候加上-DWITH_TICTOC。用CMakeList的话,就是另一套了,自己查一下吧。在Release的时候,去掉这个宏定义就行,这样编译走的就是#else的分之,里面可以不写代码(我这里还是写了几行,定义了一些宏,但是宏的操作是空的)。
总之,灵活的使用宏定义,就可以让我们的编译器按照我们的想法去工作!
二、多种精度
问题二就比较简单了,既然每设置一种精度,都要修改一下代码,不如一次性的将所有的精度都打印出来了!这部分似乎没有什么好说的,就简单的说一下,我这里用到的计时的函数吧。
#include<sys/time.h>
/*
struct timeval {
time_t tv_sec; // seconds
suseconds_t tv_usec; // microseconds
};
*/
struct timeval get_tick() {
struct timeval time;
gettimeofday(&time, NULL);
return time;
}
timeval是一个表示时间的结构体,可以精确到微秒级别,完全够我们使用了。
三、打印完整的信息
首先,对于一个计时器,为了方便调试,我们希望知道什么信息呢?这里列出来我比较关心的:
- 这个时间戳所在的位置,包括:文件名,函数名
- 时间戳是哪一段代码产生的,即:起始和结束的代码行号
- 具体的时间(按不同精度显示)
对于3,上文已经介绍了。那么如何获取文件名、函数名以及行号呢?
其实C++中(C语言中也有的)早就给我们定义好了一些宏。这里就简单的列一下常用的几个,大家感兴趣也可以自己去查询:
- __FILE__ : 宏所在的文件名
- __FUNCTION__ : 宏所在的函数名
- __LINE__ : 当前行号
- __DATE__, __TIME__ : 最后一次编译的时间
- __TIMESTAMP__ : 文件最后的修改时间
所以,我们这里主要用到三个:__FILE__, __FUNCTION__, __LINE__ 。
四、Working Everywhere
上面的问题4和5,放在一起介绍。
针对问题4,是我们在多个文件同时使用了计时器,如果通过全局变量的方式去存储时间戳,那么每个文件都会有自己的时间戳,从而导致冲突(当然,把时间戳改成static的可能可以解决)。而且,同一个文件中,如果出现函数调用,也有修改这个全局的时间戳,导致打印时间很不友好。
这里使用字典来存放时间戳,给每个文件都创建自己的时间戳,从而解决了这个问题。在〇章中,也有介绍。
那么问题5就很复杂了,多个动态库同时使用时,会崩溃。首先,为了让字典在程序中,只存在一份,我这里使用了单例模式。如果把所有的文件都编译在一起,是完全OK的。问题就出在,如果动态库使用了这个工具,而主程序也使用该工具,且又链接了动态库,那么程序中就会出现多个字典,在程序退出析构的时候,就会出现多次free的情况(很奇怪吧,明明是两个实例,居然两次析构函数都调用同一个实例)。之前也说了,用-rdynamic的方式编译会很麻烦,而且我们不可能给整个大项目的每个部分都加这个编译选项吧。我们的工具库要足够的独立!
按照之前的分析,我们其实只需要给每个函数都分配自己的一个键就可以了,其实完全没必要只有一个Global的字典,只需要给每个文件都生成自己的字典不就OK了吗。但是,怎么去实现呢?
常见的方法有两个:
- static 变量,static 关键字有一个功能,是保证这个变量只在该文件中使用。不会导出。
- 匿名命名空间,也叫匿名名字空间,这里采用的就是这个方案。
namespace {
void print() {
std::cout << "hello world" << std::endl;
}
}
上面就是最简单的匿名命名空间,如果我们在代码中这么定义,其等价于:
namespace thisisaspecificnamespace {
void print() {
std::cout << "hello world" << std::endl;
}
}
using namespace thisisaspecificnamespace;
里面的这个大长串是啥意思?
其实thisisaspecificnamespace这个名字是我瞎写的,对于编译器,他会给这个匿名命名空间生成一个独一无二的名字,保证一定不重复,然后在改文件中,using它。所以自然就只有这个文件本身能够调用里面的函数了。
我们的工具是一个纯头文件,所有的库想依赖该文件,都会直接include它,而include操作其实就是简单的copy文件的内容,所以这段代码就会进入每个文件自身中,成为其源码的一部分。如此,只要我们把单例维护的代码放在匿名命名空间中,就可以保证其在每个文件中有且只有一个。就不用担心不同的库之间的冲突了。
五、补充
最后,我编写的这个库,并没有花费太多的时间,不过编程的过程中,确实还是感受到一点快乐的。不知不觉,现在写代码的时候,更喜欢以一种工具或是框架的角度去审核自己的作品。相比于追求编程的速度,慢慢蜕变成追求更优雅的设计,更简洁和实用的功能以及尽可能好的兼容性。
这里,小喵与你共同进步!