内核再也不能通过简单地禁用中断来解决序列化问题。 注:为了避免产生严重问题,共享数据的程序必须安排好,以对数据进行串行访问,而不是并行访问。在一个程序更新一个共享数据项之前,必须确保没有其它程序(包括它本身在另一个线程里运行的另一副本)会改变该项。通常读操作可以并行地执行。 用来避免程序互相干扰的主要机制是锁。锁是一种抽象概念,它代表对访问一个或多个数据项的许可。锁定和解锁的请求是原子级的;也就是说,它们的实现方式为:其结果既不受中断也不受多处理器访问的影响。所有访问一个共享数据项的程序在处理它之前必须先获得与它相关的锁。如果这个锁已经由另一个程序(或者另一个运行同一程序的线程)占有,则请求的程序必须推迟访问,直到锁变得可用。 除了等待锁所花的时间之外,序列化也增加了一个线程成为不可分派线程所花的时间。当线程不可分派时,其它线程很可能会使这个不可分派线程的高速缓存线路被替换,这将导致线程最后获得锁并被分派时内存等待时间成本增加。 操作系统的内核包含很多共享的数据项,所以它必须在内部进行序列化。因此序列化延迟甚至可能在一个不与其它程序共享数据的应用程序中发生,因为由该程序使用的内核服务必须序列化共享的内核数据。 锁的类型开放软件基金会/1(OSF/1)1.1 的锁定方法被作为一个 AIX 的多处理器锁定功能模型使用。然而,由于系统是可抢占和可调页的,对 OSF/1 1.1 锁定模型增加了一些特征。简单和复杂的锁都是可抢占的。一个线程在尝试获得一个忙状态的简单锁时也可以睡眠,如果锁的所有者当前并不在运行的话。另外,当一个处理器在一个简单锁上自旋一段时间(这段时间是一个全系统的变量)以后,这个简单锁会变成睡眠锁。 锁粒度一个在多处理器环境中工作的程序员必须决定对共享数据一定要创建多少单独的锁。如果只有一个锁来序列化整个共享数据项的集合,则相比之下很可能出现锁争用。广泛使用锁的存在给系统吞吐量加了上限。 如果每一个不同的数据项都有自己的锁,则两个线程争用这个锁的概率相对来说就比较低。然而,每一个附加的锁定和解锁调用都会消耗处理器时间,并且多个锁的存在使得可能发生死锁。最简单的死锁情况如下图所示,其中线程 1 拥有锁 A 并且正在等待锁 B.同时,线程 2 拥有锁 B 并且正在等待锁 A.这两个程序都永远用不上会打破死锁的 unlock() 调用。通常对死锁的预防措施是建立一个协议,根据该协议,所有使用一个指定的锁集合的程序必须始终按照完全相同的顺序获得它们。 根据排队理论,一个资源闲置得越少,要得到它的平均等待时间就越长。这种关系是非线性的;如果锁的个数翻倍,平均等待这个锁的时间就比原来的两倍还要多。 减少对锁的等待时间的最有效方法是减少这个锁所保护的范围大小。下面是一些准则: 减少对任何锁的请求频率。 只锁定访问共享数据的代码,而不是一个组件的所有代码(这将减少锁的持有时间)。 只锁定特定的数据项或结构,而不是整个例程。 始终将锁和特定的数据项或结构关联起来,而不是和例程关联。 对于大的数据结构,为结构的每一元素选择一个锁,而不是为整个结构选择一个锁。 当持有一个锁时,从不执行同步 I/O 或者任何其它阻塞活动。 如果您对您组件中的同一数据有多个访问,请试着把它们移到一起,以便它们可以包含在一个锁定 — 解锁操作中。 避免双唤醒的情况。如果您在一个锁下修改了一些数据,并且不得不通知某人您做了这件事,则在公布唤醒之前请释放该锁。 如果必须同时持有两个锁,则最后请求那个最忙的锁。 另一方面,过细粒度将增加对锁的请求和释放的频率,因而会增加额外的指令。您必须在过细和过粗粒度之间找到平衡。最佳粒度不得不通过试验和错误找到,这也是一个 MP 系统中的最大挑战之一。 锁定开销请求锁,等待锁和释放锁在几方面增加了处理开销: 一个支持多处理的程序总是进行相同的锁定和解锁处理,即使它是在一个单处理器里运行或者是一个多处理器系统里对于这个锁的唯一使用者。 当一个线程请求一个由另一线程持有的锁时,发出请求的线程可能会自旋一会或者置于睡眠状态,如果可能的话,会分派另一个线程。这会消耗处理器时间。 广泛使用锁的存在给系统吞吐量加了一个上限。例如,如果一个给定的程序花 20% 的执行时间来持有一个互斥锁,这个程序最多只有五个实例能同时运行,不管系统里有多少个处理器。事实上,即使只有五个实例,它们也很可能永远不会精确同步,以免互相等待。(参阅「多处理器吞吐量可伸缩性」)。 等待锁当一个线程需要另一个线程已拥有的锁时,该线程被阻塞并且必须等到锁变得可用为止。有两种不同的等待方式: 对于只被持有很短时间的锁来说,自旋锁是很适合的。它允许等待中的线程保持其处理器重复检查某个死循环(自旋)里的锁定位,直到锁变得可用。自旋导致 CPU 时间(内核或内核扩展锁定的时间)增加。 睡眠锁适合于可能会被持有较长时间的锁。线程会睡眠到锁可用为止,当锁变得可用后,它会被放回到运行队列里。睡眠导致更多的闲置时间。 等待总会降低系统性能。如果使用自旋锁,处理器是繁忙的,但是它不是在做有用功(不是在为吞吐量出力)。如果使用睡眠锁,会导致上下文切换和分派的开销以及随之而来的高速缓存未命中的增加。 操作系统开发者们可以在两种类型的锁之间选择:在等待锁变得可用时允许进程自旋和睡眠的互斥简单锁,和在等待锁变得可用时可以自旋和阻塞进程的复杂读写锁。 一些约定管理着使用锁的规则。不管是硬件还是软件都没有实施或校验的机制。尽管使用锁已经使得 AIX V4 是“MP 安全”的,开发者们还是有责任定义和实现一个合适的锁定策略来保护他们自己的全局数据。 高速缓存一致性在设计多处理器时,工程师们对保证高速缓存的一致性给予了相当多的注意。他们取得了成功;但是高速缓存一致性是以性能为代价的。我们需要理解这个遭受攻击的问题: 如果每个处理器都有一个反映内存不同部分状态的高速缓存,就可能会有两个或更多高速缓存拥有相同线路的副本。也有可能是一个给定的线路会包含不止一个可锁定的数据项。如果两个线程对那些数据项作了适当的序列化更改,结果可能是两个高速缓存都以不同的,错误版本的内存线路而告终。换句话说,系统的状态不再一致,因为系统包含了应该是一个特定内存区域的内容的两个不同版本。 对高速缓存一致性问题的解决方案通常包括在线路修改之后,除了一条线路以外,使所有重复线路都失效。尽管硬件使用监视逻辑使线路失效,没有任何软件干预的话,任何高速缓存线路已经失效的处理器由于随之而来的延迟,将会在下一次寻址到该线路时出现高速缓存未命中。 监视是用来解决高速缓存一致性问题的逻辑。处理器中的监视逻辑每次修改了其高速缓存中的一个字后,会在总线上广播一条消息。监视逻辑也在总线上监视,寻找来自其它处理器的这种消息。 当一个处理器检测到另一个处理器已经更改了存在于它本身高速缓存内的一个地址的值时,监视逻辑会使得它自己的高速缓存中的该项失效。这被称为交叉式失效。交叉式失效提醒处理器高速缓存中的值已经无效了,处理器必须在别处(内存或其它高速缓存)寻找正确的值。由于交叉式失效增加了高速缓存未命中率,而监视协议增加了总线流量,因而解决高速缓存的一致性问题会降低所有 SMP 的性能和可伸缩性。 处理器相似性和绑定如果一个线程中断后又重新分派到同一个处理器中,该处理器的高速缓存也许仍含有属于该线程的线路。如果该线程被分派到不同的处理器,它将很可能经历一系列高速缓存未命中,直到它的高速缓存工作集从 RAM 或其它处理器的高速缓存中检索到。另一方面,如果一个可分派的线程必须等到它先前在其中运行的处理器可用,该线程也许会经历一个更长的延迟。 处理器相似性是指将一个线程分派到先前运行它的处理器之上的概率。对处理器相似性的强调程度应随线程的高速缓存工作集大小直接变化,而随自它上一次分派以来的时间长短反向变化。AIX V4 分派器强制对处理器的相似性,因此相似性是由操作系统暗中完成的。 最高程度的处理器相似性是把一个线程绑定到一个特定处理器上。绑定意味着线程将只分派到该处理器,不管其它处理器是否可用。bindprocessor 命令和 bindprocessor() 子例程将一个特定进程的线程绑定到一个特殊的处理器(参阅 bindprocessor 命令)上。显式绑定是通过 fork() 和 exec() 系统调用继承而来的。
|