并发编程·十三

第13章 并发编程
关键词:信号量,线程安全,PV操作,可重入函数,死锁
如果逻辑控制流在时间上重叠,那么它们就是并发(concurrent)的,这种一般现象,称为并发性(concurrency)。
应用级并行的作用:在多个处理器上并行地计算、访问慢速I/O设备,与人交互、通过推迟工作以减少执行时间、服务多个网络客户端。
现代操作系统提供了三种基本的构造并发程序的方法:进程、I/O多路复用、线程。
进程。用这种方法,每个逻辑控制流都是一个进程,由内核来调度和维护。因为进程有独立的虚拟地址空间,想要和其它流通信,控制流必须使用某种显式的进程间通信(interprocess communication,IPC)机制。
I/O多路复用。在这种形式的并发编程中,应用程序在一个进程的上下文中显式地调度它们自己的逻辑流。逻辑流被模型化为状态机,作为数据到达文件描述符的结果,主程序显式地从一个状态转换到另一个状态,因为程序是一个单独的进程,所有的流都共享同一个地址空间。
线程。线程是运行在一个单一进程上下文中的逻辑流,由内核进行调度。你可以把线程看成是其它两种方式的混合体,像进程流一样由内核进行调度,而像I/O多路复用流一样共享同一个虚拟地址空间。

13.1 基于进程的并发编程

基于构造并发最简单的方法就是用进程,使用那些大家都很熟悉的函数,像fork、exec和waitpid。例如,一个构造并发服务器的自然方法就是,在父进程中接受客户端的连接请求,然后创建一个新的子进程为每个新客户提供服务。
进程的优劣:对于在父、子进程间共享状态信息,进程有一个非常清醒的模型:共享文件表,但是不共享用户地址空间。有独立的进程地址空间既是优点,也是缺点。这样一来,一个进程不可能不小心覆盖另一个进程的虚拟存储器,这就消除了许多令人迷惑的错误——这是一个明显的优点。
另一方面,独立的地址空间使得进程共享状态信息变得更加困难。为了共享信息,它们必须使用显式的IPC(进程间通信)机制。基于进程的设计的另一个缺点是,他们往往比较慢,因为进程控制和IPC的开销很高。

13.2 基于I/O多路复用的并发编程

基于I/O多路复用技术:基本思想就是使用select函数,要求内核挂起进程,只有一个或多个I/O时间发生后,才将控制返回给应用程序。
事件驱动设计的一个优点是,它比基于进程的设计给了程序员更多的对程序行为的控制。另一个优点是,一个基于I/O多路复用的事件驱动服务器是运行在单一进程上下文的,因此每个逻辑流都能访问该进程的全部地址空间。这使得在流之间共享数据变得很容易。最后,事件驱动设计常常比基于进程的设计要明显地高效得多,因为它们不要求有进程上下文切换来调度新的流。
事件驱动设计一个明显的缺点就是编码复杂。
粒度是指每个逻辑流每次时间片执行的指令数目。

13.3 基于线程的并发编程

创建并发逻辑流的方法,第一种,我们为每个流使用了单独的进程。内核会自动调度每个进程。每个进程有它自己的私有地址空间,这使得共享数据很困难。第二种,我们创建自己的逻辑流,并利用I/O多路复用来显式地调度流。因为只有一个进程,所有的流共享整个数据空间。而基于线程,它是这两种方法的混合。
一个线程就是运行在一个进程上下文中的一个逻辑流。线程由内核自动调度。每个线程都有它自己的线程上下文(thread context),包括一个唯一的整数线程ID(thread ID,TID)、栈、栈指针、程序计数器、通用目的寄存器和条件码。所有的运行在一个线程里的线程共享该进程的整个虚拟地址空间。
基于线程的逻辑流结合了基于进程和基于I/O多路复用的留的特性。同进程一样,线程由内核自动调度,并且内核通过一个整数ID来识别线程。同基于I/O多路复用的流一样,多个线程运行在单一进程的上下文中,因此共享这个进程虚拟地址空间的整个内容,包括它的代码、数据、堆、共享库和打开的文件。

13.4 多线程中的共享变量

一组并发线程运行在一个进程的上下文中。每个线程都有它自己独立的线程上下文,包括线程ID、栈、栈指针、程序计数器、条件代码和通用目的寄存器值。每个线程和其他线程一起共享进程上下文的剩余部分。这包括整个用户虚拟地址空间,它是由只读文本(代码)、读/写数据、堆以及所有的共享库代码和数据区域组成的。线程也共享同样的打开文件的集合。
从实际操作的角度来说,让一个线程去读或写另一个线程的寄存器值是不可能的。另一方面,人和现场都可以访问共享虚拟存储器的任意位置。如果某个现场修改了一个存储器位置,那么其他每个线程 最终都能在它读这个位置时发现这个变化。因此,寄存器是从不共享的,而虚拟存储器总是共享的。
多线程的C程序中的变量根据他们的存储类型被映射到虚拟存储器。
1 全局变量和局部变量
全局变量(global variable)。全局变量是定义在函数之外的变量。在运行时,虚拟存储器的读/写区域只包含每个全局变量的一个实例。
本地自动变量(local automatic variable)。本地自动变量就是定义在函数内部但是没有static属性的变量。在运行时,每个线程的栈都包含它自己的所有本地自动变量的实例。即使多线程执行同一个线程例程时,也是如此。
本地静态变量(local static variable)。本地静态变量是定义在函数内部并有static属性的变量。和全局变量一样,虚拟存储器的读/写区域只包含在线程中声明的每个本地静态变量的一个实例。
2 共享变量
我们说一个变量v是共享的,当且仅当它的一个实例被一个以上的线程引用。
共享变量是十分方便,但是它们也引入了同步错误(synchronization error)的可能性。

13.5 用信号量同步线程

1 利用信号量访问共享变量(PV操作)
Edsger Dijkstra,理解和阐明并发编程领域的先锋人物,提出了一种经典的解决同步不同执行线程问题的方法,这种方法是基于一种叫做信号量(semaphore)的特殊类型变量的。信号量s是具有非负整数值的全局变量,只能由两种特殊的操作来处理,这两种操作称为P和V。
P(s):如果s是非零的,那么P将s减1,并且立即返回。如果s为零,那么就挂起进程,直到s变为非零,并且该进程被一个V操作重启。在重启之后,P操作将s减1,完成它的P操作。
S(s):V操作将s加1。如果有任何进程阻塞在P操作等待s变成非零,那么V操作将会重启这些进程中的一个,然后该进程将s减1,完成它的P操作。
P中的测试和减1操作使不可分割的,也就是说,一单预测s变为非零,就会将s减1,不能有中断。V中的加1操作也是不可分割的,也就是加载、加1和存储信号量的过程中没有中断。
名字P和V的起源: Edsger Dijkstra出生于荷兰。名字P和V来源于荷兰语单词Proberen(测试)和Verhogen(增加)。
P和V的定义确保了一个运行程序绝对不可能进入这样一种状态,也就是一个正确初始化了的信号量有一个负值。这个属性成为信号量不变性(semaphore invariant),为控制并发程序的轨线而避免不安全区提供了强有力的工具。
基本的思想是将每个共享变量(或者相关共享变量集合)与一个信号量s(初始为1)联系起来,然后用P(s)和V(s)操作将相应的临界区包围起来。以这种方式来保护共享变量的信号量叫做二值信号量(binary semaphore),因为它的值总是0或者1.
从可操作的意义上来说,由P核V操作创建的禁止区使得在任何时间点上,在被包围的临界区中,不可能有多个线程在执行。换句话说,信号量操作确保了对临界区的互斥访问(mutually exclusive access)。一般现象称为互斥(mutual exclusive)。
一点行话:母的是提供互斥的二值信号量叫做互斥锁(mutex)。在互斥锁上执行一个P操作叫做加锁。相似地,执行V操作叫做解锁。一个已经对互斥锁加锁但没有解锁的线程被称为占用互斥锁(mutual exclusion)。

13.6 其它并发性问题

1 线程安全

当我们用线程编写程序时,我们必须小心地编写那些具有称为线程安全性(thread safety)属性的函数。一个函数被称为线程安全的(thread-safe),当且仅当被多个并发线程反复地调用时,它会一直产生正确的结果。如果一个函数是不安全的,我们说它是线程不安全的(thread-unsafe)。我们能够定义出四类(有相交的)线程不安全函数:
第一类:不保护共享变量的函数。将这类线程不安全函数变成线程安全的,相对而言比较容易:利用像P和V操作这样的同步操作来保护共享的变量。这个方法的优点是在调用程序中不需要做任何修改,缺点是同步操作将减慢程序的执行时间。
第二类:保持跨越多个调用的状态的函数。一个伪随机数生成器是这类线程不安全函数的简单例子。rand函数是线程不安全的,因为当前调用的结果依赖于前次随机调用的中间结果。当我们调用srand为rand设置了一个种子之后,我们反复地从一个单线程调用rand,我们能够预期得到一个可重复的随机数字序列。然而,如果多线程调用rand函数,这种假设就不再成立了。使得rand函数为线程安全的唯一方式是重写它,使得它不再使用任何静态数据,取而代之地依靠调用者在参数中传递状态信息。这样做的缺点是,程序员现在还要被批修改调用中的代码。在一个大的程序中,可能有成百上千个不同的调用位置,这样做的修改将是非常麻烦的,而且还容易出错。
第三类:返回指向静态变量的指针的函数。某些函数将计算结果放在静态结构中,并返回一个指向这个结构的指针,如果我们从并发线程中调用这些函数,那么将可能发生灾难,因为正在被一个线程使用的结果会被另一个线程悄悄地覆盖了。
有两种方法来处理这类线程不安全函数。一种选择是重写函数,使得调用者传递存放结果的结构地址,这就消除了所有共享数据,但是它要求程序员还要改写调用者中的代码。如果线程不安全函数是难以修改或不可能修改的,那么另一种选择就是使用我们成为lock-and-copy(加锁-拷贝)的技术,这个概念将线程不安全函数与互斥锁联系了起来。在每一个调用位置,对互斥锁加锁,调用线程不安全函数,动态的为结果分配存储器,拷贝函数返回的结果到这个存储器位置,然后对互斥锁解锁。一个吸引人的变化是定义了一个线程安全的封装函数,它执行了lock-and-copy,然后通过调用这个封装函数来取代所有对线程不安全函数的调用。 lock-and-copy的缺点是额外的同步降低了程序的速度。
第四类:调用线程不安全函数的的函数。如果函数f调用线程不安全函数g,那么f就是线程不安全的吗?不一定。如果g是2类函数,即依赖于跨越多次调用的状态,那么f也是线程不安全的,而且除了重写g以外,没有什么办法。然而,如果g是一类或者三类函数,那么只要你用一个互斥锁保护调用位置和任何得到的共享数据,f可能仍然是线程安全的。

2 可重入性

有一类重要的线程安全函数,叫做可重入函数(reentrant function),其特点在于它们具有一种属性:当它们被多个线程调用时,不会引用任何共享数据。
所有的函数可以分为线程安全函数和线程不安全函数。 可重入函数集合是线程安全函数的真子集。
可重入函数通常要比不可重入的线程安全的函数高效一些,因为它们不需要同步操作。更进一步来说,将第二类线程不安全函数转化为线程安全函数的唯一方法就是重写它,使之变为可重入的。
检查某个函数的代码并先验地断定它是可重入的,这可能吗?不幸地是,不一定能这样。如果所有的函数参数都是传值传递的(也就是,没有指针),并且所有的数据引用都是本地的自动栈变量(也就是,没有引用静态或全局变量),那么函数就是显式可重入的(explicit renntrant),也就是说,无论它是被如何调用的,我们都可以断言它是可重入的。
然而,如果把我们的假设方宽松一点,允许显式可重入函数中一些参数是引用传递的(也就是说,我们允许它们传递指针),那么我们就得到了一个隐式可重入的(implicity renntrant),也就是说,在调用线程小心地传递指向非共享数据的指针时,它才是可重入的。
我们总是使用属于可重入(renntrant)来包括显式可重入函数和隐 式可重入函数。然而,认识到可重入性有时同时是调用者和被调用者的属性,并不只是被调用者单独的属性,是非常重要的。

3 竞争

当一个程序的正确性依赖于一个线程要在另一个线程到达y点之前到达它的控制流中的点x时,就会发生竞争(race)。通常发生竞争是因为程序员假定线程将按照某种特殊的轨线穿过执行状态空间,忘记了另一条准则规定:多线程必须对任何可行的轨线都正确工作。
为了消除竞争,我们可以动态地位每个证书ID分配一个独立的块,并且传递给线程例程一个指向这个块的指针。线程例程必须释放这些块以避免存储器泄露。

4 死锁

信号量陷入一种潜在的令人厌恶的运行时错误,叫做死锁(deadlock),它指的是一组线程被阻塞了,等待一个永远也不会为真的条件。
死锁的重要信息:
(1)程序员使用P和V操作顺序不当,以致两个信号量的禁止区域(forbidden region)重叠。换句话说,程序死锁是因为每个线程都在等待其它线程执行一个根本不可能发生的V操作。
(2)重叠的禁止区域引起了一组称为死锁区域(deadlock region)的状态。如果一个轨线偶然打到了一个死锁区域中的状态,那么死锁就不可避免了。,轨线可以进入死锁区域,但是它们不可能离开。
(3)死锁是一个相当苦难的问题,因为它总是不可预测的。
你可以使用一些简单而有效的方法规避死锁:
互斥锁加锁顺序规则:如果对于程序中每对互斥锁(s,t),每个既包含s又包含t的线程都按照相同的顺序同时对他们加锁,那么这个线程就是无死锁的。

13.7 小结

一个并发程序是由时间上重叠的一组逻辑流组成的。在这一章中,我们学习了三种不同的构建并发程序的机制:进程、I/O多路复用和线程。
进程时由内核自动调动的,而且因为他们有各自独立的虚拟地址空间,所以要实现共享数据,它们需要显式的IPC机制。事件驱动程序创建它们自己的逻辑流,这些逻辑流被模型化为状态机,用I/O多路复用来显式调度这些流。因为程序运行在一个单一进程中,所以在流之间共享数据速度很快而且很容易。线城市这些方法的综合。同基于进程的流一样,线程是由内核自动调动的,因此可以快速而方便地共享数据。
无论哪种机制,同步对共享数据的并发访问都是一个困难的问题。提出信号量的P和V操作就是为了帮助解决这个问题。信号量操作可以用来体工队共享数据的互斥访问,也对诸如生产者-消费者程序中共享缓冲区这样的资源访问进行调度。一个并发预线程化的echo服务器提供了这两种信号量使用的场景的很好的例子。
并发性也引入了一些其他一些困哪的问题。被线程调用的函数必须具有一种成为线程安全的属性。我们定义了四类线程不安全函数,以及一些将它们变为线程安全的建议。可重入函数是线程安全函数的一个真子集,它不访问任何共享数据。可重入函数通常比不可重入函数更为有效,因为它们不需要任何同步原语。竞争和死锁是并发程序总出现的另外一些困难的问题。当程序员错误地假设逻辑流该如何调度时,就会发生竞争。当一个流等待一个永远不会发生的事件时,就会产生死锁。

参考文献

布赖恩特, O'Hallaron D, et al. 深入理解计算机系统[M]. 中国电力出版社, 2004.
Bryant R, David Richard O H, David Richard O H. Computer systems: a programmer's perspective[M]. Upper Saddle River: Prentice Hall, 2003.
Reek K A. Pointers on C[M]. Addison-Wesley Longman Publishing Co., Inc., 1997.
Koenig A. C traps and pitfalls[M]. Pearson Education India, 1988.
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

ACMSunny

赠人玫瑰,手有余香。

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值