docs(flare): fill in nslb.md, timer.md; refresh timestamp.md (#174)

* docs(flare): fill in nslb.md, timer.md; refresh timestamp.md for current impl

Three docs got attention:

* `nslb.md` was a 4-line stub. Filled in with the Channel -> NameResolver
  + LoadBalancer / MessageDispatcher data flow, registry mechanism for
  custom name schemes, the two built-in load balancers (RoundRobin /
  ConsistentHash), and the MessageDispatcher-integrated path for
  routing services that bundle resolution + balancing (CL5, Polaris, ...).

* `timer.md` was also a 4-line stub. Covers the fiber-side API
  (`SetTimer` / `KillTimer` / `SetDetachedTimer`, the `[[nodiscard]]`
  contract), the `[this_fiber::SleepFor]` blocking-wait pattern, the
  TimeKeeper for framework-internal periodic work, the TimerWorker
  heap-based implementation, scheduling-precision expectations, and
  the fire-and-forget cancellation semantics.

* `timestamp.md` was rewritten to match the current implementation:

  - `ReadSteadyClock` / `ReadSystemClock` no longer bypass libstdc++.
    Section now reflects that they delegate to `std::chrono::*::now()`
    directly and explains why the old hand-rolled `clock_gettime`
    path was removed (clock-epoch mismatch on macOS broke
    `TimerWorker::cv_.wait_until()`; the CentOS 6 / tlinux 1.2
    self-built-GCC problem the old impl worked around is no longer
    relevant on any modern toolchain).
  - Coarse clock section still accurate; description reworked to
    show the atomic-load read path more clearly.
  - TSC section preserves the existing technical content but adds the
    "invariant TSC is now de facto standard" caveat and ties the
    `DurationFromTsc` overflow warning to the long-duration case
    more visibly.
  - Added a top-of-file simple selection guide table and a
    decision-tree at the bottom -- this is the part most readers
    actually need.

No code changes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* ci(link-check): ignore intel.com PDF UA-filtering

Intel CDN's PDF endpoint UA-filters markdown-link-check to 403 even
though the documents are reachable from any browser. Without this
ignore, the link-check fails any PR that touches a doc referencing
the canonical Intel whitepaper -- which is what just happened on
#172's timestamp.md re-shape.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* docs(timestamp): use kib.kiev.ua mirror for Intel TSC whitepaper

Intel's CDN UA-filters the link checker to 403; the kib.kiev.ua x86
documentation mirror serves the same Intel doc 333159 without the
filter, so we can verify the link in CI without an ignore-pattern
escape hatch.

Reverts the ``^https?://www\.intel\.com/.*\.pdf`` ignore added in the
previous commit on this branch -- no longer needed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
CHEN Feng
2026-06-05 00:13:29 +08:00
committed by GitHub
parent 356df6bb30
commit d8d40cf461
3 changed files with 414 additions and 49 deletions
+168
View File
@@ -1,4 +1,172 @@
# 名字服务和负载均衡
[Channel](channel.md) 在发起 RPC 之前,要先把一个 `flare://example.Service` 之类的服务名解析成一个或多个后端实例地址,再从中选择一个发起调用。这两步分别由**名字解析(Name Resolution)**和**负载均衡(Load Balancing)**完成。
flare 把两者解耦成独立的抽象,便于按场景替换或组合。
## 设计
```text
┌───────────────┐
│ Channel │
└───────┬───────┘
│ name (e.g. "example.Service")
▼
┌─────────────────────────┐
│ Path A: NSLB 分离模式 │
│ │
│ NameResolver │ 把 name 解析为 endpoints 列表
│ │ │
│ ▼ │
│ LoadBalancer │ 从列表中按策略挑一个
└─────────────────────────┘
┌─────────────────────────┐
│ Path B: NSLB 整合模式 │
│ │
│ MessageDispatcher │ 解析 + 选址一步到位
│ │ (适合 CL5 这类自带选路的服务)
└─────────────────────────┘
│
▼
一个 Endpoint
交给 Channel 发请求
```
两条路径走哪条由 [`MessageDispatcherFactory`](../rpc/message_dispatcher_factory.h) 在构造 Channel 时决定,对调用方透明。
## NameResolver
[`flare::NameResolver`](../rpc/name_resolver/name_resolver.h) 负责把字符串名字翻译成 `Endpoint` 列表。
抽象上分两层:
- **`NameResolver`**:全局单例,按 name 创建一个 `NameResolutionView`。是个轻量工厂。
- **`NameResolutionView`**:单 name 的解析"会话",提供:
- `GetPeers(std::vector<Endpoint>*)` —— 拉当前实例列表
- `GetVersion()` —— 返回当前版本号,外层 LoadBalancer 据此判断是否需要更新
版本号有两个特殊值:
| 值 | 含义 |
|---|---|
| `kNewVersion` | "每次调用都视为新版本" —— 不实现自身缓存的 resolver 用这个 |
| `kUseGenericCache` | "我不缓存,让框架替我缓存" —— 框架会另开线程周期性调 `Resolve()` |
实现既可以是同步阻塞型(DNS-like:一次 RPC 触发一次解析),也可以是异步推送型(订阅注册中心,本地维护快照)。
### NameResolver 内置实现
- [`List`](../rpc/name_resolver/list.h) —— `flare-list://1.2.3.4:80,1.2.3.5:80` 这种 inline 列表,主要用于测试和静态部署
- 业务可通过 `FLARE_RPC_REGISTER_NAME_RESOLVER(scheme, MyResolver)` 注册自己的 scheme(如 `cl5://`、`zookeeper://`、`polaris://`)
### NameResolver 注册机制
```cpp
FLARE_DECLARE_OBJECT_DEPENDENCY_REGISTRY(name_resolver_registry, NameResolver);
// 用户侧:
class MyResolver : public NameResolver { /* ... */ };
FLARE_RPC_REGISTER_NAME_RESOLVER("polaris", MyResolver);
```
注册后通过 URI scheme 自动派发——`Channel("polaris://service.Foo")` 会找到 polaris resolver。
## LoadBalancer
[`flare::LoadBalancer`](../rpc/load_balancer/load_balancer.h) 在 `NameResolver` 给出的 endpoint 列表里按策略选一个。
接口三件套:
```cpp
class LoadBalancer {
virtual void SetPeers(std::vector<Endpoint> addresses) = 0;
virtual bool GetPeer(std::uint64_t key, Endpoint* addr,
std::uintptr_t* ctx) = 0;
virtual void Report(const Endpoint& addr, Status status,
std::chrono::nanoseconds time_cost,
std::uintptr_t ctx) = 0;
};
```
- **`SetPeers`** —— `NameResolutionView::GetPeers` 出新版本时全量替换
- **`GetPeer(key, ...)`** —— 选址;`key` 用于一致性哈希等需要稳定路由的策略
- **`Report`** —— 调用结果反馈(成功/失败/超时/耗时),用于熔断或权重调整
`Status` 当前定义了 `Success` / `Overloaded` / `Failed`(`Overloaded` 暂未启用)。
### LoadBalancer 内置实现
| 实现 | 路径 | 适用场景 |
|---|---|---|
| `RoundRobin` | [round_robin.h](../rpc/load_balancer/round_robin.h) | 默认;轮询;最简单负载均衡 |
| `ConsistentHash` | [consistent_hash.h](../rpc/load_balancer/consistent_hash.h) | 按 `key` 一致性哈希;适合带分片亲和的服务(缓存、按用户 ID 分片的存储) |
### 一致性哈希要点
`ConsistentHash` 通过 `GetPeer(key, ...)` 的 `key` 把同一逻辑实体(如 user_id)路由到同一台后端。调用方需要:
- 在 `RpcClientController` 里设置一致性哈希 key(视协议而定,参见 [Controller](controller.md))
- 或在自定义协议里把 key 透传给 Channel
服务实例集合变化时(扩缩容),只有 `1/N` 的 key 会迁移,避免缓存全冷启。
### LoadBalancer 注册机制
```cpp
FLARE_DECLARE_CLASS_DEPENDENCY_REGISTRY(load_balancer_registry, LoadBalancer);
class MyBalancer : public LoadBalancer { /* ... */ };
FLARE_RPC_REGISTER_LOAD_BALANCER("my_strategy", MyBalancer);
```
注意 LoadBalancer 是 *class registry*(每个 cluster 一个实例),NameResolver 是 *object registry*(全局单例)—— 因为一个 resolver 可以服务多个 cluster,但一个 LoadBalancer 只属于一个 cluster。
## MessageDispatcher
[`flare::MessageDispatcher`](../rpc/message_dispatcher/message_dispatcher.h) 是 NSLB 整合模式的入口——一些路由服务(如腾讯内部的 CL5、L5、Polaris 等)自带"按名字得地址"和"负载均衡"两件套,再把它们拆成 NameResolver + LoadBalancer 反而绕路。
`MessageDispatcher` 一个接口把这两步合并:
```cpp
class MessageDispatcher {
virtual bool Start(std::string_view scheme, std::string_view name) = 0;
virtual bool GetPeer(std::uint64_t key, Endpoint* addr, std::uintptr_t* ctx) = 0;
virtual void Report(...);
};
```
[`MessageDispatcherFactory`](../rpc/message_dispatcher_factory.h) 在构造 Channel 时按 URI scheme 决定:
- 注册了"整合式" dispatcher 的 scheme(如 `cl5://`)—— 用对应的 `MessageDispatcher`
- 其它 scheme —— 走 NameResolver + LoadBalancer 组合
调用方完全不用关心走的哪条路径。
## 缓存与刷新
`NameResolver` 自己实现缓存时,框架不再额外缓存;返回 `kUseGenericCache` 时,框架会用 [`NameResolverUpdater`](../rpc/name_resolver/name_resolver_updater.h) 在专用线程里周期 `GetPeers()` 拉新版本。频率可通过 gflag 调整。
`LoadBalancer` 自身不需要关心刷新——`SetPeers()` 被框架主动调用。
## 自定义示例
```cpp
// 自定义 zookeeper resolver
class ZkResolver : public NameResolver {
public:
std::unique_ptr<NameResolutionView> StartResolving(
const std::string& name) override {
return std::make_unique<ZkResolutionView>(name);
}
};
FLARE_RPC_REGISTER_NAME_RESOLVER("zk", ZkResolver);
// 然后业务就可以用:
flare::RpcChannel channel;
channel.Open("zk://example.Service"); // 走 ZkResolver
```
无侵入扩展是设计目标——加新 scheme 不需要改框架核心。
---
[返回目录](README.md)
+149
View File
@@ -1,4 +1,153 @@
# 定时器
flare 提供了两种风格的定时器:**Fiber 定时器**(业务用、回调跑在 fiber 上)和 **TimeKeeper**(框架内部用、回调跑在专用线程上)。两者背后共享同一套时间堆/排程机制,但服务对象不同。
## 选哪个
| 需求 | 用哪个 |
|---|---|
| 业务代码里 "5 秒后做一件事" / "每 100ms 执行一次健康检查" | [`flare::fiber::SetTimer`](../fiber/timer.h) |
| 在 fiber 里阻塞等待一段时间 | [`flare::fiber::SleepFor` / `SleepUntil`](../fiber/this_fiber.h)(内部走 `WaitableTimer`) |
| `flare/base/` 等底层组件需要周期性 housekeeping | [`flare::internal::TimeKeeper`](../base/internal/time_keeper.h) |
| 实现 RPC 超时、连接保活等框架内部行为 | TimeKeeper 或 fiber 定时器,视调度组上下文而定 |
一句话:**业务和 fiber 上下文里能用的代码用 fiber timer;framework 底层(不能假设有 fiber runtime)用 TimeKeeper。**
## Fiber 定时器
[`flare::fiber::SetTimer`](../fiber/timer.h) 是面向业务的 API:
```cpp
namespace flare::fiber {
// 单次定时
[[nodiscard]] std::uint64_t SetTimer(
std::chrono::steady_clock::time_point at,
Function<void()>&& cb);
// 周期定时
[[nodiscard]] std::uint64_t SetTimer(
std::chrono::steady_clock::time_point at,
std::chrono::nanoseconds interval,
Function<void()>&& cb);
// 周期定时(首次 `now + interval` 触发)
[[nodiscard]] std::uint64_t SetTimer(
std::chrono::nanoseconds interval,
Function<void()>&& cb);
void KillTimer(std::uint64_t timer_id);
void DetachTimer(std::uint64_t timer_id);
void SetDetachedTimer(...); // = DetachTimer(SetTimer(...))
} // namespace flare::fiber
```
### 关键约定
- **回调跑在 fiber 上**。回调里可以 `this_fiber::Yield()`、可以等其它 fiber、可以发 RPC。不会阻塞 OS 线程。
- **`[[nodiscard]]` ID**:必须 `KillTimer` 或 `DetachTimer`,否则 abort。这是显式的"防忘记"——直接丢弃 ID 是 bug。
- **只能在 scheduling group 内调用**。也就是说必须从已经在 fiber runtime 里跑的代码里发起;从一个裸 pthread 直接 `SetTimer` 会失败。
- **`DetachTimer`** 不取消定时器,只是放弃所有权——周期定时器自我管理生命周期的常见模式。
### 示例
```cpp
// 一次性
auto timer_id = flare::fiber::SetTimer(
flare::ReadSteadyClock() + 5s,
[] { FLARE_LOG_INFO("5 秒到了"); });
// 用完必须显式销毁:
flare::fiber::KillTimer(timer_id);
// 周期性(每 100ms 健康检查),不打算手动停:
flare::fiber::SetDetachedTimer(100ms, [] { CheckHealth(); });
```
### 阻塞等待
业务里更常见的需求是"在 fiber 里等一段时间再继续"——这种用 `this_fiber::SleepFor` 而不是 SetTimer:
```cpp
flare::this_fiber::SleepFor(100ms); // 当前 fiber 挂起 100ms
// 100ms 后从这里继续
```
`SleepFor` 内部用 [`WaitableTimer`](../fiber/detail/waitable.h):定时到期时唤醒当前 fiber,期间 worker 线程跑别的 fiber。
## TimeKeeper
[`flare::internal::TimeKeeper`](../base/internal/time_keeper.h) 是 framework 底层使用的定时器。
存在的理由:`flare/base/` 的若干组件(如 object pool 的周期 GC、监控指标的周期采样)需要定时任务,但它们位于 fiber runtime 的**下层**——这些组件初始化时 fiber runtime 还不一定就绪,且这些回调本身不需要 fiber 上下文。专门给它们做一个轻量级共享定时器。
```cpp
namespace flare::internal {
class TimeKeeper {
public:
// 全局单例
static TimeKeeper* Instance();
std::uint64_t AddTimer(
std::chrono::steady_clock::time_point at,
std::chrono::nanoseconds interval,
Function<void(std::uint64_t)>&& cb,
bool dispatch_in_fiber = false);
void KillTimer(std::uint64_t timer_id);
};
} // namespace flare::internal
```
### TimeKeeper vs Fiber timer
- **回调线程**:TimeKeeper 默认在专用 OS 线程上跑(除非 `dispatch_in_fiber=true`),fiber timer 总是跑在 fiber 上
- **谁用**:TimeKeeper 给 `flare/base/` 自己用,**业务代码不该直接用**——头文件注释专门说了一句"For other components, or user code, consider using fiber timer instead"
- **生命周期**:TimeKeeper 是全局单例,进程级;fiber timer 跟随 scheduling group
## 实现机制
底层有两套排程:
### TimerWorker(fiber timer 背后)
[`flare/fiber/detail/timer_worker.h`](../fiber/detail/timer_worker.h)
- 每个 scheduling group 一个 `TimerWorker`,独立 OS 线程
- 维护一个**按到期时间排序的小顶堆**
- 主循环:取堆顶 → `wait_until` 睡到到期点 → 触发回调(投回 scheduling group 让 fiber 跑)
- 新增的定时器如果到期早于堆顶,通过 condition_variable 唤醒主循环重新排程
- 周期定时器在触发后自己重排(`expires_at += interval`)
### TimeKeeper 内部
[`flare/base/internal/time_keeper.cc`](../base/internal/time_keeper.cc)
结构同上(堆 + 专用线程),更简化:没有 fiber 分发逻辑,回调直接在 keeper 线程上调用。
### 精度
- 调度精度取决于 OS 的 `futex` / condition_variable 的唤醒精度,典型情况下 **<1 ms**
- 不适合做亚毫秒级精确定时(实时系统场景);那种需求需要 OS 层 timer / spin
## 取消语义
`KillTimer` 不保证"取消即不会触发"——存在如下竞态:
1. 定时器到期,回调已被投递到 scheduling group queue
2. `KillTimer` 调用
3. 回调还是会被执行(已 inflight)
这是 fire-and-forget 模式的代价。需要严格"取消生效"的业务,应该在回调里自己再判断一次状态(例如检查某个 atomic flag 是否仍为 active)。
## 与其它子系统的关系
- [Fiber 调度](fiber-scheduling.md):定时器到期产生 ready fiber,纳入正常调度
- [WaitableTimer](../fiber/detail/waitable.h):fiber-style "在某个时间点醒来"原语,`SleepFor` 的底层
- [Monitoring](monitoring.md):用 TimeKeeper 周期 flush 指标
- [Object Pool](object-pool.md):用 TimeKeeper 周期 GC 空闲对象
---
[返回目录](README.md)
+97 -49
View File
@@ -1,98 +1,146 @@
# 时间戳
flare中大量存在各种时间戳,因此如何获取时间戳也自然是首要问题之一。
flare 中大量需要读取时间戳——RPC 流程中的解包/打包计时、定时器排程、监控指标采样、对象池 GC 等都依赖它。读时间戳本身看起来"应该 1 个指令的事",实际上在不同实现下差异巨大。
通常我们可以直接通过[`std::chrono::steady_clock::now()`](https://en.cppreference.com/w/cpp/chrono/steady_clock/now)来读取时间戳,但是对于频繁获取时间戳的情况,这儿存在的性能问题就不得不仔细进行分析。
这篇文档介绍 flare 中可用的时间获取方式、性能特征和选用原则。
这篇文档主要解释了flare中使用到的或可能会涉及到的时间获取方式。
## 简明选用指南
## `std::chrono::xxx_clock::now()`
| 场景 | 用哪个 |
|---|---|
| 默认 / 业务代码 / 不在意纳秒级别开销 | `ReadSteadyClock()` / `ReadSystemClock()` |
| 高频读取但允许 ~10ms 误差(对象池、监控采样、限流时间窗等) | `ReadCoarseSteadyClock()` / `ReadCoarseSystemClock()` |
| 框架内部需要纳秒精度、且能容忍 TSC 的脆弱性(RPC 解包阶段计时) | `ReadTsc()` + `DurationFromTsc()` / `TimestampFromTsc()` |
| 用户接口、跨进程比较、需要可读时间 | 优先 `ReadSystemClock()`;TSC 时间戳暴露给用户前先转换 |
`std::chrono::xxx_clock::now()`是最常见的获取系统时间的方式,但是取决于环境,**[这可能会引入syscall而影响性能](https://gcc.gnu.org/bugzilla/show_bug.cgi?id=59177)**。
一句话:**默认用 `ReadSteadyClock`;不够快换 `ReadCoarse*`;只有完全清楚 TSC 风险时才用 TSC。**
对于CentOS6 / tlinux1.2环境下编译的GCC,如果没有在`configure`阶段指明[`--enable-libstdcxx-time=rt`](https://gcc.gnu.org/onlinedocs/libstdc++/manual/configure.html)(而非单纯的指定`--enable-libstdcxx-time`),那么为了避免引入对`rt`的依赖,libstdc++会使用`syscall`实现`std::chrono::xxx_clock::now()`。
## `ReadSteadyClock()` / `ReadSystemClock()`
而目前公司内流传的自行编译的GCC多有这一问题。
[`flare/base/chrono.h`](../base/chrono.h)
为了保证在各编译环境下尽可能的一致,flare提供了使用[vDSO](https://en.wikipedia.org/wiki/VDSO)实现的[相应方法](../base/chrono.h)(`ReadXxxClock()`)。
```cpp
inline std::chrono::steady_clock::time_point ReadSteadyClock();
inline std::chrono::system_clock::time_point ReadSystemClock();
```
## `ReadXxxClock()`
**当前实现就是直接 `std::chrono::steady_clock::now()` / `system_clock::now()`** —— 不做任何额外封装。
如上所述,我们提供了自己使用vDSO实现的获取时间戳的方法:
历史上这两个函数曾经绕过 libstdc++、自己用 `clock_gettime` 直读 vDSO,是为了规避 CentOS 6 / 老 tlinux 上自编译 GCC 的 `_GLIBCXX_USE_CLOCK_GETTIME_SYSCALL` 走真 syscall 的性能坑。**这个坑在现代工具链上不存在**——任何 GCC 9+ / Clang 10+ 都通过 vDSO 直接进 `__vdso_clock_gettime`。
- `ReadSteadyClock()`:对应`std::chrono::steady_clock::now()`,二者获取的时间戳属于同一时钟源,可互换。取决于编译环境,`ReadSteadyClock()`的性能可能好于或等于(但不会劣于)`std::chrono::steady_clock::now()`。
- `ReadSystemClock()`:对应[`std::chrono::system_clock::now()`](https://en.cppreference.com/w/cpp/chrono/system_clock/now),同一时钟源,可互换。性能不低于`std::chrono::system_clock::now()`。
更重要的是,自己绕过 `std::chrono` 直读 `clock_gettime` 会引入**时钟纪元不一致**的隐患:在 macOS(Darwin 24 / macOS 15)上,`CLOCK_MONOTONIC` 和 `std::chrono::steady_clock::now()` 的纪元(epoch)可以不同——前者会累计 sleep/suspend 时长,后者不会。混用两者构造出来的 `time_point` 会无声地偏移,曾经导致 `TimerWorker::cv_.wait_until()` 时间错乱。所以现在统一走标准库。
尽管如此,`ReadXxxClock()`的性能通常仍会低于通过[`rdtsc`](https://www.felixcloutier.com/x86/rdtsc)实现的[`ReadTsc()`](../base/tsc.h)。
### 性能
这是因为,通常而言,系统时钟是在内核共享的时间戳上加上增量(取决于[系统的时钟源](https://access.redhat.com/solutions/18627),对于物理机通常是`tsc`,虚拟机通常是通过hypervisor提供的,如`kvm`)获取的,因此其计算成本包含且不限于`rdtsc`,相对于直接读取`tsc`会更慢。
跟 `std::chrono::steady_clock::now()` **完全等同**。在 Linux x86_64 vDSO 下大约 **20-30 ns**;在 ARM / 不同时钟源(kvm-clock、hpet 等)下浮动较大,但都在 100 ns 量级以内。
## `ReadCoarseSteadyClock()` / `ReadCoarseSystemClock()`
[`flare/base/chrono.h`](../base/chrono.h)
```cpp
inline std::chrono::steady_clock::time_point ReadCoarseSteadyClock();
inline std::chrono::system_clock::time_point ReadCoarseSystemClock();
```
**精度 ~10ms 的"快粗"时钟。**
实现:进程启动时启一个后台线程(`CoarseClockInitializer::worker_`),周期性地把 `steady_clock::now()` / `system_clock::now()` 写到全局原子变量里;读侧只是一次 `atomic::load`——**会被内联编译为直读内存**。
这跟 Linux 的 `clock_gettime(CLOCK_MONOTONIC_COARSE)` 思路一致(通过时钟中断更新缓存),但避开了 `clock_gettime` 的函数调用开销。设计灵感来自 tRPC 性能优化文档第 3.5 节,flare 自己实现了这个 trick。
### 误差
后台线程**正常工作**时,误差 < 10ms。极端 CPU 繁忙(更新线程被饿到)时误差可能更大——所以**不要用它做精确计时**(如 RPC 超时判断),只用它做**对精度不敏感的高频读取**(对象池年龄判断、监控时间窗等)。
### Coarse 的性能
是本文所有方式中**最快**的——单次读取就是一次缓存命中的 atomic load,几乎 0 开销,比 `ReadTsc()` 还快(TSC 还有 `rdtsc` 的微码 cost)。
## `ReadTsc()`
但是需要注意的是,**使用`tsc`作为时钟通常不是一个好的选择**:
[`flare/base/tsc.h`](../base/tsc.h)
- [`tsc`可能会受到CPU的节能状态的影响](https://patchwork.kernel.org/patch/4043361/)
- 虚拟化环境下虚拟机之间迁移可能导致`tsc`漂移或速率变化([Intel VT-x](https://www.intel.com/content/dam/www/public/us/en/documents/white-papers/timestamp-counter-scaling-virtualization-white-paper.pdf)及[AMD-V](https://lore.kernel.org/patchwork/patch/235892/)提供了相应的补偿措施,具体取决于hypervisor实现)
- `tsc`和物理时钟之间转换可能需要[CPI](https://en.wikipedia.org/wiki/Cycles_per_instruction)较大的除法操作(但是参考下文的`XxxFromTsc`)
```cpp
inline std::uint64_t ReadTsc();
```
因此我们通常只推荐将`tsc`用于忙等或其他特殊场景,而不用于通用计时。仅当清楚自己在做什么的前提下,才可以使用`tsc`来计时。
直接发 [`rdtsc`](https://www.felixcloutier.com/x86/rdtsc) / 对应架构的等价指令读时钟计数器。
*目前我们在频繁获取时钟并且对精度要求较高(如记录RPC过程中识别消息边界、解包、打包等等)的场景使用TSC作为时间戳,这是一个内部实现细节并且随时可能发生变动。这些场景下,在将时间戳最终提供给用户之前,我们会将其转换为物理时间(参考下文)。*
### TSC 的几个坑
另外需要注意的是,尽管`rdtsc`只有一条指令,这条指令的CPI并不低。根据[Agner的数据](https://www.agner.org/optimize/instruction_tables.pdf),`rdtsc`在[Skylake](https://en.wikipedia.org/wiki/Skylake_(microarchitecture))上需要25个时钟周期,2.5GHz主频(我们的服务器环境通常在2GHz ~ 3GHz主频)下需要10ns。
**用 TSC 当通用时钟通常是个坏主意**:
同时,对于性能要求高但可以容忍一定误差(如对象池计算对象生存时长)的场景,提供了性能比`ReadTsc()`更高的[`ReadCoarseXxxClock()`](../base/chrono.h)。
- TSC 可能受 [CPU 节能状态影响](https://patchwork.kernel.org/patch/4043361/)(虽然现代 x86 普遍 "invariant TSC"——constant rate 不受 P-state 影响——已是事实标配,但不保证 100%)
- 虚拟化环境下虚拟机迁移可能引起 TSC 漂移或速率变化([Intel VT-x](https://kib.kiev.ua/x86docs/Intel/WhitePapers/333159-001.pdf) / [AMD-V](https://lore.kernel.org/patchwork/patch/235892/) 提供补偿,但**取决于 hypervisor 实现**)
- TSC ↔ 物理时间换算需要除法(CPI 高),见下文 `DurationFromTsc` 的优化
**再次强调,正确的使用`tsc`非常困难。用于时间戳目的时,始终应当优先考虑其他可选项。**
而且 `rdtsc` 本身 CPI 并不低——根据 [Agner](https://www.agner.org/optimize/instruction_tables.pdf) 的数据,Skylake 上 ~25 cycle,2.5 GHz 下约 **10 ns**。比 `ReadCoarseSteadyClock` 慢。
### TSC转换为物理时间
**flare 内部哪儿用了 TSC**:
我们提供了`DurationFromTsc(...)`及`TimestampFromTsc(...)`用于将`tsc`区间/`tsc`值转换为物理时间。这两个方法我们做了一定的优化,在我们的[测试](../base/tsc_benchmark.cc)中,开销并不明显(和benchmark框架空转的测试结果都是2ns)。
当前在 RPC 解包阶段记录消息边界、序列化/反序列化耗时等高频高精度场景使用了 TSC([example](../base/tsc_benchmark.cc) 里有测试代码)。这是**内部实现细节**,随时可能改。**对外暴露给用户的时间戳一定先 `TimestampFromTsc` 转换成物理时间**。
由于实现限制,**对于较大的时间差(如数千秒),`DurationFromTsc`内部可能会出现整型溢出导致不正确的结果**。通常我们只建议将`DurationFromTsc`用于计算较短且精度要求高的耗时(如单次RPC开销等)。
### TSC → 物理时间:`DurationFromTsc` / `TimestampFromTsc`
在考虑通过`tsc`维护时间戳之前,我们需要再次明确,**使用`tsc`作为时钟通常不是一个好的选择。**。
`DurationFromTsc(tsc_a, tsc_b)` 返回 `b - a` 的物理时长,`TimestampFromTsc(tsc)` 把 TSC 值转成 `steady_clock::time_point`。
#### `DurationFromTsc`
[`tsc_benchmark.cc`](../base/tsc_benchmark.cc) 测试下两者开销都接近 benchmark 框架空转(2 ns),说明优化生效了。
内部而言,我们会假定`tsc`速率是恒定的,并在在程序启动时(通过空循环)计算256M个时钟周期所对应的物理时间,以之来计算`tsc`和物理时间的比率。
#### 优化原理:`DurationFromTsc`
*我们选择256M个时钟周期是因为`物理时间 = tsc * 256M个时钟周期对应的物理时间 / 256M`。在这种情况下,除以256M可以被优化为移位操作,改善性能。对于2GHz主频的机器而言,256M大约100ms。*
程序启动时(通过空循环)测算 **256M 个 TSC tick 对应的物理时长**,记为 `T₂₅₆ₘ`。换算公式:
#### `TimestampFromTsc`
```text
物理时长 = (tsc 差) × T₂₅₆ₘ / 256M
```
内部而言我们给每个线程都通过一个线程局部变量保存了一个未来时间点的物理时间及其对应的`tsc`值。在计算时我们首先算出来当前`tsc`距离这个未来的`tsc`的时间差(通过`DurationFromTsc`),然后从未来的物理时间点减去这个时间差得到当前的物理时间。
除以 256M 可以被编译器优化为右移 28 位——避免了昂贵的整数除法。256M 在 2 GHz 主机上大约 100 ms,长度合理。
*代码中我们做了一定的设计,避免对TLS的动态初始化(即避免调用`__tls_init`),具体可以参考[代码](../base/tsc.h)。*
#### 优化原理:`TimestampFromTsc`
我们之所以选择未来的时间点而不是过去的时间点主要是考虑到我们计算的`tsc`和物理时间的比率并不精确(`tsc`*精度*高不代表*精确*)。
每个线程通过线程局部变量缓存"**未来某时刻**"的物理时间 + TSC 值。读时算"当前 TSC 距离这个未来 TSC 的差"(用 `DurationFromTsc`),再从未来物理时间反推当前。
通过使用未来的时间戳,我们可以定期的发现时间戳过期并重新计算,使得误差始终保持在一个合适的范围之内。
为啥取未来不取过去:
同时,这也会减轻NUMA节点之前迁移导致的`tsc`漂移问题:当目前的“未来时间戳”过期之后,这个线程会在新的NUMA节点重新计算这个值,因此迁移导致的`tsc`漂移对时间转换的影响是临时性的。只要线程不会在NUMA节点之间频繁的迁移(一个合理的调度算法通常都会避免这种行为),就不会对我们造成太大的影响。
1. TSC ↔ 物理时间比率不绝对精确(精度高 ≠ 精确)。用未来时间戳意味着会定期"到期"重新校准,误差不会无界累积。
2. 缓解 NUMA 迁移导致的 TSC 漂移——线程被调度到新 NUMA 节点后,下次"到期"时会用新节点的 TSC 重新校准,影响仅一次。
## `ReadCoarseXxxClock()`
代码里做了一定的设计避免 TLS 动态初始化(`__tls_init`),详见 [tsc.h](../base/tsc.h)。
内部实现而言,`ReadCoarseXxxClock()`通过旁路定期更新来加速读取。这一行为类似于[`CLOCK_MONOTONIC_COARSE`](https://linux.die.net/man/2/clock_gettime)(通过时钟中断更新)。
#### `DurationFromTsc` 的限制
*确切来说,我们曾经的实现确实通过`CLOCK_MONOTONIC_COARSE`实现,但是后续进行了优化,见下文。*
**`DurationFromTsc` 对超长时间段(几千秒)可能内部溢出导致结果错误。** 只适合短时高精度计时(如单次 RPC 全程耗时),别拿它算"程序启动到现在多久了"。
但是`clock_gettime(CLOCK_MONOTONIC_COARSE)`实际上会有不必要的函数调用开销(并且不低,但略低于`ReadTsc()`)。受trpc-cpp性能优化(3.5节)的启发,我们自行实现了类似的逻辑。因此`ReadCoarseXxxClock()`会被内联并编译为直接读取内存。
## 性能对比(数量级)
由于共享的时钟信息定期更新,因此**可能存在一定量的延迟(即误差)**。我们的实现通常可以提供不超过10ms的误差(程序极端繁忙导致异步更新延迟可能会导致时间戳误差增大)。
实际数字因 CPU / 时钟源 / 系统版本浮动大,**不给具体值**;只给数量级排序,从最快到最慢:
但是由于其内部无“复杂”的操作,因此其实际性能表现优异,是本文提及的时间获取方式中最快的。
```text
ReadCoarseXxxClock ~ 1-2 ns (内存读,最快)
ReadTsc ~ 10 ns (rdtsc 微码)
DurationFromTsc ~ 2 ns (位移 + 乘法)
TimestampFromTsc ~ 2 ns (+ TLS 读 + 偶发重校准)
ReadSteadyClock ~ 20-30 ns (vDSO clock_gettime;视时钟源浮动)
ReadSystemClock ~ 20-30 ns (同上)
```
## 实际性能
要自己测的话:
由于某些方法(主要是`std::chrono::xxx_clock::now()`及`ReadXxxClock()`)在不同环境下性能差异过大(是否触发`syscall`、时钟源是`tsc`、`kvm`、...,Xeon Scalable / EPYC,系统内核版本),因此此处不提供性能数据。
- [`chrono_benchmark.cc`](../base/chrono_benchmark.cc):`std::chrono::*::now()` / `ReadXxxClock` / `ReadCoarseXxxClock`
- [`tsc_benchmark.cc`](../base/tsc_benchmark.cc):`ReadTsc` / `Duration|TimestampFromTsc`
但是我们提供了针对不同方法的测试代码:
## 决策树
- [`std::chrono::xxx_clock::now()`、`ReadXxxClock()`、`ReadCoarseXxxClock()`](../base/chrono_benchmark.cc)
- [`ReadTsc()`](../base/tsc_benchmark.cc)
如有需要可自行测试。
```text
需要时间戳
├── 高频 + 容忍 ~10ms 误差?
│ └── 是 → ReadCoarseXxxClock (1-2 ns, 内存读)
├── 高频 + 纳秒精度 + 框架内部 + 清楚 TSC 风险?
│ └── 是 → ReadTsc + DurationFromTsc (12 ns 共, 但要懂限制)
└── 否(默认)
└── ReadSteadyClock / ReadSystemClock (20-30 ns, 标准实现)
```
---
[返回目录](README.md)