Skip to content

支持在线回溯bthread调用栈——STB(Stop The Bthread) #2797

Description

@chenBright

Is your feature request related to a problem? (你需要的功能是否与某个问题有关?)

相关isue:#2389

gdb(ptrace)+gdb_bthread_stack.py主要的缺点是要慢和阻塞进程,需要一种高效的追踪bthread调用栈的方法。

bRPC框架的协作式用户态协程无法像Golang内建的抢占式协程一样实现高效的STW(Stop the World),框架也无法干预用户逻辑的执行,所以要追踪bthread调用栈是比较困难的。

在线追踪bthread调用栈需要解决以下问题:

  1. 追踪挂起bthread的调用栈。
  2. 追踪运行中bthread的调用栈。

Describe the solution you'd like (描述你期望的解决方法)

bthread状态模型

以下是目前的bthread状态模型。

bthread状态模型

设计方案

核心思路

为了解决上述两个问题,该方案实现了STB(Stop The Bthread),核心思路可以简单总结为,在追踪bthread调用栈的过程中,状态不能流转到当前追踪方法不支持的状态。STB包含了两种追踪模式:上下文(context)追踪模式和信号追踪模式。

上下文(context)追踪模式

上下文追踪模式可以追踪挂起bthread的调用栈。挂起的bthread栈是稳定的,利用TaskMeta.stack中保存的上下文信息(x86_64下关键的寄存器主要是RIP、RSP、RBP),通过一些可以回溯指定上下文调用栈的库来追踪bthread调用栈。但是挂起的bthread随时可能会被唤醒,执行逻辑(包括jump_stack),则bthread栈会一直变化。不稳定的上下文是不能用来追踪调用栈的,需要在jump_stack前拦截bthread的调度,等到调用栈追踪完成后才继续运行bthread。所以,上下文追踪模式支持就绪、挂起这两个状态。

目前调研到,支持回溯指定上下文调用栈的库有gpertools、abseil-cpp和libunwind,其中:

  1. gpertools:对于已经使用cpu/heap profiler的用户,可以不引入额外的库满足回溯指定上下文调用栈的需求。但是在x86_64上测试验证,该场景下,gpertools 2.7版本的GetStackTraceWithContext/GetStackFramesWithContext的回溯结果还是当前调用位置的调用栈,不符合预期。此外,用户不一定使用tcmalloc(gpertools),可能使用jemalloc。
  2. abseil-cpp:最新版本要求C++14以上,早期版本支持C++11。实际效果未验证。
  3. libunwind:在x86_64上测试验证符合预期。没有其他依赖。提供了unw_set_reg函数用于设置寄存器,屏蔽了不同CPU架构下unw_context_t的差异。

综合考虑,选择libunwind

后续发现,gperftools文档提到,libunwind回溯函数使用了不安全的dl_iterate_phdr,所以并非异步信号安全,在信号处理函数中使用libunwind有重入导致死锁风险。解决办法是优化交互流程,打破死锁:

  1. 发起追踪的线程发信号给指定bthread所在worker线程,并通过sem_timedwait等worker线程就绪。此处的超时机制为了防止框架注册信号处理函数被用户覆盖了,导致没人唤醒发起追踪的线程。
  2. worker线程开始执行信号处理函数,就是就绪状态,通过sem_post唤醒发起追踪的线程,并(类似self-pipe trick)poll监听pipe的读事件。
  3. 发起追踪的线程被唤醒后就开始使用libunwind回溯调用栈。回溯完成后,通过写入一个字节到pipe通知worker线程完成回溯。此时,虽然没有了dl_iterate_phdr重入的问题,但是dl_iterate_phdr死锁的问题还没解决,因为发起追踪的线程等待worker线程dl_iterate_phdr释放锁,worker线程等待发起追踪的线程完成回溯,循环等待了。只要poll等待的时候引入超时机制,就能在超时的时候打破死锁了。

信号追踪模式

信号追踪模式可以追踪运行中bthread的调用栈。运行中bthread是不稳定的,不能使用TaskMeta.stack来追踪bthread调用栈。只能另辟蹊径,使用信号中断bthread运行逻辑,在信号处理函数中回溯bthread调用栈。使用信号有两个问题:

  1. 异步信号安全问题。libunwind回溯调用栈的函数是异步信号安全,可以满足需求。
  2. 信号追踪模式不支持jump_stack。调用栈回溯需要寄存器信息,但jump_stack会操作寄存器,这个过程是不安全的,所以jump_stack不能被信号中断,需要在jump_stack前拦截bthread的调度,等到bthread调用栈追踪完成后才继续挂起bthread。

所以,追踪模式只支持运行状态。

小结

jump_stack是bthread挂起或者运行的必经之路,也是STB的拦截点。STB将状态分成三类:

  1. 上下文追踪模式的状态:就绪、挂起。
  2. 支持信号追踪模式的状态:运行。
  3. 不支持追踪的状态。jump_stack的过程是不允许使用以上两种调用栈追踪方法,需要在jump_stack前拦截bthread的调度,等到调用栈追踪完成后才继续调度bthread。

详细流程

以下是引入STB后的bthread状态模型,在原来bthread状态模型的基础上,加入两个状态(拦截点):将运行、挂起中。

bthread STB状态模型

经过上述分析,总结出STB的流程:

  1. stb(实现STB的一个模块)收到追踪bthread调用栈的请求时,标识正在追踪。追踪完成后,标识追踪完成,并stb调signal()通知可能处于将运行或者挂起中状态的bthread。根据bthread状态,stb执行不同的逻辑:
  • 创建、就绪但还没分配栈、销毁:直接结束追踪。
  • 挂起、就绪:使用上下文追踪模式追踪bthread的调用栈。
  • 运行:使用信号追踪模式追踪bthread的调用栈。
  • 将运行、挂起中:stb调wait()等到bthread状态流转到下一个状态(挂起或者运行),bthread调signal()通知stb继续追踪。
  1. stb追踪时,bthread根据状态也会执行不同的逻辑:
  • 创建、就绪但还没分配栈、销毁、就绪:不需要额外处理。
  • 挂起、运行:唤醒stb继续追踪。
  • 将运行、挂起中:bthread调wait()等到stb追踪完成并signal()后才继续执行jump_stack。

性能

正常情况下,不会追踪bthread调用栈,该特性只是增加了记录状态流转的原子操作(CAS)。所以,可以先测试一下状态流转CAS的性能消耗:

  1. 纯框架调度场景:32个worker,起100个bthread,循环调bthread_yield,并且计数和计时,cpu没有明细变化,都是接近跑满32个核,CAS也没有导致耗时增加。注:bthread_yield会调度下一个bthread,并将当前bthread放到调度队列,消除挂起操作的影响,基本上可以认为CPU都是在执行框架调度逻辑了。
  2. rpc场景:使用multi_threaded_echo_c++,client、server的cpu核数和耗时都基本没有变化。

其他

  1. 同一时刻只能追踪一个bthread,即追踪的并发数最大为1。
  2. 目前只支持x86_64架构。后续逐渐支持其他CPU架构。

Describe alternatives you've considered (描述你想到的折衷方案)

Additional context/screenshots (更多上下文/截图)

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions