22、协程调试技巧:打印协程栈、跟踪调度日志
调试协程,说实话,跟调试普通函数完全是两码事。
普通函数出问题了,你打个断点,单步跟一下,基本就能定位。但协程呢?它会在多个函数之间跳来跳去,上下文切来切去。你刚看到变量A的值,下一秒切到另一个协程,A的值就变了。我刚开始做协程库的时候,就被这种「幽灵变量」折磨过好几次。
所以,这一章我们专门聊聊协程调试。核心就两招:打印协程栈和跟踪调度日志。这两招用好了,大部分协程问题都能快速定位。
22.1 为什么普通调试器不好使?
你想想看,GDB 这种调试器,它依赖的是线程的调用栈。每个线程有一个独立的栈,断点打下去,栈帧是连续的。
但协程不一样。多个协程可能共享一个线程,每个协程有自己的私有栈(或者共享栈)。当协程切换时,CPU 的栈指针(SP)会变,但调试器并不知道这个变化。它看到的还是当前线程的栈。
说白了,调试器被「骗」了。它以为程序还在原来的函数里,其实已经跳到另一个协程的上下文了。
22.2 打印协程栈:自己动手,丰衣足食
既然调试器靠不住,我们就自己写一个栈打印函数。核心思路很简单:从协程的私有栈底开始,沿着栈帧链往上走,把每个函数的返回地址和参数打印出来。
这里有个关键点:协程的栈底地址,我们在创建协程时就知道了。比如用 ucontext 实现的协程,uc_stack.ss_sp 就是栈底。
下面是我常用的一个栈打印函数,你可以直接拿去用:
#include <stdio.h>
#include <ucontext.h>
#include <execinfo.h>
// 打印协程栈
void print_coroutine_stack(void *stack_base, size_t stack_size) {
void *buffer[128];
int frames;
char **symbols;
// 获取当前调用栈(注意:这里获取的是当前线程的栈)
frames = backtrace(buffer, 128);
symbols = backtrace_symbols(buffer, frames);
printf("=== 协程栈信息 ===\n");
printf("栈底: %p, 栈大小: %zu\n", stack_base, stack_size);
// 遍历栈帧,只打印属于该协程栈的帧
for (int i = 0; i < frames; i++) {
// 检查返回地址是否在协程栈范围内
if (buffer[i] >= stack_base &&
buffer[i] < (void *)((char *)stack_base + stack_size)) {
printf(" [%02d] %s\n", i, symbols[i]);
}
}
free(symbols);
}
嗯,这里有个小技巧:backtrace 函数默认获取的是当前线程的栈。但协程切换后,当前线程的栈指针已经指向了协程的私有栈。所以,你只要在协程函数内部调用 print_coroutine_stack,它打印的就是该协程自己的栈。
22.3 跟踪调度日志:记录每一次切换
打印栈只能看到「静态」的信息。但协程的问题,很多时候出在「动态」的调度过程中。比如:
- 协程 A 切到协程 B,然后 B 又切回 A,结果 A 的状态不对了。
- 某个协程一直得不到调度,被「饿死」了。
- 协程切换次数太多,性能下降。
这些问题,光靠打印栈是看不出来的。你需要一个调度日志,记录每一次协程切换的详细信息。
我一般会定义一个日志结构体,像这样:
typedef struct {
uint64_t timestamp; // 时间戳(微秒)
int from_coroutine_id; // 从哪个协程切出
int to_coroutine_id; // 切入到哪个协程
const char *reason; // 切换原因(yield, resume, 等待IO等)
void *from_stack_ptr; // 切出时的栈指针
void *to_stack_ptr; // 切入时的栈指针
} schedule_log_entry_t;
#define MAX_LOG_ENTRIES 1024
schedule_log_entry_t schedule_log[MAX_LOG_ENTRIES];
int log_index = 0;
void log_schedule(int from_id, int to_id, const char *reason) {
if (log_index >= MAX_LOG_ENTRIES) {
// 日志满了,可以覆盖最旧的,或者停止记录
return;
}
struct timeval tv;
gettimeofday(&tv, NULL);
schedule_log[log_index].timestamp = tv.tv_sec * 1000000 + tv.tv_usec;
schedule_log[log_index].from_coroutine_id = from_id;
schedule_log[log_index].to_coroutine_id = to_id;
schedule_log[log_index].reason = reason;
// 栈指针可以从当前上下文中获取
schedule_log[log_index].from_stack_ptr = __builtin_frame_address(0);
schedule_log[log_index].to_stack_ptr = NULL; // 切入后更新
log_index++;
}
然后在协程切换函数(比如 coroutine_yield 和 coroutine_resume)中调用 log_schedule。
等程序跑完,或者出问题的时候,把日志 dump 出来:
void dump_schedule_log() {
printf("=== 调度日志 ===\n");
printf("时间戳\t\t从\t到\t原因\t\t栈指针\n");
for (int i = 0; i < log_index; i++) {
printf("%lu\t%d\t%d\t%s\t%p\n",
schedule_log[i].timestamp,
schedule_log[i].from_coroutine_id,
schedule_log[i].to_coroutine_id,
schedule_log[i].reason,
schedule_log[i].from_stack_ptr);
}
}
22.4 实战:用日志定位一个「协程饿死」问题
我记得有一次,项目里有个协程一直得不到执行。代码逻辑看起来没问题,但就是那个协程的 resume 从来没被调用过。
我 dump 出调度日志一看,发现问题了:
| 时间戳 | 从 | 到 | 原因 |
|---|---|---|---|
| 1000 | 0(主协程) | 1 | resume |
| 1001 | 1 | 2 | yield |
| 1002 | 2 | 1 | yield |
| 1003 | 1 | 2 | yield |
| ... | ... | ... | ... |
看到了吗?协程 1 和协程 2 在互相 yield,形成了一个「死循环」。协程 3 根本没机会被调度。这就是典型的协程饿死问题。
解决方案也很简单:在调度器里加一个「公平调度」策略,比如轮转(round-robin),或者给每个协程一个时间片。这样就不会出现两个协程霸占 CPU 的情况了。
22.5 可视化调度日志
光看文本日志,有时候不够直观。我习惯把调度日志画成时序图,一眼就能看出协程的切换顺序和耗时。
下面是我用 SVG 画的一个调度时序图示例:
从这张图里,你能很清楚地看到:协程 0 启动后,yield 给了协程 1;协程 1 运行一段时间后,yield 给了协程 2;然后协程 2 又 yield 回了协程 1。协程 3 从头到尾没出现过——它被饿死了。
22.6 调试技巧总结
最后,我把常用的协程调试技巧整理成一个清单,方便你查阅:
- 打印协程栈:在协程函数内部调用
backtrace,结合协程栈基址过滤栈帧。 - 记录调度日志:在每次 yield/resume 时记录时间戳、协程 ID、切换原因。
- 可视化日志:把日志画成时序图,快速发现调度异常。
- 加断言:在关键位置加断言,比如「当前协程必须处于运行状态才能 yield」。
- 使用条件断点:如果调试器支持,可以设置条件断点,比如「只在协程 ID 为 3 时中断」。
好了,这一章的内容就到这里。调试技巧这东西,光看没用,得在实际项目中多练。下次你遇到协程相关的 bug,不妨试试今天讲的这两招——打印栈和跟踪日志。你会发现,很多看似诡异的问题,其实都是有迹可循的。