第 3 课:日期时间 & 工具函数

对应源文件:

  • trantor/utils/Date.h / Date.cc — 微秒精度时间点
  • trantor/utils/Funcs.h — 通用辅助函数(字节序 + 字符串分割)
  • trantor/utils/NonCopyable.h — 禁拷贝基类

一、Date:微秒精度时间点

1.1 设计思路

Date 本质上只是一个 int64_t 的包装:

1
2
3
4
5
6
class Date {
  private:
    int64_t microSecondsSinceEpoch_{0};  // 从 1970-01-01 00:00:00 UTC 至今的微秒数
  public:
    static constexpr long MICRO_SECONDS_PER_SEC = 1000000LL;
};

为什么用微秒而不是毫秒/秒?

  • 定时器精度要求微秒级(定时器误差 < 1ms)
  • 日志时间戳需要显示到微秒(高频事件排查)
  • int64_t 表示微秒可以撑到 292471年,溢出不是问题

1.2 获取当前时间:Date::now()

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// Date.cc 第 52-65 行
const Date Date::date()
{
#ifndef _WIN32
    struct timeval tv;
    gettimeofday(&tv, NULL);        // Linux: 系统调用,精度 ~1 微秒
    int64_t seconds = tv.tv_sec;
    return Date(seconds * MICRO_SECONDS_PER_SEC + tv.tv_usec);
#else
    // Windows: GetLocalTime → mktime → 精度只到毫秒(wMilliseconds * 1000)
    timeval tv;
    gettimeofday(&tv, NULL);        // Windows 版本在同文件里实现
    ...
#endif
}

Linux 路径gettimeofday 是 VDSO 调用(Virtual Dynamic Shared Object),在用户空间直接读取内核映射的时钟,不陷入内核,速度极快(几十纳秒)。

Windows 路径(Date.cc 第 30-51 行):

1
2
3
4
5
6
int gettimeofday(timeval *tp, void *tzp) {
    SYSTEMTIME wtm;
    GetLocalTime(&wtm);             // 获取本地时间,精度到毫秒
    // ... mktime 转换
    tp->tv_usec = wtm.wMilliseconds * 1000;  // 微秒只精确到 ms 级!
}

⚠️ 注意:Windows 实现的 gettimeofday 精度只到毫秒,而且用的是本地时间不是 UTC,这是一个平台差异点。

1.3 时区设计:UTC 优先

trantor 的 Date 内部存储永远是 UTC(epoch 微秒数),时区只在显示时处理:

1
2
3
4
5
6
内部存储: microSecondsSinceEpoch_(UTC)
            ├─ toFormattedString()      → UTC 字符串
            ├─ toFormattedStringLocal() → 本地时区字符串
            ├─ toDbString()             → UTC 数据库字符串
            └─ toDbStringLocal()        → 本地时区数据库字符串

时区偏移计算(Date.cc 第 67-71 行):

1
2
3
4
5
6
7
8
int64_t Date::timezoneOffset()
{
    // 巧妙:用本地时间构造 1970-01-01 00:00:00,转 epoch
    // 如果在 UTC+8,mktime(1970-01-01 00:00:00 本地) = -28800 秒(-8小时)
    // 所以 -secondsSinceEpoch() = 28800 秒 = 8 * 3600
    static int64_t offset = -Date(1970, 1, 1).secondsSinceEpoch();
    return offset;
}

Date(1970, 1, 1) 构造函数内部调用 mktime(本地时间),mktime 会把本地时间转为 epoch,如果本地是 UTC+8,则返回 -28800,取反得 28800(即 +8 小时的秒数)。这是一个利用 mktime 隐式处理时区的小技巧。

1.4 关键运算函数

after(double second) — 偏移时间点

1
2
3
4
const Date Date::after(double second) const {
    return Date(static_cast<int64_t>(
        microSecondsSinceEpoch_ + second * MICRO_SECONDS_PER_SEC));
}

可以传负数表示过去的时间点:

1
2
3
4
Date now = Date::now();
Date fiveSecsLater  = now.after(5.0);     // 5 秒后
Date halfSecEarlier = now.after(-0.5);    // 0.5 秒前
Date twoMinsLater   = now.after(120.0);   // 2 分钟后

定时器系统大量使用 after() 计算到期时间。

roundSecond() — 截断到秒

1
2
3
4
const Date Date::roundSecond() const {
    return Date(microSecondsSinceEpoch_ -
                (microSecondsSinceEpoch_ % MICRO_SECONDS_PER_SEC));
}

用取模截掉微秒部分,等价于 floor(time) 到秒精度。

1
1704067890.123456 秒 → 1704067890.000000 秒

roundDay() — 截断到天

1
2
3
// 先转 time_t → localtime_r 拆解 → 清零时分秒 → mktime 重新合并
t.tm_hour = 0; t.tm_min = 0; t.tm_sec = 0;
return Date(mktime(&t) * MICRO_SECONDS_PER_SEC);

用于日志轮转判断(AsyncFileLogger 可据此每天生成一个新日志文件)。

isSameSecond(const Date &date) — 同一秒判断

1
2
3
4
bool isSameSecond(const Date &date) const {
    return microSecondsSinceEpoch_ / MICRO_SECONDS_PER_SEC ==
           date.microSecondsSinceEpoch_ / MICRO_SECONDS_PER_SEC;
}

整除去掉微秒部分,直接比秒数。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

支持格式:

1
2
3
4
5
"2018-01-01"
"2018-01-01 10:10:25"
"2018-01-01T10:10:25.102414"
"2018-01-01T10:10:25+08:00"   ← 带时区偏移
"2018-01-01T10:10:25Z"        ← UTC

内部先用 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位字节序转换

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// Funcs.h 第 21-33 行
inline uint64_t hton64(uint64_t n)
{
    static const int one = 1;
    static const char sig = *(char *)&one;  // 检测字节序:小端=1, 大端=0
    if (sig == 0)
        return n;  // 大端机器:直接返回,无需转换
    char *ptr = reinterpret_cast<char *>(&n);
    std::reverse(ptr, ptr + sizeof(uint64_t));  // 小端机器:翻转8字节
    return n;
}
inline uint64_t ntoh64(uint64_t n) { return hton64(n); }  // 转换是对称的

字节序检测原理

1
2
3
int one = 1;  在内存中的布局:
  小端(x86): [01 00 00 00]  → *(char*)&one = 0x01(非零)
  大端(SPARC):[00 00 00 01] → *(char*)&one = 0x00(零)

static const 保证只计算一次,后续调用直接读缓存值。

为什么标准库没有 hton64 htons(16位)和 htonl(32位)是 POSIX 标准,但 64 位版本不在标准内(各平台实现不统一)。trantor 自己实现了一个跨平台版本。

2.2 splitString — 字符串分割

1
2
3
4
5
// Funcs.h 第 35-53 行
inline std::vector<std::string> splitString(
    const std::string &s,
    const std::string &delimiter,        // 分隔符(支持多字符)
    bool acceptEmptyString = false)      // 是否保留空字符串

核心逻辑(手写,不用 strtok):

1
2
3
4
5
6
7
while ((next = s.find(delimiter, last)) != std::string::npos) {
    if (next > last || acceptEmptyString)
        v.push_back(s.substr(last, next - last));
    last = next + delimiter.length();
}
if (s.length() > last || acceptEmptyString)
    v.push_back(s.substr(last));  // 最后一段(后面没有分隔符)

行为示例:

1
2
3
4
5
splitString("a,b,,c", ",")           {"a", "b", "c"}     // 跳过空串
splitString("a,b,,c", ",", true)     {"a", "b", "", "c"} // 保留空串
splitString("a::b", "::")            {"a", "b"}           // 多字符分隔符
splitString("", ",")                 {}                   // 空字符串
splitString("abc", "")               {}                   // 空分隔符

Date::fromDbStringLocalfromISOString 都大量使用这个函数解析时间字符串。


三、NonCopyable:禁拷贝基类

3.1 完整实现

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// NonCopyable.h 第 25-39 行
class TRANTOR_EXPORT NonCopyable
{
  protected:
    NonCopyable() {}
    ~NonCopyable() {}
    NonCopyable(const NonCopyable &) = delete;             // 禁止拷贝构造
    NonCopyable &operator=(const NonCopyable &) = delete;  // 禁止拷贝赋值
    // some uncopyable classes maybe support move constructor....
    NonCopyable(NonCopyable &&) noexcept(true) = default;          // 允许移动构造
    NonCopyable &operator=(NonCopyable &&) noexcept(true) = default; // 允许移动赋值
};

3.2 为什么需要它?

trantor 中大量对象语义上不应该被复制:

原因
Logger每个对象代表一条正在写的日志,复制语义不明
MsgBuffer(继承处)复制大缓冲区开销高,应用 move 语义
EventLoop拥有文件描述符和线程,复制毫无意义
TcpConnection代表一个网络连接,不可复制
AsyncFileLogger拥有后台线程和互斥锁,不可复制

通过继承 NonCopyable,编译器会在任何试图拷贝这些对象时报错,而不是默默生成一个浅拷贝导致运行时问题。

3.3 为什么允许 Move?

注释说:some uncopyable classes maybe support move constructor

  • std::unique_ptrstd::thread 等都是禁拷贝但可移动
  • Move 语义转移所有权,语义清晰(“这个连接从 A 移到 B”)
  • 例如 MsgBuffer 可以被 swap,内部也支持 move

3.4 protected 构造/析构的含义

构造和析构是 protected,意味着:

  • 不能直接实例化 NonCopyable(只能作为基类)
  • 派生类可以正常构造和析构
1
2
3
4
5
// 错误:不能直接构造
NonCopyable nc;           // 编译错误,构造函数是 protected

// 正确:作为基类
class EventLoop : public NonCopyable { ... };  // OK

四、三者关系与在 trantor 中的地位

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
NonCopyable(语义保障)
    ↑ 继承
Logger ── 使用 ──► Date(时间戳)
MsgBuffer(内部存储)
AsyncFileLogger ── 使用 ──► Date(文件名生成)
TimerQueue ── 使用 ──► Date(定时器到期时间)

Funcs.h(无类工具)
    ↑ 被 MsgBuffer 用于 hton64
    ↑ 被 Date 用于 splitString 解析时间字符串

这三个文件是 trantor 的工具层基础,后面所有模块都会用到:

  • Date → 定时器(第8课)、Logger 时间戳(第1课)
  • NonCopyable → EventLoop、TcpConnection、所有核心类
  • Funcs.h → MsgBuffer 字节序(第2课)、Date 字符串解析

五、实战:游戏服务器中的典型使用

场景1:精确定时任务

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// 计划在 30 秒后执行某任务
Date fireTime = Date::now().after(30.0);

// 判断是否已过期
if (Date::now() >= fireTime) {
    executeTask();
}

// 两个时间点的差值(毫秒)
int64_t diffMs = (timeA.microSecondsSinceEpoch() -
                  timeB.microSecondsSinceEpoch()) / 1000;

场景2:日志文件按天轮转

1
2
3
4
5
6
7
8
Date lastDate = Date::now().roundDay();
// ...
Date now = Date::now();
if (!now.isSameSecond(lastDate) && now.roundDay() != lastDate) {
    // 今天第一条日志,切换日志文件
    asyncLogger.flush();
    lastDate = now.roundDay();
}

场景3:数据库时间字段存取

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// 存入 MySQL:转本地时区字符串
std::string dbTime = Date::now().toDbStringLocal();
// 执行: INSERT INTO events(time) VALUES('2024-01-01 18:30:00')

// 从 MySQL 读出:解析本地时区字符串
Date eventTime = Date::fromDbStringLocal("2024-01-01 18:30:00");

// 比较两个时间
if (eventTime < Date::now()) {
    LOG_INFO << "事件已过期";
}

核心收获

  • Dateint64_t microSecondsSinceEpoch_ 存储微秒时间戳,可直接做定时器 key 和排序比较
  • Date::now() 跨平台:Linux 用 gettimeofday,Windows 用 GetSystemTimeAsFileTime
  • NonCopyable 基类通过 = delete 拷贝构造/赋值,是整个 trantor 几乎所有核心类的基类
  • hton64 用 static 变量缓存字节序检测结果,运行期检测比条件编译更安全(兼容跨平台 CI)

六、思考题

  1. Date::now() 在 Linux 上调用 gettimeofday,而不是更新的 clock_gettime(CLOCK_REALTIME, ...),有什么优缺点?
  2. timezoneOffset() 使用 static int64_t offset,假设程序运行时系统时区变了(TZ 环境变量变化),offset 会跟着更新吗?这是 bug 还是设计?
  3. NonCopyable 的 move 构造声明为 noexcept(true),如果派生类的 move 构造可能抛异常,会发生什么?
  4. hton64 每次调用都要检查 sig 变量(虽然是 static 缓存),能不能用条件编译 #if __BYTE_ORDER == __BIG_ENDIAN 代替?两种方案各有什么优缺点?

七、思考题参考答案

1. Date::now() 在 Linux 上调用 gettimeofday,而不是更新的 clock_gettime(CLOCK_REALTIME, ...),有什么优缺点?

gettimeofday 的优点:

  1. VDSO 加速,极快:在 Linux 上,gettimeofday 通过 VDSO(Virtual Dynamic Shared Object)机制实现,直接在用户空间读取内核映射的时钟页面,不需要真正陷入内核,耗时仅几十纳秒。clock_gettime(CLOCK_REALTIME) 在现代 Linux 上同样走 VDSO,速度相当。
  2. 移植性好gettimeofday 是 POSIX 标准函数,几乎所有 Unix 系统都支持。trantor 的 Windows 路径也自己实现了一个同名函数,保持了接口统一。
  3. API 简洁:返回 struct timeval(秒 + 微秒),直接对应 Date 内部的微秒精度,不需要额外换算。

gettimeofday 的缺点:

  1. 精度上限为微秒gettimeofday 返回 timeval(精度到微秒),而 clock_gettime 返回 timespec(精度到纳秒)。对于需要纳秒级精度的场景(如高频交易),gettimeofday 不够用。不过 trantor 的 Date 本身就是微秒精度设计,所以这不是问题。
  2. 已被标记为过时:POSIX.1-2008 将 gettimeofday 标记为 obsolescent(过时),推荐使用 clock_gettime。虽然短期内不会被移除,但从标准演进角度看不是最佳选择。
  3. 不支持单调时钟gettimeofday 只能获取 wall clock 时间,受 NTP 校时影响(可能跳变)。clock_gettime 支持 CLOCK_MONOTONIC(单调时钟,不受 NTP 影响),更适合定时器间隔计算。不过 Date 的定位是"时间点"而非"时间间隔",使用 wall clock 是合理的。
  4. 时区参数已废弃gettimeofday 的第二个参数 tzp 已被废弃,必须传 NULL,这是历史包袱。

总结:对于 trantor 的使用场景(微秒精度时间戳),gettimeofday 完全够用且性能优秀。如果未来需要纳秒精度或单调时钟,再迁移到 clock_gettime 即可。


2. timezoneOffset() 使用 static int64_t offset,假设程序运行时系统时区变了,offset 会跟着更新吗?

不会更新,这是有意设计而非 bug。

看源码(Date.cc 第 67-71 行):

1
2
3
4
int64_t Date::timezoneOffset() {
    static int64_t offset = -Date(1970, 1, 1).secondsSinceEpoch();
    return offset;
}

static 局部变量在 C++ 中只会在第一次执行到该语句时初始化一次(C++11 保证线程安全初始化)。之后无论调用多少次 timezoneOffset(),都返回同一个缓存值。

为什么这是合理的设计?

  1. 服务器场景时区几乎不变:游戏服务器部署后,系统时区(TZ 环境变量)基本不会在运行时改变。服务器进程通常运行数天甚至数月,时区在启动时确定即可。

  2. 性能考量timezoneOffset()toDbString()fromDbString()fromISOString() 等函数频繁调用。如果每次都重新计算(构造 Date(1970,1,1) → 调用 mktime),会有不必要的开销。mktime 内部需要查询时区数据库文件,不是轻量操作。

  3. 一致性保障:如果时区在程序运行过程中变化,且 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 行):

1
2
NonCopyable(NonCopyable &&) noexcept(true) = default;
NonCopyable &operator=(NonCopyable &&) noexcept(true) = default;

这里 noexcept(true) 只约束 NonCopyable 自身的 move 构造——保证移动 NonCopyable 这个基类子对象不会抛异常(实际上 NonCopyable 没有任何数据成员,move 构造本身就是空操作)。

派生类的 move 构造的 noexcept 规格取决于

  • 所有基类子对象的 move 构造是否 noexcept
  • 所有成员变量的 move 构造是否 noexcept
  • 派生类自身是否声明了 noexcept

如果派生类有一个可能抛异常的成员(比如 std::list、自定义类型等),编译器生成的默认 move 构造函数就不会是 noexcept 的——即使基类 NonCopyable 的 move 是 noexcept(true)

实际影响

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
class MyClass : public NonCopyable {
    std::string name_;  // string 的 move 是 noexcept 的
    // 编译器生成的 move 构造: noexcept(true) ✓
};

class MyClass2 : public NonCopyable {
    SomeType data_;  // 假设 SomeType 的 move 可能抛异常
    // 编译器生成的 move 构造: noexcept(false)
    // 不会继承基类的 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 缓存):

1
2
3
4
5
6
7
8
inline uint64_t hton64(uint64_t n) {
    static const int one = 1;
    static const char sig = *(char *)&one;  // 只计算一次
    if (sig == 0) return n;                 // 大端:直接返回
    char *ptr = reinterpret_cast<char *>(&n);
    std::reverse(ptr, ptr + sizeof(uint64_t));
    return n;
}

优点:

  1. 100% 可靠:在任何编译器、任何平台上都能正确检测字节序,不依赖编译器/平台特定的宏定义
  2. 交叉编译安全:在 x86 机器上交叉编译 ARM 大端目标时,条件编译宏可能反映的是宿主机的字节序而非目标机的——运行期检测则永远正确
  3. static 变量只初始化一次sig 的值在首次调用时计算,后续调用直接读缓存,开销极小(一次内存读取 + 一次分支预测,且分支几乎总是走同一个方向,CPU 预测准确率极高)

缺点:

  1. 理论上有一次分支判断的开销(但在现代 CPU 上几乎可以忽略)
  2. 大端机器上仍然要做一次无意义的 if 判断
  3. std::reverse 不是最优的字节翻转实现(可以用位运算或 __builtin_bswap64 更快)

条件编译方案:

1
2
3
4
5
6
7
#if defined(__BYTE_ORDER__) && __BYTE_ORDER__ == __ORDER_BIG_ENDIAN__
inline uint64_t hton64(uint64_t n) { return n; }
#else
inline uint64_t hton64(uint64_t n) {
    // 翻转字节
}
#endif

优点:

  1. 零运行时开销:编译期就确定了路径,大端机器直接 return n,连一条分支指令都没有
  2. 可以在编译期选择 __builtin_bswap64(GCC/Clang)或 _byteswap_uint64(MSVC)等平台最优内置函数

缺点:

  1. 宏定义不统一__BYTE_ORDER__ 是 GCC/Clang 扩展,MSVC 没有这个宏;有些平台用 __BYTE_ORDER(注意下划线数量不同)、BYTE_ORDER_BYTE_ORDER 等。要覆盖所有平台需要写一长串 #if/#elif
  2. 交叉编译风险:前面提到的宿主/目标字节序不一致问题
  3. 可维护性差:条件编译分支容易遗漏测试——如果开发团队全是 x86 小端机器,大端分支的代码可能从未被编译和测试过

结论:trantor 选择运行期检测是稳妥的工程选择,优先保证正确性和可移植性。性能差异在实际应用中可以忽略不计(static 变量检测 + 分支预测命中的开销约 1 纳秒级别)。如果对性能有极致要求,可以用条件编译 + 平台特定内置函数,但需要更多的平台适配代码和测试覆盖。


学习日期:2025-03-08 | 上一课:第02课_消息缓冲区MsgBuffer | 下一课:第04课_回调类型定义