20135210程涵——信息安全系统设计基础第十三周学习总结
第12章 并发编程648
三种基本的构造并发程序的方法:进程、I/O多路复用、线程。
12.1 基于进程的并发编程
基于进程的并发服务器
关于进程的优劣
在父、子进程间共享状态信息,进程有一个非常清晰的模型:共享文件表,但是不共享用户地址空间。进程有独立的地址控件爱你既是优点又是缺点。由于独立的地址空间,所以进程不会覆盖另一个进程的虚拟存储器。但是另一方面进程间通信就比较麻烦,至少开销很高。
12.2 基于i/o多路复用的并发编程
I/O多路复用技术
基本思想:使用select函数,要求内核挂起进程,只有在一个或者多个I/O事件发生后,才将控制返给应用程序。
基于i/o多路复用的并发事件驱动服务器
事件驱动程序:将逻辑流模型化为状态机。
状态机:
- 状态
- 输入事件
- 转移
i/o多路复用技术的优劣
- 事件驱动设计的一个优点是,它比基于进程的设计给了程序员更多的对程序行为的控制。例如我们可以设想编写一个事件驱动的并发服务器,为某些客户提供他们需要的服务,而这对于新进程的并发服务器来说,是很困难的
- 另一个优点是,一个基于I/O多路复用的事件驱动器是运行在单一进程上下文中的,因此每个逻辑流都能访问该进程的全部地址空间。这使得在流之间共享数据变得很容易,一个与作为单个进程运行相关的优点是,你可以利用熟悉的调试工具,例如GDB,来调试你的并发服务器,就像对顺序程序那样。最后,事件驱动设计常常比基于进利的设计要高效得多,因为它们不需要进程上下文切换来调度新的流。
- 事件驱动设计的一个明显的缺点就是编码复杂,我们的事件驱动的并发服务器需要的代度是指每个逻辑流每个时间片执行的指令数量。基于事件的设计的另一个重大缺点是它们不能充分利利用多核处理器。
12.3 基于线程的并发编程
每个线程都有自己的线程上下文,包括一个线程ID、栈、栈指针、程序计数器、通用目的寄存器和条件码。所有的运行在一个进程里的线程共享该进程的整个虚拟地址空间。由于线程运行在单一进程中,因此共享这个进程虚拟地址空间的整个内容,包括它的代码、数据、堆、共享库和打开的文件。
线程执行模型
线程和进程的执行模型有些相似。每个进程的声明周期都是一个线程,我们称之为主线程。
posix线程
POSIX线程是在C程序中处理线程的一个标准接口。Pthreads定义了大约60个函数,允许程序创建、杀死和回收线程,与对等线程安全地共享数据,还可以通知对等线程系统状态的变化。
创建线程
线程通过调用pthread_create函数来创建其他进程。
终止线程
当顶层的线程例程返回时,线程会隐式地终止
- 通过调用pthread_exit函数,线程会显它会等待所有其他对等线程终止,然后再终止式地终止。
某个对等线程调用Unix的e×it函数,该函数终止进程以及所有与该进程相关的线程
回收已终止线程的资源660
分离线程660
初始化线程660
12.4 多线程程序中的共享变量
线程存储器模型
一组并发线程运行在一个进程的上下文中。每个线程都有它自己独立的线程上下文,包括线程ID、栈、栈指针、程序计数器、条件码和通用目的寄存器值。每个线程和其他线程一起共享进程上下文的剩余部分。这包括整个用户虚拟地址空间,它是由只读文本代码、读/写数据、堆以及所有的共享库代码和数据区域组成的。线程也共享同样的打开文件的集合。
从实际操作的角度来说,让一个线程去读或写另一个线程的寄存器值是不可能的。另一方面,任何线程都可以访问共享虚拟存储器的任意位置。如果某个线程修改了一个存储器位置,那么其他每个线程最终都能在它读这个位置时发现这个变化。因此,寄存器是从不共享的,而虚拟存储器总是共享的。
各自独立的线程栈的存储器模型不是那么整齐清楚的。这些栈被保存在虚拟地址空间的栈区域中,并且通常是被相应的线程独立地访问的。我们说通常而不是总是,是因为不同的线程栈是不对其他线程设防的所以,如果个线程以某种方式得到个指向其他线程栈的指慧:那么它就可以读写这个栈的任何部分。
将变量映射到存储器
线程化的C程序中变量根据它们的存储类型被映射到虚拟存储器:
全局变量
全局变量是定义在函数之外的变量,在运行时,虚拟存储器的读/写区域域只包含每个全局变量的一个实例,任何线程都可以引用。
本地自动变量
本地自动变量就是定义在函数内部但是没有static属性的变量,在运行时,每个线程的栈都包含它自己的所有本地自动变量的实例。即使当多个线程执行同一个线程例程时也是如此。
本地静态变量
共享变量
我们说一个变量V是共享的,当且仅当它的一个实例被一个以上的线程引用。
12.5 用信号量同步线程
进度图
进程图将n个并发进程的执行模型化为一条n维笛卡尔空间中的轨迹线。
信号量
信号量s是具有非负整数值的全局变量,只能由两种特殊的操作来处理,这两种操作称为P和V
- P(s):如果s是非零的,那么P将s减1并且立即返回。如果s为零,那么就挂起这个线程,直到s变为非零,而一个y操作会重启这个线程。在重启之后,P操作将s减1并将控制返回给调用者
- V(s):V操作将s加1。如果有任何线程阻塞在P操作等待s变成非零,那么V操作会重启这些线程中的一个,然后该线程将s减1,完成它的P操作,P中的测试和减1操作是不可分割的,也就是说,一旦预测信号量s变为非零,就会将s减1,不能有中断。V中的加1操作也是不可分割的,也就是加载、加和存储信号量的过程中没有中断。注意,V的定义中没有定义等待线程被重新启动的顺序。唯—的要求是V必须只能重启一个正在等待的进程。
使用信号量来实现互斥
信号量提供了一种很方便的方法来确保对共享变量的互斥访问。
基本思想是将每个共享变量(或者一组相关的共享变量)与一个信号量联系起来 。
以这种方式来保护共享变量的信号量叫做二元信号量,因为它的值总是0或者1。
以提供互斥为目的的二元信号量常常也称为互斥锁。在一个互斥锁上执行P操作称为对互斥锁加锁。类似地,执行V操作称为对互斥锁解锁。对一个互斥锁加了锁但是还没有解锁的线程称为占用这个互斥锁。
一个被用作一组可用资源的计数器的信号量称为计数信号量。
关键思想是这种P和V操作的结合创建了一组状态,叫做禁止区。
因为信号量的不变性,没有实际可行的轨迹线能够包含禁止区中的状态。而且,因为禁止区完全包括了不安全区,所以没有实际可行的轨迹线能够接触不安全区的任何部分。
因此,每条实际可行的轨迹线都是安全的,而且不管运行时指令顺序是怎样的,程序都会正确地增加计数器的值。
利用信号量来调度共享资源
信号量有两个作用:
- 实现互斥
- 调度共享资源
基于预线程化的并发服务器
在如图所示的并发服务器中,我们为每一个新客户端创建了一个新线程这种方法的缺点是我们为每一个新客户端创建一个新线程,导致不小的代价。一个基于预线程化的服务器试图通过使用如图所示的生产者-消费者模型来降低这种开销。服务器是由一个主线程和一组工作者线程构成的。主线程不断地接受来自客户端的连接请求,并将得到的连接描述符放在一个不限缓冲区中。每一个工作者线程反复地从共享缓冲区中取出描述符,为客户端服务,然后等待下一个描述符。
12.6 使用线程提高并行性
12.7 其他并发问题
线程安全
我们编程过程中,尽可能编写线程安全函数,即一个函数当且仅当被多个并发线程反复调用时,它会一直产生正确的结果。如果做不到这个条件我们称之为线程不安全函数。
四类线程不安全函数:
●不保护共享变量的函数。解决办法是PV操作。
●保持跨越多个调用的状态函数。比如使用静态变量的函数。解决方法是不要使用静态变量或者使用可读静态变量。
●返回指向静态变量的指针的函数。解决方法是lock-and-copy(枷锁-拷贝)
●调用线程不安全函数的函数
可重入性
有一类重要的线程安全函数,叫做可重入函数。其特点在于他们具有这样一种属性:当它们被多个线程调用时,不会引用任何共享数据。尽管线程安全和可重入有时会(正确地)被用做同义词,但是它们之间还是有清晰的技术差别的,值得留意。图展示了可重入函数、线程安全函数和线程不安全函数之间的集合关系。所有函数的集合被划分成不相交的线程安全和线程不安全函数集合。可重入函数集合是线程安全函数的一个真子集。
可重入函数通常要比不可重入的线程安全的函数高效一些,因为它们不需要同步操作。
在线程化的程序中使用已存在的库函数
竞争
- 当一个程序的正确性依赖于一个线程要在另一个线程到达y点之前到达它的控制流中的X点时,就会发生竞争。通常发生竞争是因为程序员假定线程将按照某种特殊的轨迹正确工作忘记了另一条准则规定:线程化的程序必须对任何可行的轨迹线都正确工作。
死锁
信号量引入了一种潜在的令人厌恶的运行时错误,叫做死锁。它指的是一组线程被阻塞了,等待一个永远也不会为真的条件。
程序员使用P和V操作漏序不当,以至于两个信号量的禁止区域重叠。如果某个执行轨迹线碰巧到达了死锁状态d那么就不可能有进一步的进展了,因为重叠的禁止区域阻塞了每个合法方向上的进展。换句话说,程序死锁是因为每个线程在等待一个根本不可能发生的V操作。
死锁是个相当困难的问题,因为它不总是可预测的。一些幸运的执行轨迹线将绕开死锁区域,而其他的将会陷入这个区域。