第 3 课:日期时间 & 工具函数
对应源文件:
trantor/utils/Date.h/Date.cc— 微秒精度时间点trantor/utils/Funcs.h— 通用辅助函数(字节序 + 字符串分割)trantor/utils/NonCopyable.h— 禁拷贝基类
一、Date:微秒精度时间点
1.1 设计思路
Date 本质上只是一个 int64_t 的包装:
| |
为什么用微秒而不是毫秒/秒?
- 定时器精度要求微秒级(定时器误差 < 1ms)
- 日志时间戳需要显示到微秒(高频事件排查)
int64_t表示微秒可以撑到 292471年,溢出不是问题
1.2 获取当前时间:Date::now()
| |
Linux 路径:gettimeofday 是 VDSO 调用(Virtual Dynamic Shared Object),在用户空间直接读取内核映射的时钟,不陷入内核,速度极快(几十纳秒)。
Windows 路径(Date.cc 第 30-51 行):
| |
⚠️ 注意:Windows 实现的
gettimeofday精度只到毫秒,而且用的是本地时间不是 UTC,这是一个平台差异点。
1.3 时区设计:UTC 优先
trantor 的 Date 内部存储永远是 UTC(epoch 微秒数),时区只在显示时处理:
| |
时区偏移计算(Date.cc 第 67-71 行):
| |
Date(1970, 1, 1) 构造函数内部调用 mktime(本地时间),mktime 会把本地时间转为 epoch,如果本地是 UTC+8,则返回 -28800,取反得 28800(即 +8 小时的秒数)。这是一个利用 mktime 隐式处理时区的小技巧。
1.4 关键运算函数
after(double second) — 偏移时间点
| |
可以传负数表示过去的时间点:
| |
定时器系统大量使用 after() 计算到期时间。
roundSecond() — 截断到秒
| |
用取模截掉微秒部分,等价于 floor(time) 到秒精度。
| |
roundDay() — 截断到天
| |
用于日志轮转判断(AsyncFileLogger 可据此每天生成一个新日志文件)。
isSameSecond(const Date &date) — 同一秒判断
| |
整除去掉微秒部分,直接比秒数。Logger 用此函数避免每条日志都重新格式化时间:同一秒内只格式化一次,其余复用。
1.5 字符串输出格式一览
| 方法 | 时区 | 格式示例 |
|---|---|---|
toFormattedString(false) | UTC | "20180101 10:10:25" |
toFormattedString(true) | UTC | "20180101 10:10:25.102414" |
toFormattedStringLocal(true) | 本地 | "20180101 18:10:25.102414"(UTC+8) |
toDbString() | UTC | "2018-01-01 10:10:25" |
toDbStringLocal() | 本地 | "2018-01-01" / "2018-01-01 10:10:25" |
toCustomFormattedString("%Y-%m-%d") | UTC | "2018-01-01" |
toDbStringLocal() 有一个智能输出逻辑(Date.cc 第 258-282 行):
- 如果时分秒都为零(
== roundDay())→ 只输出日期"2018-01-01" - 如果微秒不为零 → 输出到微秒
"2018-01-01 10:10:25.102414" - 否则 → 输出到秒
"2018-01-01 10:10:25"
1.6 ISO-8601 解析 fromISOString
支持格式:
| |
内部先用 splitString2(str, "T ") 分割日期和时间部分(T 或空格都接受),再逐层解析。
1.7 Date 在 trantor 中的用途总结
| 使用场景 | 具体用法 |
|---|---|
| Logger 时间戳 | Date date_{Date::now()},toFormattedString(true) |
| 定时器到期时间 | Date::now().after(3.0) — 3 秒后到期 |
| 日志文件命名 | toCustomFormattedString("%Y%m%d_%H%M%S") |
| TimingWheel 时间计算 | 微秒数直接参与算术运算 |
| 数据库时间字段 | toDbStringLocal() / fromDbStringLocal() |
二、Funcs.h:轻量工具函数
2.1 hton64 / ntoh64 — 64位字节序转换
| |
字节序检测原理:
| |
static const 保证只计算一次,后续调用直接读缓存值。
为什么标准库没有 hton64?
htons(16位)和 htonl(32位)是 POSIX 标准,但 64 位版本不在标准内(各平台实现不统一)。trantor 自己实现了一个跨平台版本。
2.2 splitString — 字符串分割
| |
核心逻辑(手写,不用 strtok):
| |
行为示例:
| |
Date::fromDbStringLocal 和 fromISOString 都大量使用这个函数解析时间字符串。
三、NonCopyable:禁拷贝基类
3.1 完整实现
| |
3.2 为什么需要它?
trantor 中大量对象语义上不应该被复制:
| 类 | 原因 |
|---|---|
Logger | 每个对象代表一条正在写的日志,复制语义不明 |
MsgBuffer(继承处) | 复制大缓冲区开销高,应用 move 语义 |
EventLoop | 拥有文件描述符和线程,复制毫无意义 |
TcpConnection | 代表一个网络连接,不可复制 |
AsyncFileLogger | 拥有后台线程和互斥锁,不可复制 |
通过继承 NonCopyable,编译器会在任何试图拷贝这些对象时报错,而不是默默生成一个浅拷贝导致运行时问题。
3.3 为什么允许 Move?
注释说:some uncopyable classes maybe support move constructor
std::unique_ptr、std::thread等都是禁拷贝但可移动- Move 语义转移所有权,语义清晰(“这个连接从 A 移到 B”)
- 例如
MsgBuffer可以被swap,内部也支持 move
3.4 protected 构造/析构的含义
构造和析构是 protected,意味着:
- 不能直接实例化
NonCopyable(只能作为基类) - 派生类可以正常构造和析构
| |
四、三者关系与在 trantor 中的地位
| |
这三个文件是 trantor 的工具层基础,后面所有模块都会用到:
Date→ 定时器(第8课)、Logger 时间戳(第1课)NonCopyable→ EventLoop、TcpConnection、所有核心类Funcs.h→ MsgBuffer 字节序(第2课)、Date 字符串解析
五、实战:游戏服务器中的典型使用
场景1:精确定时任务
| |
场景2:日志文件按天轮转
| |
场景3:数据库时间字段存取
| |
核心收获
Date用int64_t microSecondsSinceEpoch_存储微秒时间戳,可直接做定时器 key 和排序比较Date::now()跨平台:Linux 用gettimeofday,Windows 用GetSystemTimeAsFileTimeNonCopyable基类通过= delete拷贝构造/赋值,是整个 trantor 几乎所有核心类的基类hton64用 static 变量缓存字节序检测结果,运行期检测比条件编译更安全(兼容跨平台 CI)
六、思考题
Date::now()在 Linux 上调用gettimeofday,而不是更新的clock_gettime(CLOCK_REALTIME, ...),有什么优缺点?timezoneOffset()使用static int64_t offset,假设程序运行时系统时区变了(TZ环境变量变化),offset 会跟着更新吗?这是 bug 还是设计?NonCopyable的 move 构造声明为noexcept(true),如果派生类的 move 构造可能抛异常,会发生什么?hton64每次调用都要检查sig变量(虽然是 static 缓存),能不能用条件编译#if __BYTE_ORDER == __BIG_ENDIAN代替?两种方案各有什么优缺点?
七、思考题参考答案
1. Date::now() 在 Linux 上调用 gettimeofday,而不是更新的 clock_gettime(CLOCK_REALTIME, ...),有什么优缺点?
gettimeofday 的优点:
- VDSO 加速,极快:在 Linux 上,
gettimeofday通过 VDSO(Virtual Dynamic Shared Object)机制实现,直接在用户空间读取内核映射的时钟页面,不需要真正陷入内核,耗时仅几十纳秒。clock_gettime(CLOCK_REALTIME)在现代 Linux 上同样走 VDSO,速度相当。 - 移植性好:
gettimeofday是 POSIX 标准函数,几乎所有 Unix 系统都支持。trantor 的 Windows 路径也自己实现了一个同名函数,保持了接口统一。 - API 简洁:返回
struct timeval(秒 + 微秒),直接对应Date内部的微秒精度,不需要额外换算。
gettimeofday 的缺点:
- 精度上限为微秒:
gettimeofday返回timeval(精度到微秒),而clock_gettime返回timespec(精度到纳秒)。对于需要纳秒级精度的场景(如高频交易),gettimeofday不够用。不过 trantor 的Date本身就是微秒精度设计,所以这不是问题。 - 已被标记为过时:POSIX.1-2008 将
gettimeofday标记为 obsolescent(过时),推荐使用clock_gettime。虽然短期内不会被移除,但从标准演进角度看不是最佳选择。 - 不支持单调时钟:
gettimeofday只能获取 wall clock 时间,受 NTP 校时影响(可能跳变)。clock_gettime支持CLOCK_MONOTONIC(单调时钟,不受 NTP 影响),更适合定时器间隔计算。不过Date的定位是"时间点"而非"时间间隔",使用 wall clock 是合理的。 - 时区参数已废弃:
gettimeofday的第二个参数tzp已被废弃,必须传NULL,这是历史包袱。
总结:对于 trantor 的使用场景(微秒精度时间戳),gettimeofday 完全够用且性能优秀。如果未来需要纳秒精度或单调时钟,再迁移到 clock_gettime 即可。
2. timezoneOffset() 使用 static int64_t offset,假设程序运行时系统时区变了,offset 会跟着更新吗?
不会更新,这是有意设计而非 bug。
看源码(Date.cc 第 67-71 行):
| |
static 局部变量在 C++ 中只会在第一次执行到该语句时初始化一次(C++11 保证线程安全初始化)。之后无论调用多少次 timezoneOffset(),都返回同一个缓存值。
为什么这是合理的设计?
服务器场景时区几乎不变:游戏服务器部署后,系统时区(
TZ环境变量)基本不会在运行时改变。服务器进程通常运行数天甚至数月,时区在启动时确定即可。性能考量:
timezoneOffset()被toDbString()、fromDbString()、fromISOString()等函数频繁调用。如果每次都重新计算(构造Date(1970,1,1)→ 调用mktime),会有不必要的开销。mktime内部需要查询时区数据库文件,不是轻量操作。一致性保障:如果时区在程序运行过程中变化,且
offset每次动态计算,那么同一个Date对象在不同时刻调用toDbStringLocal()可能返回不同结果——这反而会导致数据不一致的 bug。
如果确实需要响应时区变化怎么办?
在 Linux 上,修改 TZ 环境变量后需要调用 tzset() 让 C 库重新加载时区信息。但即使调用了 tzset(),trantor 的 offset 仍不会更新。如果有这种需求,需要修改 timezoneOffset() 的实现,去掉 static,每次重新计算——但这会牺牲性能,且在 99.9% 的场景下没有必要。
3. NonCopyable 的 move 构造声明为 noexcept(true),如果派生类的 move 构造可能抛异常,会发生什么?
派生类的 move 构造函数不受基类 noexcept 声明的约束,它的异常规格由自身决定。
看 NonCopyable 的声明(NonCopyable.h 第 37-38 行):
| |
这里 noexcept(true) 只约束 NonCopyable 自身的 move 构造——保证移动 NonCopyable 这个基类子对象不会抛异常(实际上 NonCopyable 没有任何数据成员,move 构造本身就是空操作)。
派生类的 move 构造的 noexcept 规格取决于:
- 所有基类子对象的 move 构造是否
noexcept - 所有成员变量的 move 构造是否
noexcept - 派生类自身是否声明了
noexcept
如果派生类有一个可能抛异常的成员(比如 std::list、自定义类型等),编译器生成的默认 move 构造函数就不会是 noexcept 的——即使基类 NonCopyable 的 move 是 noexcept(true)。
实际影响:
| |
noexcept 的重要性在于 STL 容器的优化:std::vector 在扩容时,只有当元素的 move 构造是 noexcept 时才使用 move,否则会退回到 copy(对于禁止拷贝的类型则编译失败)。所以 NonCopyable 的 move 声明为 noexcept(true) 是给派生类的 noexcept 推导加分——至少基类部分不会拖后腿。
如果派生类的 move 构造可能抛异常,而又被用在 noexcept 上下文中(比如被 std::vector::push_back 触发的 move),那么一旦真的抛出异常,程序会调用 std::terminate() 直接终止——这是 C++ 标准的规定。
4. hton64 每次调用都要检查 sig 变量,能不能用条件编译代替?
可以用条件编译代替,但两种方案各有取舍。
当前方案(运行期检测 + static 缓存):
| |
优点:
- 100% 可靠:在任何编译器、任何平台上都能正确检测字节序,不依赖编译器/平台特定的宏定义
- 交叉编译安全:在 x86 机器上交叉编译 ARM 大端目标时,条件编译宏可能反映的是宿主机的字节序而非目标机的——运行期检测则永远正确
static变量只初始化一次:sig的值在首次调用时计算,后续调用直接读缓存,开销极小(一次内存读取 + 一次分支预测,且分支几乎总是走同一个方向,CPU 预测准确率极高)
缺点:
- 理论上有一次分支判断的开销(但在现代 CPU 上几乎可以忽略)
- 大端机器上仍然要做一次无意义的
if判断 std::reverse不是最优的字节翻转实现(可以用位运算或__builtin_bswap64更快)
条件编译方案:
| |
优点:
- 零运行时开销:编译期就确定了路径,大端机器直接
return n,连一条分支指令都没有 - 可以在编译期选择
__builtin_bswap64(GCC/Clang)或_byteswap_uint64(MSVC)等平台最优内置函数
缺点:
- 宏定义不统一:
__BYTE_ORDER__是 GCC/Clang 扩展,MSVC 没有这个宏;有些平台用__BYTE_ORDER(注意下划线数量不同)、BYTE_ORDER、_BYTE_ORDER等。要覆盖所有平台需要写一长串#if/#elif - 交叉编译风险:前面提到的宿主/目标字节序不一致问题
- 可维护性差:条件编译分支容易遗漏测试——如果开发团队全是 x86 小端机器,大端分支的代码可能从未被编译和测试过
结论:trantor 选择运行期检测是稳妥的工程选择,优先保证正确性和可移植性。性能差异在实际应用中可以忽略不计(static 变量检测 + 分支预测命中的开销约 1 纳秒级别)。如果对性能有极致要求,可以用条件编译 + 平台特定内置函数,但需要更多的平台适配代码和测试覆盖。
学习日期:2025-03-08 | 上一课:第02课_消息缓冲区MsgBuffer | 下一课:第04课_回调类型定义