20、协程中的错误处理:异常捕获与错误码传递
错误处理,说实话,是协程里最容易翻车的地方。
我刚开始写协程时,觉得这东西跟普通函数差不多——错了就返回 -1,或者抛个异常呗。结果一跑起来,程序直接崩了,连个像样的错误信息都没留下。后来我才明白,协程的栈是手动切换的,异常传播路径跟普通函数完全不一样。
20.1 协程错误的特殊性
普通函数调用,错误可以沿着调用栈一路往上抛。但协程呢?它的栈是独立的,每次 yield 和 resume 都在切换上下文。你想想看,如果协程 A 里抛了个异常,而当前正在执行的是调度器,这个异常该往哪传?
我在项目中遇到过这样一个场景:一个网络协程在读取数据时发生了超时,按常规写法直接 return -1。但调用方早就切到别的协程了,根本没人收这个返回值。结果就是超时错误被静默吞掉,程序行为变得诡异无比。
说白了,协程的错误处理要解决两个核心问题:
- 错误怎么从协程内部传出来——不能靠 C 语言的异常机制,因为栈不连续
- 错误怎么被正确的调用方捕获——协程的调用链是动态的,不是静态的
核心原则:协程的错误必须显式传递,不能依赖隐式机制。任何被吞掉的错误,迟早会变成线上事故。
20.2 错误码传递:最朴素也最可靠的方式
我个人习惯在协程控制块里加一个 error 字段。每次协程执行完,调度器检查这个字段,如果有错误就做相应处理。
// 协程控制块
typedef struct {
coroutine_func_t func;
void* arg;
int status; // COROUTINE_READY, RUNNING, DONE, ERROR
int error_code; // 错误码
char error_msg[128]; // 错误描述
// ... 其他字段
} coroutine_t;
// 协程内部设置错误
void coroutine_set_error(coroutine_t* co, int code, const char* fmt, ...) {
co->status = COROUTINE_ERROR;
co->error_code = code;
va_list args;
va_start(args, fmt);
vsnprintf(co->error_msg, sizeof(co->error_msg), fmt, args);
va_end(args);
}
// 调度器检查错误
void scheduler_run(scheduler_t* sched) {
while (1) {
coroutine_t* co = get_next_runnable(sched);
if (!co) break;
resume(co);
if (co->status == COROUTINE_ERROR) {
// 错误处理逻辑
handle_coroutine_error(co);
// 可以选择重试、跳过或终止
}
}
}
这种方式的好处是简单、可控。每个错误都有明确的归宿,不会被吞掉。坏处是调用方得主动检查,容易漏掉。
我的习惯:在协程返回后,强制检查一次 error_code。如果非零,立刻记录日志并决定下一步。不要等到程序崩溃了再回头查。
20.3 异常捕获:在协程里模拟 try-catch
C 语言没有 try-catch,但我们可以用 setjmp/longjmp 来模拟。不过要注意,longjmp 会跳过栈帧,如果协程栈里有未释放的资源,就会内存泄漏。
我曾经在协程里用过 longjmp,结果发现每次异常跳转后,堆内存就涨一点。排查了半天,原来是协程里分配的缓冲区没来得及释放。从那以后,我给自己定了个规矩:用 longjmp 之前,必须确保所有资源都已清理。
下面是一个相对安全的实现思路:
// 协程的异常跳转缓冲区
typedef struct {
jmp_buf env;
int exception_code;
char exception_msg[128];
} coroutine_exception_t;
// 在协程入口处设置跳转点
#define COROUTINE_TRY(co) \
coroutine_exception_t* __ex = &(co)->exception; \
int __ex_val = setjmp(__ex->env); \
if (__ex_val == 0) {
#define COROUTINE_CATCH(co) \
} else { \
co->exception_code = __ex_val; \
/* 异常处理代码 */
#define COROUTINE_END_TRY \
}
// 协程内部抛出异常
void coroutine_throw(coroutine_t* co, int code, const char* msg) {
coroutine_exception_t* ex = &co->exception;
ex->exception_code = code;
strncpy(ex->exception_msg, msg, sizeof(ex->exception_msg) - 1);
longjmp(ex->env, code); // 跳回 setjmp 处
}
// 使用示例
void my_coroutine_func(coroutine_t* co) {
COROUTINE_TRY(co) {
// 正常逻辑
char* buf = malloc(1024);
if (!buf) {
coroutine_throw(co, ERR_NO_MEMORY, "内存分配失败");
}
// ... 使用 buf
free(buf);
}
COROUTINE_CATCH(co) {
// 异常处理
log_error("协程异常: code=%d, msg=%s",
co->exception_code, co->exception_msg);
coroutine_set_error(co, co->exception_code, co->exception_msg);
}
COROUTINE_END_TRY
}
注意:longjmp 会跳过中间栈帧。如果协程里调用了其他函数,这些函数里分配的局部资源(如 malloc、打开的文件句柄)不会被自动释放。一定要在 COROUTINE_TRY 块内手动管理资源,或者用 RAII 风格封装。
20.4 错误传播策略:该停就停,该跳就跳
协程出错了,不一定非要立刻终止。有时候可以重试,有时候可以跳过,有时候必须让整个调度器停下来。我总结了几种常见策略:
| 策略 | 适用场景 | 实现方式 |
|---|---|---|
| 重试(Retry) | 网络超时、临时资源不足 | 重置协程状态,重新入队 |
| 跳过(Skip) | 非关键任务失败 | 标记为 DONE,不处理错误 |
| 终止(Abort) | 内存损坏、协议错误 | 停止调度器,记录崩溃现场 |
| 降级(Degrade) | 部分功能不可用 | 切换到备用逻辑继续执行 |
我在做嵌入式协程库时,默认策略是「重试三次,然后跳过」。因为嵌入式设备经常遇到瞬态错误,比如 SPI 总线冲突、DMA 传输失败,重试几次就好了。但如果连续失败,说明硬件可能有问题,再重试也没意义。
20.5 错误日志与诊断
错误处理不只是「捕获」和「传递」,更重要的是「诊断」。没有日志的错误处理,等于没处理。
我习惯在协程控制块里维护一个错误历史队列,记录最近 N 次错误。这样当程序出问题时,可以回溯错误链,找到根因。
#define ERROR_HISTORY_SIZE 16
typedef struct {
int code;
char msg[64];
uint64_t timestamp; // 时间戳
int coroutine_id;
} error_record_t;
typedef struct {
error_record_t records[ERROR_HISTORY_SIZE];
int head;
int count;
} error_history_t;
void log_error_with_context(coroutine_t* co, int code, const char* msg) {
error_history_t* hist = &co->error_history;
int idx = (hist->head + hist->count) % ERROR_HISTORY_SIZE;
hist->records[idx].code = code;
strncpy(hist->records[idx].msg, msg, sizeof(hist->records[idx].msg) - 1);
hist->records[idx].timestamp = get_timestamp();
hist->records[idx].coroutine_id = co->id;
if (hist->count < ERROR_HISTORY_SIZE) {
hist->count++;
} else {
hist->head = (hist->head + 1) % ERROR_HISTORY_SIZE;
}
// 同时输出到系统日志
syslog(LOG_ERR, "[coro %d] error %d: %s", co->id, code, msg);
}
小技巧:错误码最好用枚举定义,不要用魔数。我见过一个项目,错误码全是 -1、-2、-3,看日志根本不知道什么意思。用枚举加注释,调试效率翻倍。
20.6 整体流程:错误处理的完整链路
说了这么多,我们来画一张图,把整个错误处理的流程串起来。
这张图展示了我常用的错误处理流程。协程执行完后,先检查 error_code。如果没有错误,正常返回,调度器继续跑下一个协程。如果有错误,进入错误处理分支,根据错误类型选择重试、跳过或终止。不管选哪种,最后都要记录日志,方便事后排查。
20.7 避坑指南:我踩过的几个坑
最后分享几个我实际踩过的坑,希望能帮你少走弯路。
- 坑一:错误码被覆盖。 协程 A 报错了,还没来得及处理,调度器又切到协程 B,B 也报错了,把 A 的错误码覆盖了。解决方案:每个协程独立维护错误码,调度器不共享。
- 坑二:longjmp 跳过资源释放。 前面说过,longjmp 会跳过中间栈帧。如果你在协程里调用了第三方库,而第三方库内部有 malloc,那就等着内存泄漏吧。解决方案:尽量不用 longjmp,或者确保所有资源都在 TRY 块内管理。
- 坑三:错误日志太多导致性能下降。 我曾经在协程里每发生一次错误就写一次磁盘日志,结果高并发时 I/O 成了瓶颈。解决方案:错误日志先写到内存缓冲区,批量刷盘。
- 坑四:重试导致死循环。 如果错误是永久性的(比如硬件损坏),重试只会浪费 CPU。解决方案:设置最大重试次数,超过后直接跳过或终止。
总结一下:协程的错误处理,核心就三件事——显式传递、合理策略、完整日志。把这三点做好了,协程的稳定性就有保障了。