3、errno全局变量:工作原理、线程安全与正确使用

说到C语言的错误处理,errno 是个绕不开的话题。我个人觉得,它就像C标准库给我们留的一个「小纸条」——函数执行出错了,就往这个纸条上写个错误码。但这个小纸条用起来有不少讲究,搞不好就会踩坑。

3.1 errno 的工作原理

errno 其实就是一个全局整型变量。很多标准库函数在出错时,会设置 errno 的值。比如你打开一个不存在的文件,fopen() 就会把 errno 设为 ENOENT(2)。

它的工作流程很简单:

  1. 函数内部检测到错误
  2. 将错误码写入 errno
  3. 函数返回错误指示(如 NULL、-1)
  4. 调用方检查返回值,再查看 errno 获取具体错误

关键点:errno 只在函数返回错误时才有意义。如果函数执行成功,errno 的值不会被清零。所以正确的做法是:先判断返回值,再查看 errno。

看个例子:

#include <stdio.h>
#include <errno.h>

int main() {
    FILE *fp = fopen("nonexist.txt", "r");
    if (fp == NULL) {
        // 只有这里才能安全地查看 errno
        printf("errno = %d\n", errno);
    }
    return 0;
}

嗯,这里要注意:千万别在函数成功时去读 errno。我在项目中见过有人这么写:

// 错误的做法
FILE *fp = fopen("test.txt", "r");
if (errno == ENOENT) {  // 如果文件存在,errno 可能是上次遗留的值
    printf("文件不存在\n");
}

这种代码就是定时炸弹。你想想看,如果前面某个函数把 errno 设成了 ENOENT,然后 fopen() 成功了,errno 没被修改,你的程序就会误判。

3.2 线程安全性问题

说到 errno 的线程安全性,我得先讲个我踩过的坑。几年前做一个多线程的数据采集项目,两个线程同时调用 socket 函数,结果错误码全乱套了。A 线程的 connect 失败了,读到的 errno 却是 B 线程的 EAGAIN。

为什么会这样?因为传统的 errno 是全局变量。多线程环境下,一个线程设置的 errno 可能被另一个线程覆盖。

解决方案其实很直接——现代 Linux 和 POSIX 系统已经把 errno 实现为线程局部存储(TLS)了。每个线程有自己的 errno 副本,互不干扰。

验证方法:在 Linux 下,errno 通常被定义为类似 extern int *__errno_location() 的宏。每个线程调用这个函数,返回的是自己线程栈上的地址。

但这里有个陷阱:虽然 errno 本身是线程安全的,但如果你在信号处理函数里修改了 errno,而主线程正在检查 errno,那还是会出问题。我曾经在信号处理函数里调用了 write(),结果 write() 失败后修改了 errno,主线程的检查就全乱了。

避坑指南:信号处理函数中如果调用了可能设置 errno 的函数,一定要先保存旧的 errno,处理完再恢复。

void sig_handler(int sig) {
    int saved_errno = errno;  // 保存
    // 处理信号...
    errno = saved_errno;      // 恢复
}

3.3 正确使用 perror() 和 strerror()

这两个函数是 errno 的好搭档。perror() 直接打印错误信息到 stderr,strerror() 则返回错误码对应的字符串。

我个人习惯这样用:

// 方式一:快速调试用 perror()
FILE *fp = fopen("config.ini", "r");
if (fp == NULL) {
    perror("fopen failed");
    // 输出类似:fopen failed: No such file or directory
}

// 方式二:需要自定义格式时用 strerror()
if (fp == NULL) {
    fprintf(stderr, "Error %d opening config.ini: %s\n", 
            errno, strerror(errno));
}

perror() 的好处是省事,你传个前缀字符串,它自动帮你拼接错误描述。但如果你想把错误信息写到日志文件,或者需要格式化输出,那就得用 strerror()。

这里有个细节:strerror() 返回的字符串是静态分配的,在多线程环境下可能被覆盖。我记得在某个项目中,两个线程同时调用 strerror(),结果拿到的描述文本串了。

线程安全版本:使用 strerror_r(),它把结果写到用户提供的缓冲区中。

char buf[256];
strerror_r(errno, buf, sizeof(buf));
printf("Error: %s\n", buf);

3.4 常见错误码速查表

我整理了几个最常用的错误码,你写代码时肯定用得上:

错误码 值 含义 常见场景
EPERM 1 操作不允许 权限不足
ENOENT 2 文件或目录不存在 fopen()、open() 失败
EINTR 4 系统调用被信号中断 read()、write() 被信号打断
EAGAIN 11 资源暂时不可用 非阻塞 socket 无数据
ENOMEM 12 内存不足 malloc() 失败
EACCES 13 权限被拒绝 文件权限不足
EINVAL 22 无效参数 函数参数错误

3.5 核心逻辑流程图

下面这张图展示了 errno 的完整工作流程,从函数调用到错误处理:

errno 错误处理流程图 调用库函数 函数执行成功? (检查返回值) 正常处理 是 读取 errno 否 选择处理方式 perror() 打印 strerror() 格式化 strerror_r() 线程安全 注意:务必先检查返回值,再读取 errno

3.6 我的经验总结

说了这么多,总结几条我这些年用 errno 的心得:

  • 先判断返回值,再读 errno——这是铁律,没得商量
  • 多线程用 strerror_r()——别图省事用 strerror(),迟早会出事
  • 信号处理函数里保存/恢复 errno——我吃过这个亏,你最好别吃
  • 函数成功时别碰 errno——它可能是上次遗留的垃圾值
  • 日志里记录 errno 和字符串描述——光记数字没用,还得看含义

errno 这套机制虽然古老,但胜在简单可靠。只要记住上面几条规则,它就是你调试程序的好帮手。说白了,错误处理就像系安全带——平时觉得麻烦,真出事了能救命。