第四章分布式系统中的进程和处理机

合集下载
  1. 1、下载文档前请自行甄别文档内容的完整性,平台不提供额外的编辑、内容补充、找答案等附加服务。
  2. 2、"仅部分预览"的文档,不可在线预览部分如存在完整性等问题,可反馈申请退款(可完整预览的文档不适用该条件!)。
  3. 3、如文档侵犯您的权益,请联系客服反馈,我们会尽快为您处理(人工客服工作时间:9:00-18:30)。

第四章 分布式系统中的进程和处理机
前两章,我们已经了解了在分布式系统中通信和同步这两个相关的主题.本章将转到另一个不同的主题:进程。

尽管进程在单机系统中也是重要的概念,本章强调进程管理的特殊部分,它不同于经典操作系统中的内容.特别是在多处理机环境时如何对它进行处理。

在许多分布式系统中,一个进程可能有多个线程,这种能力提供了很多重要的好处,但也带来了各种问题.我们首先研究线程概念,接着研究如何XX 理机和进程,并且介绍几种不同的模型;然后介绍分布式系统中处理机的分配和调度;最后研究两种特殊的分布式系统。

4。

1 线程
在大多数传统的操作系统中,每个进程有一个地址空间和单线程控制。

事实上这几乎已成为进程的定义.然而,很多情况下希望多个线程共享一个地址空间并可并行运行,就好像它们是多个独立的进程.本节将讨论这些内容和它们的含意。

4。

1。

1 线程简介
例如,假设文件服务器有时不得不因等待磁盘的响应而阻塞,如果这个服务器有多个线程,当第一个线程阻塞时第二个线程就可运行,实际结果可能是更高的吞吐量和更好的性能。

不可能创建两个独立的服务进程来达到这个目的,因为它们共享同一缓冲区,要求它们在同一地址空间中。

因而需要一种新的机制,但在单一处理机系统的历史上还未找到一种这样的机制。

图4-1
(a) 三个进程,每一个进程有一个线程
(b) 一个进程有三个线程
图4—1(a)中,可看到一台机器有三个进程,每个进程有它自已的程序计数器、堆找、寄存器和地址空间,这些进程之间互不相干,它们能够通过系统进程间通信原语,如:信号量、管程、消息进行通信。

图4-1(b)我们看到另一台机器,有一个进程,它包含多个线程控制,经常叫线程或有时称轻量级进程。

在许多方面,线程像微小进程,每个线程按顺序执行,并有自已的程序计数器和堆栈来记录运行到什么地方.线程像进程一样共享处理机:首先是一个线程运行,然后是另一个线程运行(分时)。

仅在多处理机时它们才并行运行。

线程能创建子线程也能阻塞以等待系统调用的完成,像通常进程一样,当一个线程被阻塞时,运行同一进程中的另一线程,类似于一个进程阻塞,另一个进程运行一样。

这种情况类似:线程相对于进程,犹如进程相对于机器.
然而,同一进程中的不同线程并不像不同进程之间完全是相互独立的,所有线程有同一地址空间,也就是它们共享全局变量.由于每个线程能存取每个虚拟地址,
每个线程能读计算机计算机
(a)(b)
写,甚至清除另一线程的堆栈,线程之间没有保护。

因为(1)•这不可能保护。

(2)也没有必要。

不像进程间,•它们属于不同的用户,相互排斥,一个进程总是属于一个使用者,用户创造了多线程是为了相互合作,而不是冲突.而且共享一个地址空间,所有线程共享同一批打开文件、子进程、时钟和信号量等。

如图4—2所示。

图4—1(a )•的结构适合于当三个进程互不相干时。

当三个线程是同一任务的一部分且相互紧密合作时,图4-1(b)将是最适合的。

图4-2 每一线程与进程的内容
像传统的进程(如:只有一个线程的进程)一样,线程处于以下几种状态之一:运行、阻塞、就绪或者结束。

正在运行的线程占用CPU,并且是激活的.一个阻塞的线程等待另一正在运行的线程唤醒(例如通过信号量),•调度一个就绪线程运行.最后,一个结束的线程将退出,但它还没有被父线程回收(在UNIX 中,父进程不执行W AI T).
4。

12 线程的用途
线程的引入是为了使并行执行与顺序执行相结合,再考虑文件服务器的例子,一种可能的结构,如图4—3(a )所示:在这里某一线程是派遣者(disp atcher),它从系统邮箱内读出输入请求。

然后检查请求,选择一个空闲的工作者线程去处理它,可能是通过把指向那个请求消息的指针写入到一个与每个线程相关联的一个特殊字中。

然后派遣者唤醒睡眠的工作者。

图4-3 线程与进程的三种组织
(a)派遣者/工作者模式 (b)团队模式 (c) 管道模式
当工作者唤醒后,它检查任何一个线程可访问的共享块缓冲区是否可以满足这个请求.如不能满足,给磁盘发出消息,要求所需的数据块(假设是RE AD),且进入休眠状态等待磁盘操作的完成.现在调用调度程序,开始另一个线程,为了获得更多的工作,此线程可能是派遣者,或者可能是另一个工作者准备运行. 每个线程的项目
程序计数器
堆栈
寄存器组
子线程
状态每个进程的项目 地址空间 全局变量 打开的文件 子进程 计时器
标志
信号量
计算信息
文件服
考虑文件服务器在无多线程的情况下是怎样被写入的。

一种可能性让它作为单独线程执行。

文件服务器的主循环是接收一个请求、检查它,而且在下一个请求到来前完成它。

当文件服务器等待磁盘操作时,它是空闲的且不处理另一请求,如果文件服务器运行于一个专用的机器上(实际中,大多数情况是这样),当文件服务器等待磁盘时,CPU也是空闲的.实际结果是每秒钟可处理的请求大大减少.因而多线程能得到相当好的性能,但每个线程是以平常方式顺序执行的.
到目前为止我们看到两种可能的设计,多线程文件服务器和单线程文件服务器。

假设多线程不可用,但系统设计者又发现由于单线程而引起的性能降低是不可接受的。

那么第三种可能性是把服务器作为大的具有有限状态机运行,当请求到来后有唯一的一个线程检查它,如果缓冲区能满足,进行运行,但是如果不能,就必须向磁盘发送一条消息.
然而,这时文件服务器并不阻塞,而是把当前请求的状态记录在一张表中,然后去获得下一条消息,下条消息可能是请求一个新工作或者是磁盘关于上次操作的应答。

如果是请求一个新工作,就激活它。

如果是从磁盘发来的应答,那么从表中取出相关信息并处理这个应答.由于这里不允许发送消息并且阻塞以等待应答,因此不能使用远程过程调用,原语应是非阻塞调用的send和receive。

在此设计中,前两种情况没有使用“顺序进程"模型,对于表中每条发送和接收的消息运算的状态都必须能清楚明确的保存并恢复.实际上,我们在以一种生硬的方式模拟多线程和这些线程的堆栈.将进程作为一个有限状态机运行,它接收事件后根据事件本身特性响应处理它.
现在应该很清楚多线程能提供什么.他们既保留顺序进程的思想又实现了并行性,阻塞系统调用使编程容易,而并行性提高了性能,单线程服务器保留了阻塞系统的优点,但放弃了性能,有限状态机方法通过并行性取得高性能,但使用非阻塞调用因而编程困难,这些模型在图4—
图4-4 构造服务器的三种方法
图4—3(a)的派遣者结构不是组织多线程进程的唯一方法,图4-3(b)的团队模型也是一种方法。

在这种情况下所有的线程都是平等的,每个都获得和处理自已的请求,这里没有派遣者,有时工作来了线程不能处理,尤其是如果每个线程用来处理一种特殊的工作,这种情况下,可以维护一个作业队列,挂起的作业保持在作业队列中。

使用这种组织结构,线程在查看系统信箱前应先查看作业队列。

多线程也能用如图4-3(c)所示的管道线模型来组织.这种模型中的第一个线程产生一些数据传给下一个线程去处理。

数据持续从一个线程传到另一个线程,经过的每一个线程都进行处理。

尽管这对于文件服务器不适合,但对于其它问题如生产者—消费者问题来说可能是一种好的选择。

管道在计算系统的很多方面得到广泛使用,如从RISC CPU的内部结构到UNIX的命令行。

多线程对客户端来说通常也很有用。

例如,如果一个客户端想把某文件复制在多个服务器上,它可用—个线程与每一个服务器通信。

客户端多线程的另一用处是处理信号。

像来自键盘的中断(DEL或BREAK),不是让这些信号中断进程,而是让一个线程专用于等待这些信号,通常这个线程是被阻塞的.但当信号到来时,唤醒并处理该信号。

因此使用线程能够消除用户层中断的需求。

对多线程的另一讨论是与RPC•或通信无关的。

有些应用使用并行处理很容易编程。

例如生产者-消费者问题,生产者和消费者是否真正的并行是次要的.这样编程是为了使软件设计更简单.由于它们共享缓冲区,让它们处于不同的进程做不到这点,多线程恰好适合这种情况.
最后,尽管在这里没有明确讨论多处理机系统的情况。

但多线程真正可以在同一地址空间的不同CPU中并行运行,实际上,这也就是在那些系统中实现共享的一种主要方法。

另一方面,一个使用多线程的合理设计的程序,它应能在分时使用线程的单CPU•的条件下运行与在一个真正多处理机条件下运行其效果是一样好,所以软件中的问题对两种情况几乎完全相同.
4.1.3线程包的设计问题
与线程相关的用户可得的原语集(即库调用)叫做线程包,本节将考虑与线程包的结构和功能有关的一些问题,下一节将考虑线程包如何实现。

我们首先讨论线程管理.它可从静态多线程和动态多线程两种中选一种,对于静态设计,当程序编写或被编译时就要决定选择多少线程。

每个线程分配一个固定堆栈。

这种方法虽简单,却不灵活。

更普遍的方法是允许线程在运行过程中动态的创建和回收,线程创建调用通常指定线程的主程序(使用指向过程的指针)和堆栈的大小,也可能同时指定其它参数,例如:调度优先级。

线程创建调用通常返回一个线程标识符,用于这个线程以后的调用。

这种模型中进程以一个线程开始运行,但能根据需要创建多个线程,该线程完成后可以退出。

线程有两种方法结束:当一个线程完成它的工作时,可以自己退出;或者被外界中止。

这方面,线程像进程。

在许多情况下,像图4-3的文件服务器,进程启动后线程立即被创建,并从不被中止。

由于线程共享存储器,多个线程能利用这个特性共享数据,像生产者-消费者问题中的缓冲池。

共享数据的存取通常是用临界区方法编程实现,这样做是为了防止多个线程在同一时间存取同一数据。

临界区很容易用信号量、管程和类似的结构来实现.一种在线程包中普遍使用的技术是互斥体(mutex),也是一种消耗信号量(watered-down-s emaphore).互斥体总是处于两种状态:打开和锁住。

互斥体定义了两种操作,一个是加锁操作,如果互斥体处于打开状态,它将仅仅用一个原子操作锁住互斥体。

如果两个线程企图在同一时刻锁住同一互斥体,这仅在多处理机环境中是可能的,在这种环境下不同的线程运行在不同的CPU中,它们中一个成功锁住而另一个失败。

如一个线程要给一个已经锁住的互斥体加锁则它将被阻塞。

开锁操作是打开互斥体.如果一个或多个线程由于互斥体被锁住而等待,实际上它们之中只能有一个被开锁,其余的继续等待。

有时提供另一个操作,试锁(trylock),它尝试锁住互斥体。

如果互斥体是打开的,试锁将返回表示成功的状态标识码。

反之,如互斥体是锁住的,试锁不会阻塞线程,而是返回失败状态的标识码。

互斥体像二进制信号量(例如:仅有0和1二个值的信号量),它们不像计数信号量,用这种方法限制它们使它们更容易实现.
有时,线程包中可用的另一同步特征是条件变量,它类似于在管程中用于同步的条件变量,每一条件变量通常在创建时与一个互斥体相关联。

互斥体与条件变量的区别是互斥体用于短期加锁,以监视进入临界区。

而条件变量是用于长时间等待直到资源可用为止。

下列情况随时可能发生:一个线程锁住互斥体以进入临界区,一旦它进入临界区,就检查系统表并发现它所需的某些资源正处于忙的状态。

如果它简单地锁住第二个互斥体
(与它所需资源相关联),外部互斥体将保持锁状态,而正占用此资源的那个线程就不能进入临界区来释放该锁(与所需资源相关联),结果产生了死锁.打开外部的互斥体让其它线程可进入临界区,将产生混乱,因此这种解决方法是不可行的.
一种是用条件变量获得资源,如图4-5(a)所示。

这里,在条件变量上等待定义为执行等待和打开互斥体.稍后,当占用资源的线程释放资源后,如图4—5(b)所示,它调用w akeu p, w akeu p用于唤醒在特定条件变量上等待的一个或所有的线程。

在图4-5(a)中使用W HILE 而不用IF 是用来防止这种情况的:某线程被唤醒了但其它某个线程在它运行之前先占用了那些资源。

图4—5 互斥变量与条件变量的使用
有能力唤醒所有的线程而不仅仅是一个线程,这种需要在读者-写者问题中已阐释。

当写者完成时,它可以唤醒正在等待的读者或写者,如果选择读者,它应该唤醒所有读者而不是其中的一个。

通过使用仅仅唤醒一个线程原语和唤醒所有线程原语提供了所需的灵活性.
线程的代码通常由多个过程构成。

像一个进程一样,这些过程有局部变量、全局变量和过程参数。

局部变量和参数不会产生任何麻烦。

但相对于线程的全局变量而不是相对于整个程序的全局变量会产生麻烦。

例如:考虑UNIX 系统支持的ERRNO 变量。

当进程(或线程)的系统调用失败后,将错误代码放入 ER RNO中。

在图4—6中,线程1执行系统调用ACCESS(访问)看是否充许存取某文件。

操作系统用全局变量 ER RNO 来应答。

在控制权返回线程1后并在它读取E RRNO 前,调度程序认为线程1已经占用足够的CPU 时间了,且决定调度线程2。

线程2•执行OPEN (打开)调用失败,引起重写ER RNO,使线程1的存取代码永远丢失,当以后再调度线程1时,它将读取错误的值并且执行错误的操作.
对此问题有不同的。

第一种办法是禁止使用全局度量,不管这种思想多么有价值,还是与许多现存的软件相冲突,如UNIX.另一种办法是给每个线程分配它自己的私有全局变量.如图4-7•所示。

这种方法,每个线程有自已私有的 ER RNO 变量的一个拷贝和其它全局变量,因此可以避免冲突。

实际上,这种措施创建了新的辖区级(sc oping le vel ),对于线程中的所有过程该变量都是可见的,这不同于现存的辖区级-变量仅对一个过程可见而不是在程序中任何地方皆可见。

因为大多数的编程语言都能表达全局变量和局部变量,所以存取私有全局变量有些技巧。

但它不能表达中间形式,可以分配一大块内存给全局变量并把它作为一个额外的参数传递给线程中的每一个过程,虽然这不是一个很好的解决方法,但它却有效。

lock m utex;
check data structures;
w hile(resource busy)
w ait(condition variable);
m ark resource as busy;
unlock m utex;
(a)lock m utex; m ark resource as free;unlock m utex;w akeup(condition variable);(b)
图4-6 使用全局变量线程之间的冲突 图4—7 线程私有全局变量
另一替代方法是引入新的库过程来创建、设置和读取这些线程全局变量.第一个调用是:
Creat e_glob al (•“bu fpt r”)
它为“b uf pt r•"指针在堆栈中或在调用线程所保留的特殊的存储空间中分配所需存储空间,无论存储空间在什么地方分配,仅有调用线程能存取该全局变量,如果另一线程创建了同名的全局变量,它将分配不同的存储块,而不会与已存在的冲突。

存取全局变量需要两种调用:读和写.对于写如:
Se t_g lobal (“buf ptr "、&b uf);•它把指针的值存储在前面用C rea te_glob al 调用生成的存储区中。

为了读全局变量调用如:
bu fptr=read_global (“bu fptr ”)
这个调用将返回全局变量中存储的地址值,因此能够读取数据值。

最后讨论线程调度,线程可用不同的调度算法进行调度,包括:优先级法,轮转法等等。

线程包通常提供系统调用使用户能够指定调度算法和设置调度优先级。

4.1.4 实现一个线程包
有两种方法可以实现线程包:在用户空间中和在内核中。

对于这两种方法还存在一些争议,可能会出现一种混合的方法。

本节将讨论这些方法以及它们的优缺点。

在用户空间中实现线程
第一种方法是将线程包完全放到用户空间中去,内核对此一无所知.目前所涉及的内核,它是管理普通的、单线程的进程。

首先且最明显的好处是用户级的线程包能够在不支持线程的操作系统中实现。

例如:UNIX 并不支持线程,但已经有了为它而写的各种各样的用户空间的线程包。

所有这些的实现都有相同的结构,如图4—8(a)所示。

线程运行在运行期系统(runtime syste m)的上层,运行期系统是一些管理线程的过程集,当一个线程执行了一个系统调用时就进入休眠,对信号量或互斥体执行一个操作,或其它什么原因而挂起,它都调用一个运行期系统过程,这个过程检查线程是否必须被挂起。

如果是,它将该线程的寄存器存储到一个表中,然后寻找另一未阻塞的线程运行,将新线程所保存的值重新装入到它自己的机器寄存器中。

一旦切换了堆栈指针和程序计数器的内容,新的线程就自动进入运行状态。

如果机器有一条指令用来保存所有寄存器且有另一条指令用来装入所有寄存器的值时,整个线程切换可用有限的几条指令来完成.用这种方法实现线程切换至少比使用内核陷
线程1
线程
2Extern int errno;
时间errno被覆盖
阱快一个数量级,这也是用户级线程包优点的有力论据。

图4—8 (a) 用户级线程包 (b) 由内核管理的线程包
用户级线程包还有其它好处。

它允许每一个进程有自已定制的调度算法。

对某些应用如:有垃圾收集器线程的应用,不用担心在不合适的时刻被停止,这就是一个好处。

它们的可扩展性也很好,由于内核线程总是需要内核中的许多表和堆栈空间,如果存在大量的线程将产生问题。

尽管它们有较好的性能,但用户级线程包存在一些主要的问题。

首先阻塞调用是怎样实现的。

假设线程从空管道中读数据或其它的一些操作将导致阻塞的操作.让线程发出这样的系统调用是不可行的,因为这样将终止所有线程.引入多线程的主要目的首先是允许每一个线程使用阻塞调用,但又要阻止已阻塞的线程去影响其它的线程。

运用阻塞系统调用,不能实现这个目的。

可以将系统调用全部变为是非阻塞系统调用(例如:读一个空管道将会失败),这对于操作系统的改变是微不足道的.除此之外,对于用户级线程的一个争论恰恰是它可运行在现存操作系统中,另外改变RE AD 的语义将需要修改许多用户程序。

•如果我们能事先确定一个调用是否会引起阻塞,那么有另一种替代方法.在某些 UN IX 版本中,存在一个调用SE LE CT ,它能告诉调用者管道是否为空等等。

当这个调用出现时,库过程RE AD (读)过程可被一个新的过程替换,这个新过程首先调用SELECT;然后如果是安全的(即不阻塞)才执行R EAD 调用。

如果RE AD 调用将要阻塞,就不执行这个调用,而是运行另一线程。

运行期系统再次得到控制,它再次检查REA D调用是否安全,这种需要重写部分系统调用库的方法不仅效率低且不方便,但这几乎无可选择.系统调用外围作检查用的代码称做外壳(Jackc t)。

与阻塞系统调用类似的问题是页面错,如果一个线程产生页面错时,内核甚至并不知道这些线程的存在,自然会阻塞整个进程直到所需要的页面取出为止。

用户级线程包的另一问题是如果一个线程开始运行,进程中的其它线程除非在第一个线程自愿释放C PU 后它才能运行。

在单一进程中,没有时钟中断,也不可能实现轮转法调度,除非一个线程自愿进入运行期系统,否则没有任何机会让调度程序调度它。

同步领域中无时钟中断是致命的。

在分布式中有一种很普遍的现像。

一线程初始化一个动作另一线程必须响应,且连续进行循环检测看响应是否发生.这种条件叫旋转锁住(sp in lo ck )或忙等待。

这种方法在期望得到快速反应时很有用,同时使用同步信号量的花费又很高.如果线程能依靠时钟中断在几个微秒的时间内自动调度,这种方法工作的很
用户
空间
内核
空间用户空间内核空间
(a)(b)线

0线程1线程2线程3线程4线程5线程0线程1线程2线程3线程4线程5
好.然而,如果线程在阻塞前一直运行,这种方法就容易产生死锁。

解决线程一直运行的一个办法是让运行期系统每秒得到一个时钟信号中断,来使它得到控制权,这样会使程序太粗糙和混乱。

更高频率的周期性时钟中断并不总是可能的。

即使可能,总的费用也很大。

而且,线程需要的时钟中断可能与运行期系统使用时钟相互干扰。

另一个也可能是对用户级线程构成最大的威协是程序员通常想在线程经常阻塞的应用中使用多线程,例如多线程文件服务器,这些线程不停地进行系统调用,一旦产生陷阱进入内核执行系统调用,如果旧线程阻塞了,内核要进行线程切换就会很困难,并且这样将让内核要不停的检查系统调用是否安全,对于那些本质上完全受限于CPU和几乎不阻塞的应用程序,有多线程又有什么呢意义?没有人会认真地提出使用多线程来计算前n个质数或用它来下棋.因为这样做什么好处也得不到.
在内核中实现线程
现在让我们假设内核知道并管理线程.运行期系统不再需要,如图4—8(b)所示。

取而代之的是,当一个线程想去创建一个新线程或撤消已存在的线程时,它发出一个内核调用,由它完成创建和回收工作。

为了管理所有线程,在内核中每个进程都有一张表,每个线程在表中有一个入口。

每个入口保存着线程的寄存器、状态、优先权和其它信息.这些信息和用户级线程一样,但它现在是在内核中,而不是在用户空间中。

这些信息类似于传统内核中维护的每一个单线程进程的信息。

所有可能阻塞线程的调用,如像线程间使用信号量同步,都是按系统调用来实现的,但这将比运行期系统过程开销更大。

当一线程阻塞时,内核根据它的选择,可运行同一进程中的其它线程(如果有已准备好的线程),或者另一个进程的线程。

用户级线程中,运行期系统持续运行自已进程中的线程直到内核收回处理机(CPU)为止(或者没有已就绪的线程可运行了)。

由于在内核中创建和撤消线程需更高的花费,所以有些系统采用环境补偿的方法再循环它们的线程。

当线程撤消时,将它标记为不能运行,但它的内核数据结构却不受影响。

稍后当它作为一个新线程创建时,老的线程被重新激活,这样,节省了一些开销。

对用户级线程来说线程再循环也是可能的。

但是,由于线程管理的开销较小,因此没有理由要这样做。

内核线程不需要任何新的非阻塞系统调用,当使用旋转锁(spin lock)时,它们也不导致死锁。

而且,如果一个进程中的线程产生了页面错,内核在等待来至磁盘或网络的所需页面时,能很容易地运行另一线程。

它们的主要缺点是系统调用耗费很大。

因此如果线程操作(创建,删除,同步等)很频繁,将会引起更多的系统开销。

除了用户线程和内核线程所特有的问题外,它们还有一些相同的问题.例如,许多库函数是不可重入的。

例如,可用编程为在网络上发送一条消息,它首先在一个固定的缓冲区中组装要发送的消息,然后利用内核陷阱发送出去。

如果一个线程已经在缓冲区中组装好它要发送的消息,然后一个时钟中断强迫将该线程切换为另一个线程,那个线程又马上用自己的消息重写缓冲区,这时会发生什么情况呢?类似的,当一个系统调用完成后,发生了线程切换,可能改变前一线程读出的错误状态(errno,如前面所讨论的)。

同样,内存分配过程(像UNIX中的malloc),可随意地访问临界表而不影响设置和使用受保护的临界区,因为它们是为单线程环境设计的,而在那种环境下根本没必要。

有效的解决所有这些问题意味着重写整个库.
另一是给每个过程提供一个JACKET,当过程被启动时,它锁住一个全局信号量或互。

相关文档
最新文档