TICTOC: Header Only C++ Timer
感覺最近的更新頻率略高啊~哈哈~
這次的帶來的是一個十分簡單便利的C++計時庫。
專案地址:https://github.com/miaoerduo/tictoc 歡迎Start 和提MR 。
專案中有詳細的說明和Demo,可以很直觀的體驗到這個庫的易用性。
先看一下效果,如果我們正確使用的話,大致會出現類似下面的資訊:
demo.cpp @ main [8,13]elapsed:0.025 s24.786 ms24786 us demo.cpp @ main [8,18]elapsed:0.049 s48.709 ms48709 us demo.cpp @ main [8,23]elapsed:0.072 s72.211 ms72211 us demo.cpp @ main [8,24]elapsed:0.072 s72.225 ms72225 us demo.cpp @ main [30,36]elapsed:0.022 s21.747 ms21747 us demo.cpp @ main [36,41]elapsed:0.021 s21.463 ms21463 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_ttv_sec;// seconds suseconds_ttv_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檔案的內容,所以這段程式碼就會進入每個檔案自身中,成為其原始碼的一部分。如此,只要我們把單例維護的程式碼放在匿名名稱空間中,就可以保證其在每個檔案中有且只有一個。就不用擔心不同的庫之間的衝突了。
五、補充
最後,我編寫的這個庫,並沒有花費太多的時間,不過程式設計的過程中,確實還是感受到一點快樂的。不知不覺,現在寫程式碼的時候,更喜歡以一種工具或是框架的角度去稽核自己的作品。相比於追求程式設計的速度,慢慢蛻變成追求更優雅的設計,更簡潔和實用的功能以及儘可能好的相容性。
這裡,小喵與你共同進步!