本文为《Computer Systems: A Programmer's Perspective》第12.7节—并发编程问题的读书笔记。下面开始正文。
1. 线程安全
一个线程安全(thread-safety)的函数应满足条件:当且仅当被多个并发线程反复调用时,它会一直产生正确的结果。相对地,若一个不是线程安全的函数被称为线程不安全(thread-unsafety)函数。
我们能定义出四个(不相交)的线程不安全函数类:
1)不保护共享变量的函数
很容易理解为什么这类函数线程不安全,将这类函数改造成线程安全的也相对容易:利用类似于信号量的P/V操作或操作系统支持的其它同步操作来保护共享变量。这种改造方法的优点是在调用这类函数的上层程序中不需要做如何修改,缺点是同步操作会减慢程序的执行时间。
2)保持跨越多个调用的状态的函数
例如下面的这段伪随机数生成器代码:
unsigned int next = 1; /* rand - return pseudo-random integer on 0 - 32767 */ int rand(void) { next = next * 1103515245 + 12345; return (unsigned int)(next / 65536) % 32768; } /* srand - set seed for rand() */ void srand(unsigned int seed) { next = seed; }上面的代码中,srand()是线程不安全的,因为当前调用结果依赖于前次调用的中间结果。当调用srand()为rand()设置好随机种子后,单线程反复调用rand(),能够预期得到一个可重复的随机数字序列。然而,如果多线程调用rand(),这种假设就不再成立了。
char * ctime_ts(const time_t * timep, char * privatep) { char * sharedp; P(&mutex); sharedp = ctime(timep); strcpy(privatep, sharedp); // copy string from shared to private V(&mutex); return privatep; }博主按:上面提到的定义一个与原函数具有相同接口参数和返回值的包装函数来执行复杂操作的思路,最初是由 Richard Stevens在其经典力作《Unix Network Programming》中引入的,读过这本经典书籍的同学应该不会感到陌生。只可惜大师意外早逝,令人唏嘘啊。
2. 可重入性
有一类重要的线程安全函数,叫做可重入函数(reentrant function),它具有如下属性:当它们被多个线程调用时,不会引用任何共享数据。
下图给出了可重入函数、线程安全函数和线程不安全函数之间的集合关系:
可重入函数通常要比不可重入的线程安全函数高效,因为它们不需要同步操作。进一步讲,将上面提到的第2类函数改造为线程安全函数的唯一方法就是将其重写为可重入的。下面的代码展示了这一点,其关键思想是用调用者传递的指针取代静态的next变量。
/* rand_r - a reentrant pseudo-random integer on 0 - 32767 */ int rand_r(unsigned int * nextp) { *nextp = *nextp * 1003515245 + 12345; return (unsigned int)(*nextp / 65536) % 32168; }检查某个函数的代码并先验地断定它是可重入的,这可能吗?
4. 竞争
当一个程序的正确性依赖与一个线程要在另一个线程到达y点之前到达它的控制流中的x点时,就会发生竞争(race)。通常发生竞争是因为程序员假定线程将按照某种特殊的轨迹线穿过执行状态空间,而忘记了另一条准则规定:线程化的程序必须对任何可行的轨迹都正确工作。
要理解多线程中的竞争问题,可参考下面的代码:
#define N 4 void * thread(void * vargp); int main() { pthread_t tid[N]; int i; for(i = 0; i < N; ++i) { pthread_create(&tid[i], NULL, thread, &i); } for(i = 0; i < N; ++i) { pthread_join(tid[N], NULL); } exit(0); } /* thread routine */ void * thread(void * vargp) { int myid = *((int *)vargp); printf("Hello from thread %d \n", myid); return NULL; }上面的代码中,thread routine中打印的tid可能会产生非预期的结果。 问题是由每个对等线程和主线程之间的竞争引起的:主线程通过for循环创建对等线程时,它传递了一个指向本地栈变量i的指针。在此时,竞争出现在下次循环体调用pthread_create和thread()函数第1行参数的间接引用和赋值之间。如果对等线程在主线程执行下个循环的pthread_create前就执行了thread()的第1行,那么myid就得到了正确的ID;否则,它的值就是其它线程的ID。 令人惊慌的是,我们是否得到正确的答案依赖于内核是如何调动线程执行的。一种可能的情况是:在某些版本的操作系统中,错误的执行结果可能会暴露给程序员,而在另一些系统中,它可能总是能"正确"工作,让程序员"幸福地"错觉不到程序的严重错误。
#define N 4 void * thread(void * vargp); int main() { pthread_t tid[N]; int i, *ptr; for(i = 0; i < N; ++i) { ptr = malloc(sizeof(int)); *ptr = i; pthread_create(&tid[i], NULL, thread, ptr); } for(i = 0; i < N; ++i) { pthread_join(tid[N], NULL); } exit(0); } /* thread routine */ void * thread(void * vargp) { int myid = *((int *)vargp); free(vargp); printf("Hello from thread %d \n", myid); return NULL; }
5. 死锁
在并发编程中,死锁(deadlock)是一个让程序员头疼的问题。关于死锁,操作系统方面的权威专家Andrew S. Tanenbaum在《Modern Operating Systems》一书中用整整一章来介绍,足见死锁在系统编程方面的重要性。大部分死锁都与资源相关,本文引用该书中对死锁的规范定义:
A set of processes is deadlocked if each process in the set is waiting for an event that only another process in the set can cause.
中文翻译:如果一个进程集合中的每个进程都在等待只能由该进程集合中的其它进程才能引发的事件,那么,该进程集合就是死锁的。
程序死锁有很多原因,要避免死锁一般而言是很困难的。然而,当使用二元信号量来实现互斥时,避免死锁的规则变得相对比较简单:
互斥锁加锁顺序规则:若对于程序中每对互斥锁(s, t),每个同时占用s和t的线程都按照相同的顺序对它们加锁那么这个程序就是无死锁的。
关于死锁的更详细的讨论(如资源死锁的条件,死锁建模,死锁检测等细节)超出了本笔记的范畴。感兴趣的同学,建议阅读《Modern Operating Systems》或其它操作系统教材的相关章节。^_^
================ EOF ===============