线程安全与可重入性

概述

一组并发线程运行在同一进程上下文中,每个线程都有自己独立的线程上下文,包括线程ID、栈、栈指针、程序计数器(PC)、条件码和通用目的寄存器值。每个线程和其它线程一起共享进程上下文的其他部分,包括整个用户虚拟地址空间(由代码段、读/写数据、堆以及所有共享库的代码和数据区组成)。线程也共享打开的文件集合。
当存在共享资源的时候,对资源的访问需要同步。这时候使用线程编写程序的时候,需要编写具有线程安全性(thread safety)属性的函数。一个函数,当且仅当被多个并发线程反复调用时,能够一直产生正确的结果,才能够被称为线程安全的(thread-safe),否则我们称其为非线程安全的(thread-unsafe)。

四类线程不安全函数

第1类;不保护共享变量的函数

对共享变量的并发访问会造成竞争,请看下面的例子:

// badCounter.c

#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include "PV.h"

void *thread(void *vargp);

volatile int Counter = 0;

int main(int argc, char **argv)
{
	int niters;
	pthread_t tid1, tid2;
	
	if(argc != 2)
	{
		fprintf(stderr, "Usage : %s <niters>\n", argv[0]);
		exit(0);
	}

	niters = atoi(argv[1]);
	
	pthread_create(&tid1, NULL, thread, &niters);
	pthread_create(&tid2, NULL, thread, &niters);
	pthread_join(tid1, NULL);
	pthread_join(tid2, NULL);
	
	if(Counter != 2 * niters)
		fprintf(stderr, "BOOM! Counter = %d\n", Counter);
	else
		fprintf(stdout, "OK! Counter = %d\n", Counter);
	
	exit(0);
}

void *thread(void *vargp)
{
	int i, niters = *((int*)vargp);
	
	for(i = 0; i < niters; ++i)
		Counter++;
		
	return NULL;
}

运行一下这个例子:

$ gcc -pthread badCounter.c -o badCounter

$ ./badCounter 100000000
BOOM! cnt = 177953661

$ ./badCounter 100000000
BOOM! cnt = 185993828

$ ./badCounter 100000000
BOOM! cnt = 176414058

如果我们每次运行这个例子,结果可能都不一致,原因是:对Counter的访问存在竞争。
解决这类问题的方法是:利用像P和V操作这样的同步操作来保护共享的变量。这样做有好处也有坏处:

  • 优点是在调用程序中不需要做任何修改
  • 缺点是同步操作将增加程序的执行时间

第2类:依赖于跨越多个调用的状态的函数

一个伪随机数生成器设这类线程不安全函数的简单例子:

unsigned int next = 1;

/* rand - 返回一个在[0, 32767]范围内的伪随机数*/
int rand()
{
	next = next * 1103515245 + 12345;
	return (unsigned int)(next / 65536) % 32768;
}

/* srand - 为rand函数设置随机种子*/
void srand(unsigned int seed)
{
	next = seed;
}

rand函数是线程不安全的,因为调用当前调用的结果依赖于前次调用的中间结果。当调用srandrand设置一个种子后,我们从一个单线程中反复调用rand,能够预期得到一个可重复的随机数字序列。然而在多线程调用rand函数,这种假设就不再成立了。

解决这一类问题唯一的方法是重写它,使得它不再使用任何static数据,而是依靠调用者在参数中传递状态信息。这样做的缺点很明显:现在需要被迫修改调用rand的代码。在一个大的程序中,可能有成百上千不同的调用位置,做这样的修改将是非常麻烦的,而且容易出错。

第3类:返回指向静态变量的指针的函数

一些函数,如ctime和gethostbyname,将计算结果存放在一个static变量中,然后返回一个指向这种变量的指针。如果我们从并发线程中调用这些函数,那么将可能发生灾难,因为正在被一个线程使用的结果会被另一个线程悄悄覆盖了。
有两种方法来处理这类线程不安全函数:

  • 第一种选择是重写函数,使得调用者传递存放结果的变量的地址,这就消除了所有共享数据,但是这要求程序员能够修改函数的源代码。
  • 第二种选择是使用加锁-拷贝(lock-and-copy)技术。如果线程不安全函数难以修改或者不能修改(如代码过于复杂或者无法获得其源代码),可以采用这种方式。加锁-拷贝的基本思想是将线程不安全函数与互斥锁联系起来。在每个调用位置,对互斥锁加锁,调用线程不安全函数 ,将函数返回的结果拷贝到一个私有的存储器位置,然后对互斥锁解锁。为尽可能减少对调用者的修改,应该定义一个线程安全的包装函数,它执行加锁-拷贝,然后通过调用这个包装函数来取代对线程不安全函数的调用。

下面利用加锁-拷贝技术,给出ctime的一个线程安全的版本:

char *ctime_TS(const time_t *timep, char *privatep)
{
	char *sharedp;
	
	P(&mutex);		// 加锁
	sharedp = ctime(timep);
	strcpy(privatep, sharedp);	// 拷贝到私有的存储器空间
	V(&mutex);		// 解锁
	return privatep;
} 

第4类:调用线程不安全函数的函数

如果函数f调用线程不安全函数g,那么f不一定是线程不安全的。

  • 如果g是第2类函数(依赖于跨越多个调用的状态),那么f是线程不安全的函数。除了重写g以外,没有其他办法。
  • 如果g是第1类或第3类函数,那么只要用一个互斥锁保护调用位置和任何得到的共享数据,f仍然可能是线程安全的。上面的ctime和ctime_ts就是个很好的例子。

以下是一些线程不安全函数,以及它们的对应的线程安全版本。在编写并发线程程序的时候,尽可能使用线程安全版本的函数。

可重入性

有一类重要的线程安全函数,叫做可重入函数(reentrant function),其特点在于它们具有这样一种属性:当它们被多个线程调用时,不会引用任何共享数据。所有函数的集合被划分成不相交的线程安全和线程不安全函数集合。可重入函数集合是线程安全函数集合的一个真子集。它们的关系如下图所示。

可重入函数通常要比不可重入函数的线程安全的函数高效一些,因为它们不需要同步操作!更进一步说,线程不安全函数rand转化为可重入函数,就不要引用外部的共享变量,我们可以用一个调用者传递进来的指针取代静态的next变量:

/* rand_r 是一个可重入的伪随机数生成函数*/
int rand_r(unsigned int *nextp)
{
	*nextp = *nextp * 1103515245 + 12345;
	return (unsigned int)(*next / 65536) % 32768;
}

上面的rand_r是可重入的吗?不一定。上面说过了,可重入函数不引用任何共享变量,但是我们不能够保证调用者传递进来的nextp不是指向一个共享变量(比如全局变量或静态变量)!
实际上,我们所说的可重入函数包含两类:显式可重入函数(explicitly reentrant function)和隐式可重入函数(implicitly reentrant function)

显式可重入函数

如果所有的函数参数都是传值传递的(即没有指针),并且所有的数据引用都是本地的自动栈变量(即没有引用全局或静态变量),那么函数就是显示可重入的。也就是说,无论它是被如何调用的,我们都可以断言它是可重入的。

隐式可重入函数

如果允许显式可重入函数中的一些参数是引用传递的(即允许传递指针),那么就得到一个隐式可重入函数。也就是说,如果调用线程小心地传递指向非共享数据的指针,那么它是可重入的。例如上面的rand_r就是隐式可重入的。

通过上面的分析,有一个点值得注意:

  • 可重入性有时候既是调用者也是被调用者的属性,并不只是被调用者单独的属性。

参考资料

  • RandalE.Bryant, DavidR.O’Hallaron, 布赖恩特,等. 深入理解计算机系统[M]. 机械工业出版社, 2011.
  • W.RichardStevens, StephenA.Rago, 史蒂文斯, 等. UNIX 环境高级编程 [M]. 人民邮电出版社, 2014.
posted @ 2017-05-16 20:48  west000  阅读(3206)  评论(0编辑  收藏  举报