Linux/Unix 编程中 POSIX 函数的线程安全问题
在目前的计算机科学中,线程是操作系统调度的最小单元,进程是资源分配的最小单元。在大多数操作系统中,一个进程可以同时派生出多个线程。这些线程独立执行,共享进程的资源。在单处理器系统中,多线程通过分时复用技术来技术,处理器在不同的线程间切换,从而更高效地利用系统 CPU资源。在多处理器和多核系统中,线程实际上可以同时运行,每个处理器或者核可以运行一个线程,系统的运算能力相对于单线程或者单进程大幅增强。
多线程技术让多个处理器机器,多核机器和集群系统运行更快。因为多线程模型与生俱来的优势可以使这些机器或者系统实现真实地的并发执行。但多线程在带来便利的同时,也引入一些问题。线程主要由控制流程和资源使用两部分构成,因此一个不得不面对的问题就是对共享资源的访问。为了确保资源得到正确的使用,开发人员在设计编写程序时需要考虑避免竞争条件和死锁,需要更多地考虑使用线程互斥变量。
线程安全 (Thread-safe) 的函数就是一个在代码层面解决上述问题比较好的方法,也成为多线程编程中的一个关键技术。如果在多线程并发执行的情况下,一个函数可以安全地被多个线程并发调用,可以说这个函数是线程安全的。反之,则称之为“非线程安全”函数。注意:在单线程环境下,没有“线程安全”和“非线程安全”的概念。因此,一个线程安全的函数允许任意地被任意的线程调用,程序开发人员可以把主要的精力在自己的程序逻辑上,在调用时不需要考虑锁和资源访问控制,这在很大程度上会降低软件的死锁故障和资源并发访问冲突的机率。所以,开发人员应尽可能编写和调用线程安全函数。
判断一个函数是否线程安全不是一件很容易的事情。但是读者可以通过下面这几条确定一个函数是线程不安全的。
- a, 函数中访问全局变量和堆。
- b, 函数中分配,重新分配释放全局资源。
- c, 函数中通过句柄和指针的不直接访问。
- d, 函数中使用了其他线程不安全的函数或者变量。
因此在编写线程安全函数时,要注意两点:
- 1, 减少对临界资源的依赖,尽量避免访问全局变量,静态变量或其它共享资源,如果必须要使用共享资源,所有使用到的地方必须要进行互斥锁 (Mutex) 保护;
- 2, 线程安全的函数所调用到的函数也应该是线程安全的,如果所调用的函数不是线程安全的,那么这些函数也必须被互斥锁 (Mutex) 保护;
举个例子(参考 例子 1),下面的这个函数 sum()是线程安全的,因为函数不依赖任何全局变量。
例子 1
int sum(int i, int j) { return (i+j); } |
但如果按下面的方法修改,sum()就不再是线程安全的,因为它调用的函数 inc_sum_counter()不是线程安全的,该函数访问了未加锁保护的全局变量 sum_invoke_counter。这样的代码在单线程环境下不会有任何问题,但如果调用者是在多线程环境中,因为sum()有可能被并发调用,所以全局变量 sum_invoke_counter很有可能被并发修改,从而导致计数出错。
例子 2
static int sum_invoke_counter = 0; void inc_sum_counter(int i, int j) { sum_invoke_counter++; } int sum(int i, int j) { inc_sum_counter(); return (i+j); } |
我们可通过对全局变量 sum_invoke_counter添加锁保护,使得 inc_sum_counter()成为一个线程安全的函数。
例子 3
static int sum_invoke_counter = 0; static pthread_mutex_t sum_invoke_counter_lock = PTHREAD_MUTEX_INITIALIZER; void inc_sum_counter(int i, int j) { pthread_mutex_lock( &sum_invoke_counter_lock ); sum_invoke_counter++; pthread_mutex_unlock( &sum_invoke_counter_lock ); } int sum(int i, int j) { inc_sum_counter(); return (i+j); } |
现在 , sum()和 inc_sum_counter()都成为了线程安全函数。在多线程环境下,sum()可以被并发的调用,但所有访问inc_sum_counter()线程都会在互斥锁 sum_invoke_counter_lock上排队,任何一个时刻都只允许一个线程修改sum_invoke_counter,所以 inc_sum_counter()就是现成安全的。
除了线程安全还有一个很重要的概念就是 可重入(Re-entrant),所谓可重入,即:当一个函数在被一个线程调用时,可以允许被其他线程再调用。显而易见,如果一个函数是可重入的,那么它肯定是线程安全的。但反之未然,一个函数是线程安全的,却未必是可重入的。程序开发人员应该尽量编写可重入的函数。
一个函数想要成为可重入的函数,必须满足下列要求:
- a) 不能使用静态或者全局的非常量数据
- b) 不能够返回地址给静态或者全局的非常量数据
- c) 函数使用的数据由调用者提供
- d) 不能够依赖于单一资源的锁
- e) 不能够调用非可重入的函数
对比前面的要求,例子 1的 sum()函数是可重入的,因此也是线程安全的。例子 3中的 inc_sum_counter()函数虽然是线程安全的,但是由于使用了静态变量和锁,所以它是不可重入的。因为 例子 3中的 sum()使用了不可重入函数 inc_sum_counter(), 它也是不可重入的。
如果把一个非线程安全的函数作为线程安全对待,那么结果可能是无法预料的,例如 下面这段代码是对 basename()的错误用法:
例子 4
#include <unistd.h> #include <stdio.h> #include <stdarg.h> #include <pthread.h> #include <string.h> #include <libgen.h> void printf_sa(char *fmt, ...) { static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER; va_list args; va_start(args, fmt); pthread_mutex_lock(&lock); vprintf(fmt, args); pthread_mutex_unlock(&lock); va_end(args); } void* basename_test(void *arg) { pthread_t self = pthread_self(); char *base = basename((char*)arg); printf_sa("TI-%u: base: %s\n", self, base); } int main(int argc, char *argv) { int i = 0; pthread_t tids[2]; char msg[1024]; strcpy(msg, "/tmp/test"); pthread_create(&tids[0], NULL, basename_test, msg); msg[7] -= 32; pthread_create(&tids[1], NULL, basename_test, msg); pthread_join(tids[0], NULL); pthread_join(tids[1], NULL); return 0; } |
这段代码的意思是在两个并发的线程中同时执行函数 basename(),然后打印出对于路径 "/tmp/test"和"/tmp/teSt"的结果。
编译 ( 注意:如编译器提示 pthread_create函数不能找到可能需要连接库 pthread,请需添加 -lpthread选项 ) 执行这段代码,你会发现大部分情况下屏幕上都会打印类似出如下结果 :
TI-3086846864: base: teSt TI-3076357008: base: teSt |
实际上我们期待的值应该 :
TI-3086846864: base: test TI-3076357008: base: teSt |
虽然只是一个字母的差别,但这其实涉及到函数 basename()的线程安全特征。造成这个问题的原因是函数 basename()的返回值指向了输入字符串的一个片段,所以当输入字符串发生变化以后,basename()的返回值也发生了变化。
因为 basename()的函数声明只提供了一个参数,所以该函数不得不通过修改输入参数或使用静态变量的方式来将结果返回给用户。
参考 Linux帮助手册,dirname()和 basename()可能会修改传入的参数字符串,所以调用者应该传入参数字符串的拷贝。并且,这两个函数的返回值可能指向静态分配的内存,所以其返回值的内容有可能被随后的 dirname()或 basename()调用修改。
因为多线程技术和线程安全概念出现得相对较晚,所以 POSIX规范中收纳的一些函数并不符合线程安全要求。
下表是 UNIX环境高级编程列出 POSIX.1规范中的非线程安全的函数:
POSIX.1 规范中的非线程安全函数
asctime | ecvt | gethostent | getutxline | putc_unlocked |
---|---|---|---|---|
basename | encrypt | getlogin | gmtime | putchar_unlocked |
catgets | endgrent | getnetbyaddr | hcreate | putenv |
crypt | endpwent | getnetbyname | hdestroy | pututxline |
ctime | endutxent | getopt | hsearch | rand |
dbm_clearerr | fcvt | getprotobyname | inet_ntoa | readdir |
dbm_close | ftw | getprotobynumber | L64a | setenv |
dbm_delete | getcvt | getprotobynumber | lgamma | setgrent |
dbm_error | getc_unlocked | getprotoent | lgammaf | setkey |
dbm_fetch | getchar_unlocked | getpwent | lgammal | setpwent |
dbm_firstkey | getdate | getpwnam | localeconv | setutxent |
dbm_nextkey | getenv | getpwuid | lrand48 | strerror |
dbm_open | getgrent | getservbyname | mrand48 | strtok |
dbm_store | getgrgid | getservbyport | nftw | ttyname |
dirname | getgrnam | getservent | nl_langinfo | unsetenv |
dlerror | gethostbyaddr | getutxent | ptsname | wcstombs |
drand48 | gethostbyname | getutxid | ptsname | ectomb |
目前大部分上述函数目前已经有了对应的线程安全版本的实现,例如:针对 getpwnam的 getpwnam_r(),( 这里的 _r表示可重入 (reentrant),如前所述,可重入的函数都是线程安全的)。 在多线程软件开发中,如果需要使用到上所述函数,应优先使用它们对应的线程安全版本。而对于某些没有线程安全版本的函数,开发人员可按自己需要编写线程安全版本的实现。
在编写自己的线程安全版本函数之前,应首先仔细阅读 POSIX标准对函数的定义,以及通过充分的测试熟悉函数的输入和输出。理论上来说,所有的线程安全的版本函数应该与非线程安全版本函数在单线程环境下表现一致。
这里给出一个针对 basename()的线程安全版本的例子。
在熟悉了 basename() 函数的功能之后,下面是一个线程安全版本的实现。
/* thread-safe version of basename() */ char* basename_ta(char *path, char *buf, int buflen) { #define DEFAULT_RESULT_DOT "." #define DEFAULT_RESULT_SLASH "/" /* 如果输入的路径长度小于 PATH_MAX, * 则使用自动变量 i_fixed_bufer 作为内部缓冲区 , * 否则申请堆内存做为字符串存放缓冲区。 */ char i_fixed_buf[PATH_MAX+1]; const int i_fixed_buf_len = sizeof(i_fixed_buf)/sizeof(char); char *result = buf; char *i_buf = NULL; int i_buf_len = 0; int adjusted_path_len = 0; int path_len = 0; int i, j; char tmp = 0; if (path == NULL) { /* 如果输入为空指针,则直接返回当前目录 */ path = DEFAULT_RESULT_DOT; } /* 分配内部缓冲区用来存放输入字符串 */ path_len = strlen(path); if ((path_len + 1) > i_fixed_buf_len) { i_buf_len = (path_len + 1); i_buf = (char*) malloc(i_buf_len * sizeof(char)); } else { i_buf_len = i_fixed_buf_len; i_buf = i_fixed_buf; } /* 拷贝字符串到缓冲区,以便接下来对字符串做预处理 */ strcpy(i_buf, path); adjusted_path_len = path_len; /* 预处理:删除路径未的路径符号 '/'; */ if (adjusted_path_len > 1) { while (i_buf[adjusted_path_len-1] == '/') { if (adjusted_path_len != 1) { adjusted_path_len--; } else { break; } } i_buf[adjusted_path_len] = '\0'; } /* 预处理:折叠最后出现的连续 '/'; */ if (adjusted_path_len > 1) { for (i = (adjusted_path_len -1), j = 0; i >= 0; i--) { if (j == 0) { if (i_buf[i] == '/') j = i; } else { if (i_buf[i] != '/') { i++; break; } } } if (j != 0 && i < j) { /* 折叠多余的路径符号 '/'; */ strcpy(i_buf+i, i_buf+j); } adjusted_path_len -= (j - i); } /* 预处理:寻找最后一个路径符号 '/' */ for (i = 0, j = -1; i < adjusted_path_len; i++) { if (i_buf[i] == '/') j = i; } /* 查找 basename */ if (j >= 0) { /* found one '/' */ if (adjusted_path_len == 1) { /* 输入的是跟路径 ("/"),则返回根路径 */ if (2 > buflen) { return NULL; } else { strcpy(result, DEFAULT_RESULT_SLASH); } } else { if ((adjusted_path_len - j) > buflen) { /* 缓冲区不够,返回空指针 */ result = NULL; } else { strcpy(result, (i_buf+j+1)); } } } else { /* no '/' found */ if (adjusted_path_len == 0) { if (2 > buflen) { /* 如果传入的参数为空字符串 ("") */ return NULL; /* 直接返回当前目录 (".") */ } else { strcpy(result, DEFAULT_RESULT_DOT); } } else { if ((adjusted_path_len+1) > buflen) { result = NULL; /* 缓冲区不够,返回空指针 */ } else { strcpy(result, i_buf); /* 拷贝整个字符串做为返回值 */ } } } if (i_buf_len != i_fixed_buf_len) { /* 释放缓冲区 */ free(i_buf); i_buf = NULL; } return result; } |
这个线程安全版本的函数将处理结果存储在外部分配的内存中,所以函数内部并无对全局资源的再依赖。因此,这个函数可以安全地被多个线程所使用。
POSIX标准在 Linux和 AIX平台上表现一致,以上所讲述的线程安全内容均可适用于 AIX多线程编程环境。
通过熟悉和掌握线程安全技术,您可以编写和设计出更优质和高效 C 程序函数。使用线程安全函数,会使得您的程序更加稳定高效,同时也能更大程度上避免死锁和资源并发访问冲突。