這一篇講windows系統下TimeTicks的實作。
對于tick,V8寫了相當長的一段discussion來讨論windows系統上計數的三種實作方法以及各自的優劣,注釋在time.cc的572行,這裡直接簡單翻譯一下,不貼出來了。
CPU cycle counter.(Retrieved via RDTSC)
CPU計數器擁有最高的分辨率,消耗也是最小的。然而,在一些老的CPU上會有問題;1、每個處理器獨立唯一各自的tick,并且處理器之間不會同步資料。2、計數器會因為溫度、功率等原因頻繁變化,有些情況甚至會停止。
QueryPerformanceCounter (QPC)
QPC計數法就是之前libuv用的API,分辨率也相當的高。比起CPU計數器,優點就是不存在多處理器有多個tick,保證資料的唯一。但是在老的CPU上,也會因為BIOS、HAL而出現一些問題。
System Time
通過别的windowsAPI傳回的系統時間來計數。
上一篇Clock類的構造函數中,對TimeTicks屬性的初始化也隻是調用了老TimeTicks的Now方法,是以直接上Now的代碼。
TimeTicks InitialTimeTicksNowFunction();
using TimeTicksNowFunction = decltype(&TimeTicks::Now);
TimeTicksNowFunction g_time_ticks_now_function = &InitialTimeTicksNowFunction;
TimeTicks TimeTicks::Now() {
TimeTicks ticks(g_time_ticks_now_function());
DCHECK(!ticks.IsNull());
return ticks;
}
windows系統下,會預先一個初始化方法,這裡的文法不用去了解,隻需要知道調用InitialTimeTicksNowFunction方法後,将其傳回作為參數構造一個TimeTicks對象,傳回的就是硬體時間戳。
這個方法比較簡單,如下。
TimeTicks InitialTimeTicksNowFunction() {
InitializeTimeTicksNowFunctionPointer();
return g_time_ticks_now_function();
}
可以看到,那個g_time_ticks_now_function又被調用了一次,但是作為一個函數指針,第二次調用的時候指向的就不是同一個方法。至于為什麼特意弄一個函數指針,後面會具體解釋。
看這裡的第一個方法。
void InitializeTimeTicksNowFunctionPointer() {
LARGE_INTEGER ticks_per_sec = {};
if (!QueryPerformanceFrequency(&ticks_per_sec)) ticks_per_sec.QuadPart = 0;
// 如果windows不支援QPC或者該方法不可靠 會降級去使用低分辨率的lowB方法
TimeTicksNowFunction now_function;
CPU cpu;
// QPC不好使的情況
if (ticks_per_sec.QuadPart <= 0 || !cpu.has_non_stop_time_stamp_counter() ||
IsBuggyAthlon(cpu)) {
now_function = &RolloverProtectedNow;
}
// 好使的情況
else {
now_function = &QPCNow;
}
// 這裡不需要擔心多線程問題 因為更改的都是同一個全局變量
g_qpc_ticks_per_second = ticks_per_sec.QuadPart;
// 先不管這個 不然講不完
ATOMIC_THREAD_FENCE(memory_order_release);
g_time_ticks_now_function = now_function;
}
從幾個指派可以看到,整個函數都是圍繞着函數指針now_function的指向,其實也就是g_time_ticks_now_function,根據系統對QPC的支援,來選擇不同的方法實作TimeTicks。
是以,特意用一個函數指針來控制Now方法的目的也明顯了,理論上隻有第一次調用會進到這個特殊函數,檢測目前作業系統的QPC是否适用,然後選擇對應的方法。後面再次調用的時候,就直接進入選好的方法(具體思想可以參考《JavaScript進階程式設計》進階技巧章節的惰性載入函數)。這個情況有一點像我在解析node事件輪詢時提到的線程池初始化情形,不同的是,這裡V8沒有特意去加一個鎖來防止多線程競态。原因也很簡單,因為此處隻是對一個全局的函數指針做指派,就算多指派幾次對後續的線程并沒有任何影響,沒有必要特意做鎖。
關于QueryPerformanceFrequency方法(這些函數名都好TM長)的具體用法,可以參考我别的部落格,啥都解釋寫不完啦。
存在兩種情況的實作,先看支援QPC的,删掉了合法性檢測宏,這些宏無處不在,太礙眼了。
TimeTicks QPCNow() { return TimeTicks() + QPCValueToTimeDelta(QPCNowRaw()); }
V8_INLINE uint64_t QPCNowRaw() {
LARGE_INTEGER perf_counter_now = {};
// According to the MSDN documentation for QueryPerformanceCounter(), this
// will never fail on systems that run XP or later.
// https://msdn.microsoft.com/library/windows/desktop/ms644904.aspx
// 這裡說理論上XP以後的系統都支援QPC
BOOL result = ::QueryPerformanceCounter(&perf_counter_now);
return perf_counter_now.QuadPart;
}
// To avoid overflow in QPC to Microseconds calculations, since we multiply
// by kMicrosecondsPerSecond, then the QPC value should not exceed
// (2^63 - 1) / 1E6. If it exceeds that threshold, we divide then multiply.
static constexpr int64_t kQPCOverflowThreshold = INT64_C(0x8637BD05AF7);
TimeDelta QPCValueToTimeDelta(LONGLONG qpc_value) {
// 這裡的if/else邏輯見上面靜态變量的注釋 也可以看我下面翻譯的
// 理論上的計算公式是 (qpc_count * 1e6) / qpc_count_per_second 得到微秒機關的硬體時間戳
// 但是int64類型最大隻能處理2^63 - 1 而這個windowsAPI傳回的數字(換算乘以1e6後)可能超過這個範圍
// 如果數字過大 就用先除再乘的方式計算避免溢出
// 正常情況
if (qpc_value < TimeTicks::kQPCOverflowThreshold) {
return TimeDelta::FromMicroseconds(
qpc_value * TimeTicks::kMicrosecondsPerSecond / g_qpc_ticks_per_second);
}
// 溢出情況
// 先除得到一個秒機關的時間戳
int64_t whole_seconds = qpc_value / g_qpc_ticks_per_second;
// 計算餘數
int64_t leftover_ticks = qpc_value - (whole_seconds * g_qpc_ticks_per_second);
// 用整除數+餘數得到最終的微秒機關時間戳
return TimeDelta::FromMicroseconds(
(whole_seconds * TimeTicks::kMicrosecondsPerSecond) +
((leftover_ticks * TimeTicks::kMicrosecondsPerSecond) /
g_qpc_ticks_per_second));
}
直接看注釋就好了,不過我有一些問題,先記錄下來,後面對C++深入研究後再來解釋。
- 按照英文注釋,qpc乘以1e6後過大,再除以一個數時會溢出。但是下面的那個方法用的是1個溢出數加上1個小整數,為啥這樣就不會出問題。難道加減不存在threshold?
- 那個計算誤差是我了解的,實際上如果上過國小,把上面的變量代入第二個算式,會得到leftover_ticks為0,這裡的邏輯暫時沒理清。
補充
1、第一個問題我真不知道答案,在我電腦上qpc_value已經是大于那個臨界值了,但是測試了一下也感覺溢出跟加減沒啥差別,如下。
static constexpr int64_t kQPCOverflowThreshold = INT64_C(0x8637BD05AF7);
int main()
{
LARGE_INTEGER a,b;
QueryPerformanceCounter(&a);
QueryPerformanceFrequency(&b);
LONGLONG qpc = a.QuadPart;
INT64 qpc_per = b.QuadPart;
bool bl = qpc < kQPCOverflowThreshold;
// 0
cout << bl << endl;
// 927641572774
cout << int64_t(a.QuadPart * 1e6 / b.QuadPart) << endl;
int64_t w = qpc / qpc_per;
int64_t l = qpc - (w * qpc_per);
// 927641572774
cout << int64_t(w * 1e6 + (l * 1e6) / qpc_per) << endl;
}
2、我太蠢了,那個計算是為了取餘數。如果qpc、qpc_per分别是111和10,那麼這個leftover算式相當于111 - (111 / 10 * 10),得到的是餘數1,然後用整除後的整數、餘數分别進行換算後相加。
總之,最後還是利用了QPC的兩個API得到硬體時間戳,跟libuv的套路差不多。
下面來看不支援QPC的情況,不過先過一下那個if。
CPU cpu;
if (ticks_per_sec.QuadPart <= 0 || !cpu.has_non_stop_time_stamp_counter() ||
IsBuggyAthlon(cpu)) {
now_function = &RolloverProtectedNow;
有三個條件表明QPC不适用。
第一個很直白,API在目前作業系統不支援。
第二個是通過CPU判斷QPC是否可靠,具體原理十分麻煩,有興趣單獨開一篇解釋吧。
第三個就比較簡單,有些牌子的CPU就是垃圾,直接根據内置API傳回的參數判斷是不是不支援的類型,如下。
bool IsBuggyAthlon(const CPU& cpu) {
// On Athlon X2 CPUs (e.g. model 15) QueryPerformanceCounter is unreliable.
return strcmp(cpu.vendor(), "AuthenticAMD") == 0 && cpu.family() == 15;
}
正式進入QPC不支援分支。
union LastTimeAndRolloversState {
// 完整的32位時間
int32_t as_opaque_32;
struct {
// 時間頭8位
uint8_t last_8;
// 時間重置次數
uint16_t rollovers;
} as_values;
};
TimeTicks RolloverProtectedNow() {
// 見上面的解釋
LastTimeAndRolloversState state;
DWORD now; // DWORD is always unsigned 32 bits.
// 這是一個原子操作數 線程安全
int32_t original = g_last_time_and_rollovers.load(std::memory_order_acquire);
while (true) {
// 類型為int32位整數
state.as_opaque_32 = original;
// 定義如下 實際上就是windowsAPI的timeGetTime
// DWORD timeGetTimeWrapper() { return timeGetTime(); }
// DWORD (*g_tick_function)(void) = &timeGetTimeWrapper;
now = g_tick_function();
// 移位後隻擷取頭8位
uint8_t now_8 = static_cast<uint8_t>(now >> 24);
// 當頭8位的時間比儲存的要小時 說明傳回值重置了
if (now_8 < state.as_values.last_8) ++state.as_values.rollovers;
state.as_values.last_8 = now_8;
// 當兩次相同時 代表目前的值是穩定可信的 直接傳回
if (state.as_opaque_32 == original) break;
if (g_last_time_and_rollovers.compare_exchange_weak(
original, state.as_opaque_32, std::memory_order_acq_rel)) {
break;
}
}
// 傳回次數 * 2^32 加上 目前時間
return TimeTicks() +
TimeDelta::FromMilliseconds(
now + (static_cast<uint64_t>(state.as_values.rollovers) << 32));
}
這塊的内容相當多,首先需要解釋一下上面的核心方法timeGetTime,官網的解釋如下。
The timeGetTime function retrieves the system time, in milliseconds. The system time is the time elapsed since Windows was started.(檢測系統啟動後所經過的毫秒數)
The return value wraps around to 0 every 2^32 milliseconds, which is about 49.71 days.(傳回值會從0一直漲到2^32,然後又從0開始無限循環)
上面的第二段表明了為什麼要用那麼複雜的處理,因為這個傳回值不是無限變大,而是會重置為0。而且union這個東西也很有意思,JS裡面找不到對比的資料類型,類似于struct結構體,但不同點是記憶體共用。拿源碼中的union舉例子,記憶體結構如下所示。

整個過程大概是這樣的。
- 每次擷取timeGetTime的值,隻擷取頭8位的值now_8。
- 判斷now_8是否小于union裡面儲存的last_8,如果小了(從1111...1111變成000...1),說明時間重置了,将重置次數+1。
- 替換last_8為新擷取的now_8。
- 判斷目前整個整數是否與上一次擷取時相同(涉及多線程操作),相同的話直接傳回輸出結果。
最後傳回值的計算也很簡單了,就是重置次數rollovers乘以重置一次的時間2^32,加上目前擷取的now,得到總的硬體時間戳。
完事了。