
摘要:前文拆解了bthread栈管理。本文转向TaskMeta——每个bthread的核心数据结构。它由ResourcePool管理,字段分为Not Reset(跨复用保留)和Reset(每次重置)。version_butex实现join等待,current_waiter支持butex阻塞唤醒,local_storage保存线程局部存储。
brpc 学习笔记(十一):bthread 的栈管理——执行栈的分配、复用与保护
在上一篇文章中,我们拆解了 bthread 的栈管理——三种大小的栈通过 StackFactory 模板和 ObjectPool 复用,mmap+mprotect 实现 guard page 防溢出。
现在我们转向 bthread 最核心的数据结构:TaskMeta。
每个 bthread 对应一个 TaskMeta 实例。它存储了 bthread 的全部运行时信息:入口函数、栈、状态、统计、TLS 等。理解 TaskMeta 是理解 bthread 调度、同步、生命周期的基础。
struct TaskMeta {// ===== [Not Reset] 跨复用保留 =====butil::atomic<ButexWaiter*> current_waiter{NULL};uint64_t current_sleep{TimerThread::INVALID_TASK_ID};bool sleep_failed{false};bool stop{false};bool interrupted{false};bool about_to_quit{false};pthread_spinlock_t version_lock{};uint32_t* version_butex{NULL};// ===== [Reset] 每次创建时重置 =====bthread_t tid{INVALID_BTHREAD};void* (*fn)(void*){NULL};void* arg{NULL};ContextualStack* stack{NULL};bthread_attr_t attr{BTHREAD_ATTR_NORMAL};int64_t cpuwide_start_ns{0};TaskStatistics stat{};LocalStorage local_storage{};TaskStatus status{TASK_STATUS_UNKNOWN};bool traced{false};pthread_t worker_tid{};};
TaskMeta 的字段分为两类:
| [Not Reset] | ||
| [Reset] | bthread_start_* |
为什么需要这种区分?因为 TaskMeta 从 ResourcePool 分配(参见本系列第三篇),bthread 结束后 TaskMeta 被归还到池中,下次创建新 bthread 时直接复用。[Not Reset] 字段在整个 TaskMeta 生命周期中只初始化一次,而 [Reset] 字段在每次创建新 bthread 时重置。
struct TaskStatistics {int64_t cputime_ns; // 消耗的 CPU 时间(纳秒)int64_t nswitch; // 上下文切换次数int64_t cpu_usage_ns; // CPU 使用时间,用于计算利用率};
每次 bthread 被调度执行时记录 cpuwide_start_ns,被切换出去时计算差值累加到 cputime_ns。nswitch 记录上下文切换次数。这些统计通过 bvar 暴露,便于性能分析。
struct LocalStorage {KeyTable* keytable; // bthread_key_create 创建的线程特定数据void* assigned_data; // 用户分配的数据指针void* rpcz_parent_span; // 分布式追踪 span(weak_ptr<Span>*)};
每个 bthread 有独立的 local_storage。bthread 被调度执行时,从 local_storage 恢复到 tls_bls(__thread 变量,访问更快);被切换出去时,从 tls_bls 保存回 local_storage。
为什么不能直接用 local_storage? 因为 __thread 变量(tls_bls)的访问速度比结构体成员快得多——它是通过 TLS 段直接寻址的。bthread 框架内部一律使用 tls_bls,只在上下文切换时与 local_storage 同步。
enum TaskStatus {TASK_STATUS_UNKNOWN, // 未知(初始值)TASK_STATUS_CREATED, // 已创建,尚未进入调度TASK_STATUS_FIRST_READY, // 首次就绪,等待首次调度TASK_STATUS_READY, // 就绪,在调度队列中等待TASK_STATUS_JUMPING, // 正在跳转(上下文切换中)TASK_STATUS_RUNNING, // 正在运行TASK_STATUS_SUSPENDED, // 已挂起(阻塞等待)TASK_STATUS_END, // 已结束};
CREATED → FIRST_READY → READY → RUNNING ⇄ SUSPENDED → END↘ JUMPING ↗
TaskTracer 通过定期采样 status 字段生成 bthread 的执行轨迹。
这些字段在构造时初始化,复用时不重置。它们承载了跨 bthread 生命周期的"基础设施"。
butil::atomic<ButexWaiter*> current_waiter{NULL};当前阻塞在 butex 上的等待者链表头。当 bthread 在 butex_wait 中阻塞时(如 mutex lock、semaphore wait),TaskGroup 会将当前的 ButexWaiter 插入到对应 butex 的等待队列。
为什么要原子操作?因为 bthread_interrupt 需要从外部(其他线程)修改此字段来唤醒阻塞的 bthread。
uint64_t current_sleep{TimerThread::INVALID_TASK_ID};当 bthread 调用 bthread_usleep 等睡眠函数时,会在 TimerThread 中注册一个定时任务(参见本系列第十篇),ID 保存在这里。INVALID_TASK_ID 表示没有在睡眠。
睡眠的取消(如被中断唤醒)通过这个 ID 调用 TimerThread::unschedule 实现。
pthread_spinlock_t version_lock{};uint32_t* version_butex{NULL}; // 初始值为 1
这两个字段是 bthread_join 机制的核心:
version_butex是一个独立分配的 butex(从 butex ObjectPool 获取),值是 TaskMeta 的版本号version_lock是自旋锁,保护 version_butex 的可见性为什么用 butex 而不是普通变量?bthread_join 需要阻塞等待 bthread 结束。butex 提供了高效的阻塞/唤醒机制——join 方通过 butex_wait(version_butex, expected_version) 挂起,bthread 结束时递增 version 并 butex_wake 唤醒等待者。
为什么不需要原子操作?version_butex 只会被一个 bthread 修改(正在运行的 bthread 本身),其他 bthread 只是读取。不需要 CAS,只需要保证可见性——通过 version_lock 自旋锁实现。
为什么初始值为 1?bthread_t 永远不为 0(因为 tid = make_tid(version, slot),version=0 意味着 tid 的版本部分为 0,可能与"未初始化"混淆)。初始版本号 1 确保第一个 bthread 的 tid 是有效的。
bool sleep_failed{false}; // 定时调度失败标志bool stop{false}; // 内置停止标志bool interrupted{false}; // 中断标志bool about_to_quit{false}; // 延迟调度标志
这四个标志属于 [Not Reset] 类型(从源码和注释来看,实际上 sleep_failed 和部分标志在 reset 时也会被处理,但核心语义是跨生命周期的控制标志):
sleep_failed | ||
stop | bthread_stop() | |
interrupted | bthread_interrupt() | |
about_to_quit |
这些字段在每次 bthread_start_* 时重置,承载每个 bthread 独有的运行时信息。
bthread_t tid{INVALID_BTHREAD}; // bthread 标识符void* (*fn)(void*){NULL}; // 入口函数void* arg{NULL}; // 入口函数参数
tid 是 bthread 的标识符,实际上是 TaskMeta 地址的编码(make_tid(version, TaskMeta*))。将 tid 存在 TaskMeta 中是为了方便——很多地方需要从 TaskMeta 获取 tid,直接存一份比每次编码更快。
fn 和 arg 是 bthread 的入口函数和参数。bthread_start_* 时设置,bthread 被调度执行时调用 fn(arg)。
ContextualStack* stack{NULL}; // 执行栈bthread_attr_t attr{BTHREAD_ATTR_NORMAL}; // 创建属性
stack 指向 bthread 的执行栈(包含 fcontext 和栈内存,参见本系列第十一篇)。STACK_TYPE_PTHREAD 时为 NULL(使用 pthread 原生栈)。bthread 切换时,jump_stack 使用此字段找到目标栈。
attr 是创建时的属性,包含栈类型(stack_type)、标志、tag、名称等。bthread_start 时从参数复制。
int64_t cpuwide_start_ns{0}; // 上次调度时的 CPU 时间戳TaskStatistics stat{}; // 累计统计
每次 bthread 被调度执行时记录 cpuwide_start_ns(通过 butil::cpuwide_time_ns()),被切换出去时计算差值累加到 stat.cputime_ns。这是 bthread 级别的 CPU 时间统计——不依赖操作系统,纯用户态实现。
LocalStorage local_storage{};bthread 的线程局部存储。前面已经分析过——在上下文切换时与 tls_bls 同步。
TaskStatus status{TASK_STATUS_UNKNOWN}; // 当前状态bool traced{false}; // 是否被追踪pthread_mutex_t trace_lock{}; // 追踪锁pthread_t worker_tid{}; // 当前 worker pthread ID
这些字段用于 TaskTracer(BRPC_BTHREAD_TRACER 宏启用时)。trace_lock 保证追踪完成后再进行上下文跳转,避免追踪器读到中间状态。worker_tid 记录当前执行此 bthread 的 worker pthread,用于分析 bthread 在 pthread 之间的迁移。
TaskMeta() {pthread_spin_init(&version_lock, 0);version_butex = butex_create_checked<uint32_t>();*version_butex = 1;pthread_mutex_init(&trace_lock, NULL);}~TaskMeta() {pthread_mutex_destroy(&trace_lock);butex_destroy(version_butex);version_butex = NULL;pthread_spin_destroy(&version_lock);}
构造函数只初始化 [Not Reset] 字段:自旋锁、version_butex、trace_lock。[Reset] 字段通过类内默认值初始化,在 bthread_start_* 时被覆盖。
析构函数按逆序释放:trace_lock → version_butex(归还 butex ObjectPool)→ version_lock。
version_butex 是理解 bthread 生命周期管理的关键。它实现了一个高效的 join 机制:
bthread A 创建 bthread B:TaskMeta* m = get_resource<TaskMeta>(...);m->version_butex 的值就是当前版本号(比如 1)bthread C 调用 bthread_join(B):1. 定位到 B 的 TaskMeta2. 读取 version_butex 的当前值 expected3. butex_wait(version_butex, expected) // 阻塞等待版本变化bthread B 执行完毕:1. 递增 *version_butex(1 → 2)2. butex_wake(version_butex) // 唤醒 join 方bthread C 被唤醒:version_butex 已变化(2 ≠ 1),join 返回
这种设计与条件变量类似——用 butex 值的变化通知等待者。但比条件变量更轻量:不需要 mutex,只需要一个 butex。
为什么版本号不会回绕?因为 bthread 的生命周期通常很短(毫秒到秒级),而 uint32_t 的范围是 42 亿次。即使每秒创建/销毁 100 万个 bthread,也需要 4200 秒(约 70 分钟)才会回绕。而且 TaskMeta 从 ResourcePool 分配,同一 slot 的复用间隔足够长。
voidset_stack(ContextualStack* s){stack = s;}ContextualStack* release_stack(){ContextualStack* tmp = stack;stack = NULL;return tmp;}StackType stack_type()const{return static_cast<StackType>(attr.stack_type);}
三个简单的辅助方法:
set_stackrelease_stackstack_typeattr.stack_type 获取栈类型(Small/Normal/Large)将所有字段按功能分类:
| 版本/同步 | ||
| butex 等待 | ||
| 定时睡眠 | ||
| 控制标志 | ||
| 标识 | ||
| 入口 | ||
| 执行栈 | ||
| 统计 | ||
| TLS | ||
| 追踪 |
关键观察:Not Reset 字段都是"基础设施"——版本号、等待者、定时器、控制标志。它们需要在 bthread 复用时保留,因为外部可能还在引用旧的版本号或等待者。Reset 字段是"业务数据"——入口函数、栈、统计。每次创建新 bthread 时都应该从干净状态开始。
TaskMeta 的设计可以归纳为一句话:用 ResourcePool 管理生命周期,用 Not Reset/Reset 分类字段,用 version_butex 实现 join,用 current_waiter 支持 butex 阻塞唤醒。
四个关键设计:
1. Not Reset / Reset 字段分类。 构造函数只初始化基础设施(版本号、锁、等待者),业务数据在每次 bthread_start_* 时重置。这种分类避免了复用时的状态污染,同时减少了初始化开销。
2. version_butex 实现 join。 用一个独立分配的 butex 作为版本号计数器。bthread 结束时递增版本并唤醒等待者,join 方通过 butex_wait 阻塞等待版本变化。比条件变量更轻量,比忙等更高效。
3. current_waiter 连接 butex 机制。 每个TaskMeta 持有当前的 ButexWaiter 指针,使得 bthread_interrupt 等外部操作能定位并唤醒阻塞中的 bthread。这是 butex 等待/唤醒机制(本系列第四篇)与 TaskMeta 的连接点。
4. local_storage + tls_bls 的双存储设计。 TaskMeta 中的 local_storage 是 bthread TLS 的"持久存储",tls_bls 是"快速访问入口"。上下文切换时在两者之间同步——运行时访问 tls_bls(__thread 变量,快),切换时保存到 local_storage(结构体成员,持久)。
本文基于 Apache brpc 源码(src/bthread/task_meta.h)撰写。