【读书笔记】并发编程需要注意的几个典型问题
本文为《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(),这种假设就不再成立了。
将类似于rand()这样的函数改造为线程安全函数的唯一方法是重写它,使得其不再使用任何static或global数据,而是依靠调用者在参数中传递状态信息。这样做的缺点是:程序员需要被迫修改调用程序中的代码,在大型程序中,可能会修改成百上千个调用位置,很容易引起bug。
3)返回指向静态变量的指针的函数
某些函数,如ctime和gethostbyname,将计算结果放到一个static变量中,然后返回指向这个变量的指针。如果从并发线程中调用这类函数,将可能引发灾难,因为正在被一个线程使用的结果会被另一个线程悄悄覆盖。
有两种方法来处理这类线程不安全函数。一种选择是重写函数,由调用者来传递存放结果的变量地址,这消除了所有的共享数据,但需要程序员修改函数的源代码和上层的调用代码(很多时候,修改源代码是不现实的,例如标准库函数或第三方提供的库)。第二种选择是使用加锁-拷贝(lock-and-copy)技术,基本思想是将线程不安全函数用互斥锁保护起来,即在每个调用位置,程序的执行路径为:加互斥锁->调用线程不安全函数->将函数返回结果拷贝到调用者提供的私有存储变量中->解互斥锁。为尽可能减少对调用者的修改,我们可以定义一个线程安全的包装函数,由它来执行加锁拷贝,上层调用者通过调用这个包装函数来取代所有对线程不安全函数的调用,从而实现线程安全。
下面给出利用lock-and-copy技术实现的一个线程安全版本的ctime:
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》中引入的,读过这本经典书籍的同学应该不会感到陌生。只可惜大师意外早逝,令人唏嘘啊。
4)调用线程不安全函数的函数
若函数f调用线程不安全函数g,那么f就是线程不安全的吗?
答案是:不一定。如果g是上面提到的第2类函数,即依赖与跨越多次调用的状态,则f也是线程不安全的,而且除了重写g外,没有什么改造办法。然而,若g是第1类或第3类函数,那么只有我们用一个互斥锁来保护调用位置和任何得到的共享数据,f仍然可能是线程安全的。上面给出的利用加速-拷贝技术实现的ctime_ts代码就是一个例子,在该示例中,我们使用lock-and-copy编写了一个线程安全函数,它调用了一个线程不安全的函数。
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; }
检查某个函数的代码并先验地断定它是可重入的,这可能吗?
答案是:不一定。
若所有的函数参数都是值传递,且所有的数据引用都是本地自动栈变量,则函数就是显式可重入的(explicitly reentrant),也即,无论它是被如何调用的,我们都可以断定它是可重入的。
若将显式可重入函数的某些参数改为指针传递,我们就得到了一个隐式可重入(implicitly reentrant)函数,也即,如果调用线程小心地传递指向非共享数据的指针,那么它是可重入的。
3. 在线程化的程序中使用已存在的库函数
所有定义在标准C库中的函数都是线程安全的。大多数Unix函数也都是线程安全的,只有一小部分是例外,这些函数如下图所列。
Unix系统提供大多数线程不安全函数的可重入版本。可重入版本的名字总是以"_r"后缀结尾。例如,gethostbyname的可重入版本是gethostbyname_r。我们应尽可能使用这些函数。
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。 令人惊慌的是,我们是否得到正确的答案依赖于内核是如何调动线程执行的。一种可能的情况是:在某些版本的操作系统中,错误的执行结果可能会暴露给程序员,而在另一些系统中,它可能总是能"正确"工作,让程序员"幸福地"错觉不到程序的严重错误。
博主按:对新手来说,这个bug非常隐蔽,请务必引起重视。
下面是消除竞争后的示例代码:
#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》或其它操作系统教材的相关章节。^_^
【参考资料】
1. <Computer Systems: A Programmer's Perspective>. chapter 12 - Concurrent Programming with Threads
2. mcu cs online material: www.cs.cmu.edu/~kgao/course/15213/notes/week16.ppt
3. <Modern Operating Systems, 2nd Edition>. chapter 3 - DeadLock
================ EOF ===============