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 有错误 无错误 错误处理 正常返回 重试 (Retry) 跳过 (Skip) 终止 (Abort) 记录错误日志 继续调度下一个协程

这张图展示了我常用的错误处理流程。协程执行完后,先检查 error_code。如果没有错误,正常返回,调度器继续跑下一个协程。如果有错误,进入错误处理分支,根据错误类型选择重试、跳过或终止。不管选哪种,最后都要记录日志,方便事后排查。

20.7 避坑指南:我踩过的几个坑

最后分享几个我实际踩过的坑,希望能帮你少走弯路。

  • 坑一:错误码被覆盖。 协程 A 报错了,还没来得及处理,调度器又切到协程 B,B 也报错了,把 A 的错误码覆盖了。解决方案:每个协程独立维护错误码,调度器不共享。
  • 坑二:longjmp 跳过资源释放。 前面说过,longjmp 会跳过中间栈帧。如果你在协程里调用了第三方库,而第三方库内部有 malloc,那就等着内存泄漏吧。解决方案:尽量不用 longjmp,或者确保所有资源都在 TRY 块内管理。
  • 坑三:错误日志太多导致性能下降。 我曾经在协程里每发生一次错误就写一次磁盘日志,结果高并发时 I/O 成了瓶颈。解决方案:错误日志先写到内存缓冲区,批量刷盘。
  • 坑四:重试导致死循环。 如果错误是永久性的(比如硬件损坏),重试只会浪费 CPU。解决方案:设置最大重试次数,超过后直接跳过或终止。

总结一下:协程的错误处理,核心就三件事——显式传递、合理策略、完整日志。把这三点做好了,协程的稳定性就有保障了。

公众号:蓝海资料掘金营,微信deep3321