信息安全系统设计基础 第13周学习笔记
第十二章
一、并发编程
1、如果逻辑控制流在时间上重叠,那么它们就是并发的。这种现象,称为并发。
2、为了允许服务器同时为大量客户端服务,比较好的方法是:创建并发服务器,为每个客户端创建各自独立的逻辑流。现代OS提供的常用构造并发的方法有:
进程和线程。
1)每个逻辑流都是一个进程,由内核来调度维护。每个进程都有独立的虚拟地址空间,控制流通过IPC机制来进行通信。
2)线程:运行在单一进程上下文中的逻辑流,由内核进行调度,共享同一进程的虚拟地址空间。
由于进程控制和IPC的开销较高,所以基于进程的设计比基于线程的设计慢。
常见IPC有:管道,FIFO,共享存储器,信号。
3、基于线程的并发编程
线程由内核自动调度,每个线程都有它自己的线程上下文(thread context),包括一个惟一的整数线程ID(Thread ID,TID),栈,栈指针,程序计数器,通用目的寄存器和条件码。每个线程和其他线程一起共享进程上下文的剩余部分,包括整个用户的虚拟地址空间,它是由只读文本(代码),读/写数据,堆以及所有的共享库代码和数据区域组成的,还有,线程也共享同样的打开文件的集合。
1)线程执行模型
线程不像进程那样,不是按照严格的父子层次来组织的。和一个进程相关的线程组成一个对等线程池(a pool of peers),独立于其他线程创建的线程(The threads associated with a process form a pool of peers, independent of which threads were created by which other threads.个人理解,这句是说独立于其他进程的线程池中的线程)。进程中第一个运行的线程称为主线程。对等(线程)池概念的主要影响是,一个线程可以杀死它的任何对等线程,或者等待它的任意对等线程终止;进一步来说,每个对等线程都能读写相同的共享数据。
2)关于posix线程示例,及其相关函数,参见原书13.3.2节中。这部分举的例子很经典,值得一读
3)分离线程
在任何一个时间点上,线程是可结合的(joinable),或者是分离的(detached)。一个可结合的线程能够被其他线程收回其资源和杀死。在被其他线程回收之前,它的存储器资源(如栈)是不释放的。相反,一个分离的线程是不能被其他线程回收或杀死的,它的存储器资源在它终止时由系统自动释放。
二、共享
1、共享变量
1)线程存储模型
线程由内核自动调度,每个线程都有它自己的线程上下文(thread context),包括一个惟一的整数线程ID(Thread ID,TID),栈,栈指针,程序计数器,通用目的寄存器和条件码。每个线程和其他线程一起共享进程上下文的剩余部分,包括整个用户的虚拟地址空间,它是由只读文本(代码),读/写数据,堆以及所有的共享库代码和数据区域组成的,还有,线程也共享同样的打开文件的集合。
寄存器从不共享,而虚拟存储器总是共享。
2)将变量映射到存储器
和全局变量一样,虚拟存储器的读/写区域只包含在程序中声明的每个本地静态变量的一个实例。每个线程的栈都包含它自己的所有本地自动变量的实例。
3)我们说变量v是共享的,当且仅当它的一个实例被一个以上的线程引用。
2、用信号量同步
当对同一共享变量,有多个线程进行更新时,由于每一次更新,对该变量来说,都有“加载到寄存器,更新之,存储写回到存储器”这个过程,多个线程操作时,便会产生错位,混乱的情况,有必要对共享变量作一保护,使这个更新操作具有原子性。
信号量s是具有非页整数值的全局变量,只能由两种特殊的操作来处理,称为P,V操作。
1)基本思想是,将每个共享变量(或者相关共享变量集合)与一个信号量s(初始值1)联系起来,然后用P(s),V(s)操作将相应的临界区(一段代码)包围起来。以这种方法来保护共享变量的信号量叫做二进制信号量(binary semaphore),因为值总是1,0。
2)二进制信号量通常叫做互斥锁,在互斥锁上执行一个P操作叫做加锁,V操作叫做解锁;一个已经对一个互斥锁加锁而还没有解锁的线程被称为占用互斥锁。
3、用信号量来调度共享资源
这种情况下,一个线程用信号量来通知另一个线程,程序状态中的某个条件已经为真了。如生产者-消费者问题。
三、并发问题
1、基于预线程化(prethreading)的并发服务器
常规的并发服务器中,我们为每一个客户端创建一个新线程,代价较大。一个基于预线程化的服务器通过使用“生产者-消费者模型”来试图降低这种开销。
服务器由一个主线程和一组worker线程组成的,主线程不断地接受来自客户端的连接请求,并将得到的连接描述符放在一个共享的缓冲区中。每一个worker线程反复从共享缓冲区中取出描述符,为客户端服务,然后等待下一个描述符。
2、其他并发问题
一个函数被称为线程安全(thread-safe)的,当且仅当多个线程反复地调用时,它会一下产生正确的结果。
下面是四类不安全(相交)的函数:
1)不保护共享变量的函数
利用P,V操作解决这个问题。
2)保持跨越多个调用的状态的函数
srand设置种子,调用rand生成随机数。多线程调用时就出问题了。我们可以重写之解决,使之不再使用任何静态数据,取而代之地依靠调用者在参数中传递状态信息。
3)返回指向静态变量的指针的函数
某些函数(如gethostbyname)将结果放在静态结构中,并返回一个指向这个结构的指针。多线程并发可能引发灾难,因为正在被一个线程使用的结果会被另一个线程悄悄覆盖。
两种方法处理:
一是重写之。使得调用者传递存放结果的结构的地址,这就消除了共享数据。
第二种方法是:使用称为lock-and-copy的技术。在每一个调用位置,对互斥锁加锁,调用线程不安全函数,动态地为结果分配存储器,copy函数返回结果到这个存储器位置,对互斥锁解锁。
4)调用线程不安全函数的函数
f调用g。如果g是2)类函数,则f也是不安全的,只能得写。如果g是1)或3)类函数,则利用互斥锁保护调用位置和任何想得到的共享数据,f仍是线程安全的。
3、可重入性
可重入函数(reenterant function)具有这样的属性:当它们被多个线程调用时,不会引用任何共享数据。
可重入函数通常比不可重入函数高效一些,因为不需要同步操作。
如果所有的函数参数都是传值传递(没有指针),且所有的数据引用都是本地的自动栈变量(没有引用静态或全局变量),则函数是显式可重入的,无论如何调用,都没有问题。
允许显式可重入函数中部分参数用指针传递,则隐式可重入的。在调用线程时小心传递指向非共享数据的指针,它才是可重入。如rand_r。
可重入性同时是调用者和被调用者的属性。
4、C库中常用的线程不安全函数及unix线程安全版本
5、竞争
当一个程序的正确性依赖于一个线程要在另一个线程到达y点之前到达它的控制流中的x点时,就会发生竞争(race)。
6、死锁
信号量引入一个潜在的运行是错误-死锁。死锁是因为每个线程都在等待其他线程运行一个根本不可能发生的V操作。
避免死锁是很困难的。当使用二进制信号量来实现互斥时,可以用如下规则避免:
如果用于程序中每对互斥锁(s,t),每个既包含s也包含t的线程都按照相同顺序同时对它们加锁,则程序是无死锁的。
四、参考资料
五、学习体会
对于进程和线程的并发问题在操作系统课程中就有所涉猎了,所以有些知识现在学起来就不是那么难理解了。不过对于可重入性还是缺少些实例来验证,不是很明白。查阅资料后也只是了解了其原理,并不能很准确地判断哪些函数是可重入的。
满足下面条件之一的多数是不可重入函数:
1.使用了静态数据结构
2.调用了malloc或free
3.调用了标准I/O函数;标准I/O库很多实现都一不可重入的方式使用全局数据结构
4.进行了浮点运算。许多的处理器/编译器中,浮点一般都是不可重入的(浮点运算大多使用协处理器或者软件模拟来实现)
总体上,本周的学习还是有所进展的。