時(shí)鐘:system_clock與steady_clock的選型指南)
最近幫朋友排查一個(gè)線上問題他的服務(wù)用system_clock統(tǒng)計(jì)接口耗時(shí)結(jié)果 NTP 校時(shí)把系統(tǒng)時(shí)間往回?fù)芰藘擅氡O(jiān)控里直接出現(xiàn)負(fù)數(shù)耗時(shí)告警瞬間打爆。我一看代碼第一反應(yīng)就是老毛病又犯了——C 時(shí)間編程里最經(jīng)典的system_clock和steady_clock選型錯(cuò)誤。這兩兄弟從 C11 就在標(biāo)準(zhǔn)庫里了但直到今天依然有大量項(xiàng)目把這倆混著用。本文我準(zhǔn)備一次性把它們掰開揉碎各自到底是什么、底層怎么實(shí)現(xiàn)的、該在什么場(chǎng)景用哪個(gè)以及那些只有跑在生產(chǎn)環(huán)境才會(huì)撞上、普通文檔里根本不會(huì)寫的坑。1. 一個(gè)真實(shí)的坑用 system_clock 計(jì)耗時(shí)結(jié)果出現(xiàn)負(fù)數(shù)1.1 問題現(xiàn)場(chǎng)那個(gè)服務(wù)的邏輯很簡(jiǎn)單每個(gè)請(qǐng)求進(jìn)來時(shí)記錄一下system_clock::now()處理完再記錄一次兩個(gè)時(shí)間點(diǎn)相減得到耗時(shí)毫秒數(shù)。平時(shí)一切正常數(shù)據(jù)也很平滑。結(jié)果某天凌晨監(jiān)控平臺(tái)突然報(bào)警顯示某個(gè)核心接口耗時(shí)是負(fù)數(shù)而且持續(xù)了好幾分鐘。排查過程先懷疑是不是代碼里數(shù)值溢出然后懷疑是不是數(shù)據(jù)上報(bào)單位錯(cuò)了最后順著日志往前翻發(fā)現(xiàn)出問題的那幾分鐘剛好撞上了系統(tǒng)的 NTP 校時(shí)時(shí)間。查內(nèi)核日志時(shí)鐘被校時(shí)任務(wù)往回調(diào)整了約 2 秒所有在途請(qǐng)求的“耗時(shí)”瞬間成了負(fù)數(shù)。這個(gè)例子非常典型不是數(shù)學(xué)題出了問題而是時(shí)間本身的語義搞錯(cuò)了。system_clock讀到的不是“物理上真實(shí)流逝的時(shí)長(zhǎng)”而是“操作系統(tǒng)維護(hù)的墻上時(shí)間”。墻上時(shí)間被往回?fù)軆纱蝞ow()相減當(dāng)然可能是負(fù)的。1.2 為什么會(huì)這樣system_clock::now()返回的是從某個(gè)紀(jì)元開始累加的時(shí)長(zhǎng)在主流平臺(tái)上就是對(duì) Unix 時(shí)間戳的封裝從 1970 年 1 月 1 日 UTC 開始計(jì)算。問題在于這個(gè)“累加值”并不是硬件自己一節(jié)一節(jié)往前走而是由操作系統(tǒng)根據(jù)各種來源維護(hù)的NTP 校時(shí)服務(wù)會(huì)定期與時(shí)間服務(wù)器對(duì)齊發(fā)現(xiàn)偏差后可能小步調(diào)整也可能大步跳變運(yùn)維人員可能手動(dòng)執(zhí)行date命令修正時(shí)間虛擬機(jī)場(chǎng)景下宿主機(jī)時(shí)鐘漂移也會(huì)觸發(fā)前向或后向校正系統(tǒng)啟動(dòng)時(shí)從 RTC 芯片讀取時(shí)間如果 RTC 本身就不準(zhǔn)也會(huì)產(chǎn)生跳變操作系統(tǒng)允許“把墻上的掛鐘往前撥、往后撥”因?yàn)樗WC的是“人眼的可讀時(shí)間”正確而不是“物理時(shí)長(zhǎng)”正確。system_clock直接暴露的就是這個(gè)可撥動(dòng)的值所以它天然不保證單調(diào)后一次調(diào)用可能比前一次小。一個(gè)很直觀的類比你看著客廳的掛鐘9:30 看了一眼9:32 再看一眼當(dāng)然可以算出過了 2 分鐘。但要是走到臥室發(fā)現(xiàn)臥室的鐘被人調(diào)過、比客廳慢了一個(gè)小時(shí)那你用兩個(gè)房間的鐘來計(jì)算“過了多久”就沒有任何意義了。1.3 為什么測(cè)試環(huán)境很難復(fù)現(xiàn)很多同學(xué)在本地開發(fā)時(shí)從來沒踩過這個(gè)坑于是覺得網(wǎng)上那些“不要用 system_clock 測(cè)耗時(shí)”的說法是聳人聽聞。原因很簡(jiǎn)單本地測(cè)試代碼生命周期短幾分鐘就完事NTP 校時(shí)根本不會(huì)碰巧落在兩次now()調(diào)用之間。生產(chǎn)環(huán)境則完全不同。幾百上千臺(tái)機(jī)器7x24 小時(shí)跑幾個(gè)月校時(shí)發(fā)生頻率遠(yuǎn)比你想象得高。虛擬化平臺(tái)的時(shí)鐘漂移問題更是常見某些云主機(jī)甚至?xí)芷谛猿霈F(xiàn)幾毫秒到幾百毫秒的跳變。哪怕是幾十毫秒的跳變已經(jīng)足夠讓你的耗時(shí)采樣出現(xiàn)異常毛刺只是平時(shí)沒人注意到而已。2. system_clock它是日歷時(shí)鐘的化身2.1 它到底代表什么system_clock的定位非常明確回答“現(xiàn)在的時(shí)刻是什么”。它的時(shí)間點(diǎn)可以借助標(biāo)準(zhǔn)庫直接轉(zhuǎn)換成time_t再進(jìn)一步轉(zhuǎn)成年月日時(shí)分秒輸出給人看。這正是“墻上時(shí)間”的用途。#include chrono #include ctime #include iostream int main() { auto now std::chrono::system_clock::now(); std::time_t tt std::chrono::system_clock::to_time_t(now); std::cout std::ctime(tt) std::endl; return 0; }這段代碼輸出的是可讀的本地時(shí)間。底層實(shí)現(xiàn)上Linux 平臺(tái)通常對(duì)應(yīng)CLOCK_REALTIMEWindows 平臺(tái)對(duì)應(yīng)系統(tǒng)文件時(shí)間的封裝。它的紀(jì)元在主流實(shí)現(xiàn)里確實(shí)是 1970 年 1 月 1 日所以拿它來做跨進(jìn)程、跨機(jī)器的時(shí)間戳交換沒有任何問題。2.2 三個(gè)容易被忽略的特性第一它可能回退。前文已經(jīng)反復(fù)強(qiáng)調(diào)這是語義問題不是實(shí)現(xiàn) bug。操作系統(tǒng)允許校時(shí)它就允許回退。第二它的精度依賴具體平臺(tái)常見為納秒步進(jìn)但實(shí)際分辨率可能只有 100 納秒甚至更低。你拿到的是一個(gè)納秒級(jí)的duration但讀數(shù)的“真實(shí)分辨能力”由系統(tǒng)時(shí)間源決定。第三它和日歷語義強(qiáng)綁定。只要你的數(shù)據(jù)將來要被人讀或者要和外部系統(tǒng)做時(shí)間對(duì)齊就必然要用它。比如日志時(shí)間戳、數(shù)據(jù)庫的創(chuàng)建時(shí)間、審計(jì)記錄、接口返回給客戶端的 Unix 秒戳。2.3 什么時(shí)候非它不可判斷標(biāo)準(zhǔn)其實(shí)很樸素當(dāng)你的業(yè)務(wù)里出現(xiàn)了“那一刻是哪一天”“當(dāng)前是幾點(diǎn)幾分”這類問題選system_clock基本不會(huì)錯(cuò)。典型場(chǎng)景包括打業(yè)務(wù)日志日志要能被外部工具和人類查看寫入數(shù)據(jù)庫的創(chuàng)建時(shí)間、更新時(shí)間需要跨請(qǐng)求、跨進(jìn)程比較生成給前端或客戶端展示的時(shí)間字符串計(jì)算某個(gè)絕對(duì)過期時(shí)刻比如“今晚凌晨 00:00 過期”與第三方系統(tǒng)對(duì)接按協(xié)議傳遞 Unix 時(shí)間戳這些場(chǎng)景的共同點(diǎn)是你關(guān)注的是“某個(gè)瞬間在人類時(shí)間軸上的位置”而不是“兩個(gè)瞬間之間隔了多久”。3. steady_clock單調(diào)計(jì)數(shù)器的正確打開方式3.1 為什么叫“單調(diào)”steady_clock的核心承諾只有一條單調(diào)不遞減。標(biāo)準(zhǔn)庫要求它的靜態(tài)成員is_steady恒為true也就是說在任意兩次調(diào)用now()的順序中后一次返回的時(shí)間點(diǎn)永遠(yuǎn)不小于前一次。它不關(guān)心掛鐘幾點(diǎn)只維護(hù)一個(gè)“只會(huì)往前走、不會(huì)往后退”的計(jì)數(shù)器。這正是測(cè)量耗時(shí)、計(jì)算間隔、判斷超時(shí)所需要的語義。用一個(gè)只會(huì)單調(diào)遞增的計(jì)數(shù)器前后相減得到的就是真正流逝的時(shí)長(zhǎng)無論操作系統(tǒng)怎么校時(shí)、人工怎么修改時(shí)間都影響不到它。3.2 底層實(shí)現(xiàn)與一個(gè)關(guān)鍵限制Linux 平臺(tái)上steady_clock通常映射到CLOCK_MONOTONIC也就是從操作系統(tǒng)啟動(dòng)那一刻開始累加的內(nèi)部計(jì)時(shí)器。Windows 平臺(tái)上主流映射是QueryPerformanceCounter也就是高精度性能計(jì)數(shù)器。因?yàn)榈讓邮恰跋到y(tǒng)啟動(dòng)以來的計(jì)數(shù)”標(biāo)準(zhǔn)庫并沒有規(guī)定它的紀(jì)元到底是什么。這就帶來一個(gè)關(guān)鍵限制你不能把它直接轉(zhuǎn)換成日歷時(shí)間??催@段代碼auto t std::chrono::steady_clock::now(); std::time_t tt std::chrono::system_clock::to_time_t(t); // 編譯錯(cuò)誤steady_clock::time_point與system_clock::time_point是兩種不同的類型不能直接像上面那樣轉(zhuǎn)換。如果你硬要做類型轉(zhuǎn)換再輸出得到的是一個(gè)毫無意義的“從啟動(dòng)時(shí)刻開始計(jì)數(shù)的數(shù)字”把它格式化成日期只會(huì)讓人一頭霧水。3.3 用生活化類比理解它把system_clock想成你手腕上的手表它告訴你現(xiàn)在幾點(diǎn)了但可以被你手動(dòng)調(diào)快調(diào)慢。把steady_clock想成手機(jī)里的秒表秒表永遠(yuǎn)只記錄“從按下開始那一刻起過去了多久”你在按下之前是上午還是凌晨對(duì)秒表毫無影響。需要“記一段時(shí)長(zhǎng)”的時(shí)候用秒表需要“記住這是哪個(gè)時(shí)刻”的時(shí)候看手表。這就是選型的全部邏輯。4. 兩個(gè)時(shí)鐘的正面比較精度、開銷與語義差異4.1 一張表看清核心區(qū)別維度system_clocksteady_clock核心語義墻上時(shí)間可回退、可跳變單調(diào)遞增不回退典型 Linux 底層CLOCK_REALTIMECLOCK_MONOTONIC典型 Windows 底層系統(tǒng)文件時(shí)間封裝QueryPerformanceCounter紀(jì)元1970-01-01 UTC 附近實(shí)現(xiàn)定義如系統(tǒng)啟動(dòng)時(shí)刻可轉(zhuǎn)日歷時(shí)間可以標(biāo)準(zhǔn)庫直接支持不行沒有標(biāo)準(zhǔn)轉(zhuǎn)換適合測(cè)量耗時(shí)不適合非常適合跨進(jìn)程、跨機(jī)器可比較可以不行線程安全是是這張表基本覆蓋了日常開發(fā)中的全部決策點(diǎn)??吹健翱邕M(jìn)程可比較”和“可轉(zhuǎn)日歷”兩個(gè)屬性你就能快速判斷該用誰。4.2 精度與調(diào)用開銷很多同學(xué)關(guān)心這兩個(gè)時(shí)鐘哪個(gè)精度高、哪個(gè)快。實(shí)際測(cè)下來在 Linux x86_64 平臺(tái)上兩者的now()調(diào)用開銷基本在同一個(gè)量級(jí)幾十納秒到一百多納秒具體取決于內(nèi)核版本和硬件。Windows 平臺(tái)上現(xiàn)代版本的system_clock底層走高精度系統(tǒng)時(shí)間接口分辨率通??梢赃_(dá)到 100 納秒級(jí)steady_clock走 QPC在高頻計(jì)數(shù)器下精度通常優(yōu)于 1 微秒。對(duì)絕大多數(shù)業(yè)務(wù)系統(tǒng)來說now()的調(diào)用開銷根本不該成為選型依據(jù)。兩個(gè)都足夠快。真正要命的是語義錯(cuò)誤而不是那幾十納秒的性能差。4.3 休眠與超時(shí)sleep_until 的隱藏差異std::this_thread::sleep_until接收一個(gè)time_point參數(shù)。如果你傳system_clock::now() 5s線程會(huì)“睡到那個(gè)絕對(duì)時(shí)刻”。一旦系統(tǒng)時(shí)間被往前撥線程可能提前醒來被往后撥線程可能睡到猴年馬月。更隱蔽的是循環(huán)定時(shí)任務(wù)。假設(shè)一個(gè)線程希望每隔 5 秒執(zhí)行一次任務(wù)// 錯(cuò)誤示范 auto next std::chrono::system_clock::now() std::chrono::seconds(5); while (running) { std::this_thread::sleep_until(next); do_task(); next std::chrono::system_clock::now() std::chrono::seconds(5); }如果系統(tǒng)時(shí)間在這期間被 NTP 往前跳了一個(gè)小時(shí)下一次進(jìn)入sleep_until時(shí)next會(huì)直接落在“未來一小時(shí)多”的位置任務(wù)停頓整整一小時(shí)。如果時(shí)間被往后撥線程則會(huì)提前醒來甚至連續(xù)空轉(zhuǎn)。換成steady_clock寫同樣邏輯這個(gè)問題就不存在。std::this_thread::sleep_for同樣更安全標(biāo)準(zhǔn)建議它的行為等價(jià)于sleep_until(steady_clock::now() rel_time)。也就是說它內(nèi)部天然以單調(diào)時(shí)鐘為基準(zhǔn)校時(shí)不會(huì)干擾它。4.4 條件變量等待wait_until 的推薦寫法condition_variable::wait_for和wait_until是并發(fā)編程里的高頻場(chǎng)景。C11 標(biāo)準(zhǔn)明確指出wait_until如果使用steady_clock時(shí)間點(diǎn)則基于單調(diào)時(shí)鐘進(jìn)行超時(shí)計(jì)算。推薦寫法如下std::condition_variable cv; std::mutex mtx; bool ready false; std::unique_lockstd::mutex lk(mtx); auto deadline std::chrono::steady_clock::now() std::chrono::seconds(3); if (!cv.wait_until(lk, deadline, [] { return ready; })) { // 處理 3 秒超時(shí) }用steady_clock的deadline系統(tǒng)時(shí)間再怎么撥動(dòng)超時(shí)點(diǎn)都穩(wěn)定在三秒后。5. 經(jīng)典踩坑合集只有上線后才見鬼的問題5.1 用 steady_clock 給日志打時(shí)間戳有段時(shí)間我接手過一個(gè)遺留服務(wù)日志里帶的時(shí)間戳看起來完全不可讀。排查后發(fā)現(xiàn)前一個(gè)團(tuán)隊(duì)為了“避免校時(shí)干擾”把所有時(shí)間戳都換成了steady_clock::now()然后直接輸出計(jì)數(shù)器值。問題在于服務(wù)重啟之后這個(gè)計(jì)數(shù)器會(huì)從頭開始日志里某一行顯示 12345.678 毫秒你根本不知道它對(duì)應(yīng)真實(shí)世界的哪個(gè)時(shí)間點(diǎn)。多機(jī)對(duì)比日志時(shí)更是災(zāi)難A 機(jī)的 1000 毫秒和 B 機(jī)的 1000 毫秒完全不是同一時(shí)刻。日志時(shí)間戳必須用system_clock沒有商量余地。5.2 跨進(jìn)程持久化時(shí)間點(diǎn)有人會(huì)把steady_clock的時(shí)間點(diǎn)序列化到數(shù)據(jù)庫用來做超時(shí)判斷。比如在數(shù)據(jù)庫里存一條記錄的“創(chuàng)建時(shí)間”取出來和當(dāng)前時(shí)間比較是否超時(shí)。這種做法在單進(jìn)程單機(jī)部署下勉強(qiáng)能跑一旦重啟進(jìn)程或者換一臺(tái)機(jī)器開始清理任務(wù)所有時(shí)間點(diǎn)全部作廢因?yàn)閟teady_clock的紀(jì)元因機(jī)器、因啟動(dòng)次數(shù)而異。正確做法是凡是要落到數(shù)據(jù)庫、要跨機(jī)器傳遞的絕對(duì)時(shí)間一律用system_clock的 Unix 時(shí)間戳。進(jìn)程內(nèi)的超時(shí)判斷可以用steady_clock但請(qǐng)不要把它寫進(jìn)任何持久化存儲(chǔ)。5.3 high_resolution_clock 并不是 steadyhigh_resolution_clock是標(biāo)準(zhǔn)庫提供的“盡可能高精度”的時(shí)鐘但它并不保證單調(diào)。在某些標(biāo)準(zhǔn)庫實(shí)現(xiàn)中它甚至就是system_clock的別名。如果你用它來測(cè)耗時(shí)等于又把system_clock換了個(gè)名字請(qǐng)了回來??缙脚_(tái)代碼里涉及性能統(tǒng)計(jì)、耗時(shí)上報(bào)請(qǐng)直接寫steady_clock。這樣才能保證語義一致不被某個(gè)平臺(tái)的實(shí)現(xiàn)細(xì)節(jié)坑到。5.4 duration_cast 的整數(shù)截?cái)鄿y(cè)量到的真實(shí)耗時(shí)只有 0.9 毫秒時(shí)duration_caststd::chrono::milliseconds(d).count()會(huì)返回 0因?yàn)閐uration_cast會(huì)做向零截?cái)?。如果你用毫秒整?shù)累計(jì)大量小耗時(shí)最終總和會(huì)明顯偏小。想要更精細(xì)的統(tǒng)計(jì)數(shù)據(jù)用浮點(diǎn)時(shí)長(zhǎng)更穩(wěn)妥double ms std::chrono::durationdouble, std::milli(d).count();在性能敏感且追求精度的場(chǎng)景我也推薦直接輸出納秒或微秒整數(shù)再在統(tǒng)計(jì)層自行換算??傊灰?yàn)檎麛?shù)截?cái)嘧寛?bào)表數(shù)據(jù)悄悄失真。6. 選型決策清單與個(gè)人實(shí)踐建議6.1 三秒判斷表需求描述推薦選擇要顯示給人看的日期時(shí)間、日志、數(shù)據(jù)庫時(shí)間system_clock要計(jì)算兩段代碼之間的耗時(shí)steady_clock要判斷本地超時(shí)steady_clock要定時(shí)在某個(gè)絕對(duì)時(shí)刻執(zhí)行如每日凌晨system_clock要做跨機(jī)器、跨進(jìn)程的時(shí)間戳對(duì)比system_clock要做進(jìn)程內(nèi)的緩存過期時(shí)間steady_clock 或直接用 duration要配合 wait_for / wait_until 做超時(shí)等待steady_clock要做性能基準(zhǔn)測(cè)試、耗時(shí)分布統(tǒng)計(jì)steady_clock這張表沒有覆蓋到的場(chǎng)景就回到最本源的問題你想知道的到底是“現(xiàn)在幾點(diǎn)”還是“過了多久”6.2 幾條來自實(shí)踐的個(gè)人經(jīng)驗(yàn)我在團(tuán)隊(duì)里立過兩條規(guī)矩代碼 review 的時(shí)候很管用第一條時(shí)間戳落到日志和數(shù)據(jù)庫只允許system_clock。任何人用steady_clock打點(diǎn)直接打回。第二條做耗時(shí)統(tǒng)計(jì)和超時(shí)控制只允許steady_clock??吹侥胹ystem_clock做減法的地方重點(diǎn)追問一句如果 NTP 校時(shí)了這個(gè)減法還成立嗎另外我還習(xí)慣在項(xiàng)目里固定一個(gè)類型別名把單調(diào)時(shí)鐘語義固化下來避免每次寫代碼時(shí)糾結(jié)using Clock std::chrono::steady_clock; using Duration Clock::duration; using TimePoint Clock::time_point;只要涉及“進(jìn)程內(nèi)計(jì)時(shí)”的地方從這一個(gè)統(tǒng)一類型出發(fā)團(tuán)隊(duì)成員不容易再繞到別的時(shí)鐘上去。最后再提一個(gè)容易被忽視的小點(diǎn)如果你的服務(wù)運(yùn)行在經(jīng)常睡眠恢復(fù)的設(shè)備上比如筆記本、移動(dòng)終端CLOCK_MONOTONIC通常不計(jì)入休眠時(shí)間恢復(fù)后steady_clock的讀數(shù)看起來會(huì)“少了一截”。這種場(chǎng)景下想統(tǒng)計(jì)真實(shí)的物理時(shí)長(zhǎng)需要額外留意系統(tǒng)是否提供了包含休眠時(shí)間的時(shí)鐘源。服務(wù)器場(chǎng)景基本不會(huì)遇到但移動(dòng)端項(xiàng)目還是值得在方案設(shè)計(jì)階段想清楚。