线程安全与可重入函数
一、可重入函数
1.可重入函数介绍
main函数调⽤insert函数向⼀个链表head中插⼊节点node1,插⼊操作分为两步,刚做完第⼀步的 时候,因为硬件中断使进程切换到内核,再次回⽤户态之前检查到有信号待处理,于是切换 到sighandler函数,sighandler也调⽤insert函数向同⼀个链表head中插⼊节点node2,插⼊操作的 两步都做完之后从sighandler返回内核态,再次回到⽤户态就从中断继续 往下执⾏,先前做第⼀步之后被打断,现在继续做完第⼆步。结果是,main函数和sighandler先后 向链表中插⼊两个节点,⽽最后只有⼀个节点真正插⼊链表中了。如下图示:
函数调用关系如下:
像上例这样,insert函数被不同的控制流程调⽤,有可能在第⼀次调⽤还没返回时就再次进⼊该函 数,这称为重⼊, i n s e r t函数访问⼀个全局链表,有可能因为重⼊⽽造成错乱,像这样的函数称为 不可重⼊函数,反之,如果⼀个函数只访问⾃⼰的局部变量或参数,则称为可重⼊
( R e e n t r a n t) 函数。的函数称为 不可重⼊函数,反之,如果⼀个函数只访问⾃⼰的局部变量或参数,则称为可重⼊( R e e n t r a n t)函数。
保证函数的可重入性的方法:在写函数时候尽量使用局部变量(例如寄存器、堆栈中的变量),对于要使用的全局变量要加以保护(如采取关中断、信号量等方法),这样构成的函数就一定是一个可重入的函数。
2.可重入函数的分类
(1)显式可重入函数
如果所有函数的参数都是传值传递的(没有指针),并且所有的数据引用都是本地的自动栈变量(也就是说没有引用静态或全局变量),那么函数就是显示可重入的,也就是说不管如何调用,我们都可断言它是可重入的。
(2)隐式可重入函数
可重入函数中的一些参数是引用传递(使用了指针),也就是说,在调用线程小心地传递指向非共享数据的指针时,它才是可重入的。可重入函数可以有多余一个任务并发使用,而不必担心数据错误,相反,不可重入函数不能由超过一个任务所共享,除非能确保函数的互斥(或者使用信号量,或者在 代码的关键部分禁用中断)。可重入函数可以在任意时刻被中断,稍后再继续运行,不会丢失数据,可重入函数要么使用本地变量,要么在使用全局变量时保护自己 的数据。
3.一个可重入函数需要满足的是:
1、不使用全局变量或静态变量;
2、不使用用malloc或者new开辟出的空间;
3、不调用不可重入函数;
4、不返回静态或全局数据,所有数据都有函数的调用者提供;
5、使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据;
4.不可重入特点
如果一个函数符合以下条件之一的,则是不可重入的:
(1)调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的。
(2)调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
(3)可重入体内使用了静态的数据结构。
下面举例:
1 A. 可重入函数 2 void strcpy(char *lpszDest, char *lpszSrc) { 3 while(*lpszDest++=*lpszSrc++); 4 *dest=0; 5 } 6 B. 不可重入函数1 7 char cTemp;//全局变量 8 void SwapChar1(char *lpcX, char *lpcY) { 9 cTemp=*lpcX; 10 *lpcX=*lpcY; 11 lpcY=cTemp;//访问了全局变量 12 } 13 C. 不可重入函数2 14 void SwapChar2(char *lpcX,char *lpcY) { 15 static char cTemp;//静态局部变量 16 cTemp=*lpcX; 17 *lpcX=*lpcY; 18 lpcY=cTemp;//使用了静态局部变量 19 }
二、原子性操作
当我们决定完成一个任务,通常情况下,在计算机中,看似很简单的任务也是有多个不同的步骤共同完成。该步骤是由cpu 的 一些指令完成的。比如我们常见的 i++ ;这是一个非原子性操作,因为它先.从内存取出 i 的地址,把 i 的值装入寄存器,然后再增加内存中 i 的值,经过三个步骤完成,如果在中间一个步骤被其他线程影响了,那么就可能出现错误。
举个实际例子:我想完成过安检的过程,我会先取下包,然后放在检验机上,我走过去,然后等待通过检查,最后拿回来。但是实际过程发现有小偷在我将包放到检测机上,还没有进入检查过程中,被拿走了,然后我走过去,发现我的包没过来...这个悲剧的问题就发生了!
所以很容易就理解了所谓原子操作是指不会被线程调度机制打断的操作;这种操作一旦开始,就一直运行到结束,中间不会有任何 context switch (切换到另一个线程)。
三、线程安全
当多个线程访问一个对象,如果不考虑这些线程在运行时环境下的调度和交替执行,也不需要额外的同步,或者调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那么这个对象是线程安全的。
我的理解:多线程访问一个对象,任何情况下,都能保持正确行为,就是对象就是安全的。
线程安全有强弱划分,分为5类:
1、不可变
不可变的对象的,也就是被声明成fianl的对象,只要被正确构建出来,在不发现this逃逸的情况下,其外部状态永远不会改变,永远不会看到多个线程中处于不一致的状态。也就是说所有对象的共享变量都声明成final ,那么就是安全的。
2、绝对线程安全
在某些情况下,我们希望我们的程序能在任何情况下都是安全的,比如加了final 类型的基本类型变量,这里可以认为是的,但是这种不可变的变量没有太大意义。而像StringBulider 类似的变量,即使加了final 类型,也不能认为是线程绝对安全,final 只能保证地址值不动。
3、线程相对安全
这里的相对安全比如我们了解的vector,StirngBuffer 等线程安全的类,也许vector 类的所有操作我们都加上内部锁,但是在使用过程中比如:声明一个 vector 的变量,然后A,B 线程并发操作它。假设A线程在增加元素,B线程在遍历获取元素,那么就会出现错误(元素个数不对),因此线程安全性更多的表现为对同一操作的正确执行安全,也是相对的安全。
4、线程兼容
简单的说就是,这个类本人不是线程安全的,但那时可以使用一些为外部手段,使其完成我们的线程安全。比如ArrayList,HashMap 本身不是线程安全的,但是如果你使用Collections.synchronizedList(Map)就可以达到安全效果,其实现原理很简单,就是对List 或者Map 进行封转,对其主要方法都加上内部锁,相当于集成一个List(Map),全部重写方法加上锁,调用父类执行体。具体的这里不深究。
5、线程对立
简单的说无论我们是否采用了线程安全的机制(比如加锁),或者其他同步措施,都不能保证多线程并发是安全的。比如Thread 的supend()和resume()方法,一个线程去中断线程,另一个线程去恢复线程。那么并发就容易产生死锁,这里两个方法也就废弃了。其他例子暂时不举了。
四、线程不安全
线程安全:一个函数被称为线程安全的(thread-safe),当且仅当被多个并发进程反复调用时,它会一直产生正确的结果。如果一个函数不是线程安全的,我们就说它是非线程安全的(thread-unsafe),但它不一定是线程不安全的。线程安全性不是一个非真即假的命题。我们定义四类(有相交的)线程不安全函数。
1.不保护共享变量的函数
将这类线程不安全函数变为线程安全的,相对比较容易:利用像P和V操作这样的同步操作来保护共享变量。这个方法的优点是在调用程序中不需要做任何修改,缺点是同步操作将减慢程序的执行时间。
2.保持跨越多个调用的状态函数
一个伪随机数生成器是这类不安全函数的简单例子。
1 unsigned int next = 1;
2 int rand(void)
3 {
4 next = next * 1103515245 + 12345;
5 return (unsigned int) (next / 65536) % 32768;
6 }
7
8 void srand(unsigned int seed)
9 {
10 next = seed;
11 }
rand函数是线程不安全的,因为当前调用的结果依赖于前次调用的中间结果。当我们调用srand为rand设置了一个种子后,我们反复从一个单线程中调用rand,我们能够预期一个可重复的随机数字序列。但是,如果有多个线程同时调用rand函数,这样的假设就不成立了。
使得rand函数变为线程安全的唯一方式是重写它,使得它不再使用任何静态数据,取而代之地依靠调用者在参数中传递状态信息。这样的缺点是,程序员现在要被迫改变调用程序的代码。
3.返回指向静态变量指针的函数
某些函数(如gethostbyname)将计算结果放在静态结构中,并返回一个指向这个结构的指针。如果我们从并发线程中调用这些函数,那么将可能发生灾难,因为正在被一个线程使用的结果会被另一个线程悄悄地覆盖了。
有两种方法来处理这类线程不安全函数。一种是选择重写函数,使得调用者传递存放结果的结构地址。这就消除了所有共享数据,但是它要求程序员还要改写调用者的代码。
如果线程不安全函数是难以修改或不可修改的(例如,它是从一个库中链接过来的),那么另外一种选择就是使用lock-and-copy(加锁-拷贝)技术。这个概念将线程不安全函数与互斥锁联系起来。在每个调用位置,对互斥锁加锁,调用函数不安全函数,动态地为结果非配存储器,拷贝函数返回的结果到这个存储器位置,然后对互斥锁解锁。一个吸引人的变化是定义了一个线程安全的封装(wrapper)函数,它执行lock-and-copy,然后调用这个封转函数来取代所有线程不安全的函数。例如下面的gethostbyname的线程安全函数。
1 struct hostent* gethostbyname_ts(char* host) 2 { 3 struct hostent* shared, * unsharedp; 4 unsharedp = Malloc(sizeof(struct hostent)); 5 P(&mutex) 6 shared = gethostbyname(hostname); 7 *unsharedp = * shared; 8 V(&mutex); 9 return unsharedp; 10 }
4.调用线程不安全函数的函数
如果函数 f 调用线程不安全函数 g,那么 f 就是线程不安全的吗?不一定。如果 g 是第2类函数,即依赖于跨越多次调用的状态,那么f也是不安全的,而且除了重写g以外,没有什么办法。然而如果g是第1类或者第3类函数,那么只要用互斥锁保护调用位置和任何得到的共享数据,f可能仍然是线程安全的。比如上面的gethostbyname_ts。
一个类或者程序所提供的接口对于线程来说是原子操作或者多个线程之间的切换不会导致该接口的执行结果存在二义性,也就是说我们不用考虑同步的问题。
线程安全问题都是由全局变量及静态变量引起的。若每个线程中对全局变量、静态变量只有读操作,而无写操作,一般来说,这个全局变量是线程安全的;若有多个线程同时执行写操作,一般都需要考虑线程同步,否则的话就可能影响线程安全。一般来说,一个函数被称为线程安全的,当且仅当被多个并发线程反复调用时,它会一直产生正确的结果。
根据线程的同步与互斥,也就是当两个线程同时访问到同一个临界资源的时候,如果对临界资源的操作不是原子的就会产生冲突,使得结果并不如最终预期的那样。例如以下程序:
当设置 i< 50时可以正确输出,而当数值比较大时:
再次运行程序:
发现上述结果中所得结果不是1000,并且多次运行结果都不同,因此此程序线程是不安全的。因此,线程安全是指当多个线程访问同一个区域的时候其最终的结果是可预期的,并不会因为产生冲突或者异常中断再次恢复而使结果不可预期。
五、可重入函数与线程安全
可重入函数是线程安全函数的一种,其特点在于它们被多个线程调用时,不会引用任何共享数据。可重入函数通常要比不可重入的线程安全函数效率高一些,因为它们不需要同步操作。更进一步说,将第2类线程不安全函数转化为线程安全函数的唯一方法就是重写它,使之可重入。
1 下面为rand函数的一个可重入版本 2 int rand_r(unsigned int* nextp) 3 { 4 *nextp = *nextp * 1103515245 + 12345; 5 return (unsigned int) (*nextp / 65536) % 32768; 6 }
函数可以是可重入的,也可以是线程安全的,或者两者皆是,或者两者皆非。不可重入函数不能由多个线程使用。
1、线程安全是在多线程情况下引发的,而可重入函数可以在只有一个线程的情况下发生。
2、线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
3、如果一个函数有全局变量,则这个函数既不是线程安全也不是可重入的。
4、如果一个函数当中的数据全身自身栈空间的,则这个函数即使线程安全也是可重入的。
5、如果将对临界资源的访问加锁,则这个函数是线程安全的;但如果重入函数的话加锁还未释放,则会产生死锁,因此不能重入。
6、线程安全函数能够使不同的线程访问同一块地址空间,而可重入函数要求不同的执行流对数据的操作不影响结果,使结果是相同的。