当前位置:Linux教程 - Linux - Solaris2.4 多线程编程指南7--编程指南

Solaris2.4 多线程编程指南7--编程指南

本文出自:BBS水木清华站 作者:Mccartney (coolcat) (2002-01-29 20:33:46)

7 编程指南

本章给出线程编程的一些要点。特别强调了单线程和多线程编程方法的差别。
重新认识全局变量
提供静态局部变量
线程同步
避免死锁
一些基本的注意事项
用多处理器编程

在历史上,大多数代码以单线程的方式来编程。如果在C程序里调用库函数则
尤其是这样:
· 如果你给全局变量赋值,并且在一会以后读该变量,则读的结果和写的是一样的。
· 对于非全局的,静态存储也是如此
· 不需要同步机制,因为没有什么可以同步的
在下面的几个多线程例子当中讨论了采用以上假设将会发生的问题,以及你
如何处理这些问题。

7.1重新认识全局变量

传统情况下,单线程C和UNIX有处理系统调用错误的传统。系统调用可以返回
任何值(例如,write()返回传输的字节数)作为功能性的返回值。然而,-1被保留,
它意味着出错。所以,如果一个系统调用返回-1,你就知道是失败了。

Code Example 7-1 全局变量和错误码errno

Extern int errno;

if(write(file_desc,buffer,size)==-1){
/* the system call failed */
fprintf(stderr,""something went wrong, error code = %d "",errno);
exit(1);
}

函数并不直接返回错误码(将会和正常的返回值混淆),而是将错误码放入一个
名为errno的全局变量中。如果一个系统调用失败,你可以读errno来确定问题所在。
现在考虑在多线程程序中,两个线程几乎同时失败,但错误码不同。他们都希望
在errno中寻找问题,但一个errno不能存放两个值。这个全局变量不能被多线程程序
使用。
Solaris线程包通过一种在概念上全新的存储类型解决了这个问题--线程专有数据。
与全局变量类似,在线程运行时任何过程都可以访问这块内存。然而,它是线程私有
的--如果两个线程参考同名的线程专有存储区,它们实际上是两个存储区。
所以,如果使用线程,每个对errno的操作是线程专有的,因为每个线程有一个
errno的私有拷贝。

7.2提供给静态局部变量

示例7-2显示了一个类似与errno错误的问题,但涉及到静态存储,而不是全局存
储。Gethostbyname(3N)函数用计算机名称作为参数。返回值是一个指向结构的指针,
该结构包含通过网络访问指定计算机的必要信息。

Code Example 7-2 gethostbyname问题

Struct hostent *gethostbyname(char *name){
Static struct hostent result;
/*lookup name in hosts database */
/*put answer in reault*/
return(&result);
}
返回指向自动局部变量不是一个好的选择,尽管在这个例子是可以的,因为所指
定的变量是静态的。但是,如果两个线程用不同的计算机名同时访问这个区域,对静
态存储的使用就会发生冲突。
线程专有数据可以代替静态储存,就象在errno问题中那样,但是这涉及到动态
分配内存,并且增加了调用的开销。
一个更好的办法是调用者提供存放数据的存储区。这需要在函数调用中增加一个
参数,一个输出参数。即需要一个gethostbyname的新的版本。
在Solaris里,这种技术被用来处理很多类似问题。在大多数情况下,新接口的
名字都带有""_r""后缀,例如gethostbyname_r(3N)。

7.3线程同步

应用程序中的线程在处理共享数据和进程资源是必须使用同步机制。
在多个线程控制一个对象时会出现一个问题。在单线程世界里,对这些对象的同
步访问不是问题,但正如示例7-3所示,在多线程编程中需要注意。(注意Solaris
printf(3S)对多线程程序是安全的;此例说明如果printf不安全将会发生的问题。)

Code Example 7-3 printf()问题
/*thread 1*/
printf(""go to statement reached"");

/*thread 2*/
printf(""hello world"");

printed on display:
go to hello

7.3.1单线程策略

一个办法是采用单一的,应用程序范围有效的互斥锁,在调用printf时必须使用
互斥锁保护。因为每次只有一个线程可以访问共享数据,每个线程所见的内存是一致
的。
Because this is effectively a single-threaded program, very little is
gained bythis strategy.

7.3.2重入(reentrant)函数

更好的办法是采用模块化和数据封装的思想。一个替代函数被提供,在被几个线
程同时调用时是安全的。写一个替代函数的关键是搞清楚什么样的操作是""正确的""。
可以被几个线程调用的函数一定是重入的。这也许需要改变函数接口的实现。
访问全局状态的函数,例如内存和文件,都存在重入问题。这些函数需要用
Solaris线程提供的正确的同步机制来保护自己访问全局状态。
两个保证函数重入的基本策略是代码锁和数据锁。

7.3.2.1代码锁

代码锁是函数调用级的策略,它保证函数完全在锁的保护下运行。该策略假设对
数据的所有访问都是通过函数。共享数据的函数应当在同一个锁的保护下执行。
有些并行编程语言提供一种名为监视器(monitor)的机制,在monitor的内部,
函数的代码被隐含地用所来保护。一个monitor也可以用互斥锁实现

7.3.2.2数据锁

数据锁保证对数据集合(collection of data)维护的一致性。对于数据锁,仍
然有代码锁的概念,但代码锁是仅仅围绕访问共享数据进行。对于一个互斥锁协议,
仅有一个线程来操作每一个数据集合。???
在多读单写协议当中,几个读操作或一个写操作可以被允许。在操作不同的数据
集合,或者在同一个数据集合上不违反多读单写的协议的前提下,一个模块中的多个
线程可以同时执行。所以,数据锁比代码锁提供了更多的同时性。
如果你需要使用锁的话,你要用哪一种(互斥锁,条件变量,信号量)呢?你需
要尝试只在必要时加锁来允许更多的并发呢(fine-grained locking 细纹锁),还
是使锁在相当一段时间内有效来避免加锁和释放锁的额外开销呢(coarse-grained
locking 粗纹锁)?
锁的纹理(可以理解成加锁和释放锁的频率,频率越高则纹理越细--译者注)依
赖于所保护的数据量。一个粗纹锁可以是一个保护所有数据的单一的锁。把数据由适
当数量的锁分开来保护是很重要的。如果纹理过细可能会影响性能,过多的加锁和解
锁操作会累计到相当的程度。
普通的做法是:用一个粗纹锁开始,找到限制性能的瓶颈,然后在需要时加入细
纹锁来减缓瓶颈。看上去这是一个合理的办法,但你需要自己判断来达到最好效果。

7.3.2.3不变量

不论是代码锁还是数据锁,不变量对于控制锁的复杂度都具有重要的意义。一个
不变量是一个永真的条件或关系。
这个定义在应用在同时执行时需要进行一定的修改:一个不变量是一个永真的条
件或关系,如果相关的锁尚未设置。一旦锁被设置,不变量就可能为假。然而,拥有
锁的代码在释放所前一定要重新建立不变量。
一个不变量也可以是永真的条件或关系,如果锁尚未设置。条件变量可以被认为
拥有一个不变量,那就是它的条件。

Code Example7-4 用assert(3X)来测试不变量

mutex_lock(&lock);
while(condition)
cond_wait(&cv);
assert((condition)==TRUE);
.
.
.
mutex_unlock();

Assert()命令是测试不变量的。Cond_wait()函数不保护不变量,所以线程返回
时一定要重新估价不变量。
另外一个例子是一个控制双链表元素的模块。对链表中每一个组件,一个好的不
变量是指向前项的指针,以及指向其后项的指针。
假设这个模块使用代码锁,即仅仅用一个全局互斥锁进行保护。如果某一项被删
除或者某一项被添加,将会对指针进行正确操作,然后释放互斥锁。显然,在操作指
针的某种意义上不变量为假,但在互斥锁被释放之前不变量会被重新建立。

7.4避免死锁

死锁是一系列线程竞争一系列资源产生的永久阻塞。某些线程可以运行并不说明
其它线程没有死锁。
导致死锁的最常见的错误是自死锁(self deadlock)或者递归死锁(recursive
deadlock):一个线程在拥有一个锁的情况下试图再次获得该锁。递归死锁是编程
时非常容易发生的错误。
例如,如果一个代码监视器在调用期间让每一个模块的函数都去获得互斥锁,那
么任何在被互斥锁保护的模块之间调用的函数都将立即导致死锁。如果一个函数调用
模块以外的一些代码,而这些代码通过一个复杂或简单的路径,又反过来调用该模块
内部被同一互斥锁保护的函数,也会发生死锁。
解决这种死锁的办法是避免调用模块以外的函数,如果你并不知道它们是否会在
不重建不变量的情况下回调本模块并且在调用之前丢弃所有已获得的锁。当然,在调
用完成后锁会重新获得,一定要检查状态以确定想要进行的操作仍然合法。
死锁的另外一种情况是,线程1和线程2分别获得互斥锁A和互斥锁B。这时线程1
想获得互斥锁B,而同时线程2想获得互斥锁A。结果,线程1阻塞等待B,而线程2阻塞
等待A,造成死锁。
这类死锁可以通过为互斥锁编排顺序来避免(锁的等级 lock hierarchy)。如
果所有线程通过指定顺序申请互斥锁,死锁就不会发生。
为锁定义顺序并非最优的做法。如果线程2在拥有互斥锁B时对于模块的状态有很
多的假设,则放弃互斥锁B来申请互斥锁A,然后再按照顺序重新申请互斥锁B将导致
这些假设失去意义,而不得不重新估价模块的状态。
阻塞同步原语通常有不阻塞的版本,例如mutex_trylock()。它允许线程在没有
竞争时打破锁等级。如果有竞争,已经获得的锁通常要释放,然后按照顺序来申请。

7.4.1死锁调度

因为锁的获得没有顺序的保证,一个线程编程的普遍问题是一个特定线程永远不
会得到一个锁(通常是条件变量),即使它看上去应当获得。
这通常发生在拥有互斥锁的线程释放了锁,在一段时间之后又重新获得了这个锁。
因为锁被释放了,似乎其他线程会获得这个锁。但是因为没有谁能阻塞这个已经获得
了锁的线程,它就继续执行到重新获得互斥锁的时候,这样其他线程无法进行。
通常可以用在重新获得锁之前调用thr_yield(3T)来解决这一类型的问题。它允
许其它线程运行并获得锁。
因为应用程序需要的时间片变化很大,线程库不能做强制规定。只有调用
thr_yield()来保证线程按你需要的那样共享资源。

7.4.2加锁的注意事项

以下是有关锁的一些简单的注意事项。
· 在长时间的操作(例如I/O)上尽量不要加锁,这样会对性能造成负影响。
· 在调用可能重新进入本模块的函数时不要加锁。
· 不要尝试极端的处理器同时性。在不涉及系统调用和I/O操作的情况下,锁通常只
被线程占有很短的时间,冲突是很少发生的。只有在确知竞争情况时,才能长时间占
有一个锁。
· 如果使用多个锁,用锁等级来防止死锁。

7.5遵循基本的注意事项

· 搞清你引入的内容以及它们是否安全。
一个线程程序不能任意进入非线程代码。
· 只有在初始线程中线程代码才可以调用非安全代码。
这保证了与初始线程关联的静态存储只能被该线程使用。
· Sun提供的库如果没有明确地标识为unsafe,则被定义为safe。
如果man page不声称函数是MT-Safe的,则它是安全的。所有的MT-unsafe函
数都在man page里明确标出。
· 使用编译标志来控制二进制不兼容的源代码改变。
在编译时指定-D_REENTRANT或者保证在头文件中定义_REENTRANT。
· 如果一个库是多线程安全的,不要将全局进程操作线程化。???
不要把全局操作(或者有可能影响全局的操作)改成线程风格。例如,如果
文件的I/O操作被设置为线程级的操作,多个线程将不能正确访问文件。
对于线程级的操作,或者thread cognizant的操作,要使用线程工具。例如,如
果main()函数仅仅终止正在退出main函数的那个线程,则main()函数的结尾应当为
thr_exit();
/*not reached */

7.5.1创建线程

Solaris线程包对线程数据结构,堆栈以及LWP设置缓存,这样重复创建非绑定线
程开销会降低。
非绑定线程的创建比其进程创建或者绑定线程的创建来开销都要小的多。实际上,
这种开销和从一个线程切换到另外一个线程的开销是相当的。
所以,在需要时不断地创建和清除线程,比起维护一个等待独立任务的线程池通
常要划算一些。
一个好的例子是,一个RPC服务器的工作方式是为监听到的每一个请求创建一个
线程,并且在提供服务后清除这个线程,而不是维护很多线程来提供服务。
虽然线程创建比进程创建开销要小,但是比起几条指令来代价并不低。因此仅在
需要执行几千条以上机器指令时才考虑创建线程。

7.5.2线程同时性

在缺省状态下,Solaris线程通过对非绑定线程调整执行资源(LWP)来实现对实
际活动的线程数的匹配。如果说Solaris线程包不能进行完美的调度,它至少可以保证
进程继续运行。
如果你需要让一定数量的线程同时处于活动状态(执行代码或系统调用),需要
通过thr_setconcurrency(3T)来通知线程库。

例如:
· 如果一个数据库服务器给每一个用户开设一个服务线程的话,它应当把期望得到的
用户数目告诉操作系统Solaris。
· 如果一个窗口服务器给每一个客户开设一个线程,它应当把期望的活动客户端的
数目通知Solaris。
· 一个文件拷贝程序拥有一个读线程和一个线程,它应当通知Solaris它的同时性等级
为2。
或者,同时性等级可以在创建线程时使用THR_NEW_LWP标志来增加。
在计算线程的同时性时,需要把因为进程间的同步变量而处于阻塞状态的线程考虑
进来。

7.5.3效率

用thr_create(3T)创建一个新线程比重新启动一个线程花费的时间少。这意味
着,在需要时创建一个线程并且在任务结束后用thr_exit(3T)立刻杀掉它,比起维护一
大堆的空闲线程并且在它们中间切换要划算的多。

7.5.4绑定线程

绑定线程比起非绑定线程的开销要大。因为绑定线程可以改变它所在的LWP的属性,
LWP在绑定线程退出后不会被缓存,在新的绑定线程生成时,操作系统将提供一个新的LWP。
仅仅在线程需要只有在所在的LWP内可用的资源时(例如虚拟的定时器或者一个指定
的堆栈),或者为了实现实时调度而必须使线程对于内核可见的场合下,才需要使用绑
定线程。
即使在你希望所有的线程都同时活动时,你也应当使用非绑定线程。因为非绑定线程
允许Solaris高效率地分配系统资源。

7.5.5线程创建指南

在使用线程时有如下简单的注意事项:
· 在有多个执行大量任务的操作时,使用多线程编程。
· 使用线程来更好地利用CPU的同时性。
· 只有在不得已的情况下再使用绑定线程,就是说,需要LWP的特殊支持的时候。
使用thr_setconcurrency(3T)来告诉Solaris你希望有多少个线程同时执行。

7.6关于多处理器

Solaris线程包使你能够充分利用多处理器。在很多情况下,程序员必须关心程序
是在单处理器还是在多处理器的环境下运行。
这样的情况下涉及到多处理器的内存模型。你不能够假设一个处理器对内存所做的
改变可以被另一个处理器立即看到。
另一个与多处理器有关的问题是如何实现""多个线程在到达同一点后再向下执行""的
有效同步。
--------------------------------------
注意-如果同步原语已经被应用于共享的内存,这里讨论的问题将不重要。
--------------------------------------

7.6.1基本建构

如果多个线程对共享存储区的访问使用的是Solaris的线程同步函数,那么程序在
多处理器的环境和单处理器的环境下的运行效果是一样的。
然而,在很多情况下,有些程序员希望更充分地发挥多处理器的优势,希望使用一
些""巧妙""的办法避开线程同步函数。如示例7-5和7-6所示,这样的办法是危险的。
了解一般的多处理器结构支持的内存模型有助于了解这种危险。
主要的多处理器组件是:

处理器本身
CPU缓冲区(Store buffers),它连接处理器和其高速缓存(caches)
高速缓存(caches),保存最近访问和修改过的存储地址
内存(memory),主要存储器,被所有的处理器共享

在简单的传统模型里,多处理器的操作就象是直接与内存打交道:一个处理器A
向一个内存单元写数后,另一个处理器B立刻读取该单元,取出的数一定是处理器A刚
刚写入的。高速缓存可以被用来加快平均的内存访问速度,如果高速缓存之间保持一
致的话,的确可以达到期望的效果。
这种简单方法的一个问题在于,处理器必须有一定的延迟来保证期望的语义效果
实现。许多新的多处理器结构使用各种办法来减少这种延迟,结果不得不改变了内存
模型的语义。在如下的两个例子当中,我们会解释两个这种技术和它们的效果。

7.6.1. 1""共享内存""的多处理器系统

考虑示例7-5的生产者/消费者解决方案。尽管这个程序在现在的SPARC多处理器
系统上是可行的,但它假定了所有的多处理器系统都有高度有序的内存,因而是不
可移植的。

示例7-5 生产者/消费者问题--共享内存的多处理器

char buffer[size];
unsigned int in=0;
unsigned int out=0;
void producer(char item){
do
;/*nothing*/
while
(in - out == BSIZE);
buffer[in%BSIZE] = item;
in++;
}

char consumer(void){
char item;
do
;/*nothing*/
while
(in - out == 0);
item = buffer[out%BSIZE];
out ++;
}

如果这个程序仅有一个生产者和一个消费者,并且运行在一个共享内存的多
处理器系统当中,它似乎是正确的。In和out的差别是缓冲区中的产品数。生产者
等待直到有空位置,消费者等待直到缓冲区中有产品。
对于高度有序的内存(例如,一个处理器对内存的改变立刻对另一个处理器
生效),这种解决是正确的(即使考虑到in和out将最终溢出,程序仍然是正确的,
因为BSIZE比word型数据能表示的最大整数要小)。
共享内存的多处理器系统不一定拥有高度有序的内存。一个处理器对于内存
的改变未必会立刻通知其他处理器。如果一个处理器改变了两处内存,其他处理
器不一定看到两处改变按照预期的顺序发生,因为对内存的改变不是立即执行的。
写操作首先保存在CPU缓冲区里,对高速缓存是不可见的。处理器自己对这些缓冲
数据的维护是可靠的,但它对其它的处理器不可见,所以在数据写到高速缓存之
前,其他处理器认为该写操作没有发生。
Solaris同步原语(见第三章)使用特殊的指令将数据从CPU缓冲区写入高速
缓存。这样,在共享数据的访问前后加上锁的保护,就可以保证内存的一致性。
如果内存的顺序保护非常松散,在示例7-5中,消费者看到in变量被生产者
增加时,也许产品item还没有放入产品缓冲区。这种情况被称为weak ordering
(弱顺序),因为一个处理器的操作在另一个处理器看来是打乱次序的(但内存对
同一个处理器总是保持一致)。解决这个问题的办法是使用互斥锁来强制更新高
速缓存。
现在的处理器趋向于""弱顺序""。因此,程序员必须在操作全局或共享内存时
使用锁。象在示例7-5和7-6中那样,锁是必不可少的。

7.6.1.2彼得森算法(Peterson''s Algorithm)

示例7-6是Peterson''s Algorithm的一个实现,它控制两个线程的互斥。这段
代码试图保证一个时间最多只有一个线程在执行关键代码,进而当一个线程调用
mut_excl()时,它在很""近""的一个时刻进入该关键代码。

假定线程进入关键代码后很快退出。

示例7-6 两个线程的互斥?

Void mut_excl(int me /*0 or 1*/){
Static int loser;
Static int interested[2]={0,0};
Int other;/* local variable */

Other = 1-me;
Interested[me]=1;
Loser=me;
While (loser == me && interested[other]);
/* critical section */
interested[me];
}
这个算法在多处理器有高度有序的内存时,可能是可以正确运行的。
一些多处理器系统,包括一些SPARC系统,都有CPU缓冲区。如果一个线程发出
一条存储指令,数据被放入CPU缓冲。这些数据最终都被送往高速缓存,但不是立刻
(注意高速缓存对其它的处理器来说是可见的,一致的,但数据并非立刻写入高速
缓存)。
如果同时写入多个内存地址,这些改变将按顺序到达高速缓存和内存,但有一
定延迟。拥有这种属性的SPARC多处理器系统被称为全存储顺序
(TSO:Total Store Order)。
如果某个时间一个处理器向A地址写,然后读取B地址,而另一个处理器向B地址
写,然后读取A地址,其预期结果是或者第一个处理器得到B的新值,或者第二个处
理器得到B的新值,或者二者都得到,但不会发生两个处理器都得到旧值的情形。
但是,由于从CPU缓冲区存取的延迟存在,这种不可能的事情就会发生。
在Peterson''s Algorithm算法中可能发生的是:两个线程分别在两个处理器上
运行,数据都保存在CPU缓冲区中,然后读取另外一个,他们看到的都是旧的值(0),
表示另外的线程当前不在关键代码中,所以他们共同进入关键代码(注意,这种问
题在你测试的时候可能不会表示出来,但它是可能发生的)。
如果你用线程同步原语,就可以避免这个问题。同步原语强制地将CPU缓冲区
的数据写入高速缓存。

7.6.1.3在共享内存的并行计算机里并行一个循环

在很多应用程序中,特别是数值计算,算法的某些部分可以并行,而另外一
些部分必须顺序执行(如示例7-7所示)

Code Example7-7多线程合作(Barrier同步)

While(a_great_many_iterations){
Sequential_computation
Parallel_computation
}

例如,你也许通过严格的线形计算获得了一个矩阵,然后对这个矩阵进行并
行计算,再用运行结果创建另外一个矩阵,然后再进行并行计算,等等等等。
此类计算的并行算法的特点是在计算时不需要太多同步,但使用结果时必须保
证结果都已得出。
如果执行并行计算的时间远比创建和同步线程的时间长,那么线程的创建和
同步不成问题。但是如果运算时间不是很长,线程的创建和同步时间就显得非常重要。

7.2小结

这个编程指南包含了线程编程的基本注意事项。参看附录A""示例应用程序"",可
以看到很多讨论过的特点和风格。

推荐读物:
Algorithms for Mutual Exclusion by Michel Raynal (MIT Press, 1986)
Concurrent Programming by Alan Burns & Geoff Davies
(Addison-Wesley, 1993)
Distributed Algorithms and Protocols by Michel Raynal (Wiley, 1988)
Operating System Concepts by Silberschatz, Peterson, & Galvin
(Addison-Wesley, 1991)
Principles of Concurrent Programming by M. Ben-Ari (Prentice-Hall, 1982)