大多数代码以单线程的方式来编程。如果在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\n",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()来保证线程按你需要的那样共享资源。