ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

C++11毫秒时间戳获取与日期字符串互转:跨平台工具类实战

C++11毫秒时间戳获取与日期字符串互转:跨平台工具类实战 时间戳这个东西看起来是“取个时间嘛”的小事但毫秒级的互转一旦做错排查起来比内存泄漏还让人头疼。我最近整理一套上报系统的日志字段时看到老代码里还散落着time(NULL)取秒、再自己凑毫秒的写法心里特别没底。C11 的chrono其实早已把取毫秒这件事封装得很顺手关键是你得知道用哪个时钟、怎么解析字符串、时区语义怎么定。这篇就把我沉淀下来的方案完整写出来从获取当前毫秒时间戳到日期字符串和毫秒时间戳互转最后给一个能直接放进工程的工具类。写 C 的朋友也别急着走里面的坑和思路是通用的代码只是 C11 的壳。1. 为什么是毫秒而不是秒日志和监控让秒级时间戳现了原形1.1 秒的粒度在日志和监控里真的不够用单看“日期时间格式带不带毫秒”好像只是多三个数字的区别但真实业务里差距非常大。我遇到过最典型的场景是耗时抖动排查。服务接口平均 200ms某次流量高峰后出现一批 3500ms 的超时请求。如果日志时间戳只精确到秒输出大概是这样子的2024-01-15 14:30:25 INFO request finished, cost: 3560ms时间戳停留在14:30:25完全看不清这次超时是从哪一秒开始、和哪一次流量波峰重叠。如果日志里带着.123、.900这样的毫秒精度按时间排序后就能准确还原出“上游在某毫秒内突然打进来一批请求”“数据库连接池在那几毫秒里卡住”这样的因果链路。另一个常见需求是任务跨天、跨分钟归属。比如某个定时任务在23:59:59.800开始处理到第二天00:00:00.250结束。如果只按秒级时间戳统计这条任务可能会被归到 23 号而不是 24 号如果协议里的结算、对账、积分流水也依赖这个时间差一天可是大事故。毫秒时间戳虽然不能根治业务逻辑问题但至少能把顺序关系看清楚谁先到、谁后到、谁在哪个窗口里过期。对后台服务、日志系统、监控上报、设备通信协议来说毫秒精度早已是标配不是可选项。1.2 老代码里的 time(NULL) 和 gettimeofday 为什么别扭翻老 C/C 项目最常看到的是这几类做法time(NULL)拿到秒级time_t然后手动* 1000凑毫秒。结果就是根本拿不到真毫秒只能硬编。用gettimeofday()拿timeval这是 POSIX 接口Windows 下没有等价物为了跨平台得额外包一层#ifdef。在 Windows 下改用GetSystemTimeAsFileTime()但这函数给的单位是 100 纳秒还得换算直接劝退不少人。更野一点的做法是二次封装一堆_ftime64、localtime_s不同编译器参数顺序还不一样代码里全是宏。这些方案不是不能用问题是每个平台一套语义不统一而且很容易在“秒转毫秒”“毫秒取秒”的过程中把溢出和时间溢出搞错。C11 标准库的chrono把时钟、时间点、时长统一抽象出来目的就是让我们少写这些平台适配。它不是性能最好的方案但它是跨平台语义最清晰、最不容易写错的方案。后面所有代码我都默认目标环境支持 C11。2. 从 system_clock 里安全取出毫秒别再碰 high_resolution_clock2.1 为什么不能随便选 high_resolution_clock很多初学者看完网上的例子习惯写出这样一行auto now std::chrono::high_resolution_clock::now();这行代码看起来通用实际上隐藏着一个大坑high_resolution_clock不保证基于 Unix epoch也不保证能换算成日期。它在不少实现里只是steady_clock的别名计时起点可能是系统启动时间甚至可能是某个随机时刻。你拿去测代码耗时没问题但拿它当“当前时间的毫秒时间戳”去和日历日期互转结果完全不可控。正确的选择只有一个std::chrono::system_clock。这个名字里的“system”指的就是系统的墙上时钟它在 C11 标准里被定义为能表示系统范围的实时时钟时间点可以和time_t互通多数实现直接基于 Unix epoch。我们要做“毫秒时间戳和日期互转”必须用它。2.2 从 time_point 到 int64_t核心代码其实就几行获取当前毫秒时间戳的完整写法如下#include chrono #include cstdint int64_t now_utc_ms() { auto now std::chrono::system_clock::now(); return std::chrono::duration_caststd::chrono::milliseconds( now.time_since_epoch()) .count(); }拆开看三步system_clock::now()返回一个time_point它代表“当前这个时刻”在某个时钟坐标系里的位置。.time_since_epoch()把这个时间点转换成自 epoch 以来的时长。对system_clock来说epoch 一般就是1970-01-01 00:00:00 UTC。duration_castmilliseconds把时长强制切到毫秒精度.count()取出对应的整数毫秒数。这里的关键是你必须理解time_point和duration的分工前者是“墙上的一点”后者是“一段时间”。很多人被chrono的模板类型绕晕其实只要记住这个分工后面所有转换都顺了。2.3 有时要拆出“秒 毫秒余数”两种写法看着办有些场景不只要总毫秒数还希望拿到“整秒部分”和“毫秒余数”。这时候不要用两次system_clock::now()那在两次调用之间系统时间可能已经前进了几微秒造成秒和毫秒对不齐。正确做法是只取一次时间点再分别切auto now std::chrono::system_clock::now(); int64_t total_ms std::chrono::duration_caststd::chrono::milliseconds(now.time_since_epoch()).count(); int64_t sec_part total_ms / 1000; int32_t ms_part total_ms % 1000;因为total_ms本身就是 epoch 以来的总毫秒数除以 1000 的商就是整秒余数就是毫秒。这里要提醒一句上面的除法和取模对total_ms 0的场景不成立因为 C11 的整数除法是向零截断的。比如-1 / 1000在数学上是-0.001向零截断后得到0余数是-1。如果你要支持 1970 年以前的时间戳这里必须特殊处理我后面在格式化章节会专门说。平台精度其实不必担心Windows 上system_clock::now()底层通常是GetSystemTimeAsFileTime()提供的精度是 100 纳秒Linux 上一般基于clock_gettime(CLOCK_REALTIME)能到纳秒。取毫秒完全是降维使用。3. 日期字符串转毫秒时间戳解析、转换与“UTC还是本地时间”的抉择3.1 先看看 std::get_time 方案能写但坑不少很多人第一反应是用std::get_time解析日期字符串#include iomanip #include sstream std::tm tm{}; std::istringstream ss(2024-01-15 14:30:25); ss std::get_time(tm, %Y-%m-%d %H:%M:%S);这段代码在多数编译器上能跑但有几个长期被低估的问题get_time不会帮你解析毫秒字段。字符串里带.123就得先切掉或者再单独读一次。如果输入是 ISO 8601 风格2024-01-15T14:30:25.123Z格式串还要改成%Y-%m-%dT%H:%M:%S而且 Windows 标准库的get_time实现和 Linux 上的行为存在微妙差异对输入流里空白的处理方式可能不同。解析之后多余字符不一定被自动检查。字符串尾部多了一个空格或乱字符你很难立刻发现。默认情况下std::istringstream会跳过输入开头的空白但日期字符串内部的空格需要格式串精确匹配某些旧编译器的get_time实现在这里翻过车。所以我的建议是项目里的紧凑代码不要用它做“日期字符串转时间戳”的主逻辑get_time更适合交互式配置解析这种宽松场景。生产环境互转往下看。3.2 用 sscanf %n 做严格解析可读性和正确性都能保住我实际用的解析函数长这样#include cstdio #include cstring #include string bool parse_ms(const std::string text, int64_t out_ms, bool utc true) { std::string t text; // 兼容 ISO 8601: 2024-01-15T14:30:25.123Z if (t.size() 10 t[10] T) { t[10] ; } if (!t.empty() t.back() Z) { t.pop_back(); } int y 0, mo 0, d 0, h 0, mi 0, s 0, ms 0; int consumed 0; int n sscanf(t.c_str(), %4d-%2d-%2d %2d:%2d:%2d.%3d%n, y, mo, d, h, mi, s, ms, consumed); if (n 7 || consumed 0) { return false; } // 严格检查字符串必须被完全消费允许尾部空白 while (consumed static_castint(t.size()) (t[consumed] || t[consumed] \t || t[consumed] \r || t[consumed] \n)) { consumed; } if (consumed ! static_castint(t.size())) { return false; } std::tm tmv{}; tmv.tm_year y - 1900; tmv.tm_mon mo - 1; tmv.tm_mday d; tmv.tm_hour h; tmv.tm_min mi; tmv.tm_sec s; tmv.tm_isdst 0; time_t secs 0; if (utc) { #ifdef _WIN32 secs _mkgmtime(tmv); #else secs timegm(tmv); #endif } else { tmv.tm_isdst -1; secs mktime(tmv); } out_ms static_castint64_t(secs) * 1000 ms; return true; }几个设计点我展开说一下。第一用%4d而不是%d能限制年份最多读 4 位避免20240115 143025这种粘连字符串被错误拆开。虽然正常日志不会这么写但解析函数面对不可信数据时严格是好事。第二%n是 sscanf 的“当前已消费字符数”输出它不参与返回值计数。所以判断顺序是先看n 7有 7 个有效字段再看consumed是否把整个字符串吃干净。尾部允许少量空白但不允许出现2024-01-15 14:30:25.123abc这种内容。第三utc参数决定了字符串的语义。utc true表示输入的日期时间就是 UTC 时刻utc false表示这是本地时间。这是最容易埋雷的地方下一节细说。第四毫秒字段%3d最多读 3 位。如果字符串是2024-01-15 14:30:25.1会被解析成 1 毫秒如果是2024-01-15 14:30:25.100就是 100 毫秒。为了避免歧义业务日志最好强制写满 3 位。3.3 本地时间还是 UTCmktime 与 timegm 的差异必须说透这是日期字符串转时间戳里最重要的语义问题。同样的字符串2024-01-15 14:30:25在 UTC8 时区如果按本地时间理解对应 epoch 毫秒数会比按 UTC 理解少 8 小时整整 28800000 毫秒。我见过的很多事故都是把“日志里打印出来的人类可读时间”当成了绝对时间然后直接用它去做时间计算。实际上日志打印的字符串是什么时区取决于格式化时用了localtime还是gmtime解析时也必须保持一致。mktime(tm)把tm当成系统当前时区的本地时间换算成 epoch 秒。它自己会处理时区偏移也会修改tm的tm_wday、tm_yday字段。对带夏令时的地区还依赖tm_isdst标志。timegm(tm)把tm当成 UTC 时间直接换算成 epoch 秒。它是 GNU 扩展Windows 上没有Windows 对应的是_mkgmtime(tm)。_mkgmtime(tm)MSVC 提供的 UTC 版本和timegm行为一致。所以代码里用了条件编译#ifdef _WIN32 secs _mkgmtime(tmv); #else secs timegm(tmv); #endif如果不需要支持 1970 年前的场景在使用_mkgmtime/timegm之前把tmv.tm_isdst设置成0就够了它能避免一部分未初始化内存导致的随机结果。补一个常见误区有人为了省事先timegm把 UTC 转成 epoch再调localtime展示完全没问题但要小心解析方向和展示方向必须配对。解析时按本地时间展示时却用 UTC最后存库的时间戳会自动偏移几个小时而且这类问题特别难发现因为它只在跨时区的服务器上看得出异常。3.4 不想依赖系统时区函数手写日期算法更可控如果写的是嵌入式、核心路由或对可测试性要求很高的代码可能连timegm都不想让它们依赖系统 TZ 环境。这时可以直接用日期算法把“年月日时分秒毫秒”算成 epoch 毫秒完全按 UTC 或固定偏移解释。这是我在项目中用的一个纯函数版本避免平台差异int64_t days_from_civil(int y, int m, int d) { y - m 2; const int era (y 0 ? y : y - 399) / 400; const unsigned yoe static_castunsigned(y - era * 400); const unsigned doy (153 * (m (m 2 ? -3 : 9)) 2) / 5 d - 1; const unsigned doe yoe * 365 yoe / 4 - yoe / 100 doy; return era * 146097 static_castint64_t(doe) - 719468; }配合关系式int64_t seconds days_from_civil(y, mo, d) * 86400 h * 3600 mi * 60 s; out_ms seconds * 1000 ms;这套算法来自现代 C20chrono日期底层实现的公开推算逻辑我自己在项目里验证过多年跨度包括闰年边界。优点是不依赖平台宏、不依赖 TZ、纯输入输出非常适合单元测试。代价是没有本地时间语义。如果你真的需要“把本地时间字符串转成 epoch 毫秒”那就躲不开mktime它内部要查系统时区规则。所以我的最终建议是业务内部统一用 UTC 字符串解析时也走utc true只有面向用户展示时才转成本地时间。4. 毫秒时间戳转回日期字符串格式化、补零和线程安全4.1 先拆秒和毫秒负数时间戳要单独处理从int64_t total_ms转成日期字符串第一步永远是拆整数int64_t sec_part total_ms / 1000; int32_t ms_part static_castint32_t(total_ms % 1000);如前面提到的负数时 C 整数除法向零取整total_ms -1会得到sec_part 0, ms_part -1。直接拿去格式化秒和毫秒拼出来就是畸形的1970-01-01 00:00:00.-1。所以正式格式化前要做一个逆标准化if (ms_part 0) { sec_part - 1; ms_part 1000; }这个是毫秒时间戳互转里最常见也最少有人写的细节。别觉得 1970 年之前不重要设备上报、历史回放、跨时区结算都可能遇到负时间戳而且万一某个中间件传了一个坏值你至少不会打印出完全没法解析的字符串。4.2 localtime 与 gmtime 的线程安全版本跨平台宏就两行拿到sec_part之后需要把它拆成年月日时分秒。绝不建议直接用localtime或gmtime这两个函数在传统实现里返回的是静态缓冲区指针多线程下数据会被互相覆盖。正确做法是用带_r或_s后缀的线程安全版本。我一般封装成这样#include ctime #include cstdint inline void safe_break_time(int64_t sec, bool utc, std::tm out) { time_t raw static_casttime_t(sec); #ifdef _WIN32 if (utc) { gmtime_s(out, raw); } else { localtime_s(out, raw); } #else if (utc) { gmtime_r(raw, out); } else { localtime_r(raw, out); } #endif }这里最大的坑是参数顺序。Windows 的gmtime_s/localtime_s第一个参数是输出tm*第二个是输入const time_t*Linux 的gmtime_r/localtime_r第一个参数是输入const time_t*第二个才是输出tm*。顺序写反不会编译报错但运行结果完全乱掉。cast成time_t也要注意。如果你在 32 位系统上用了老的 32 位time_tsec_part超过 2038 年就会溢出表现是日期“回到”1901 年。现代 64 位系统基本没有这个问题但写跨平台库时最好自己确认下time_t的宽度。4.3 用 snprintf 补零格式化输出串一眼就能对齐拆完tm和毫秒余数格式化就简单了。我推荐直接用snprintf不绕弯子#include cstdio std::string format_ms(int64_t total_ms, bool utc true) { int64_t sec_part total_ms / 1000; int32_t ms_part static_castint32_t(total_ms % 1000); if (ms_part 0) { sec_part - 1; ms_part 1000; } std::tm tmv{}; safe_break_time(sec_part, utc, tmv); char buf[40]; snprintf(buf, sizeof(buf), %04d-%02d-%02d %02d:%02d:%02d.%03d, tmv.tm_year 1900, tmv.tm_mon 1, tmv.tm_mday, tmv.tm_hour, tmv.tm_min, tmv.tm_sec, ms_part); return std::string(buf); }几个格式位%04d保证年份四位%02d保证月、日、时、分、秒两位%03d保证毫秒三位。输出风格是2024-01-15 14:30:25.123。如果要存日志文件或上报协议我建议统一成这个格式因为人类可读、机器也能用同一套parse_ms原样解析回去。如果想输出 ISO 8601 UTC 字符串把格式改成%04d-%02d-%02dT%02d:%02d:%02d.%03dZ并且在调用safe_break_time时传入utc true这样日志里能明确看到Z后缀跨系统追溯时不会误判时区。5. 一个可以直接带走的跨平台工具类完整代码与性能实测心得5.1 完整头文件拷贝进项目就能用前面几段把原理拆开了这里我把它们整合成一个不依赖第三方库的头文件。假设你的项目就是 C11文件名我一般叫MillisClock.h。#pragma once #include chrono #include cstdint #include cstdio #include ctime #include string namespace millisclock { inline int64_t now_ms() { auto now std::chrono::system_clock::now(); return std::chrono::duration_caststd::chrono::milliseconds( now.time_since_epoch()) .count(); } inline void safe_break_time(int64_t sec, bool utc, std::tm out) { time_t raw static_casttime_t(sec); #ifdef _WIN32 if (utc) { gmtime_s(out, raw); } else { localtime_s(out, raw); } #else if (utc) { gmtime_r(raw, out); } else { localtime_r(raw, out); } #endif } inline bool parse_ms(const std::string text, int64_t out_ms, bool utc true) { std::string t text; if (t.size() 10 t[10] T) { t[10] ; } if (!t.empty() t.back() Z) { t.pop_back(); } int y 0, mo 0, d 0, h 0, mi 0, s 0, ms 0; int consumed 0; int n sscanf(t.c_str(), %4d-%2d-%2d %2d:%2d:%2d.%3d%n, y, mo, d, h, mi, s, ms, consumed); if (n 7 || consumed 0) { return false; } while (consumed static_castint(t.size()) (t[consumed] || t[consumed] \t || t[consumed] \r || t[consumed] \n)) { consumed; } if (consumed ! static_castint(t.size())) { return false; } std::tm tmv{}; tmv.tm_year y - 1900; tmv.tm_mon mo - 1; tmv.tm_mday d; tmv.tm_hour h; tmv.tm_min mi; tmv.tm_sec s; tmv.tm_isdst 0; time_t secs 0; if (utc) { #ifdef _WIN32 secs _mkgmtime(tmv); #else secs timegm(tmv); #endif } else { tmv.tm_isdst -1; secs mktime(tmv); } out_ms static_castint64_t(secs) * 1000 ms; return true; } inline std::string format_ms(int64_t total_ms, bool utc true) { int64_t sec_part total_ms / 1000; int32_t ms_part static_castint32_t(total_ms % 1000); if (ms_part 0) { sec_part - 1; ms_part 1000; } std::tm tmv{}; safe_break_time(sec_part, utc, tmv); char buf[40]; snprintf(buf, sizeof(buf), %04d-%02d-%02d %02d:%02d:%02d.%03d, tmv.tm_year 1900, tmv.tm_mon 1, tmv.tm_mday, tmv.tm_hour, tmv.tm_min, tmv.tm_sec, ms_part); return std::string(buf); } } // namespace millisclockLinux 上如果编译器没开 GNU 扩展timegm会编译失败。两个解决办法一是编译时加-D_GNU_SOURCE二是干脆在文件顶部加#if !defined(_WIN32) #define _GNU_SOURCE 1 #endif必须放在所有系统头文件之前。如果你不想碰宏把utc true的解析换成第 3 章的days_from_civil纯算法也能完全摆脱timegm。5.2 性能实测心得别在热路径上反复调 mktime有人会担心这些转换函数的开销尤其是日志系统每秒输出几十万行时。我实测下来的感受是format_ms的主要成本在localtime_r/gmtime_r它内部每次都要查时区规则、校正夏时令状态。而parse_ms的主要成本在mktime/timegm特别是本地时间模式下mktime会做一次完整的日历归一化。在我手头一台普通服务上纯循环调用format_ms100 万次大概能感觉到明显的耗时但通常只有几百毫秒级别如果一次服务启动要格式化几亿条日志那就不能在一个转换上斤斤计较而是要考虑用第 3.4 节的days_from_civil算法替换gmtime_r纯整数运算会快很多。对固定时区做缓存。例如你的服务只在 UTC8 运行可以把时区偏移缓存成一个常量避免每次调用都走 TZ 环境变量。日志模块批量刷盘时一次格式化一批再统一写减少上下文切换和锁竞争。顺便说一句日志里面如果既要人类可读字符串又要 epoch 毫秒最好两个都打印出来。比如int64_t t millisclock::now_ms(); LOG_INFO(time_ms%lld time_str%s, t, millisclock::format_ms(t).c_str());这样排查问题时用字符串肉眼定位用time_ms做跨进程排序两边互相印证。5.3 业务约定统一存储为 UTC 毫秒展示时再转本地工具类只是“术”真正让团队少踩坑的其实是“约定”。我现在的项目里定下三条铁律存储、传输、计算的内部时间一律用int64_tUTC 毫秒任何数据库表、协议字段、日志字段都这么定义。只有日志展示、用户界面输出才允许出现本地时间字符串而且必须带时区或调用明确的format_ms(...)函数。所有外部输入的日期字符串入口处统一用parse_ms解析一次转成 UTC 毫秒后内部流转不要再保留字符串格式到处传。这样做的好处是数据在系统内部永远是同一种语义不会因为某台服务器TZ没配置、某台机器被设置成不同时区导致毫秒值错位。以前我见过最头疼的问题就是同一份日志一台机器打印出来是14:30另一台是22:30两边还在对着一个时间戳吵架。6. 我在实际项目里踩过的坑和最后几条建议6.1 夏令时会让“本地时间解析”彻底变得不可预测mktime配合tm_isdst -1看起来能自动判断夏令时但真实情况是一旦系统时区跨过春季夏令时切换日某段本地时间根本不存在秋季切换时某段本地时间又会重复出现。解析2024-03-10 02:30:00这类时间结果可能比你预期多一小时也可能少一小时。如果业务涉及跨境、跨季节调度我强烈建议不要用“本地时间字符串 绝对时间”这种模型。要么所有字符串本身就是 UTC要么解析时固定按一个业务区偏移解释。用utc true永远比依赖mktime判断夏令时更可控。6.2 system_clock 会随系统时间跳变计时别拿它做system_clock是墙上时钟NTP 同步、管理员手动改时间都会让它瞬间跳变。如果拿它去计算定时任务的剩余毫秒数或者拿两次now_ms()的差值当耗时系统时间一旦回拨就会出现负值调度逻辑可能直接乱掉。正确分工是记录事件发生的时间戳用system_clock测量一段代码的执行耗时用steady_clock。steady_clock是单调时钟不受系统时间调整影响。两条线不要混着用否则线上会冒出很多“不可能”的负耗时。6.3 最后几条实在建议解析函数返回bool而不是默默返回0是我改代码时最坚持的一点。一个非法日期字符串如果被解析成“1970 年 1 月 1 日”后续排查成本远高于让调用方立刻感知失败。单元测试里最值得加的用例就是往返断言int64_t t 1705307425123; std::string s millisclock::format_ms(t, true); int64_t back 0; assert(millisclock::parse_ms(s, back, true)); assert(back t);这个用例能同时覆盖格式化、解析、补零逻辑还可以顺手测一下负数、跨年、闰年边界。工具类在系统里跑久了真正出问题的往往不是普通日期而是这些边界值。我还喜欢在日志输出的最前面统一带上毫秒时间戳整数比如[1705307425123]这样即使后续格式调整了外部工具也能根据整数字段精确排序。别小看这些细节线上事故排查的时候多一个可复现的毫秒锚点往往比多查两小时日志有价值得多。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表