并发错误处理与容错:线程安全的错误处理、错误码的线程局部存储、异常安全与线程、超时与重试机制

多线程编程里,错误处理是个老大难问题。你想想看,单线程时代我们习惯了用全局 errno,或者返回 -1 表示失败。但到了多线程环境,这套玩法直接崩了——两个线程同时写同一个全局错误码,你读到的到底是哪个线程的错误?

我个人习惯把并发错误处理拆成四个维度来思考:线程安全的错误传播错误码的隔离存储异常安全与线程的博弈、以及超时重试的兜底策略。今天咱们一个一个聊透。

线程安全的错误处理

先看一个最经典的坑。我在项目中遇到过这样的代码:

// 错误示范:全局 errno 在多线程下是灾难
void* worker(void* arg) {
    int fd = open("/some/file", O_RDONLY);
    if (fd == -1) {
        // 此时 errno 可能已经被其他线程覆盖了!
        fprintf(stderr, "open failed: %s\n", strerror(errno));
    }
    return NULL;
}

为什么会这样?因为 errno 在传统实现中是一个全局 int 变量。两个线程同时调用 open,第一个线程的 errno 刚被设置,第二个线程的 open 又失败了,直接把 errno 覆盖了。等你打印错误信息时,看到的可能是第二个线程的错误码。

核心原则:多线程环境下,错误信息必须与线程绑定。要么用线程局部存储,要么用返回值传递错误码,绝不要依赖全局变量。

我建议的做法是:函数返回值携带错误码。比如这样:

// 推荐做法:通过返回值传递错误码
typedef struct {
    int code;
    const char* message;
} error_t;

error_t do_something(int param) {
    if (param < 0) {
        return (error_t){-1, "param must be non-negative"};
    }
    // ... 正常逻辑
    return (error_t){0, NULL};
}

void* worker(void* arg) {
    error_t err = do_something(*(int*)arg);
    if (err.code != 0) {
        // 这里的错误信息是当前线程独有的,不会串
        fprintf(stderr, "Thread %lu error: %s\n", pthread_self(), err.message);
    }
    return NULL;
}

错误码的线程局部存储

有些场景下,你确实需要一个类似 errno 的机制,但又不想改函数签名。这时候 线程局部存储(TLS) 就派上用场了。

说白了,就是给每个线程分配一份独立的错误码变量。C11 标准提供了 _Thread_local 关键字,POSIX 线程库也提供了 pthread_key_create 接口。

// 使用 _Thread_local 实现线程安全的错误码
#include <stdio.h>
#include <pthread.h>

_Thread_local int tls_errno = 0;

void set_error(int code) {
    tls_errno = code;
}

int get_error(void) {
    return tls_errno;
}

void* worker(void* arg) {
    set_error(42);  // 只影响当前线程
    printf("Thread %lu: tls_errno = %d\n", pthread_self(), get_error());
    return NULL;
}

小技巧:如果你用 POSIX 线程库,记得在创建线程局部键时注册析构函数。我曾经因为忘了释放 TLS 数据,导致内存泄漏查了一整天。嗯,血的教训。

pthread_key 的方式更灵活,但要注意:

// POSIX 线程局部存储
pthread_key_t error_key;

void error_destructor(void* value) {
    free(value);  // 线程退出时自动清理
}

void init_error_key() {
    pthread_key_create(&error_key, error_destructor);
}

void set_error_tls(int code) {
    int* val = pthread_getspecific(error_key);
    if (val == NULL) {
        val = malloc(sizeof(int));
        pthread_setspecific(error_key, val);
    }
    *val = code;
}

异常安全与线程

C 语言没有 C++ 的 try-catch,但异常安全的概念同样重要。多线程环境下,一个线程崩溃可能导致整个进程挂掉,或者留下未释放的锁、未关闭的文件描述符。

我总结了三类异常安全问题:

  • 基本保证:线程崩溃不会导致资源泄漏(锁、内存、文件句柄)
  • 强保证:操作要么完全成功,要么回滚到初始状态
  • 不抛异常保证:关键路径上绝不发生未处理错误

举个例子,一个线程安全的队列,如果 push 操作中途失败,必须保证队列状态不变:

// 异常安全的线程安全队列(伪代码)
typedef struct {
    int* data;
    size_t capacity;
    size_t size;
    pthread_mutex_t mutex;
} safe_queue_t;

int safe_queue_push(safe_queue_t* q, int value) {
    pthread_mutex_lock(&q->mutex);
    
    // 先检查容量,避免中途失败
    if (q->size >= q->capacity) {
        pthread_mutex_unlock(&q->mutex);
        return -1;  // 队列满,不修改任何状态
    }
    
    // 只有确认能成功,才修改状态
    q->data[q->size] = value;
    q->size++;
    
    pthread_mutex_unlock(&q->mutex);
    return 0;
}

注意:千万不要在持有锁的时候调用可能阻塞或失败的外部函数。我曾经见过一个同事在锁内调用 malloc,结果内存不足直接挂掉,锁永远没释放——其他线程全部死锁。这种 bug 极难排查。

超时与重试机制

多线程编程中,死锁、活锁、资源争抢是家常便饭。超时机制就是你的最后一道防线。

我个人习惯用 带超时的锁 来避免死锁:

// 带超时的互斥锁
pthread_mutex_t mutex;
struct timespec timeout;

clock_gettime(CLOCK_REALTIME, &timeout);
timeout.tv_sec += 2;  // 2秒超时

int ret = pthread_mutex_timedlock(&mutex, &timeout);
if (ret == ETIMEDOUT) {
    // 超时处理:记录日志、回滚、或者尝试其他策略
    fprintf(stderr, "Lock acquisition timed out\n");
    return -1;
}

重试机制要讲究策略。无脑重试只会让系统更糟。我常用的策略是 指数退避 + 随机抖动

// 指数退避重试
int retry_with_backoff(int (*func)(void*), void* arg, int max_retries) {
    int base_delay_ms = 10;   // 初始等待10ms
    int max_delay_ms = 1000;  // 最大等待1秒
    
    for (int attempt = 0; attempt < max_retries; attempt++) {
        int result = func(arg);
        if (result == 0) return 0;  // 成功
        
        // 计算退避时间:2^attempt * base + random(0, base)
        int delay = (1 << attempt) * base_delay_ms;
        delay += rand() % base_delay_ms;  // 随机抖动
        if (delay > max_delay_ms) delay = max_delay_ms;
        
        usleep(delay * 1000);  // 转换为微秒
    }
    
    return -1;  // 所有重试都失败
}

关键点:重试不是万能的。如果错误是永久性的(比如文件不存在、权限不足),重试只会浪费资源。一定要区分「可重试错误」和「不可重试错误」。

我一般会在错误码里加一个标志位:

#define ERR_RETRYABLE  0x01  // 可重试
#define ERR_FATAL      0x02  // 不可重试

int classify_error(int err) {
    switch (err) {
        case EAGAIN:   // 资源暂时不可用
        case ETIMEDOUT: // 超时
        case EBUSY:    // 资源忙
            return ERR_RETRYABLE;
        case EACCES:   // 权限不足
        case ENOENT:   // 文件不存在
        case EINVAL:   // 参数无效
            return ERR_FATAL;
        default:
            return ERR_RETRYABLE;  // 保守起见,默认可重试
    }
}

最后,我用一张图来总结本章的知识体系:

并发错误处理与容错知识体系 并发错误处理 线程安全错误处理 错误码线程局部存储 异常安全与线程 超时与重试机制 返回值传递 避免全局errno _Thread_local pthread_key 资源不泄漏 状态回滚 指数退避 随机抖动 可重试判断

嗯,以上就是并发错误处理的四个核心维度。我个人觉得,线程局部存储超时重试是最容易被忽视的。很多新手写多线程代码,只关注了锁和条件变量,却忘了错误处理本身也需要线程安全。希望今天的分享能帮你少踩几个坑。