
brpc TimerThread 定时器线程深度解析从全局锁竞争到无竞争的高性能 RPC 超时管理【免费下载链接】brpcbrpc is an Industrial-grade RPC framework using C Language, which is often used in high performance system such as Search, Storage, Machine learning, Advertisement, Recommendation etc. brpc means better RPC.项目地址: https://gitcode.com/GitHub_Trending/brpc/brpcbrpc 中的 TimerThread 是 bthread 调度层为 RPC 超时、backup request 等场景提供的一套多线程定时器实现。本文以 docs/cn/timer_keeping.md 为主线结合 src/bthread/timer_thread.cpp 与 src/bthread/timer_thread.h 的源码、test/bthread_timer_thread_unittest.cpp 的测试用例完整讲解在几点几分做某件事这件看似简单、实则棘手的事为什么 RPC 场景下的 timer 极其依赖插入/删除开销单线程 eventloop 与多线程锁小顶堆方案为何不适用r31791 之后的新 TimerThread 又是如何用13 个 Bucket 链表 单个内部小顶堆的设计同时解决竞争、唤醒与删除三大难题的。读完本文你将掌握 brpc 定时器的核心机制、相关 gflags 配置、精确到微秒的时间管理细节以及可复现的性能收益数据。一、为什么在几点几分做某件事比看上去难在 RPC 框架中定时器是一个基础却容易被低估的组件。以 brpc 为例它的典型使用方式见 docs/cn/timer_keeping.md是发起 RPC 时设定一个 timer在超时时间到达后取消还在等待中的 RPC。几乎所有的 RPC 调用都有超时限制因此每次调用都会设置这个 timerRPC 结束前删除 timer大部分 RPC 都由正常返回的 response 导致结束timer 很少真正触发。这意味着在 RPC 场景下timer 更像是一种保险机制——绝大多数情况下它不会发挥作用却要在每次 RPC 的创建与销毁路径上付出成本。因此我们自然希望它的开销越小越好一个几乎不触发的功能需要两次系统调用这显然不理想。在 brpc 的实际调用链中RPC 超时 timer 正是通过bthread_timer_add挂到全局 TimerThread 上的。见 src/bthread/bthread.cpp以及 src/brpc/channel.cpp 中为 backup request 与 RPC 超时设置 timer 的代码。二、系统层能提供的 timer 机制及其局限在讨论应用层实现之前先看 Linux/POSIX 系统本身提供了什么signal 方式timer_create SIGALRM/SIGEV_SIGNAL内核可以以 signal 的方式告知 timer 触发但它逼迫开发者使用全局变量、编写 async-signal-safe 的函数。在面向用户的编程框架中应当尽力避免使用 signalfd 方式timerfd_createLinux 2.6.27内核可以生成一个可读的 fd从而把 timer 的触发通知放进 epoll与传输数据的 fd 统一管理。唯一的问题是timerfd_create本身是一次系统调用且其多线程下的表现不明确。从精度上看底层机制也各有短板原文档在与 Linux 时间管理相关的知识一节中专门说明epoll_wait的超时精度是毫秒级较差pthread_cond_timedwait的超时使用timespec精度到纳秒实际延时一般在 60 微秒左右出于性能考虑TimerThread 使用wall-time墙上时钟而非单调时钟因此可能受系统时间调整的影响。具体来说如果在测试中把系统时间往前或往后调一个小时程序行为将完全 undefined。未来可能会让用户选择单调时间在 CPU 支持nonstop_tsc和constant_tsc的机器上brpc 和 bthread 会优先使用基于rdtsc的cpuwide_time_us——这两个 flag 表示rdtsc可作为 wall-time 使用不支持的机器上则退回到较慢的内核时间。文档作者所在机器的 Intel Xeon 系列大都有这两个 flag。rdtsc作为 wall-time 使用时是否会受系统调时影响属于未测试的未知项。三、单线程框架与多线程框架的 timer 实现对比讨论应用框架中如何实现 timer需要区分单线程和多线程两种场景。3.1 单线程框架eventloop / coroutine 的小顶堆方案在以 libevent、libev 为代表的 eventloop 类库或以 GNU Pth、StateThreads 为代表的 coroutine/fiber 类库中一般以小顶堆记录触发时间epoll_wait前以堆顶的时间计算出参数timeout的值如果在该时间内没有其他事件epoll_wait也会按时醒来从堆中弹出已超时的元素调用相应的回调函数。整个框架周而复始地运转timer 的建立、等待、删除都发生在一个线程中。只要所有回调都是非阻塞的、逻辑不复杂这套机制就能提供基本准确的 timer。但正如 docs/cn/threading_overview.md 所述这不是 RPC 的场景多线程 RPC 框架中不存在所有回调都非阻塞且集中在一个线程的理想前提。3.2 多线程框架的朴素方案锁保护的小顶堆在多线程框架中任何线程都可能被用户逻辑阻塞较长时间因此需要独立的线程实现 timer这种线程就是 TimerThread。一个非常自然的做法是使用锁保护的小顶堆线程需要创建 timer 时先获得锁把对应的时间插入堆如果插入的元素成为最早的唤醒 TimerThreadTimerThread 的逻辑与单线程类似等待堆顶元素超时若等待期间有更早的时间插入插入线程会唤醒它避免睡过头。这个方法的问题在于每个 timer 都要竞争一把全局锁、操作一个全局小顶堆这会在多核上触发 cache bouncing。同样数量的 timer 操作比单线程下慢 10 倍是非常正常的——而尴尬的是这些 timer 基本不触发纯属花钱买保险。原文档明确记录在 r31791 之前brpc 一直沿用一把锁保护的 TimerThread。由于大部分用户 qps 较低不足以暴露这个扩展性问题它长期是brpc 在默认配置下唯一的高频竞争点是一笔一直清楚的技术债。随着 brpc 在高 qps 系统中应用越来越多这个问题终于到了必须解决的时候。四、多线程 TimerThread 设计的三大难点要改进 TimerThread需要正视三个互相牵扯的难点难点一唤醒带来的上下文切换开销一个惯例思路是把 timer 的需求散列到多个 TimerThread但这恰恰对 TimerThread 效果不好。注意那个制约因素一旦插入的元素是最早的就要唤醒 TimerThread。假设 TimerThread 足够多、每个 timer 都散列到独立的 TimerThread那么每次插入都要唤醒对应的线程。唤醒意味着触发 Linux 的调度函数、触发上下文切换。在非常流畅的系统中这个开销大约 3~5 微秒——比抢锁和同步 cache 还慢。多个 TimerThread 减少了对单个小顶堆的竞争压力但同时也引入了更多唤醒。这是提高 TimerThread 扩展性的第一个难点。难点二删除的两种方式都不完美一般用 id 指代一个 Timer通过 id 删除 Timer 有两种方式方式做法代价定点删除抢锁通过一个 map 查到对应 timer 在小顶堆中的位置定点删除map 要与堆同步维护插入逻辑更复杂删除也要抢锁线程竞争更激烈标记删除通过 id 找到 Timer 的内存结构打个标记留待 TimerThread 自行发现和删除小顶堆内留一大堆已删除元素堆明显变大插入和删除都变慢难点三TimerThread 不应该经常醒一个极端的想法是让 TimerThread永远醒着或以较高频率醒过来比如每 1ms 醒一次这样插入线程就不用负责唤醒了再把插入请求散列到多个堆降低竞争——问题看似解决了。但事实上这个方案提供的 timer 精度较差一般高于 2ms。原因在于它的逻辑没法按堆顶元素的时间等待由于插入线程不唤醒一旦有更早的元素插入TimerThread 就会睡过头。它唯一能做的是睡眠固定时间而这与现代 OS scheduler 的假设冲突频繁 sleep 的线程优先级最低。在 Linux 下即使只 sleep 很短的时间最终醒过来也可能超过 2ms——因为在 OS 看来这个线程不重要。一个高精度的 TimerThread 必须有唤醒机制而不是定期醒。附加难点更并发的数据结构也不奏效更并发的数据结构同样难以奏效。感兴趣的同学可以搜索 concurrent priority queue 或 concurrent skip list这些数据结构一般假设插入的数值较为散开从而可以同时修改结构内的不同部分。但在 RPC 场景中这一点也不成立相互竞争的线程设定的时间往往聚集在同一个区域因为程序的超时大都是一个固定值加上当前时间后都差不多。五、新 TimerThread 的设计r31791 后的无竞争实现新 TimerThread 的设计原则可以概括为一个 TimerThread避免唤醒开销、多个散列 Bucket降低插入竞争、Bucket 内链表 全局最近运行时间广播避免每次插入都唤醒、删除只做标记完全离线、小顶堆私有化消灭 cache bouncing。5.1 数据结构总览结合 src/bthread/timer_thread.h 与 src/bthread/timer_thread.cpp 的源码核心组成如下一个 TimerThread而不是多个所有任务最终由唯一的brpc_timer线程src/bthread/timer_thread.cpp 中通过PlatformThread::SetNameSimple(brpc_timer)命名执行从根上避免了多 TimerThread 的唤醒开销13 个 BucketTimerThreadOptions::num_buckets默认 13对应 gflagbrpc_timer_num_buckets创建的 timer 通过butil::fmix64(pthread_numeric_id()) % num_buckets散列到不同 Bucket见 src/bthread/timer_thread.cpp按 pthread id 散列更利于 cache locality降低线程间的竞争Bucket 内不用小顶堆而是链表 nearest_run_time字段插入时若新时间早于 Bucket 的nearest_run_time则覆盖该字段并进一步与全局_nearest_run_time比较若更早则修改全局值并唤醒 TimerThreadsrc/bthread/timer_thread.cpp链表节点在锁外使用 ResourcePool 分配Bucket::schedule中先用butil::get_resourceTask(slot_id)取节点锁外只在链表头插入这一小段持锁src/bthread/timer_thread.cpp。关于 ResourcePool 的背景可参考 docs/cn/memory_management.md删除通过 id 直接定位内存结构、只修改标志unschedule用slot_of_task_id解出 ResourceId、address_resource定位 Task对version做一次 CAS 原子加 2 完成标记删除src/bthread/timer_thread.cpptimer 结构总是由 TimerThread 释放TimerThread 内部维护私有小顶堆唤醒后把全局_nearest_run_time置为max of int64取出所有 Bucket 的链表并把 Bucket 的nearest_run_time也置为max of int64将未删除的 timer 插入小顶堆维护——这个堆只有它一个线程使用src/bthread/timer_thread.cpp。5.2 TaskId 的编码版本号 资源偏移量与 bthread id 类似TaskId 由 64 位组成高 32 位是版本号version低 32 位是 ResourcePool 的槽位偏移量make_task_id/slot_of_task_id/version_of_task_id见 src/bthread/timer_thread.cpp。Task 的version字段承担了状态机与 ABA 防护双重职责src/bthread/timer_thread.cppinitial_version尚未运行initial_version 1正在运行initial_version 2已被删除同时也是复用该结构的下一个 Task 的版本起点构造时即置为 2 以跳过 0。5.3 一次完整的时间轮转流程把 src/bthread/timer_thread.cpp 中TimerThread::run的主循环展开一次完整的轮转如下清空全局最近运行时间抢全局锁把_nearest_run_time置为max of int64并顺带检查_stop避免错过stop_and_join的复位——这样之后插入的更早任务不会被漏掉消费所有 Bucket遍历 13 个 Bucketconsume_tasks()取出整条链表并复位该 Bucket 的nearest_run_time对每个节点先try_delete()检查是否已被标记删除未删除的推入内部小顶堆std::push_heap执行到期任务前复查全局在运行堆顶任务前再抢一次全局锁若task1-run_time _nearest_run_time说明有更早的任务被插入了置pull_again true并回到第 2 步重新消费 Bucket运行到期任务std::pop_heap弹出堆顶调用run_and_delete()内部对 version 做 CAS 从id_version到id_version 1成功后执行fn(arg)再置为id_version 2并归还 ResourcePool见 src/bthread/timer_thread.cpp准备睡眠前再次复查计算next_run_time堆顶任务的 run_time堆空则为max of int64抢全局锁比较若next_run_time _nearest_run_time则continue重来否则把全局_nearest_run_time更新为next_run_time并记录当前的_nsignals睡眠futex_wait_private(_nsignals, expected_nsignals, ptimeout)挂起等待堆顶时间到达或被插入线程的futex_wake_private提前唤醒src/bthread/timer_thread.cpp。注意唤醒并非直接依赖 64 位的_nearest_run_timefutex 无法直接等待 64 位值而是通过专门的_nsignals计数器完成插入线程修改全局_nearest_run_time时_nsignals并futex_wake_private见 src/bthread/timer_thread.cpp这也是 src/bthread/timer_thread.h 注释中说明的设计细节。5.4 为什么这套设计有效原文档给出了七个层面的原因逐一对应源码Bucket 锁内操作是 O(1) 的就是插入一个链表节点src/bthread/timer_thread.cpp临界区很小节点内存分配在锁外完成大部分插入的时间是递增的早于 Bucket 的nearest_run_time从而参与全局竞争的 timer 很少参与全局竞争的 timer 只是和全局_nearest_run_time比一下临界区很小极少数 timer 早于全局_nearest_run_time才会唤醒 TimerThread唤醒也在全局锁外删除不参与全局竞争只做一次 CAS 标记完全不碰锁与堆TimerThread 自己维护小顶堆没有任何 cache bouncing效率很高TimerThread 醒来的频率大约等于 RPC 超时的倒数比如超时 100msTimerThread 一秒内大约醒 10 次已经接近最优。至此brpc 在默认配置下不再有全局竞争点。原文档给出的实测结论是在 400 个线程同时运行时profiling 显示几乎没有对锁的等待。六、性能收益实测数据原文档在示例程序example/mutli_threaded_echo_c中对比了新老 TimerThread24 核 E5-2620开启超线程50 个 bthread 同步发送r31791 后节省约 4% CPU差不多 1 个核qps 提升 10% 左右400 个 bthread 同步发送qps 从 30 万上升到 60 万新 TimerThread 的表现与完全关闭超时时接近。这说明在并发度高、timer 操作频繁的场景下timer 本身的开销已经从RPC 性能的明显拖累变成了几乎无感。七、工程实现中的细节与配套 gflags原文档指出工程实现中还有不少细节问题这些细节在新版本源码中体现为可配置的行为全部定义在 src/bthread/timer_thread.cppgflag默认值作用brpc_timer_num_buckets13timer 散列的 Bucket 数量。TimerThreadOptions::num_buckets的默认值与之对应src/bthread/timer_thread.hstart()校验其取值范围为 1~1024src/bthread/timer_thread.cppbrpc_timer_heap_sweep_min_size4096内部小顶堆超过该规模后TimerThread 才执行清扫sweep把被 unschedule 的死任务从堆中剔除并归还 ResourcePool将内存占用约束在约 qps × timeout 量级小堆不付出 O(N) 清扫代价brpc_timer_max_wakeup_interval_ms0最大唤醒间隔毫秒。当所有待执行任务都在遥远的未来时限制 TimerThread 的睡眠长度使其周期性醒来消费 Bucket、清扫堆以限制已调度又被删除任务的回收延迟0 表示不设上限即保持老行为睡到最近 run_time值得说明的是num_buckets并非越大越好。TimerThreadOptions注释明确指出更大的 Bucket 数不一定带来更强的可扩展性——因为值越大每个 Bucket 越稀疏、越容易触发对全局锁的竞争建议不要修改该值src/bthread/timer_thread.h。此外TimerThread 通过bvar_prefix暴露监控指标scheduled_second、triggered_second、usage见 src/bthread/timer_thread.cpp。全局 TimerThread 在初始化时以bthread_timer为前缀、以FLAGS_brpc_timer_num_buckets为 Bucket 数创建src/bthread/timer_thread.cpp。八、单元测试如何验证这些行为test/bthread_timer_thread_unittest.cpp 用四组用例覆盖了核心行为可作为理解实现的活教材RunTasks注册 1s/2s/10s 后触发的多个任务1 秒后 unschedule 其中两个验证到期任务在 ±50ms 误差内触发、被删任务不触发test/bthread_timer_thread_unittest.cppstart_after_schedule验证未 start 时schedule返回INVALID_TASK_IDstart 后正常调度且过去时间点的任务立即执行test/bthread_timer_thread_unittest.cppschedule_and_unschedule_in_task在正在运行的任务内部再次 schedule / unschedule验证unschedule的三种返回值——0删除了尚未运行的任务、1任务正在运行、-1任务不存在test/bthread_timer_thread_unittest.cppsweep_unscheduled_tasks_in_heap与periodic_wakeup_drains_buckets分别通过ScopedFlag调低brpc_timer_heap_sweep_min_size、调高brpc_timer_max_wakeup_interval_ms验证清扫机制能把堆内死任务控制在阈值附近、周期性唤醒能排空 Buckettest/bthread_timer_thread_unittest.cpp。九、使用 TimerThread 的注意事项结合头文件注释与源码使用时注意以下几点回调必须轻量TimerThread是任何时刻最多运行一个任务的单线程执行模型不要在回调中放耗时逻辑否则会显著延迟其他任务src/bthread/timer_thread.hschedule失败与停止语义TimerThread 处于_stop或未 start 状态时schedule返回INVALID_TASK_ID即 0stop_and_join之后再次schedule同样返回INVALID_TASK_IDsrc/bthread/timer_thread.cpp。bthread_timer_add在schedule失败时返回ESTOP在 TimerThread 未创建时返回ENOMEMsrc/bthread/bthread.cpp内存占用的权衡被 unschedule 的任务结构由 TimerThread 统一回收在 TimerThread 醒来前可能堆积约qps × timeout个任务由于ResourcePoolTask为每个线程缓存 128K 槽位当timeout / latency 2730128K / sizeof(Task)时未删除任务不占用额外内存src/bthread/timer_thread.cpp 的注释给出了完整推导——这也是unschedule()没有就地复用任务的原因时间语义TimerThread 基于 wall-time 而非单调时钟系统时间大幅调整可能导致行为 undefined高精度场景需关注epoll_wait毫秒级与pthread_cond_timedwait纳秒级实际约 60 微秒的精度差异。十、小结brpc 的 TimerThread 用一套一个线程 13 个散列 Bucket 链表 私有小顶堆 标记删除的组合拳逐项化解了多线程 timer 的三大难点以单一 TimerThread 消除多线程唤醒的上下文切换开销以 Bucket 散列 链表 O(1) 插入化解全局锁竞争以 CAS 标记删除让删除完全脱离临界区最终把 TimerThread 从默认配置下唯一的高频竞争点变成了对 RPC 性能几乎无影响的组件。理解这套设计不仅有助于在超高 qps 场景下正确调优 brpc对于任何需要自己实现多线程定时器、事件循环超时管理的系统设计都是极具参考价值的范例。【免费下载链接】brpcbrpc is an Industrial-grade RPC framework using C Language, which is often used in high performance system such as Search, Storage, Machine learning, Advertisement, Recommendation etc. brpc means better RPC.项目地址: https://gitcode.com/GitHub_Trending/brpc/brpc创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考