[笔记]深入解析Windows操作系统《三》系统机制(三)

简介: [笔记]深入解析Windows操作系统《三》系统机制(三)

互锁操作

同步机制的最简单形式,莫过于直接依赖硬件.上对于多处理器安全操作整数值的支持,以及对于比较操作的支持。这包括诸如InterlockedIncrement、 InterlockedDecrement、InterlockedExchange和InterlockedCompareExchange等函数。例如,InterlockedDecrement函 数在减操作过程中,利用x86的lock指令前缀(比如lock xadd)来锁住多处理器总线,因此,如果另一个处理器也要修改这一被减内存单元,它就不可能在减操作读取原始值和写入结果值的过程中间修改此内存单元。内核和驱动程序用到了这种最基本的同步形式。在今天的Microsoft编译器套件中,这些函数被称为固有的( intrinsic),因为它们的代码在编译阶段以内联的方式直接被生成,而无需通过一一个函数调用。(很可能是,将参数压到栈中,再调用函数,将参数拷贝到寄存器中,然后将参数出栈,再返回调用者,这一系列过程比这些函数实际要做的工作昂贵得多。)

自旋锁

内核用来实现多处理器互斥的机制称为自旋锁(spinlock)。自旋锁是一个与某个全局数据结构相关联的锁原语,比如图3.25中与自旋锁关联的数据结构是DPC队列。在进入如图中所示的临界区以前,内核必须先获得与被保护的DPC队列相关联的自旋锁。如果自旋锁并非空闲的话,则内核一直尝 试着获取该锁,直到成功为止。自旋锁之所以得名“(自)旋转”,是基于这样的事实:内核(因此也即处理器)一直等待,“旋转”,直到获得锁。

自旋锁如同它们所保护的数据结构-样,也驻留在非换页内存中,且被映射到系统地址空间中。获取和释放-一个自旋锁的代码是用汇编语言来编写的,一方面是为了速度, 另一方面也是要充分发掘底层处理器体系结构所提供的锁机制。在许多体系结构上,自旋锁是通过硬件支持的test-and-set操作来实现的,即在一条原子指令内测试–个锁变量的值并且获得该锁。

在单条指令内测试和获取-一个锁可以避免第二个线程在“第一-个线程测试锁变量的时间点”与“它获得锁的时间点”之间抓取到该锁。而且,前面提到过的lock指令也可以被用在test-and-set操作.上,结果就是组合的lockbts汇编操作,它也锁住了多处理器总线;若不然的话,有可能多个处理器同时原子性地执行该操作(若没有lock,则该操作只能保证在当前处理器上是原子的)。

在Windows中,所有的内核模式自旋锁都有一个与之关联的IRQL,并且它总是在DPC/Dispatch或者更高的级别上。因此,当一个线程试图获得一一个 自旋锁的时候,该处理器上凡是在该自旋锁的IRQL或者更低级别上的所有其他活动都要停止下来。因为线程分发动作也工作在DPC/Dispatch级别上,所以,如果-一个线程持有 -一个自旋锁,则它永远也不会被抢占,因为此级别的IRQL屏蔽了线程分发机制。如果一段代码正在执行-一个受自旋锁保护的临界区,则这种屏蔽能力使得该段代码可以继续执行,从而它可以更快地释放该自旋锁。内核在使用自旋锁的时候非常小心,尽可能地使它在持有一个自旋锁的过程中执行最少数量的指令。任何一个试图要获取自旋锁的处理器本质上处于忙状态,它会无限等待下去,消耗电源(忙等会导致100%的CPU使用率),但又不执行任何实际的工作。

在x86和x64处理器上,一条特殊的pause汇编指令可以被插入到忙等循环中。该指令向处理器提供了一个“线索”,指明它正在处理的循环指令是自旋锁(或类似性质的结构)的获取循环部分。

这条指令提供了三方面的好处:

  • 它可以极大地降低电源消耗,其做法是,总是微微地延迟其核的处理,而不是持续地循环。
  • 在超线程核上,它可以让CPU意识到,正在自旋的逻辑核所要做的“工作”其实没那么重要,因而把更多的CPU时间分配给第二个逻辑核。
  • 因为忙等循环会导致有大量的总线读请求来自于这一正在等待的线程(这些读请求可能会以乱序方式产生),CPU一旦检测到一个写请求(即,当拥有锁的线程释放该锁时),则试图纠正这些内存乱序的情形。因此,一旦自旋锁被释放,CPU对于正在等待的内存读操作重新进行排序,以确保顺序的正确性。这一重排序过程将导致极大的系统性能损失,利用pause指 令可以避免此过程。

内核提供了一组内核函数,包括KeAcquireSpinlock和KeReleaseSpinlock, 从而使得执行体的其他部分也可以使用自旋锁。例如,设备驱动程序为了保证设备寄存器和其他的全局数据结构在同一时刻只能被该设备驱动程序的某一部分(并且只能由一一个处理器)访问,就可以请求自旋锁。自旋锁不是给用户程序使用的一用户程序应该使用下一小节讲述的同步对象。

设备驱动程序也需要考虑来自其关联的中断中对其数据结构的访问,以保护这些数据结构。因为自旋锁API往往只是将IRQL升高至DPC/Dispatch级别,这对于防止来自中断的访问还是不够的。由于这一原因, 内核也导出了KeAcquireInterruptSpinLockKeReleaseInterruptSpinLockAPI,它们携带一个KINTERRUPT对象为参数( 关于KINTERRUPT对象,本章前文已经讨论过)。系统将会在中断对象中,检查与中断相关联的DIRQL,并且将IRQL升高至恰当的级别,以确保对数据结构的访问能够正确地与ISR共享。设备也可以使用KeSynchronizeExecution API,将整个函数与ISR进行同步,而不是仅仅一个临界区。在所有这些情形下,由中断自旋锁保护的代码必须极其快速地执行一-任何延迟都会招致超乎寻常的中断时延,并且带来显著的负面性能影响。

对于使用内核自旋锁的代码,自旋锁也带来了一些限制。因为自旋锁总是有一个DPC/Dispatch或更高级别的IRQL,所以,正如上文所述的,如果一段代码正在持有一个自旋锁,若它企图让调度器执行一个分发操作,或者它引发了一个页面错误,则会导致系统崩溃。

排队的自旋锁

为了提高自旋锁的伸缩性,有一种特殊的自旋锁类型称为排队的自旋锁(queuedspinlock),它们被用于某些非标准自旋锁的场合下。

排队的自旋锁的工作方式如下:

当一个处理器想要获得一个当前已被其他处理器持有的排队的自旋锁时,它把自己的标识符放在与该自旋锁关联的一一个队列中。如果当前正持有该自旋锁的处理器释放了该锁,则它将该锁移交给队列中标识的第一一个处理器。同时,如果一个处理器正在等待一个忙着的自旋锁,则它并不是检查该自旋锁本身的状态,而是检查一个针对每个处理器的标志;在队列中位于该处理器之前的处理器会设置这一标志,以表明该轮到这个正在等待的处理器了。排队的自旋锁的结果是,处理器在这些针对每个处理器的标志上旋转,而不是在全局自旋锁上旋转。这有两种效果。

  • 第一是,多处理器的总线不会因为处理器之间的同步而招致繁重的流量。
  • 第二是,对于一组正在等待获取某个自旋锁的处理器,排队的自旋锁强加了先进先出(FIFO) 的获取顺序,而不是随机选择一个处理器。

FIFO顺序意味着在一组访问同样锁的处理器之间有了更加一致的性能表现。Windows定义了许多全局排队的自旋锁,它在每个处理器的“处理器区域控制块(PRCB)”所包含的一个数组中保存了指向这些全局排队的自旋锁的指针。

只需在调用KeAcquireQueuedSpinlock的时候将一个全局自旋锁的指针在PRCB数组中的索引传递进去,就可以获得对应的全局自旋锁。全局自旋锁的数量随着操作系统的每个发行版本而不断增加,WDK头文件Wdm.h中公开了这些全局自旋锁的索引定义表。然而,要注意,在一个设备驱动程序中获取这些排队的自旋锁是不被支持的,也是应该极力要避免的操作。这些锁是保留给内核自己内部使用的。

实验:查看全局排队自旋锁


栈内排队自旋锁

设备驱动程序可以通过KeAcquireInStackQueuedSpinLockKeReleaselnStackQueuedSpinLock这两个函数来使用动态分配的排队自旋锁。

有几个组件一一包括缓存管理器 、执行体内存池管理器和NTFS——充分使用了这些类型的锁,而并非使用全局的排队自旋锁。

KeAcquireInStackQueuedSpinlock带一个指针参数指向一个自旋锁数据结构,以及一个自旋锁队列句柄。此自旋锁句柄实际上是一个数据结构,内核将有关该锁的状态信息保存在此数据结构中,状态信息包括该锁的所有权和-一个处理器队列(该队列中的每个处理器都可能在等待该锁)。出于这个原因,句柄不应该是一个全局变量,它往往是一个栈变量,这样可保证对于调用者线程的局部性,这种类型自旋锁以及相应API名称中的InStack部分正因此而得来。

执行体的互锁操作

内核提供许多简单的、建立在自旋锁基础之上的同步函数,来支持-些更加高级的操作,比如在单向链表和双向链表中插入和删除元素。这样的例子有,ExInterlockedPopEntryList和ExInterlockedPushEntryList支持单向链表,ExInterlockedInsertHeadList 和ExInterlockedRemoveHeadList支持双向链表。所有这些函数都要求带–个标准的自旋锁作为参数,内核和设备驱动程序中到处使用了这些函数。

这些函数并不是依赖标准的API来获取和释放自旋锁参数,相反地,它们把代码放在内联区域,并且使用一种不同的时序方案。以Ke打 头的自旋锁API首先测试并设置(test-and-set)锁位,以确定该锁是否已被释放,然后原子性地执行一个带lock的test-and-set操作,来实际执行获取操作,与此不同的是,执行体的这些互锁例程将禁止该处理器上的中断,并立即试图执行一个原子性的test-and-set。如果这次尝试失败了,则再允许中断,并继续执行标准的忙等算法,直到test-and-set操 作返回0一-在这种情况 下整个函数又重新执行。由于这些微妙的差异,用于执行体互锁函数的自旋锁不能再用于前面讨论过的标准内核API。自然地,非互锁的链表操作不能与互锁的操作混合在一起。

注 某些特定的执行体互锁操作在可能的情况下实际上只是简单地忽略自旋锁。

例如,ExInterlockedIncrementLong和ExInterlockedCompareExchange AP|实际上与标准的互锁函数和固有函数使用同样的lock前缀。这些函数在老的系统.上(或非x86系统上)是有用的,因为在这些系统上lock操作不适合或根本不能用。由于这个原因,这些调用现在已经不再被鼓励使用,而应该使用固有函数。

低IRQL的同步

在多处理器环境中,内核之外的执行体软件也需要对全局数据结构的访问进行同步。例如,内存管理器只有一一个页帧数据库,所以在访问该页帧数据库时将它当做-一个 全局数据结构,而且,设备驱动程序也需要确保它们在访问设备的时候是独占的。执行体通过调用内核函数,可以创建-一个自旋锁,获取该锁,以及释放该锁。

然而,自旋锁只是部分地满足了执行体对于同步机制的需求。因为在一般情况下, 等待一个自旋锁意味着要使一一个 处理器停止下来,所以,自旋锁只能被用于以下一-些严格受限的场合:

  • 对于受保护的资源,必须快速访问,并且与其他的代码没有复杂的交互关系;
  • 临界区代码的内存页不能被换出去,这些代码不能引用那些可被换页的数据,不能调用外部过程(包括系统服务),也不能产生中断或者异常。

这些约束条件并不是在所有的情况下都能满足的。而且,除了互斥以外,执行体还需要完

成其他类型的同步操作,并且它必须为用户模式提供同步机制。

以下列出一些当自旋锁不适合时可以使用的其他同步机制:

  • 内核分 发器对象( Kernel Dispatcher Objects);
  • 快速互斥体和守护互斥体 (Fast Mutexes and Guarded Mutexes);
  • 推锁 ( Pushlocks);
  • 执行体 资源( Executive Resources)。

此外,在低IRQL上执行的用户模式代码必须具备它自己的锁原语。Windows支持 各种专门用于用户模式的同步语义:

  • 条件变量 (CondVars);
  • Slim读者 -写者锁(SRWs); .
  • 一次运行初始化(InitOnce);
  • 临界区 (critical sections)。

我们在后面将会讨论用户模式语义以及它们的底层内核模式支持;现在我们将注意力集中在内核模式对象上。表3.18可以作为一张参考表,它比较了这些同步机制的能力,以及它们与内核模式APC的交互关系。

内核分发器对象

内核以内核对象的形式,向执行体提供了额外的同步机制,这些内核对象合起来统称为分发器对象( dispatcher object)

那些对于Windows API可见的同步对象,正是从这些内核分发器对象中获得它们的同步能力。每个对WindowsAPI可见的且支持同步的对象都封装了至少一个内核分发器对象。通过WailForSingleObject和WaitForMultipleObjects函数,执行体的同步语义对于Windows程序员是可见的,Windows子系统通过调用类似的、由对象管理器提供的系统服务来实现这两个等待函数。Windows应用程序中的线程可以通过各种对象进行同步,包括Windows进程、线程、事件、信号量、互斥体、可等待的定时器、I/O完成端口、ALPC端口、注册表键,或者文件对象

事实.上,内核暴露的几乎所有对象都可以被用来进行等待。这其中有些对象正是分发器对象,而其他有些则是内含一个分发器对象的更大型对象(比如端口、键或文件)。表3.19显示了正宗的分发器对象,所以,任何其他的Windows API允许等待的对象可能内部包含了这其中某一个分发器对象。

值得提及的另外一种执行 体同步对象称为执行体资源( executive resource)。 执行体资源既提供了独占访问的能力(像- 一个互斥体),也提供了共享读访问的能力(多方共享了对一个.数据结构的只读访问能力)。然而,它们仅仅可用于内核模式的代码,因此通过Windows API是无法访问的。本小节余下的部分介绍了等待分发器对象的实现细节。

等待分发器对象

一个线程可以与一一个分发器对象进行同步,做法是等待该对象的句柄。这样做使得内核

将该线程置于等待状态。在任何给定的时刻,一个同步对象总是处于两种状态之.- -:有信号状态( signaled state),或者无信号状态(nonsignaledstate)。一个线程在它的等待条件被满足以前,不能恢复执行。

如果该线程正在等待一个分发器对象的句柄,而且,该分发器对象经历了一次状态改变(从无信号状态改变成有信号状态,例如,当-一个线程设置了一个事件对象时),那么,该线程等待的条件就会满足。- 一个线程为了与一个对象同步,调用对象管理器提供的几个等待系统服务之一,同时传递给它一- 个对象句柄,该句柄指向它所要同步的对象。该线程可以等待一个或者几个对象,还可以指定:如果在特定长度的时间段以内等待过程还没有结束的话,则取消等待。无论何时当内核将一个对象设置成有信号状态时,内核的某一个信号例程就会进行检查,看是否有任何线程正在等待该对象(而且并不同时还在等待其他对象变成有信号状态)。

如果存在这样的线程,则内核将这些线程中的一一个或者多个从它们的等待状态中释放出来,从而它们可以继续执行。

以下设置事件的例子演示了同步与线程分发是如何相互影响的:

  • 一个用户模式的线程等待一个事件对象的句柄;
  • 内核将该线程的调度状态改变成等待状态,然后将该线程加入到正在等待该事件的线程列表中:
  • 另一个线程设置了该事件;
  • 内核沿着该事件的等待线程列表向前搜索。如果一 个线程的等待条件满足的话(参看下面的注释),内核将该线程从等待状态中解放出来。如果它是一个可变优先级的线程,则内核可能也要提升它的执行优先级。(关于线程调度的更多细节,请参考第5章。)

注 有些线程可 能在等待多个对象,所以它们会继续等待,除非它们指定了WaitAny方式的等待。对于WaitAny这种等待方式,只要有一一个对象(不是全部)变成有信号状态,则正在等待它的线程就可以被唤醒。.

如何让对象有信号

对于不同的对象,有信号状态的定义也有所不同。一个线程对象在它的生命周期中处于无信号状态,当线程终止的时候,它被内核设置为有信号状态。类似地,当一个进程的最后一个线程终止的时候,内核将该进程对象设置为有信号状态。与此不同的是,定时器对象,就像一个闹钟一样,在特定的时候被设置为“响铃”。当它的时间到期时,内核将定时器对象设置为有信号状态。

在选择同步机制时,程序必须考虑到那些“控制各种同步对象的行为”的规则。当一个对象被设置为有信号状态时一个线程的等待是否结束,随着该线程所等待的对象的类型而有所不同,如表3.19所示。

当一个对象被设置成有信号状态时,那些正在等待该对象的线程一般会立即从它们的等待状态中解除出来。–些可导致这种状态改变的内核分发器对象和系统事件如图3.26所示。

例如,通知类型的事件对象( 在Windows API中称为手工重置的事件)被用来宣布某一件事情发生了。当该事件对象被设置为有信号状态时,所有正在等待该事件的线程都被解除。

一个例外是,对于任何同时在等待多个对象的线程,这一条并不成立;这样的线程可能要继续等待,直到其他的对象也变成有信号状态。

与事件对象不同的是,互斥体对象有与之关联的所属权(除非它是在DPC中被获得的)。所属权的用途是,获得对一个资源的互斥访问,即同一时刻只有一个线程可以持有该互斥体。当互斥体对象变成空闲的时候,内核将它设置成有信号状态,然后选择一个正在等待的线程来执行,同时也会继承任何已经适用的优先级提升。(有关优先级提升的更多信息,参考第5章。)内核选中的线程获得该互斥体对象,所有其他的线程继续等待。

互斥体对象也可以被遗弃:这发生在当拥有该互斥体对象的线程终止的时候。当一个线程终止时,内核会枚举该线程拥有的所有互斥体,并且将它们设置为遗弃状态。从信号逻辑的角度,遗弃状态可以被看成是有信号状态,即互斥体的所属权可以被转移到一个正在等待的线程。

这里简短的讨论并不是要列举出使用这些执行体对象的所有理由和应用场合,而只是列出它们的基本功能和同步行为。关于如何在Windows程序中使用这些对象的更多信息,请参见关于同步对象的Windows参考文档,或者Jeffrey Richter和Christophe Nasarre合著的Windows via C/C++书。

数据结构

有三个数据结构对于搞清楚谁正在等待、它们如何等待的、它们正在等待什么,以及整个

等待操作处于什么状态,是至关重要的。

这三个数据结构就是

  • 分发器头
  • 等待块
  • 等待状态寄存器。

前两个结构被公开定义在WDK包含文件Wdm.h中:后一个文件没有被文档化。

分发器头是一个很紧凑的结构,因为它需要在一个固定大小的结构中维护大量的信息。(参见下文的“实验:查看等待队列”部分,可以看到分发器头数据结构的定义。)主要的窍门之一是,在结构中同样的内存位置(偏移)处定义互斥的标志。通过Type域,内核知道这些域中的哪些域是真正适用的。例如,一个互斥体可能已被遗弃,但是一-个定时器可能是绝对或相对的。类似地,一个定时器可以被插入到定时器列表中,但DebugActive域仅对进程才 有意义。另一方面,分发器头确实也包含了对于所有分发器对象都通用的信息:对象类型、信号状态,以及正在等待该对象的线程列表。

等待块结构代表了一个线程正在等待-一个对象。处于等待状态的每个线程都有一个等待块列表,这些等待块代表了该线程正在等待的对象。每个分发器对象都有一个等待块列表,这些等待块代表了哪些线程正在等待该对象。由于分发器对象维护了这一-列表,所以,当一个分发器对象有信号时,内核可以很快地确定谁正在等待该对象。最后,因为在每个CPU上运行的平衡集管理器(关于平衡集管理器的更多信息,参见第5章)需要分析每个线程已经等待的时间(为了决定是否要换出内核栈),所以,每个PRCB都有一个等待线程的列表。

等待块结构中有一个指针指向正在被等待的对象,另一个指针指向正在等待该对象的线程,还有一个指针指向下一个等待块(如果该线程正在等待多个对象的话)。它也记录了等待的类型(等待任-对象,或者,等待所有对象),以及在WaitForuMlipleObjets调用中,调用者线程传递的句柄数组中当前项所在的位置(如果该线程只等待一个对象,则位置为0)。等待类型在等待满足过程中非常重要,因为它决定了该线程在当前对象有信号之后是否所有的等待块都应该被处理:对于“wait any (等待任一对象)", 分发器并不关心其他对象的状态,因为该线程等待的对象中至少有一个(即当前对象)已经有信号了。另一方面,对于“waitall(等待所有对象)”,只有当所有其他的对象也处于有信号状态的情况下,分发器才可以唤醒该线程,这就要求遍历所有的等待块和关联的对象。

等待块也包含一 个易变的等待块状态,它定义了这一等待块在它当前参与的事务型等待操作中的当前状态。表3.20解释了各种不同的状态、它们的含义,以及它们在等待逻辑代码中的影响。

因为在等待操作尚在进行过程中,线程的总体状态(或者在开始等待时要求等待的任何一个对象的总体状态)可以改变(因为并没有阻止另外的线程在其他的逻辑处理器上给这些对象发送信号,或者警醒该线程,甚至向它发送一-个APC), 所以,内核分发器需要为每个正在等待的线程记录下两个额外的数据:该线程当前的细粒度等待状态,以及任何可能修改此等待操作结果的可能状态变化。

当一个线程被指示要求等待一个给定的对象(比如由于WaitForSingleObject调用)时,它首先开始此等待操作,试图进入in-progress等待状态( WaitInProgress)。如果此刻没有尚未完成的警醒操作(根据该等待是否可警醒,以及该等待的当前处理器模式,这决定了警醒操作是否可以抢占该等待),则这一-操作成功。如果有一个警醒操作,则该等待根本就不会进入,调用者会接收到恰当的状态代码;否则的话,该线程现在进入WaitInProcess状态,在这个点上主线程的状态被设置为Waiting,等待理由和等待时间也记录下来,有任何指定的超时也被注册到系统中。

一旦该等待在进行中了,该线程可以根据需要初始化一 些等待块( 并且在进行过程中将它们标记为WaitBlockActive),然后将这次等待中涉及的所有对象锁住。因为每个对象有它自己的锁,所以,很重要的一点是,当多个处理器可能在分析-一个由许多对象构成的等待链(由WaitForMulitpleObjects调用而导致)的时候,内核能够维护-一个-致的锁顺序方案。内核使用一项称为地址排序 (address ordering)的技术来做到这一点: 因为每个对象有一个特有的静态内核模式地址,所以,这些对象可以按照单调递增的地址顺序进行排序,这样可以保证这些锁总是被调用者按照同样的顺序获取和释放。这意味着,调用者提供的对象数组将被复制,并据此而排列顺序。

下一个步骤是检查该等待是否可以立即被满足,比如当一个线程 被告知要等待一一个已经被释放了的互斥体时,或者等待一个已经处于有信号状态的事件时。在这样的情况下,该等待可立即被满足,这将导致从等待链解除相关联的等待块(然而,在这种情况下,尚未有等待块被插入),并执行一个等待退出(继续进行任何在等待状态寄存器中标记的尚未完成的调度器操作)。如果这一快捷路径失败,内核接下来试图检查该等待所指定的超时( 如果有的话)是否已经到期。若确实已到期,则该等待未被“满足”,而仅仅是“超时”,这将导致略微更快地进入退出代码的处理过程,尽管结果是一样的。

如果这些快捷处理都未见效,那么,等待块被插入到线程的等待列表中,现在该线程试图提交其等待。(同时,对象锁已经被释放了,从而允许其他的进程修改“现在该线程应该已经正在等待的任何一个对象”的状态。)假定在非竞争的情形下,即其他的处理器对这个线程或者它所等待的对象毫无兴趣,那么,只要等待状态寄存器没有标记任何尚未完成的改变,该等待就切换到已提交的状态。此提交操作把这一-等待线程链接到PRCB列表中,若有必要的话激活一个额外的等待队列线程,并且插入与等待超时有关的定时器(若有的话)。因为到这个时候,可能已经过去了大量的指令周期,所以,此时又有可能该超时已经到期。在这种情况下,插入定时器可能会导致立即向线程发送信号,因而在该定时器上此等待被满足,从而此等待以超时结束。否则,在更为一般的情形下,现在CPU被切换环境,到下一个已经准备好要执行的线程。(关于线程调度的更多信息,参见第5章。)

在多处理器机器上高度竞争的代码路径上,极有可能正在试图提交其等待的线程在此等待进行过程中已经经历了一次变化。一种可能的情形是,它正在等待的某一个对象刚刚变成了有信号状态。正如前面所提及的,这使得它所关联的等待块进入到WaitBlockBypassStart状态,该线程的等待状态寄存器现在显示了WaitAborted等待状态。另一种可能的情形是,有一个警醒或者APC被发给此等待线程,而该线程并没有设置WaitAborted状态,但置上了等待状态寄存器中对应的那一一位。 因为APC可以打断等待(取决于APC的类型、等待模式,以及是否可警醒),该APC被交付,等待被终止(abort)。其他有一些操作可以修改等待状态寄存器但不会产生一个完全的终止指令,包括修改线程的优先级或亲和性,当线程由于未能提交等待而退出等待的时候(如同前面描述的情形一样)此修改操作将会被处理。

图3.27显示了分发器对象与等待块以及线程、PRCB之间的关系。在这个例子中,CPU0有两个等待线程(已提交):线程1正在等待对象B,线程2正在等待对象A和B。如果对象A变成有信号状态的话,内核将会看到:因为线程2也在等待另一个对象,所以线程2不可能马上准备执行。另一方面,如果对象B变成有信号状态,则内核可以立即让线程1准备执行,因为它并没有在等待任何其他对象。(或者,如果线程1也在等待其他的对象,但是它的等待类型是WaitAny,那么内核仍然可以唤醒它。)

实验:查看等待队列

带键的事件( keyed event)

一种 被称为带键的事件(keyed event)的同步对象值得特别- -提,因为它在用户模式互斥同步语义中有特别的地位。实现带键的事件的最初意图是,帮助进程在使用临界区同步对象时发生的低内存情形,这里临界区是一-种用户模式同步对象,稍后我们将会进-一步讨论。带键的事件并没有被文档化,它使得-一个线程可以指定-一个等待 “键”,当同- -进程中的另一个线程用同样的键使该事件有信号时,等待的线程被唤醒。

如果发生竞争的话,EnterCriticalSection动态地分配- 一个事件对象,因而,想要获取临界区对象的线程等待目前正拥有该临界区对象的线程在LeaveCriticalSection中给它信号。不幸的是,这引入了一个新的问题。若没有带键的事件,系统有可能耗光了内存,从而获取临界区的操作失败,因为系统不能分配所请求的事件对象。这种低内存条件本身也有可能由于应用程序试图获取临界区对象而引发,所以,系统在这种情况下可能会死锁。低内存并不是导致获取临界区操作失败的唯一-情形:另一种可能性较小的情形是句柄被用光。如果-一个进程已经达到了16兆个句柄的极限,则为事件对象分配新句柄就会失败。

由于低内存条件而引起的失败往往是负责获取临界区对象的代码中的一一个异常。不幸的是,结果得到的是一个被损坏的临界区,这使得这种情形很难调试,而且该对象对于重新获取也毫无用处。试图执行LeaveCriticalSection将 导致试图分配另-一个事件对象,又进-一步产生异常,并破坏数据结构。分配一个全局的标准事件对象并不能修复这一-问题,因为标准的事件原语只能被用于单个对象。一个进程中的每个临界区仍然要求它自己的事件对象,所以,同样的问题还会再现。

带键的事件使得多个临界区(等待者)使用同一个全局的带键事件句柄(每个进程-一个)。这使得临界区函数即使在内存暂时很低的情况下也可以正确地工作。当一个线程用信号通知一-个带键的事件,或者在带键的事件上执行等待时,它使用一个称为键(key)的唯一标识符, 这标明了带键事件的一一个实例(将带键的事件与- -个临界区关联起来)。当拥有带键事件的线程释放了该带键事件对象(使它有信号)时,只有一个正在等待该键的线程被唤醒(与同步事件对象而非通知事件对象有同样的行为)。此外,只有当前进

程中的等待者才会被唤醒,所以,这里的键是跨越进程被隔离的,也意味着实际上整个系统只有一一个带键的事件对象。当临界区使用带键的事件时,EnterCriticalSection将 临界区的地址设置为键,再执行等待。

当EnterCriticalSetion调 用NtWaitForKeyedEvent以在带键的事件上执行等待时,它现在只需指定一个NULL句柄参数作为带键的事件,告诉内核它不能创建一一个带键的事件。 内核理解这一行为,它使用一个名为ExpCritSecOutOfMemoryEvent的全局带键事件。这样做主要的好处是,进程不必再为一个命名的带键事件浪费一个句柄,因为内核会跟踪该对象和它的引用。

然而,带键的事件不仅仅是低内存条件的退路对象。当多个等待者在同样的键上等待,都需要被唤醒时,该键实际上会被通知多次,这要求该对象要维护所有的等待者,以便能够为每一一个等待者执行一-次“唤醒”操作(前面提到了,使一个带键的事件变成有信号状态,等同于使一个同步事件变成有信号状态)。然而,- -个线程在通知一一个带键的事件时并无其他线程在等待者列表中。在这种情形下,设置信号状态的线程实际上在等待事件本身。如果没有这样的应变措施,那么,有可能发生这样的情形:用户模式代码看到带键事件是无信号状态,然后试图等待该事件,在此期间,- 一个线程设置该对象的信号状态。用户模式代码的等待有可能在设置线程设置了信号状态之后才到达,这样会导致一次错位的信号匹配,所以等待线程将会死锁。在这种情形下,强制设置线程进行等待,因而只有当有人正在检查带键事件的状态(即等待)的时候,设置线程才真正设置带键事件的信号状态。

注:当带键的事件的等待代码本身需要执行一次等 待时,它用到了内核模式线程对象(ETHREAD)中内置的一个称为KeyedWaitSemaphore的信号量。(该信号量实际上与ALPC的等待信号量共享同-一个位置。)关于线程对象的更多信息,请参考第5章。

然而,在临界区的实现中,带键的事件并不能替代标准的事件对象。最初在Windows XP开发过程中,其理由是,带键的事件在繁重使用的情形下并不能提供良好的伸缩性能。前面提到了,所有讲述的算法都是针对在紧急的、低内存的情形下使用的,此时性能和伸缩性都是不重要的。若替代标准的事件对象,则带键事件尚未实现和处理的一些压力问题也随之而来。主要的性能瓶颈是,带键事件用一个双链表来维护等待者列表。这种链表有很差的遍历速度,即循环链表- -遍所需要的时间。在这种情况下,此时间长度取决于等待者线程的数量。

因为带键的事件对象是全局的,所以,有可能有几十个线程位于等待者列表中,这导致每次一个键被设置或者等待,都需要很长的遍历时间。

尽管链表的头被记录在 带键的事件对象中,不过,这些线程实际上是通过内核模式线程对象

(ETHREAD)中的KeyedWaitChain域(实际上该域与线程的退出时间共享,退出时间域的

类型为L ARGE INTEGER,与双链表的大小相同)链接起来的。关于线程对象的更多信息,请

参考第5章。

Windows改进了带键的事件的性能,它使用一个散列表,而不再使用链表来维护等待者线

程。这一优化使得Windows引入了三种新的轻量级的用户模式同步原语(稍后将会讨论),它

们全都依赖于带键的事件。然而,临界区仍然使用事件对象,主要是为了应用程序兼容性以

及调试的目的,因为事件对象和它的内部机理已经广为人知,并且有很好的文档,而带键的

事件则是不透明的,没有被暴露到Win32 API中。

快速互斥体( fast mutex )和守护互斥体( guarded mutex )

快速互斥体,也称为执行体互斥体,通常比互斥体对象提供了更好的性能,原因是,尽管它们也是建立在分发器事件对象基础之.上的,但只有当快速互斥体有竞争的时候它们才通过分发器对象执行等待,而标准的互斥体总是试图通过分发器来执行获取操作。这使得快速互斥体在一个多处理器环境中具有特别好的性能。快速互斥体广泛应用于设备驱动程序中。

然而,快速互斥体仅适用于当普通的内核模式APC(本章前面已经介绍过)的交付能够被禁止的时候。执行体定义了两个函数来获得快速互斥体: ExAcquireFastMutex 和ExAcquireFastMutexUnsafe.前- -个函数将处理器的IRQL提升到APC级别上,从而阻止所有的APC被交付;后一个函数期望在被调用的时候普通的内核模式APC交付是禁止的,这可以通过提升IRQL至APC级别来做到。ExTryAcquireFastMutex 完成的功能与前-一个函数类似,但如果快速互斥体已经被持有的话,它并不真正执行等待,而是返回FALSE。快速互斥体的另一个局限性是,它们不能被递归获取,而互斥体对象则可以。守护互斥体本质上与快速互斥体是相同的(不过,它在内部使用了不同的同步对象:KGATE)。通过KeAcquireGuardedMutex和KeAcquireGuardedMutexUnSafe函数可以获得守护互斥体,但是,它们并非通过提升IRQL至APC级别来禁止APC,相反,它们通过调用KeEnterGuardedRegion来禁止所有内核模式APC的交付。与快速互斥体类似,也存在一个KeTryAcquireGuardedMutex方法。回忆一下, 守护的区域与临界的区域不同,守护的区域禁止特殊的和普通的内核模式APC,因此,守护互斥体不必提升IRQL。

三个实现上的不同使得守护互斥体比快速互斥体更快:

  • 由于不需要提升IRQL,所以内核可以避免与总线上的每个处理器的本地APIC进行通话,而这恰好是在负担繁重的SMP系统上的关键操作。在单处理器系统上,由于延迟的IRQL计算,这不是一个问题,但是,降低IRQL可能仍然要求访问PIC。
  • 门对象的原语是事件对象的一个优化版本。获取和释放- 一个门对象的代码已经被大力地进行了优化,它没有同步和通知两个版本,并且门对象是一种可让线程等待的互斥对象。门对象甚至有它们自己的分发器锁,而不必获取整个分发器数据库。
  • 在没有竞争的情况下,守护互斥体的获取和释放操作在单个位上,通过一一个原子的
    位测试和重置( bit test-and-reset)操作进行工作,而不像快速互斥体那样通过更加复
    杂的整数操作。

注:快速互斥体的代码也是经过优化的,几乎考虑到了所有这些优化 它使用了同样的原子锁

操作,而且,事件对象实际上是一一个门对象(不过,如果在内核调试器中转储其类型的话,

你将会看到一一个事件对象结构,这实际上只是一个兼容性幌子)。然而,快速互斥体仍然提升

IRQL,而并非使用守护区域。

因为负责禁止特殊内核APC交付(和守护区域功能)的标志直至Windows Server 2003以后才被加入进来,所以,大多数驱动程序并没有充分利用守护互斥体。这样做(指使用守护互斥体)将会引起与以前版本Windows的兼容性问题,要求重新编译–个仅仅使用快速互斥体的驱动程序。然而,在Windows内 部,内核已经将所有使用快速互斥体的地方换成了守护互斥体, .由于两者有相同的语义,所以很容易相互交换。

与守护互斥体相关的另一个问题是内核函数KeAreApesDisabled。在Windows Server 2003以前,该函数指明了普通的APC是否已被禁止,它检查该代码是否运行在一个临界区内部。在Windows Server 2003中,该函数发生了变化,它指明了该代码是否运行在一个临界的或守护的区域内部;其功能也有所改变,如果特殊内核APC也被禁止的话,它也返回TRUE。

由于当特殊内核APC被禁止的时候,有-些特定的操作驱动程序不应该执行,因此,调用KeGetCurrentIrq|来查看一下当前IRQL是否在APC级别是非常有意义的,这是特殊内核APC可能已经被禁止的唯一做法。 然而,因为内存管理器使用了守护互斥体,所以这一检查将会失败,因为守护互斥体并不提升IRQL。因此,驱动程序应该为此目的调用KeAreAllApcsDisabled.该函数检查特殊内核APC是否已经被禁止,以及/或者IRQL是否在APC级别一-这 是可以同时检测守护互斥体和快速互斥体的行之有效的方法。

执行体资源

执行体资源是一种支持共享和独占访问的同步机制;如同快速互斥体一样,在获取执行体资源以前,它们要求普通的内核模式APC交付已被禁止。它们也建立在分发器对象之上,不过,只有当出现竞争的时候才会用到分发器对象。执行体资源也被应用于整个系统之中,特别是在文件系统的驱动程序中,因为这样的驱动程序倾向于有长时间的等待周期,在此期间I/O应该在某种程度上仍然是允许的( 比如读操作)。.

如果一个线程正在等待获得对一个资源的共享访问权,则它等待-一个与该资源相关联的信号量;如果一个线程正在等待获得对一个资源的独占访问权,则它等待一个事件。具有无限计数值的信号量被用于共享的等待者,因为当一个独占持有者通过给信号量发信号来释放一个资源时,这些共享等待者全部可被唤醒,并且被赋予对该资源的访问权。当一个线程在等待独占访问一个资源,而该资源当前正被其他线程拥有的时候,该线程等待-一个同步事件对象,因为当该事件有信号时,只有一个等待者将被唤醒。在前面关于同步事件的章节中,曾经提到过,有些事件的解除等待操作实际上会导致优先级提升:当使用执行体资源时,这种情形就会发生,这正是为什么它们像互斥体对象一样也要跟踪所属权的原因之一-。(关于执行体资源的优先级提升的更多信息,参见第5章。)

鉴于共享和独占访问所提供的灵活性,有以下的-一些函数可被用于获取资源:

ExAcquireResourceSharedLite、ExAcquireResourceExclusiveLite、 ExAcquireSharedStarveExclusive和ExAcquireSharedWaitForExclusive。WDK中有文档介绍这些函数。

实验:列出已获得的执行体资源

相关文章
|
23小时前
|
Linux 网络安全
CentOS系统openssh-9,网络安全大厂面试真题解析大全
CentOS系统openssh-9,网络安全大厂面试真题解析大全
|
2天前
|
安全 Windows
关于 Windows 操作系统的 Recovery 目录
关于 Windows 操作系统的 Recovery 目录
6 0
|
3天前
|
Rust 安全 程序员
使用Rust进行系统编程:安全性优势深度解析
【5月更文挑战第14天】Rust,Mozilla开发的系统编程语言,以其内存安全、并发支持和静态类型系统在系统编程中脱颖而出。所有权和借用检查机制消除内存错误,无锁并发原语提升安全性,静态类型减少运行时错误,最小权限原则降低权限风险。强大的社区支持和安全审计进一步确保了代码的安全性和稳定性,使Rust成为安全高效系统编程的理想选择。
|
3天前
|
机器学习/深度学习 人工智能 算法
构建高效AI系统:深度学习优化技术解析
【5月更文挑战第12天】 随着人工智能技术的飞速发展,深度学习已成为推动创新的核心动力。本文将深入探讨在构建高效AI系统中,如何通过优化算法、调整网络结构及使用新型硬件资源等手段显著提升模型性能。我们将剖析先进的优化策略,如自适应学习率调整、梯度累积技巧以及正则化方法,并讨论其对模型训练稳定性和效率的影响。文中不仅提供理论分析,还结合实例说明如何在实际项目中应用这些优化技术。
|
3天前
|
算法 Linux Windows
FFmpeg开发笔记(十七)Windows环境给FFmpeg集成字幕库libass
在Windows环境下为FFmpeg集成字幕渲染库libass涉及多个步骤,包括安装freetype、libxml2、gperf、fontconfig、fribidi、harfbuzz和libass。每个库的安装都需要下载源码、配置、编译和安装,并更新PKG_CONFIG_PATH环境变量。最后,重新配置并编译FFmpeg以启用libass及相关依赖。完成上述步骤后,通过`ffmpeg -version`确认libass已成功集成。
21 1
FFmpeg开发笔记(十七)Windows环境给FFmpeg集成字幕库libass
|
3天前
|
监控 供应链 数据可视化
深度解析BPM系统:优化业务流程,提升组织效率
本文探讨了业务流程管理系统(BPM)的核心价值和功能,以及低代码如何优化流程管理。BPM通过自动化和标准化流程,提高效率,降低技术复杂性,促进协作和监控。低代码平台加速了开发进程,增强了流程自动化,使得非专业开发者也能构建应用程序。结合低代码,企业能更轻松地适应市场变化,实现流程简化和业务增长。
12 1
|
3天前
|
存储 SQL 自然语言处理
RAG技术全解析:打造下一代智能问答系统
一、RAG简介 大型语言模型(LLM)已经取得了显著的成功,尽管它们仍然面临重大的限制,特别是在特定领域或知识密集型任务中,尤其是在处理超出其训练数据或需要当前信息的查询时,常会产生“幻觉”现象。为了克服这些挑战,检索增强生成(RAG)通过从外部知识库检索相关文档chunk并进行语义相似度计算,增强了LLM的功能。通过引用外部知识,RAG有效地减少了生成事实不正确内容的问题。RAG目前是基于LLM系统中最受欢迎的架构,有许多产品基于RAG构建,使RAG成为推动聊天机器人发展和增强LLM在现实世界应用适用性的关键技术。 二、RAG架构 2.1 RAG实现过程 RAG在问答系统中的一个典型
49 2
|
3天前
|
存储 安全 网络安全
Windows操作系统中:共享文件夹以及防火墙介绍
Windows操作系统中:共享文件夹以及防火墙介绍
|
3天前
|
供应链 监控 安全
全面剖析:新页ERP系统不为人知的一面,以及系统的工作流程解析!
全面剖析:新页ERP系统不为人知的一面,以及系统的工作流程解析!
|
23小时前
|
Linux 网络安全 Windows
网络安全笔记-day8,DHCP部署_dhcp搭建部署,源码解析
网络安全笔记-day8,DHCP部署_dhcp搭建部署,源码解析

推荐镜像

更多