RocksDB 协程读取统计隔离修复解析:TLS 统计状态在异步读与协程读之间的正确隔离
【免费下载链接】rocksdbA library that provides an embeddable, persistent key-value store for fast storage.项目地址: https://gitcode.com/gh_mirrors/ro/rocksdb
导读
本文围绕 RocksDB 仓库中 unreleased_history/bug_fixes/coroutine_stats_request_isolation.md 记录的缺陷修复展开:当关闭统计的协程读与异步读与开启统计的读请求在同一执行器(executor)上交错执行时,前者会错误继承后者通过线程局部存储(TLS)暴露的统计状态。本文将从缺陷成因、修复方案(按调用捕获配置 + 请求上下文隔离计数)、核心源码实现到测试验证进行完整剖析,帮助读者理解 RocksDB 在CoroDB::CoGet、DB::GetAsync/MultiGetAsync路径上如何保证每次读取的统计配置与计数彼此独立。
背景:RocksDB 的协程读与异步读
RocksDB 在启用USE_COROUTINES宏(依赖 folly coroutine 支持,参见 util/coro_utils.h 中的DECLARE_SYNC_AND_ASYNC*系列宏与CO_AWAIT/CO_RETURN宏)时,可以走基于 C++20 协程的读取路径,把一次读取的多个 IO 阶段调度到 IO 线程执行器上,从而以更低的线程切换成本实现异步化。仓库中主要的协程读入口集中在 db/coro_db.cc(CoroDB::CoGet,覆盖DB*与SstFileReader*两种对象),异步读入口则位于 db/db_impl/db_impl.cc 的DB::GetAsync与DB::MultiGetAsync。
RocksDB 的运行时统计分为两大类:
- PerfContext:基于
PerfLevel(定义于 include/rocksdb/perf_level.h,从kDisable = 1、kEnableCount = 2、kEnableWait = 3、kEnableTimeExceptForMutex = 4、kEnableTimeAndCPUTimeExceptForMutex = 5到kEnableTime = 6逐级递增)门控,记录 block 读取次数、等待耗时、CPU 耗时等; - IOStatsContext:通过
disable_iostats标志门控,记录bytes_read、cpu_read_nanos、按温度(temperature)分类的文件 IO 统计等(相关访问宏见 monitoring/iostats_context_imp.h 中的IOSTATS_ADD等,宏内部先判断if (!iostats_context.disable_iostats)再累加)。
这两类上下文都以thread_local形式存放(参见 monitoring/iostats_context_imp.h 中extern thread_local IOStatsContext iostats_context;),也就是说统计状态天然绑定到"当前线程"。对于普通的同步读,一个线程同一时刻只执行一次读取,TLS 状态不会串扰;但协程读会在执行器线程上挂起(suspend)与恢复(resume),多个逻辑请求在同一线程上交错执行,这就为统计状态的"串台"埋下了隐患。
缺陷本身:TLS 统计状态在交错执行时被错误继承
修复条目原文指出:
Fixed stats-disabled coroutine and asynchronous reads inheriting enabled TLS statistics state when interleaved with stats-enabled reads on the same executor.
在修复之前,一个典型的错误场景是这样的:
- 线程 A 上的读请求 R1 开启统计(
PerfLevel非kDisable、disable_iostats = false),并在执行器线程上设置了 TLS 统计; - 同一执行器线程上交错运行着请求 R2,而 R2 本应在关闭统计的状态下执行(例如用户对 R2 显式设置了
PerfLevel::kDisable/disable_iostats = true); - 由于 TLS 状态是线程级的,R2 在挂起/恢复时"继承"了 R1 留下的 TLS 统计配置,导致本应关闭统计的读取也被采集了统计,或者读取到了属于 R1 的计数。
更隐蔽的问题是计数污染:即便配置正确,若两个开启统计的协程读交错执行,各自在 TLS 中累加的计数也可能被对方读到,造成统计结果串账。而协程可以在多个线程间迁移执行(resume 到其他线程),TLS 统计在迁移过程中会丢失或被覆盖,进一步加剧了不确定性。
修复思路:按调用捕获配置 + 请求上下文隔离计数
这次修复把"统计状态"从线程级 TLS 的共享心智模型重构为按请求(per-call)捕获并隔离的模型,核心包含两个层面:
- 配置按调用捕获:在进入协程读/异步读之前,先把当前线程的统计配置快照进一个
CoroutineStatsConfig结构体(随后立即把 TLS 统计禁用),而不是在执行过程中实时读 TLS; - 计数按请求隔离:开启统计的请求,其计数放在与 folly
RequestContext绑定的EnabledCoroutineStatsRequestData对象里,在挂起/恢复时与 TLS 之间搬移(save/load),从而在任意时刻只暴露当前请求自己的计数。
1. CoroutineStatsConfig:每次调用捕获的配置快照
结构体定义在 util/coro_stats_util.h:
struct CoroutineStatsConfig { PerfLevel perf_level = PerfLevel::kEnableCount; bool per_level_perf_context_enabled = false; bool iostats_disabled = false; };它一次性携带了决定统计行为的三个要素:
| 字段 | 含义 | 关联源码 |
|---|---|---|
perf_level | 本次调用应使用的 PerfLevel 级别 | 定义见 include/rocksdb/perf_level.h |
per_level_perf_context_enabled | 是否启用按 LSM 层级(level)细分的 per-level perf context | 由PerfContext::EnablePerLevelPerfContext()管理 |
iostats_disabled | 是否禁用 IOStatsContext 统计 | 对应IOStatsContext::disable_iostats,见 monitoring/iostats_context_imp.h |
配套的捕获函数也定义在 util/coro_stats_util.h:
CaptureCoroutineStatsConfig():仅快照当前 TLS 配置,不改动 TLS 状态;CaptureAndDisableCoroutineStatsConfig():先快照配置,然后立即禁用 TLS 统计(DisableCoroutineStatsInTLS()会把disable_iostats置 true 并把PerfLevel设为kDisable)。这样在协程读真正开始执行前,线程级 TLS 就不再有"残留的开启态"可供别的请求继承;IsCoroutineStatsEnabled():判断捕获到的配置是否真的需要采集统计(perf_level != kDisable或!iostats_disabled),用于决定是否需要走计数隔离路径。
2. 调用链上的标准使用模式
在 db/coro_db.cc 的CoroDB::CoGet中可以看到这套模式的标准用法:
CoroutineStatsConfig stats_config = CaptureAndDisableCoroutineStatsConfig(); if (coro_db == nullptr || read_event_base == nullptr) { // 降级为同步路径:同样用捕获的配置包一层 CoroutineStatsContextScope stats_scope(std::move(stats_config), db->GetEnv()); status = db->Get(options, column_family, key, value, timestamp); } else { auto task = [](CoroutineStatsConfig task_stats_config, Env* task_env, ...) { CoroutineStatsContextScope stats_scope(std::move(task_stats_config), task_env); co_return co_await folly::coro::co_nothrow( task_db->GetCoroutine(...)); }(...); status = co_await folly::coro::co_nothrow(folly::coro::co_withExecutor( folly::Executor::getKeepAliveToken(read_event_base), std::move(task))); }即:配置在调用方线程上捕获,随任务闭包迁移到读执行器,由CoroutineStatsContextScope在任务体内安装。无论走同步降级还是协程路径,捕获到的都是发起调用那一刻的配置,不再受执行器线程上其他请求影响。
同样的模式也出现在 db/db_impl/db_impl.cc 的DB::GetAsync(第 8119 行起)与DB::MultiGetAsync(第 8164 行起)中:CaptureAndDisableCoroutineStatsConfig()后把CoroutineStatsConfig传入在read_event_base上启动的任务,任务内部再以CoroutineStatsContextScope包裹GetCoroutine/MultiGetCoroutine调用;而在非协程降级路径上,则使用AsyncReadStatsScope(同文件第 8101 行,配合ThreadLocalStatsEnabledForAsyncRead()判断是否需要重置/采集 TLS 统计),两条路径的统计口径在修复后保持一致。
3. CoroutineStatsContextScope:挂起/恢复时的计数搬移
核心类CoroutineStatsContextScope定义在 util/coro_stats_util.h,实现于 util/coro_stats_util.cc。它的职责是:在作用域内把"本请求的统计配置与计数"安装到 TLS,并在挂起/恢复、离开作用域时正确搬移与清理。
其内部依赖 folly 的RequestContext/RequestData机制:开启统计的请求会创建一个EnabledCoroutineStatsRequestData(继承自folly::RequestData),通过folly::ShallowCopyRequestContextScopeGuard挂到请求上下文中。该类拥有自己的PerfContext perf_context_与IOStatsContext iostats_context_副本,并在:
onSet()(请求上下文被恢复/进入):仅当当前线程是"创建线程"(IsOwnerThread(),通过env_->GetThreadID()与owner_thread_id_比对)时,才把私有计数搬回 TLS(LoadThreadLocalStats())、安装捕获的配置(InstallCoroutineStatsConfigToTLS())并启动 CPU 计时;onUnset()(请求上下文被挂起/离开):同样仅在创建线程上执行,停止 CPU 计时、把 TLS 计数搬回私有对象(SaveThreadLocalStats())并把 TLS 统计禁用(DisableCoroutineStatsInTLS())。
头文件注释对此有非常明确的约定(见 util/coro_stats_util.h):
Installs the captured stats configuration for one coroutine call. Enabled calls preserve their counters across suspensions and publish them on exit. Request-context restores on other threads are ignored because the collected stats remain owned by the creating thread. All calls leave TLS stats collection disabled.
这段话点出了三个关键设计决策:
- 启用统计的调用在挂起期间保留自己的计数,恢复时继续累加,退出时统一发布(publish);
- 在其他线程上的请求上下文恢复被忽略——因为统计归属于创建线程,协程迁移到别的线程执行时,不把计数搬到那个线程的 TLS 上,避免串账与丢失;
- 所有调用在离开时都把 TLS 统计采集置为禁用——这样无论下一个在该线程上执行的请求是否开启统计,都不会继承前一个请求的"开启态",从根上消除了本次修复针对的继承问题。
CoroutineStatsContextScope还通过assert(GetCoroutineStatsData() == nullptr)(NDEBUG 之外启用)强制不允许嵌套,保证同一时刻一个线程上只有一个活动的统计请求上下文;析构时同样有对应断言,确保配对清理。CPU 计时(get_cpu_nanos)只在PerfLevel >= kEnableTimeAndCPUTimeExceptForMutex时启动(StartGetCpuTimer()内的判断),并且把"请求上下文未安装的时间段"排除在本次请求的 CPU 统计之外(见测试CoroutineStatsContextScopeCollectsGetCpuNanos,用folly::RequestContextScopeGuard模拟 unset 期间的 CPU 消耗不应被计入)。
测试验证:交错执行下的隔离性
仓库在 db/perf_context_test.cc(PerfContextTest测试套件,仅USE_COROUTINES下编译)中为本次修复提供了直接的回归测试。
其中CoroutineStatsContextsRemainIsolatedWhenInterleaved(第 175 行起)完整复现了"交错"场景:
- 构造三个配置:disabled(
perf_level = kDisable且iostats_disabled = true)、first(开启计数)、second(开启计数,但disable_iostats = true等不同组合),对应文档中"关闭统计的读"与"开启统计的读"两类请求; - 测试线程预先在 TLS 中塞入"脏数据"(
block_read_count = 100、bytes_read = 200),模拟上一个请求残留的 TLS 状态; - 通过
folly::coro::collectAll让三个make_task交错在同一个ManualExecutor上执行,每个任务内部在CoroutineStatsContextScope作用域内验证:- 安装的
PerfLevel、per_level_perf_context_enabled、disable_iostats与自己捕获的配置完全一致,而不是继承 TLS 的残留值; - 挂起(
co_await folly::coro::co_reschedule_on_current_executor)前后,配置保持一致,且自己的计数在挂起期间被保留(恢复后block_read_count仍等于挂起前累加的值),说明计数没有混入其他请求;
- 安装的
- 最终断言三个任务的返回计数分别为
0(disabled)、11(1+10)、22(2+20),互不串扰; - 所有任务结束后,
ManualExecutor所在线程的 TLS 被重置为PerfLevel::kDisable且disable_iostats = true,验证"离开后 TLS 统计采集保持禁用"的清理约定。
同文件中的其他相关测试还包括:
CoroutineStatsContextScopeCollectsStats(第 98 行):验证开启统计的请求在co_await挂起恢复后,PerfContext(含 per-level 计数、block_read_time)与IOStatsContext(bytes_read、cpu_read_nanos、按温度统计)的计数在退出作用域后完整发布;CoroutineStatsContextRestoredOnForeignThread(第 281 行):模拟请求上下文被saveContext()捕获后在另一个std::thread上恢复,验证外部线程上的onSet()被忽略、计数仍归属创建线程(创建线程上block_read_count == 3、bytes_read == 5保持不变);CoroutineStatsContextScopeCollectsGetCpuNanos(第 258 行):验证 CPU 计时精确覆盖"请求上下文安装期间",unset 期间的 CPU 时间不计入。
对使用者的意义与注意事项
对 RocksDB 用户而言,本次修复不改变任何公开 API,而是修正了内部统计的一致性,但从使用角度可以提炼出几点实践认知:
- 统计配置是"调用时点"的:协程读/异步读的
PerfLevel与iostats开关以发起读取那一刻捕获到的 TLS 配置为准。若希望某次CoGet/GetAsync关闭统计,应在调用前通过SetPerfLevel(PerfLevel::kDisable)与IOSTATS_SET_DISABLE(true)设置,不必担心执行器线程上其他并发请求的设置影响本次读取; - 计数归属"发起线程":开启统计的协程读,其
PerfContext/IOStatsContext计数最终在发起请求的线程上发布(创建线程退出作用域时写回 TLS),即便执行期间协程被调度到其他线程运行,统计也不会"跟着"迁移; - 统计读取时机:由于计数在请求退出
CoroutineStatsContextScope(即协程任务体返回)后才最终发布,若要在协程任务内读取统计,应确保在CoroutineStatsContextScope作用域内读取(如测试所示,挂起恢复后、离开作用域前读取到的都是本请求的计数); - 隔离性与嵌套限制:同一线程上同时只允许一个活动的统计请求上下文(嵌套会被
assert拦截),这是保证隔离正确性的前提,也意味着不要在已经处于CoroutineStatsContextScope内的协程体中再去启动另一层统计请求上下文。
小结
coroutine_stats_request_isolation修复本质上是把"线程级 TLS 统计"在协程/异步读场景下重构为"按请求捕获配置、按请求隔离计数、离开即禁用 TLS"的三段式模型:CaptureAndDisableCoroutineStatsConfig()负责快照并清空 TLS,CoroutineStatsContextScope+EnabledCoroutineStatsRequestData负责在挂起/恢复时搬移计数并在退出时发布,IsOwnerThread()保证跨线程恢复时统计不迁移。配合 db/perf_context_test.cc 中的交错、跨线程、CPU 计时三类回归测试,RocksDB 确保了在同一执行器上交错执行的协程读与异步读,无论统计开关如何组合,都能保持配置与计数的严格隔离。相关实现细节可继续阅读 util/coro_stats_util.cc、db/coro_db.cc 与 db/db_impl/db_impl.cc。
【免费下载链接】rocksdbA library that provides an embeddable, persistent key-value store for fast storage.项目地址: https://gitcode.com/gh_mirrors/ro/rocksdb
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考