一、线程安全
一个函数被称为线程安全的,当且仅当被多个并发线程反复地调用时,它会一直产生正确的结果。如果一个函数不是线程安全的,我们就说它是线程不安全的。
四个(不相交的)线程不安全函数类:
1.不保护共享变量的函数
将这类线程不安全的函数变成线程安全的,相对比较容易:利用像P和V操作这样的同步操作来保护共享的变量。这个方法的优点是在调用程序中不需要做任何的修改;缺点是同步操作将减慢程序的执行时间。
2.保持跨越多个调用的状态的函数
一个伪随机数生成器是这类线程不安全函数的简单例子。rand函数是线程不安全的,因为当前调用的结果依赖于前次调用的中间结果,当调用srand为rand设置了一个种子后,我们从一个单线程中反复地调用rand,能够预期得到一个可重复的随机数字序列。然而,如果多线程调用rand函数,这个假设就不再成立了。
使得像rand这样的函数线程安全的唯一方法是重写它,使得它不再使用任何static数据,而是依靠调用者在参数中传递状态信息。这样做的缺点是程序员还要被迫修改调用程序中的代码。在一个大的程序中,可能有成百上千个不同的调用位置,做这样的修改将是非常麻烦的,而且容易出错。
3.返回指向静态变量的指针的函数
某些函数,例如ctime和gethostbyname,将计算结果放在一个static变量里,然后返回一个指向这个变量的指针。如果从并发线程中调用这些函数,那么将可能发生灾难,因为正在被一个线程使用的结果会被另一个线程悄悄地覆盖了。
有两种方法来处理这类线程不安全函数。一种选择是重写函数,使得调用者传递存放结果的变量的地址。这就消除了所有共享数据,但是它要求程序员能够修改函数的源代码。
如果线程不安全函数是难以修改或不可能修改的(例如代码非常复杂或是没有源代码可用),那么另外一种选择就是使用加锁-拷贝(lock-and-copy)技术。基本思想是将线程不安全函数与互斥锁联系起来。在每一个调用位置,对互斥锁加锁,调用线程不安全函数,将函数返回的结果拷贝到一个私有的存储器位置,然后对互斥锁解锁。
4.调用线程不安全函数的函数
如果函数f调用线程不安全函数g,那么f就是线程不安全的吗?不一定,如果g是第2类函数,即依赖于跨越多次调用的状态,那么f也是线程不安全的,而且除了重写g以外,没有什么办法。然而,如果g是第1类或者第3类函数,那么只要用一个互斥锁保护调用位置和任何得到的共享数据,f仍然可能是线程安全的。
二、可重入性
可重入函数:是一类重要的线程安全函数,其特点在于它们具有这样一种属性,当它们被多个线程调用时,不会引入任何共享数据。
尽管线程安全与可重入有时会被用作同义词,但它们并不完全等价。
可重入函数集合是线程安全函数的一个真子集。
可重入函数通常要比不可重入的线程安全的函数高效一些,因为它们不需要同步操作。将第2类线程不安全函数转化为线程安全函数的唯一方法就是重写它,使之变为可重入的。
例:rand函数的可重入版本(关键思想是用一个调用者传递进来的指针取代了静态变量):
#include <stdio.h>
#include <unistd.h>
int rand_r(unsigned int *nextp)
{
*nextp = *nextp * 1103515245 + 12345;
return (unsigned int)(*nextp / 65536) % 32768;
}
int main()
{
unsigned int i = 11;
unsigned int *nextp = &i;
while(1)
{
sleep(1);
unsigned int ret = rand_r(nextp);
printf("%ud\n", ret);
}
return 0;
}
如果所有的函数参数都是传值传递的(即没有指针),并且所有的数据引用都是本地的自动栈变量(即没有引用静态或全局变量),那么函数就是显示可重入的。也就是说无论它是如何被调用的,我们都可以断言它是可重入的。
然而,如果允许显示可重入函数的一些参数是引用传递的(即允许它们传递指针),那么我们就得到了一个隐式可重入的函数,也就是说,如果调用线程小心地传递指向非共享数据的指针,那么它是可重入的。
大多数Unix函数,包括定义在标准C库中的函数(例如malloc,free,printf,scanf)都是线程安全的,只有一小部分例外:
如果我们需要在一个线程化的程序中调用这些函数,对调用者来说最不惹麻烦的方法是加锁-拷贝。然而,加锁-拷贝方法有许多缺点。首先,额外的同步降低了程序的速度;其次,像gethostbyname这样的函数返回指向复杂结构的指针,要拷贝整个结构层次,需要深层拷贝结构。且加锁-拷贝这样的方式对第2类线程不安全函数并不有效。
因此,Unix系统提供大多数线程不安全函数的可重入版本,尽可能地应该使用这些函数。