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,而头文件里面就是各种常用的小工具,而最常用到的就是时间的打印。

之后,我专门要到了他的百宝箱,仔细分析了一下,发现计时器模块仍然存在一些问题:

  1. 在Debug的时候,如果加上工具代码,在Release的时候,还得一点点删掉,很麻烦。
  2. 修改时间精度的话,需要修改源码,略麻烦。
  3. 打印的时间戳的信息不完整,看不出来该段时间具体的代码的范围。
  4. 计时器如果在多个文件中都用到,会有各种奇怪的错误,重复定义变量啊,或者找不到变量啥的。
  5. 对更复杂的程序,比如各种库的编译,多个库的链接调用不支持。

上面说的问题,说大不大,说小不小。如果能有个工具能解决上面5个问题,那也是一件十分惬意的事情。所以,也就有了本文和 TICTOC 这个库。接下来,我们会从上面的5个问题开始,一点一点介绍C++的小技巧。

〇、设计思路

其实计时器的思路很简单,就是定义两个宏 TICTOC,如果插入 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。用CMakeLists的话,就是另一套了,自己查一下吧。在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 是一个表示时间的结构体,可以精确到微秒级别,完全够我们使用了。

三、打印完整的信息

首先,对于一个计时器,为了方便调试,我们希望知道什么信息呢?这里列出来我比较关心的:

  1. 这个时间戳所在的位置,包括:文件名,函数名
  2. 时间戳是哪一段代码产生的,即:起始和结束的代码行号
  3. 具体的时间(按不同精度显示)

对于3,上文已经介绍了。那么如何获取文件名、函数名以及行号呢?

其实C++中(C语言中也有的)早就给我们定义好了一些宏。这里就简单的列一下常用的几个,大家感兴趣也可以自己去查询:

  1. __FILE__ : 宏所在的文件名
  2. __FUNCTION__ : 宏所在的函数名
  3. __LINE__ : 当前行号
  4. __DATE__, __TIME__ : 最后一次编译的时间
  5. __TIMESTAMP__ : 文件最后的修改时间

所以,我们这里主要用到三个:__FILE__, __FUNCTION__, __LINE__

四、Working Everywhere

上面的问题4和5,放在一起介绍。

针对问题4,是我们在多个文件同时使用了计时器,如果通过全局变量的方式去存储时间戳,那么每个文件都会有自己的时间戳,从而导致冲突(当然,把时间戳改成 static 的可能可以解决)。而且,同一个文件中,如果出现函数调用,也有修改这个全局的时间戳,导致打印时间很不友好。

这里使用字典来存放时间戳,给每个文件都创建自己的时间戳,从而解决了这个问题。在〇章中,也有介绍。

那么问题5就很复杂了,多个动态库同时使用时,会崩溃。首先,为了让字典在程序中,只存在一份,我这里使用了单例模式。如果把所有的文件都编译在一起,是完全OK的。问题就出在,如果动态库使用了这个工具,而主程序也使用该工具,且又链接了动态库,那么程序中就会出现多个字典,在程序退出析构的时候,就会出现多次 free 的情况(很奇怪吧,明明是两个实例,居然两次析构函数都调用同一个实例)。之前也说了,用 -rdynamic 的方式编译会很麻烦,而且我们不可能给整个大项目的每个部分都加这个编译选项吧。我们的工具库要足够的独立!

按照之前的分析,我们其实只需要给每个函数都分配自己的一个键就可以了,其实完全没必要只有一个Global的字典,只需要给每个文件都生成自己的字典不就OK了吗。但是,怎么去实现呢?

常见的方法有两个:

  1. static 变量static 关键字有一个功能,是保证这个变量只在该文件中使用。不会导出。
  2. 匿名命名空间,也叫匿名名字空间,这里采用的就是这个方案。
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文件的内容,所以这段代码就会进入每个文件自身中,成为其源码的一部分。如此,只要我们把单例维护的代码放在匿名命名空间中,就可以保证其在每个文件中有且只有一个。就不用担心不同的库之间的冲突了。

五、补充

最后,我编写的这个库,并没有花费太多的时间,不过编程的过程中,确实还是感受到一点快乐的。不知不觉,现在写代码的时候,更喜欢以一种工具或是框架的角度去审核自己的作品。相比于追求编程的速度,慢慢蜕变成追求更优雅的设计,更简洁和实用的功能以及尽可能好的兼容性。

这里,小喵与你共同进步!