UNIX 编程中错误输出的线程安全问题
http://www.ibm.com/developerworks/cn/aix/library/0806_xiazq_thread/
在多线程的 UNIX 应用程序中,系统调用出错时,错误输出有时可能不会像在单线程系统中那样正确的反应错误所在,因为需要考虑多线程情况下所使用的错误输出方式是否安全等问题。希望本文能对大家在多线程场景下选择错误报告的输出方式有所启发。
在 UNIX 编程中,我们会经常使用系统调用来完成期望的功能;而与此同时,我们也需要付出大段的代码来检测、输出错误和其他意外情况。
以下是系统调用失败的可能原因:
- 系统可能出现资源短缺或者程序使用的资源可能超过系统为单个程序规定的上限。常见的情况有:程序可能尝试分配大量内存,或者同时打开很多文件等。
- 程序执行操作时,可能会由于权限不足而被系统阻止。例如,程序可能会试图写一个只读的文件,或者企图访问其他进程的内存空间。
- 传入系统调用的参数可能无效,原因可能是用户提供无效输入或者程序本身的 bug。例如,程序可能会传入一个无效的内存地址或者无效的文件描述符。
- 系统调用还有可能因为程序之外的原因出错。系统调用访问硬件的时候经常会有这种情况发生。设备可能会出现异常错误或者不支持特定的操作,或者可能会出现磁盘没有插入驱动器中的情况出现。
- 系统调用有的时候会被外部事件 ( 如信号等 ) 中断。这可能不代表真正的调用失败,但是如果有必要,程序应当重新尝试执行系统调用。
|
Glibc 为上述系统调用失败场景提供了丰富的库函数来处理错误输出。但是任何事物都存在双刃剑,这些错误输出库函数在为我们带来便利的同时,也给我们带来了一定的安全隐患——线程安全问题。
线程安全是为了避免数据竞争或者数据设置的正确性依赖于多个线程修改数据的顺序。假设你的代码所在的进程中有多个线程在同时运行,而这些线程可能会同时运行这段代码。如果每次运行结果和单线程运行的结果是一样的,而且其他的变量的值也和预期的是一样的,就是线程安全的。
对于函数来说,在多线程或有异常控制流的情况下 , 当某个函数运行到中途时 , 控制流 ( 也就是当前指令序列 ) 就有可能被打断而去执行另一个函数。而这个函数很有可能是它本身。如果在这种情况下不会出现问题 , 比如说数据或状态不会被破坏,行为确定。那么这个函数就被称做 " 可重入 " 的。
在多线程编程中,有两种方法使库函数可以保证其安全。一个是简单的将合适的代码使用互斥锁包起来,这样可以保证同时只有一个线程执行这一段例程。虽然这种方法大部分情况下都能奏效,但是它的性能却非常糟糕。而且对于诸如 strtok 函数,该方法就完全不能工作了,因此很多 UNIX 系统都存在 _r 的接口函数。
另一个更好的办法是确保库函数可以同时在多个线程情况下安全的执行。这里指的不仅仅是带有后缀 _r 的可重入对等函数;毕竟可重入和线程安全(Thread-Safe)是两个不同的概念:可重入函数一定是线程安全的;线程安全的函数可能是重入的,也可能是不重入的;线程不安全的函数一定是不可重入的。所以诸如 malloc,free 等函数也在此列,属于线程安全的库函数。
当然,如果你在单线程应用程序中使用线程安全函数会在一定程度上降低性能,所以尽量避免在单线程应用程序中使用它们。
|
这里先快速浏览一下本文中所要使用的样例程序代码,方便后面的比较说明。
清单 1 尝试打开一个文件,如果打开文件失败,将打印一串错误信息并退出程序。注意:调用 fopen 函数如果操作成功的话返回一个打开文件的描述符,当操作失败的时候返回 NULL。
清单 1 系统调用的错误输出
//filename:test.c |
编译该程序时候,需要用到 GCC 条件编译的知识,这里使用 -Dmacro 选项,如 gcc –o test test.c –DPERROR,其结果如下所示:
清单 2,PERROR 开关对应的输出内容
$ gcc -o test test.c - DPERROR |
其他预编译开关打开方法相同。
|
类 UNIX 系统中多数系统调用在执行失败的时候会通过一个特殊的全局变量 errno 来保存错误相关的信息。
全局变量 errno 是在系统头文件 中被定义的。注意没有函数会将 errno 清零,所以在调用可能设置 errno 的函数之前先将 errno 清零,以防在本次系统调用无错误发生的情况下,读取到上次系统调用遗留的错误信息;错误发生之后,也应立即用其他变量保存起来。因为下一次系统调用有可能会重写 errno 的值。
Errno 的可能值都是整型的,是以“E”开头的宏来表示的。例如,EACCES 和 EINVAL。程序中使用这些宏会更为形象。在使用 errno 的时候应首先包含 头文件。
Glibc 提供的错误输出库函数包括上面样例中提到的 perror, strerror 以及其线程安全版 strerror_r,这些函数可能是最常用的输出错误方式。而另一个优于前三者的错误输出方式却常为大家所忽略,那就是使用输出格式转换符 %m。下面首先对这四种方式逐一进行描述。
perror 函数用来将上一个函数发生上次系统调用所产生的错误信息输出到标准错误 stderr。由 perror 传入的字符串参数作为前缀(如果该参数不为空),跟一冒号和空格,后面再加上错误原因字符串。此错误原因字符串是由 errno 变量中报告的当前错误的数值映射而来的。perror 函数也不是线程安全的,后面会具体分析原因。
函数原型如下:
#include
void perror(const char *s);
strerror 函数把错误编码映射为一个字符串,该字符串可以用于程序输出的错误信息中。其函数原型如下:
#include |
但是 strerror 函数并不是线程安全的,后面会具体分析原因。
strerror_r 函数是 strerror 的线程安全版本,该函数返回一个包含错误信息字符串的指针。这个指针指向参数 buf 的缓冲区。
其函数原型如下:
#include |
转换符 %m 是 GNU C 库的扩展,UNIX libc5 开始添加对它的支持。其用途是打印 errno 中错误码所对应的字符串,表达的效果与 strerror 以及 strerror_r 函数无异。因此:
fprintf (stderr, "can't open ’%s’: %m/n", filename); |
等价于:
fprintf (stderr, "can't open ’%s’: %s/n", filename, strerror (errno)); |
或者
strerror_r (errno, buf, sizeof(buf)) |
以上是对 4 种错误处理方式的简单描述,详细内容可以查看 man 手册。接下来从线程安全的角度来比较分析这 4 个库函数,从中我们可以看到为保证线程安全为什么使用第三节中提到的方法二更好以及系统调用错误输出为什么使用 printf, %m 更优。
|
在 glibc 的实现中,perror 与 strerror 最终都是调用 __strerror_r 函数来实现将 errno 对应的错误信息输出,只不过 perror 直接将结果送到标准错误输出流 stderr。注意这两个函数都不是线程安全的,在单线程环境中可以正常运行。Perror 最大的一个弊病是在调用后,很可能会把 errno 设置成 ESPIPE( 对应值为 29,错误描述为”Illegal seek”),影响后面 errno 的使用,这也是它线程不安全的原因之一,我们可以通过下面的实验得出此结论。
说明:宏 PERROR_STRERROR 对应的代码块使用 perror 函数来捕获系统调用 fopen() 得到的错误,然后使用 strerror 函数来查看 perror 函数是否改变 errno 的值。如果两者输出结果不一致,则说明 errno 值被 perror 函数更改了。
清单 3,strace 程序的 ELF 文件
$gcc –o test test.c –DPERROR_STRERROR |
其部分结果如下:
mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x2a95558000 |
观察这里发现,perror 搜索 stderr 导致错误的发生。诸如这类不是程序代码本身所导致的问题,会使你慢慢抛弃使用 perror 函数。至于 strerror 函数为什么不是线程安全的,在后面 strerror 与 strerror_r 的比较中可以找到答案。其他有关 perror 与 strerror 的区别可以查看 man 手册中两个函数的描述。
上面提到 strerror 函数并不是线程安全的,其原因可以从 glibc 具体实现中找到答案。该函数在静态缓冲区中设置错误消息的格式并将返回指向该缓冲区的指针,其他地方再调用 strerror 函数时将会覆盖该缓冲区的内容。
POSIX 1003.1 标准定义了 strerror 的对等可重入函数 strerror_r 函数,该函数除接受错误值之外,还接受缓冲区中的指针和缓冲区大小,这样可以保证每个线程拥有自己的缓冲区空间,不至于其他线程或者后续调用该函数时而导致覆盖问题,从而保证了在多线程场景下是安全的。
另外正如前面提到的,如果单线程中使用 strerror_r 函数时,会导致性能下降;所以非多线程环境下,尽量使用 strerror 函数。
可以通过分别打开清单 1 中 STRERROR 与 STRERROR_R 的开关来做比较。
为了线程安全我们使用了可重入函数 strerror_r() 来输出错误,但是却牺牲了一定的空间,并且需要你自己考虑分配存储错误信息 buffer 的大小以及后续回收问题。更优的策略当然是既要保证线程安全又可以省去后顾之忧,这便是选择使用输出格式转换符 %m 的理由所在。
清单 4,FORMAT_M 开关对应的输出内容
$ gcc -o test test.c - DFORMAT_M |
从清单 1 中代码比较可以看出,开关 STRERROR_R 部分比开关 FORMAT_M 部分多分配了 64byte 字节的空间,这一点转换符 %m 就不需要。
作为打印函数 fprintf(), printf(), snprintf(), sprintf();vfprintf(), vprintf(),
vsnprintf(), vsprintf() 都可以说是线程安全的。存在一种例外情况就是,当这些函数执行的时候,如果有其它线程调用 setlocale 函数,那么可能会出现不安全的可能。不过我们通常不会轻易设置 locale 的值,所以大可以放心的在打印函数中使用转换符 %m。
|
Glibc 所提供的上述错误输出方式,使得 UNIX 应用程序在应对运行时错误时,可以最大程度地告知用户所发生了什么。通过上述描述,我们可以得出使用 strerror_r 函数和转换符 %m 是线程安全的,并且转换符 %m 使用起来更加方便。所以在 UNIX 多线程编程中,推荐大家使用 printf, %m 来输出错误。