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

本文涉及的产品
公共DNS(含HTTPDNS解析),每月1000万次HTTP解析
全局流量管理 GTM,标准版 1个月
云解析 DNS,旗舰版 1个月
简介: [笔记]深入解析Windows操作系统《三》系统机制(四)

推锁

推锁是另一种建立在门对象基础之上的优化同步机制,如同守护互斥体-样,只有当在一个推锁上存在竞争的时候,它们才会等待-一个门对 象。相比守护互斥体,它们提供的好处是,它们可以按照共享的或者独占的模式来获得。然而,它们的主要优势在于它们的大小:

资源对象是56字节,但推锁是-一个指针的大小。不幸的是,在WDK中推锁并没有被文档化,因此,它们仅被保留给操作系统使用(不过,API函数已被导出,所以内部驱动程序用到了推锁)。有两种类型的推锁:普通的推锁和可感知缓存的推锁。普通的推锁只要求一个指针大小的存储空间( 在32位系统上是4字节,在64位系统上是8字节)。当一个线程获取一个普通的推锁时,如果它当前尚未被占有,则推锁代码将它标记为已被占有。如果该推锁已被独占方式占有,或者该线程希望以独占方式获取该推锁但它却被一组线程以共享方式占有着, 则该线程在自己的栈上分配一个等待块,并且初始化该等待块中的-一个门对象,然后将该等待块加入到与推锁相关联的等待列表中。当- 一个线程释放-一个推锁的时候,如果有等待者的话,则唤醒一个等待者,其做法是,向该等待者的等待块中的门对象发出信号。

因为推锁只是指针的大小,所以,它实际上包含了多个位来描述它的状态。随着一个推锁从竞争状态到非竞争状态的变化,这些位的含义也有所不同。在初始状态下,推锁包含下面的结构:

  • 1个锁位,如果推锁被获取,则置为1;
  • 1个等待位,如果推锁是竞争的,并且有线程正在等待该推锁,则等待位置1; ;
  • 1个唤醒位,如果推锁正在被授予-一个线程,等待者列表需要被优化调整,则唤醒位
    置1;
  • 1个多方共享位,如果推锁已被共享,当前已被多个线程获取,则多方共享位置1;
  • 28个(在32位Windows上)或60个(在64位Windows上)共享计数位,包含了已经获
    取该推锁的线程的个数。

正如前面所讨论,当一个线程要以互斥模式获取一- 个推锁,而该推锁已经被多个读访问线程或一个写访问线程获取时,内核将会分配-一个推锁等待块。推锁值的结构本身也会发生变化。共享计数现在变成了指向该等待块的指针。因为此等待块是在栈上分配的,并且头文件中包含了一个特殊的对齐编译指示符强制等待块必须是16字节对齐的,所以,任何一个推锁等待块结构的最低4位总是0。因此,这些位对于指针引用的用途可被忽略,相反地,上面显示的4位可以跟指针值结合在一起。因为这一-对 齐做法去除了共享计数位,所以,共享计数值现在被保存到等待块中了。

可感知缓存的推锁在普通(基本)推锁的基础上加入了层次,它为系统中的每个处理器分配一个推锁,然后将这些推锁与自己关联起来。当一个线程希望以共享访问模式获取-一个可感知缓存的推锁时,它只是简单地以共享模式获取对应于当前处理器的那个推锁;如果该线程希望以独占访问模式获取-一个可感知缓存的推锁,则它以独占模式获取每–个处理器的推锁。

除了更小的内存印迹,推锁超越执行体资源的一一个大的优势是,在非竞争的情形下,推锁不要求过度的计数和整数操作来执行获取或释放动作。由于推锁与指针–样大小,所以内核可以使用原子的CPU指令来执行这些任务(用到了lock cmpxchg,该指令可以以原子方式来比较并交换新锁和老锁)。如果原子的比较和交换操作失败,则该锁中包含的并非是调用者期望的值(调用者往往期望该锁当前未被使用,或者已被共享模式获取),然后,再调用一个更加复杂的竞争版本。为了进一步挖掘性能潜力,内核将推锁的功能暴露成内联函数,这意味着在非竞争的情形下根本不需要函数调用—直接在每个函数中插入汇编代码。这会略微地增加代码的尺寸,但避免了函数调用带来的延迟。最后,推锁使用了几方面的算法技巧来避免锁封护(lockconvoy,这是指当多个相同优先级的线程都在等待-一个锁,而实际只有极少的的

工作得以完成的一种情形),它们都是自优化的:在一个推锁上等待的线程的列表将会定期地重新组织,以便当推锁被释放时可以提供更为公平的行为。

推锁的使用范围包括对象管理器和内存管理器。在对象管理器中,它们可保护全局的对象管理器数据结构和对象安全描述符;而在内存管理器中,通过可感知缓存的推锁来保护AWE;(Address Windowing Extension)数据结构。

利用驱动程序检验器( Driver Verifier )来检测死锁

死锁是一个同步问题,它源于两个线程或者处理器分别持有另一个想要的资源,并且谁也不会让出自己所属的资源。这种情况可能会导致系统或者进程停止。在本书下册第8章和第9章中将介绍的驱动程序检验器有–个选项可以检查与自旋锁、快速互斥体和互斥体有关的死锁问题。有关何时启用驱动程序检验器来帮助解决系统停止问题的信息,请参见本书下册第14章。

临界区

Windows在内核提供的同步原语的基础上,向用户模式应用程序提供了多种同步原语,临界区(eriticalsetion)正是其中主要的同步原语之–。临界区和稍后我们将要看到的其他用户模式原语与内核中的同步原语相比,一个主要的优势是,当没有锁竞争的时候,它们可以节省下进出内核模式的来回开销( 往往占99%的时间甚至更多)。然而,在竞争情形下,它们仍然要调用内核,因为只有系统代码才能执行复杂的唤醒和分发逻辑,而这对于这些同步对象是必不可少的。

临界区之所以能够保持在用户模式下,是因为它利用了-个局部的位来提供主要的互斥锁逻辑,非常类似于自旋锁。如果该位为0,则该临界区可以被获取,于是所有者将该位设置为1。此操作并不要求调用内核,而是使用了前面讨论过的互锁CPU操作。释放临界区的过程也类似,利用一个互锁操作将该位从1变成0。另一-方面,你可能已经猜到了,当该位已经被置为1,而另一个调用者试图获取该临界区的时候,它必须调用内核,以便将该线程置为等待状态。最后,因为临界区不是内核对象,它们有一些特 定的限制。最主要的限制是,你不可能获得一个指向临界区的内核句柄;同样地,没有安全性,没有名称,对象管理器的其他功能也无法适用于临界区对象。两个进程不可能使用同样的临界区来协调它们的操作,复制和继承特性也不适用于临界区对象。

用户模式资源

用户模式资源也提供了比内核同步原语更为精细的锁机制。–个资源可以被共享或互斥模式获取,从而使它可以作为多个读者(multiple-reader, 共享)单个写者(single-writer) 锁,用于像数据库这样的数据结构。当一个资源被共享模式获取,而其他的线程试图也以共享模式获取该资源时,无需进入到内核,因为这些线程都不需要等待。只有当-一个线程试图以互斥模式获取该资源的时候,或者该资源已经被-一个互斥的所有者锁住的时候,才需要进入内核。

为了使用前面我们已经看到过的内核中的分发和同步机制,资源实际上使用了已有的内核原语。一个资源数据结构(RT_ RESOURCE)实际,上包含了一个内核互斥体和一个内核信号量对象。当该资源被多个线程以互斥方式获取的时候,内核互斥体将会起作用,因为它只允许一个所有者。当该资源被多个线程以共享模式获取的时候,信号量对象就会起作用,因为它允许多个所有者参与计数。这一层细节对于程序员往往是隐藏的,程序员永远也不需要直接使用这些内部对象。

最初实现资源的用途是为了支持SAM (Security Account Manager,安全账户管理器,在第6章中讨论),但并没有通过Windows API暴露给标准的应用程序。稍后要介绍的Slim读写锁(SRW Lock)是在Windows Vista中实现的,并且通过-一个文档化的API暴露 了一个类似的锁原语,不过,有些系统组件仍然使用资源机制。

条件变量

条件变量可以同步一组正在等待某个结果进行条件测试的线程,是Windows提供的一个原生实现。尽管利用其他的用户模式同步方法也有可能实现这样的操作,但是,没有一种原子性的机制可以既检查条件测试的结果,也开始等待该结果上的变化。这要求在实现这种功能的代码片断上使用额外的同步手段。

为了初始化一一个条件变量,用户模式线程调用IitializeConditionVariable来建立起初始的状态。当它想要激发-一个在该变量上的等待动作时,它可以调用SleepConditionVariableCS,该函数使用一个临界区(该线程必须已经初始化此临界区对象了)来等待该变量上的变化。而设置线程在修改了该变量以后,必须使用WakeConditionVariable或者WakeAllConditionVariable(没有自动的检测机制)。此函数调用将释放-一个线程或所有线程的临界区等待,取决于哪个函数被调用。

在引入条件变量以前,常用的做法是,使用通知事件或同步事件(曾经提到过,在WindowsAPI中它们分别被称为自动重置或手工重置, auto-reset或manual-reset)来通知一一个变量的变化,比如一个辅助队列的状态的变化。为了等待一个变化, 要求首先获取一一个临界区, 然后释放该临界区,接着在-一个事件上等待。在等待之后,必须要重新获取该临界区。在这- - 系列的获取和释放过程中,该线程可能有环境切换,如果有线程调用PulseEvent的话则会引发问题(个类似于带键的事件已经解决的问题,即在没有等待者的情况下强制等待信号线程)。利用条件变量,临界区的获取动作可以SleepConditionVariableCS被调用时由应用程序来维护,并且只有当实际工作完成以后,临界区才被释放。这使得编写工作队列的代码(以及类似的实现)更加简单,并且具有可预测性。

在内部,条件变量可以被看作内核模式下已有的推锁算法的一个移植,加上了SleepConditionVariableCS API内部获取和释放临界区的额外复杂性。条件变量也是指针大小(就像推锁),避免使用线程分发器(分发器要求一次环转换,进入到内核模式下,这使得条件变量的优势更为显著),在等待操作过程中自动地优化等待列表,并且保护锁封护(lock convoy)的发生。此外,条件变量充分使用了带键的事件,而不是开发人员他们自己使用的普通事件对象,这使得即使在竞争的情形下也有更优化的性能表现。

Slim读写锁

虽然条件变量是-一种同步机制,但它们并非基本的锁对象。我们已经看到了,它们仍然依赖于临界区锁,其获取和释放操作用到了标准的分发器事件对象,所以仍然要进入到内核模式,并且调用者仍然要初始化大的临界区对象。如果说条件变量与推锁具有足够多的相似之处,那么slim读写锁( SRW Locks, Slim Reader Writer Locks)与推锁几乎是等同的。它们也是指针大小,使用原子操作来实现获取和释放,重新安排等待者列表,保护避免锁封护,可以支持共享模式或互斥模式的获取操作。然而,与推锁还是有一些差异, 包括SRW锁不能被“升级”,或者说不能从共享锁转变成互斥锁,反之也不行。而且,它们不能被递归地获取。最后,SRW锁专用于用户模式代码,而推锁专用于内核模式代码,两者不能共享,或者从一层暴露.给另一层。

在应用程序代码中,SRW锁不仅可以完全地替代临界区,而且还提供了多个读者-单个写者的功能。SRW锁必须首先通过InitializeSRWLock进行初始化,之后可以通过适当的API函数,以共享模式或互斥模式进行获取或释放: AcquireSRWLockExclusive 、ReleaseSRWLockExclusive、AcquireSRWLockShared和ReleaseSRWLockShared.

与大多 数其他的Windows API不同,如果SRW锁不能被获取的话,这些SRW锁函数并不返回一个值一相反地, 它们会产生异常。显然地,若一个获取操作失败了,如果调用代码假定该获取操作成功,则这样的代码会终止,而不会继续执行并且潜在地破坏用户数据。

Windows的SRW锁并不偏向于读者或写者,这意味着,在两种情况下的性能应该是一样的。这也使得它们更适合于替换临界区,因为临界区是仅对于写者的同步机制,或者说互斥的同步机制;相对于资源机制,它们提供了进一步的优化。 如果SRW锁针对读者而优化的话,那么,若它们作为仅用于互斥的锁的话,就会性能很差,但实际情形并非如此。因此,前面讲述的条件变量机制也允许使用SRW锁,来代替临界区,做法是改用SleepConditionVariableSRW API。最后,SRW锁也使用带键的事件,来代替标准的事件对象,所以,结合条件变量和SRW 锁可以在极少进入内核模式的情况下,获得可伸缩的、指针大小的同步机制一一而 在竞争的情形下,已经做了优化,以期使用更少的时间和内存来唤醒等待者和设置状态(因为使用了带键的事件)。

一次运行初始化 ( Run Once Initialization )

让一段负责执行某种初始化任务的代码以原子方式来执行,这种能力是多线程程序设计中的一个典型问题。这样的初始化任务包括申请内存、初始化特定的变量,或者根据需要而创建对象,等等。在一段可以被多个线程并发调用的代码(–个很好的例子是DIIMain例程,它负责初始化DLL)中,有几种方法可以确保初始化任务被正确地、唯一地以原子方式执行。

在这种情形下,Windows实现了一次初始化(init once),或者一次性初始化( one-time initialization,在内部也称为run once initialization,即一次运行初始化)。这一机制既允许一段特定的代码被同步执行(意味着其他线程必须等待初始化完成),也允许被异步执行(意味着其他线程可以试图执行它们自己的初始化进行竞争)。我们先介绍同步机制,然后再看一看异.步执行背后的逻辑。在同步情况下,开发人员通常这样编写代码:在- -个专门的函数中双重检查(double-checking)了一个全局变量以后再执行一段功能代码。此例程需要的任何信息可以通过一- 次初始化例程所接受的parameter变量来传递。任何输出信息则通过context变量来返回( 初始化状态本身被作为-一个布尔值返回)。为了确保执行正确,开发人员所需要做的工作是,在利用InitOnceInitialize API来初始化- -个INIT_ ONCE对象以后,调用InitOnceExecuteOnce,并将

paramenter、context和一 次运行的函数指针传递给它。系统将会处理余下的一切。

对于那些想要使用异步模型的应用程序,其线程调用InitOnceBeinInitialize,接收一个布尔类型的pending status和前面描述的context。如果pending status是FALSE,那么初始化已经发生了,该线程使用context的值作为结果。( 也有可能函数本身返回FALSE,意味着初始化失败了。)然而,如果pending status在返回时为TRUE,那么,该线程现在应该是在竞争第一个创建对象。随后的代码将执行任何初始化任务所需要的事情,比如创建对象或申请内存。当这些工作完成时,该线程调用InitOnceComplete,将当前的执行结果作为context传给它,并接收一个布尔类型的status。如果status是TRUE, 则该线程赢得了竞争,它所创建的对象或者申请的内存应该是全局对象。现在该线程可以保存该对象,或者将该对象返回给调用者,取决于具体的用法。

在一个更加复杂的情形下,当status是FALSE时, 这意味着该线程在竞争中输掉了。现在该线程必须取消(undo) 所有它做过的工作,比如删除对象,或者释放内存,然后再次调用InitOnceBeginInitialize。然而,这一次它不再像前面那样请求发动一次竞争,而是使用INIT_ ONCE CHECK_ _ONLY标志,表明它知道已经输掉了,因而请求赢者的context (例如,赢者所创建或分配的对象或内存)。这次返回另一个status,它可能是TRUE,表明context是有效的,可以被使用或返回给调用者;也可能是FALSE,表明初始化失败了,没有线程能够真正执行初始化工作(比如,可能在低内存条件的情形下)。

在同步和异步两种情形下,一次运行初始化机制与条件变量机制和SRW锁机制非常相似。

一次运行(init once)结构也是指针大小,对于非竞争的情形,使用了SRW获取/释放代码的内联汇编版本;而当竞争发生时(发生在同步模式下使用该机制的时候),使用了带键的事件,其他的线程必须等待初始化。在异步的情形下,锁是以共享模式来使用的,所以多个线程可以同时执行初始化。

3.4 系统辅助线程

在系统初始化的过程中,Windows在System进程中创建了几个线程,这些线程称为系统辅助线程它们的用途只是代表其他的线程来完成一些工作

在许多情况下,在DPC/Dispatch级别上执行的线程需要执行一些只有在更低IRQL级别上才能执行的函数。例如,一个DPC例程在任意线程环境中以DPC/Dispatch级别IRQL在执行(因为DPC的执行可以篡夺系统中的任何线程),它可能需要访问换页内存池,或者等待一个分发器对象以便与一个应用程序线程保持同步。因为DPC例程不能降低IRQL,所以,它必须要将这样的处理过程传递给一个在低于DPC/Dispatch级别的IRQL上执行的线程。

有些设备驱动程序和执行体组件创建了它们自己的线程,由这些线程专门在被动级别上处理一些工作;然而,绝大多数设备驱动程序和执行体组件使用系统辅助线程,从而可以避免在系统中因这些额外线程而招致的不必要的调度和内存开销。执行体组件通过调用执行体函数ExQueueWorkltem或IoQueueWorkltem,可以请求一个系统辅助线程的服务;设备驱动程序只能使用后一个函数(因为这会将工作项目与一个Device对象关联起来,可以允许更好的记录能力,以及处理“当工作项目尚在激活时而驱动程序却要卸载”的情形)。这两个函数把一个工作项目(work item)放在一个队列分发器对象上,系统辅助线程在这个对象上寻找工作来做(有关队列分发器对象的更多细节信息,请参见本书下册第8章中的“IO完成端口”一节)。

loQueueWorkItemEx、loSizeofWorkItem、lolnitializeWorkItem和IoUninitializeWorkIltem这些API函数的工作方式类似,但它们将工作项目与一个驱动程序的Driver对象或其中某一个Device对象建立关联。

工作项目包括一个例程指针以及一个参数,当系统辅助线程处理该工作项目时它会把此参数传递给该例程。该例程是由请求被动级别执行模式的设备驱动程序或者执行体组件实现的。例如,如果一个DPC例程必须等待一个分发器对象,那么它可以初始化一个工作项目,让它指向该驱动程序内部的一个专门等待此分发器对象的例程,可能还指向一个该对象的指针。在某个阶段上,系统辅助线程将该工作项目从它的队列中删除,并执行此驱动程序的例程。

在驱动程序的例程完成以后,系统辅助线程检查一下,看是否还有其他的工作项目要处理。如果没有其他的工作项目了,则该系统辅助线程被阻塞,直到有新的工作项目被放到其队列中。当系统辅助线程处理一个工作项目时,其DPC例程可能已经完成执行了,也可能尚未完成。

系统辅助线程有以下三种类型:

  • 延迟型辅助线程,它们在优先级12上执行,处理一些被认为并非时间紧急的工作项
    目,而且当它们在等待工作项目的时候允许它们的栈页面被换出到页面文件中。对象管理器使用一个延迟型工作项目来执行延迟的对象删除操作,当内核对象被安排了要释放以后该工作项目负责删除这些内核对象。
  • 紧急型辅助线程,它们在优先级13上执行,处理一些时间紧急的工作项目,在Windows Server系统上,它们的栈始终位于物理内存中。
  • 一个超紧急型辅助线程,它在优先级15上执行,其栈总是在内存中。进程管理器( process manager)使用这一超紧急型工作项目来执行线程“回收”功能,即释放已被终止的线程。

通过执行体的ExpWorkerInitialization函数(在系统引导过程的早期被调用)来创建的延迟型和紧急型辅助线程的数目取决于系统中内存的数量,以及该系统是否为服务器系统。表3.22显示了在默认系统配置上创建的线程初始数目。你可以通过注册表HKLMISYSTEM\CurrentControlSet\ControllSession Manager\Executive键下面的 AdditionalDelayedWorkerThreadsAdditionalCriticalWorkerThreads值,来指定ExpInitializeWorker创建至多16个额外的延迟型辅助线程,以及至多16个额外的紧急型辅助线程。

(此处有图)

执行体试图在系统执行过程中,让紧急型辅助线程的数目符合工作负载的变化。每隔一秒钟,执行体函数ExpWorkerThreadBalanceManager确定是否应该创建一个新的紧急型辅助线程。

由ExpWorkerThreadBalanceManager创建的紧急型辅助线程称为动态的辅助线程,在创建这样的辅助线程以前下面的条件必须全部满足:

  • 在紧急工作队列中存在工作项目。
  • 不活动的紧急型辅助线程(指因为等待工作项目而被阻塞的线程,或者在执行一个
    工作例程时被阻塞在分发器对象上的线程)的数目必须少于系统中处理器的数目。口动态辅助线程的数目少于16个。

动态辅助线程在10分钟不活动之后就会退出。因此,当工作负载需要时,执行体可以创建至多16个动态辅助线程。

实验:列出系统辅助线程


3.5 Windows全局标志

Windows有一组全局的标志,保存在一个名为NtGlobalFlag的系统范围的全局变量中,通过它可以打开操作系统内部的调试、跟踪和验证支持。 系统变量NtGlobalFlag是在系统引导时候根据注册表HKL\MISYSTEMI\CurrentControlSet\ControllSession Manager键中的GlobalFlag值来初始化的。

该注册表值的默认值是0,所以很有可能在你的系统上,你没有使用任何全局标志。而且,每个映像也有一组全局标志,它们也能打开内部的跟踪和验证代码(但是,这些标志的位布局完全不同于系统范围的全局标志)。

幸运的是,调试工具箱包含了一个名为Gflags.exe的工具,它使得你可以查看并改变系统全局标志(既可以是注册表中的标志值,也可以是当前正在运行的系统中的标志值)以及映像全局标志

Gflags既有命令行界面,也有GUI界面。为了看清楚命令行标志,你可以输入gflags /P。如果你运行该工具时不加任何开关,则显示出如图3.28所示的对话框。

你可以在“System Registry”页面上配置一个变量在注册表中的设置,在“Kernel Flags”页面上配置一个变量在系统内存中的当前值。

“Image File”页面要求你填写一个可执行映像的文件名称。该选项被用于改变一组仅适用于单个映像(而并非整个系统)的全局标志。请注意,图3.29中的标志不同于图3.28中显示的操作系统标志。

实验:查看和设置NtGlobalFlag

你可以使用!gflag内核调试器命令来查看和设置NtGlobalFlag内核变量的状态。!gflag命令列出所有已被启用的标志。你可以使用!gflag -?来获得所有已支持的全局标志的完整列表。


3.6 高级本地过程调用

所有的现代操作系统都需要一种机制来安全地在用户模式下在一个或多个进程之间传输数据,或者允许内核中的服务与用户模式下的客户之间传输数据。

典型情况下,为了移植性的原因,可以使用诸如邮件槽(mailslot)、文件、命名管道和套接字(socket)这样的UNIX机制,而对于图形应用程序,开发人员往往使用窗口消息。

Windows实现了一种称为高级本地过程调用(advanced local procedure call)ALPC的内部IPC机制这是一种高速的、可伸缩的、安全的消息传递设施,可用于传递任意大小的消息。

尽管ALPC是一种内部机制,因而第三方开发人员无法使用,但是它本身被广泛应用于Windows的各个部分:

  • 使用了远过程调用(RPC,一个已文档化的API)的Windows应用程序,如果它们指定了基于ncalrpc的本地RPC,则会间接地使用ALPC。ncalrpc是一种RPC的形式,用于在同一个系统上的进程之间进行通信。网络栈使用的内核模式RPC也使用了ALPC。
  • 无论何时,当Windows进程和/或线程启动的时候,或者在Windows子系统操作(比如有的控制台I/O)时,也通过ALPC与子系统进程(CSRSS)进行通信。所有的子系统通过ALPC与会话管理器(SMSS)进行通信。
  • Winlogon使用ALPC与本地安全认证服务器进程LSASS进行通信;
  • 安全引用监视器(一个执行体组件,在第6章中介绍)使用ALPC与LSASS进程进行通信。
  • 用户模式电源管理器电源监视器通过ALPC与内核模式电源管理器进行通信,比如当LCD亮度发生改变的时候。
  • Windows错误报告机制使用ALPC来接收崩溃进程的环境信息。
  • 用户模式驱动程序框架(User-Mode Driver Framework,UMDF)允许用户模式驱动程序使用ALPC进行通信。

注: ALPC替代了最初Windows NT内核设计中引入的老式IPC机制(称为LPC),所以,即使在今

天,在特定的变量、域和函数中仍然用“LPC”来引用。记住,为了兼容性的原因,LPC现在是在ALPC上模拟的,它本身已经从内核中移除了(以前的系统调用仍然存在,它们内部包装了ALPC调用)。

连接模型

ALPC通常被用于在一个服务器进程和该服务器的一个或者多个客户进程之间进行通信。既可以在两个或多个用户模式进程之间建立起一个ALPC连接,也可以在一个内核模式组件和一个或多个用户模式进程之间建立起ALPC连接。ALPC导出了一个称为端口对象( port object)的执行体对象,来维护在通信过程中所需要的状态信息。尽管只有一个对象,但实际上可以代表几种ALPC端口。

  • 服务器连接端口,这是一个命名的端口,也是服务器连接请求点。客户通过连接到该端口上,就可以连接至服务器。
  • 服务器通信端口,这是一个未命名的端口,服务器利用该端口与一个特定的客户进行通信。针对每个活动的客户,服务器都有一个这样的端口。
  • 客户通信端口,这是一个未命名的端口,客户线程利用该端口与一个特定的服务器进行通信。
  • 未连接的通信端口,这是一个未命名的端口,客户利用该端口与自身进行本地通信。

ALPC遵从的连接和通信模型多少会让人想起BSD套接字编程模型。服务器首先创建一个服务器连接端口(NtAlpcCreatePort),而客户试图连接到该服务器(NtAlpcConnectPort)。如果服务器正处于监听的状态,它就会接收到一个连接请求消息,于是可以选择接受该请求(NtAlpcAcceptPort)。在这么做的过程中,客户和服务器通信端口都被创建起来,每一个端点进程都接收到一个句柄,指向它的通信端口。然后通过该句柄来发送消息(NtAlpcSendWaitReceiveMessage),通常是在专门的线程中发送的,所以,服务器可以继续在原来的连接端口上监听连接请求(除非该服务器只被设计用于一个客户)。

服务器也具备能力可以拒绝此连接请求,或者出于安全的原因,或者由于协议或版本的问题。因为客户可以在连接请求中发送一段自定义的负荷数据,所以,很多服务常常利用这一点来确保只有正确的客户,或者只有一个客户在与服务器通话。如果发现了任何异常行为或情形,服务器可以拒绝该连接请求,甚至可以有选择地返回一段负荷数据,其中包含了为什么此客户被拒绝的信息(这使得客户可以采取正确的行动,或者出于调试的目的)。

一旦连接已建立起来,有一个连接信息结构(实际上是一个blob,稍后将会讲述)保存了所有不同端口之间的连接关系,如图3.30所示。

消息模型

通过ALPC,客户和使用阻塞消息的线程,每一方依次执行一个循环,来调用NtAlpcSendWaitReplyPort系统调用;在该系统调用中,一方发送一个请求,并等待应答,另一方则正好相反。然而,因为ALPC支持异步消息,所以任何一方都可以不阻塞,而是执行其他的运行时任务,以后再来检查消息(稍后将会讲述这样一些方法)。ALPC支持以下三种在所发送的消息中交换负荷的方法:

一个消息可以通过标准的双缓冲机制被发送至另一个进程。

在这种机制中,内核维护了该消息的一份拷贝(从源进程拷贝该消息),然后切换到目标进程,再从内核的缓冲区中拷贝消息数据。由于兼容性的原因,若使用了老式的LPC,则只有不超过256字节的消息可以用这种方式来发送;而ALPC有能力为不超过64KB的消息分配一个扩展的缓冲区。

可以把消息存放在一个ALPC内存区对象中,客户和服务器进程都映射该内存区对象的视图。(关于内存区映射的更多信息,参见本书下册第10章。)

消息可以存放在一个消息区(message zone)中。消息区是一个内存描述符列表(MDL),它代表了包含消息数据的物理页面,可以被映射到内核的地址空间中。

这种发送异步消息能力的一个重要额外效应是,消息可以被取消—一例如,当一个请求花了太长时间,或者用户指示她想要取消ALPC所实现的操作时。ALPC通过NtAlpcCancelMessage系统调用来支持这一行为。

一个ALPC消息可以位于ALPC端口对象所实现的四种不同队列之一:

  • 主队列(main queue),消息已经被发送,客户正在处理该消息。
  • 待处理队列(pending queue),消息已经被发送,调用者正在等待应答,但是应答尚未被发出。
  • 大消息队列(large message queue),消息已经被发送,但是调用者的缓冲区太小因而不能接收该消息。调用者获得另一次机会来申请一个更大的缓冲区,并再次请求该消息的负荷数据。
  • 已取消的队列( canceled queue),原本发送给该端口对象的消息,但是此后已被取消。
  • 注意,还有第五个队列,称为等待队列( wait queue),它并没有把消息链接起来,相反,它把所有正在等待某个消息的线程链接起来了。

实验:查看子系统ALPC端口对象


异步操作

ALPC的同步模型与早期NT设计中最初的LPC架构紧密关联,也类似于其他的阻塞类型的IPC机制,比如Mach端口。虽然阻塞的IPC算法设计起来非常简单,但是这样的算法包含各种死锁的可能性,而解决这些死锁场景需要引入复杂的代码,这些代码要求支持一种更加灵活的异步(非阻塞)模型。同样地,ALPC最初设计的目的是为了支持异步的操作,这是可伸缩的RPC和其他用途的一个需求,例如在用户模式驱动程序中支持尚未完成的IO( pending I/O)。ALPC的一个基本特性是,阻塞的调用可以有一个超时参数。这一特性在以前的LPC中是不存在的。它使得以前遗留的应用程序避免某些特定的死锁情形。

然而,ALPC专门为异步消息做了优化,针对异步通知提供了三种不同的模型。第一种模型并不真正通知客户或服务器,而只是简单地拷贝了有效的数据负荷。在这种模型下,由实现者来选择可靠的同步方法。例如,客户和服务器可以共享一个通知事件对象,或者,客户可以主动查询数据是否到达。这种模型使用的数据结构是ALPC完成列表(ALPC completion list,注意,不要与Windows lO完成端口混淆)。ALPC完成列表是一个非常高效、非阻塞的数据结构,它允许在客户之间以原子方式传递数据,其内部机理将在后面的“性能”小节中进一步讲述。

第二种通知模型是一种等待模型,用到了Windows完成端口机制(在ALPC完成列表基础之上)。这使得一个线程可以一次获取多个有效负荷、可以控制并发请求的最大数量,以及充分利用原生的完成端口功能。用户模式线程池(将在本章后面讲述)的具体实现提供了内部的API,进程通过这些API可以在与辅助线程同样的设施内部管理ALPC消息(辅助线程也是用这种模型来实现的)。Windows中的RPC系统当使用本地RPC(通过ncalrpc)的时候,也利用了这一内核支持来提供高效的消息投递能力

最后,因为驱动程序也可以使用异步ALPC,但是通常并不在这样的高层上支持完成端口,因此,ALPC也提供了一种机制,通过使用执行体的回调对象来提供一种更加基础、基于内核的通知。驱动程序可以利用NtAlpcSetInformation来注册其回调环境,之后,当接收到一个消息时,它就会被调用到。例如,在内核中针对用户模式提供的电源管理器接口使用了这种机制来实现笔记本电脑的异步LCD背光操作。

视图、区域和内存区

服务器和客户不再相互之间发送消息缓冲区,而是选择一种更加高效的数据传递机制,该机制也正好位于Windows内存管理器的核心,即内存区对象( section object)。(更多的信息,参见本书下册第10章。)

这允许一块内存被分配成共享的,客户和服务器对这一内存有一个一致的、等同的视图。

在这种情况下,在这一内存中能容纳多少数据,就可以传输多少数据;数据只要被拷贝到这一地址范围中,另一方立即就可以使用这些数据了。不幸的是,像传统LPC提供的共享内存通信也有一些缺点,尤其是当考虑到安全性的时候。

其中一个缺点是,因为客户和服务器必须都能访问这一共享内存,所以,非特权客户可以利用这一点来破坏服务器的共享内存,甚至构建出可执行的负荷数据来发掘潜在的软件漏洞。而且,因为客户知道服务器的数据的位置,所以,它可以利用这一信息来绕过ASLR保护措施。(更多信息,参见本书下册第8章。)

ALPC在内存区对象提供的安全性基础之上又提供了它自己的安全性。利用ALPC,必须通过正确的NtAlpcCreatePortSection API来创建一个特定的ALPC内存区对象,该API将建立起对端口的正确引用,以及允许自动的内存区垃圾回收。(也存在一个手工API用于删除)。随着ALPC内存区对象的所有者开始使用这一内存区,就会逐渐分配出相应的内存块(chunk),称为ALPC区域(ALPC region),它们代表了在该内存区内部已经被使用的地址范围,并且也加上了对该消息的一个额外引用。最后,在共享内存的范围内,所有客户获得该内存的视图,这些视图代表了在它们的地址空间内部的本地映射。

ALPC区域也支持一组安全选项。首先,既可以通过安全模式,也可以通过非安全模式来映射区域。在安全模式下,只有两个视图被允许映射到一个区域。这种模式通常被用于当服务器想要与单个客户进程私有地共享数据时的情形下。而且,对于共享内存中给定的地址范围,在给定的端口环境中只能打开一个区域。最后,ALPC区域也可以被标记为写-访问(write-access)保护,这就使得只有一个进程环境(即服务器)可以对该视图进行写访问(利用MmSecureVirtualMemoryAgainstWrites)。与此同时,其他的客户将只能进行读访问。这些设置可以缓解许多发生在共享内存攻击上的特权提升( privilege-escalation)攻击,它们也使得ALPC比传统的IPC机制更有恢复能力。

属性

ALPC比简单的消息传递提供了更多的功能:它也允许在每个消息上加上特定的与环境有关的信息,可以让内核跟踪此信息的有效性、生命期和具体实现。ALPC的用户也可以指定它们自己的环境信息。无论是系统管理的信息,还是用户管理的信息,ALPC都把这些数据称为属性( attribute)。内核管理的属性有三种:

  • 安全属性,其中包含一些关键信息,允许客户模仿以及高级的ALPC安全功能(稍后
    进一步介绍)。
  • 数据视图属性,负责管理与一个ALPC内存区的区域相关联的不同视图。
  • 句柄属性,其中包含了与该消息关联的那些句柄的信息(更多细节,在稍后的“安全性”小节中介绍)。

通常,这些属性是在最初当消息被发送的时候由服务器或客户传递进来,然后被转换成内核自己的ALPC内部表示。如果ALPC用户要求传回这一数据,那么传回的数据会被安全地送回来。ALPC通过实现这种模型,并且将它与自己的内部句柄表结合起来,从而保证关键的数据在客户和服务器之间是不透明的,同时仍然保持在内核模式下使用真实的指针。

最后,ALPC还支持第四个属性,称为环境属性(context attribute)。这一属性支持传统的、LPC风格的、用户特定的环境指针,此环境指针可以与给定的消息关联起来;在有些场景下,自定义的数据有必要与“客户/服务器”对相关联,这种情况仍然可以支持。

为了正确地定义属性,有各种各样的API可以供内部的ALPC消费者使用,比如AlpcInitializeMessageAttribute和AlpcGetMessageAttribute

Blob、句柄和资源

虽然ALPC库通过对象管理器只暴露了一个对象类型(port),但是,它内部必须管理很多数据结构,以便可以执行它的机制所要求的各项任务。例如,ALPC需要分配和跟踪与每个端口相关联的消息,以及消息属性;它必须跟踪它们生命周期的整个过程。ALPC并没有使用对象管理器的例程来管理数据,而是实现了它自己的一种轻量的对象,称为blob

与对象类似,blob可以自动被分配和垃圾回收,可以被跟踪引用,以及通过同步机制来锁定。而且,blob可以有客户定义的分配和还原回调函数,这使得它们的所有者可以控制额外的信息,比如可以用于跟踪每个blob的使用情况。最后,ALPC也用到了执行体的句柄表的实现(可以使用对象和PID/TID),有一个专门ALPC的句柄表,使得ALPC可以为blob生成私有的句柄,而不是使用指针。

在ALPC模型中,例如,消息是blob,它们的构造函数生成一个消息ID,消息ID本身是一个指向ALPC句柄表的句柄。其他的ALPC blob包括以下:

  • 连接blob,它保存了客户和服务器的通信端口,以及服务器连接端口和ALPC句柄表。
  • 安全blob,它保存了必要的安全数据,以便允许模仿一个客户。它也存储了安全属性。
  • 内存区、区域和视图blob,它们描述了ALPC的共享内存模型。最终由视图blob负责
    存储数据视图属性。
  • 保留blob,它实现了对ALPC保留对象的支持。(参见本章前面的“保留对象”章节。)
  • 句柄数据blob,它包含了支持ALPC句柄属性而需要的信息。

因为blob是从可换页的内存中分配的,所以,它们必须要小心地维护好,以便在适当的时候被删除掉。对于特定种类的blob,这是很容易做到的。例如,当一个ALPC消息被释放的时候,用于包含该消息的blob也相应地被删除掉。然而,有些特定的blob可能代表了附着于某个ALPC消息的诸多属性,因而内核必须要正确地管理它们的生命周期。例如,因为一个消息可以有多个视图附着于它(当许多客户访问同一个共享内存的时候),所以,这些视图必须要通过引用它们的消息来进行跟踪。ALPC利用资源的概念来实现这一功能。每个消息都关联了一个资源列表,任何时候当一个消息关联的blob(不是通过一个简单的指针)被分配时,该blob也会被作为一个资源加入到该消息的资源列表中。依次地,ALPC库提供了查找、刷新和删除这些关联资源的功能。安全blob、保留blob和视图blob都是以资源的形式来存储的。

安全性

ALPC实现了几种安全机制,它有完全的安全边界,能够在一般的IPC解析错误的情况下缓解各种攻击。在最基础的层面上,ALPC端口对象是由同样的对象管理器接口来管理的,能够管理对象安全性、阻止非特权的应用通过ACL来获得指向服务器端口的句柄。在此之上,ALPC提供了一个基于SID的信任模型,继承自最初的LPC设计。该模型使客户可以不仅仅通过端口名称来验证它们正在连接的服务器。通过一个受保护的端口,客户进程将它期望的在端点另一侧的服务器进程的SID提交给内核,内核验证该客户是否真正连接着所期望的服务器,从而可缓解当一个非可信的服务器创建一个端口来欺骗服务器时所发生的名字空间蹲守攻击( namespace squatting attacks)。

ALPC也允许客户和服务器都自动地唯一标识出负责每条消息的线程和进程。通过NtAlpcImpersonateClientThread API,ALPC也支持完整的Windows模仿模型。还有其他的API,可以让ALPC服务器有能力查询所有连接的客户相关联的SID,以及查询客户的安全令牌的LUID(本地唯一标识符)(关于安全令牌,将在第6章中进一步讲述)。

相关文章
|
2天前
|
JSON iOS开发 数据格式
tauri2-vue3-macos首创跨平台桌面OS系统模板
自研Tauri2.0+Vite6+Pinia2+Arco-Design+Echarts+sortablejs桌面端OS管理平台系统。提供macos和windows两种桌面风格模式、自研拖拽式栅格引擎、封装tauri2多窗口管理。
28 3
|
21天前
|
安全 前端开发 Android开发
探索移动应用与系统:从开发到操作系统的深度解析
在数字化时代的浪潮中,移动应用和操作系统成为了我们日常生活的重要组成部分。本文将深入探讨移动应用的开发流程、关键技术和最佳实践,同时分析移动操作系统的核心功能、架构和安全性。通过实际案例和代码示例,我们将揭示如何构建高效、安全且用户友好的移动应用,并理解不同操作系统之间的差异及其对应用开发的影响。无论你是开发者还是对移动技术感兴趣的读者,这篇文章都将为你提供宝贵的见解和知识。
|
22天前
|
PHP 开发者 UED
PHP中的异常处理机制解析####
本文深入探讨了PHP中的异常处理机制,通过实例解析try-catch语句的用法,并对比传统错误处理方式,揭示其在提升代码健壮性与可维护性方面的优势。文章还简要介绍了自定义异常类的创建及其应用场景,为开发者提供实用的技术参考。 ####
|
27天前
|
存储 缓存 监控
后端开发中的缓存机制:深度解析与最佳实践####
本文深入探讨了后端开发中不可或缺的一环——缓存机制,旨在为读者提供一份详尽的指南,涵盖缓存的基本原理、常见类型(如内存缓存、磁盘缓存、分布式缓存等)、主流技术选型(Redis、Memcached、Ehcache等),以及在实际项目中如何根据业务需求设计并实施高效的缓存策略。不同于常规摘要的概述性质,本摘要直接点明文章将围绕“深度解析”与“最佳实践”两大核心展开,既适合初学者构建基础认知框架,也为有经验的开发者提供优化建议与实战技巧。 ####
|
26天前
|
缓存 NoSQL Java
千万级电商线上无阻塞双buffer缓冲优化ID生成机制深度解析
【11月更文挑战第30天】在千万级电商系统中,ID生成机制是核心基础设施之一。一个高效、可靠的ID生成系统对于保障系统的稳定性和性能至关重要。本文将深入探讨一种在千万级电商线上广泛应用的ID生成机制——无阻塞双buffer缓冲优化方案。本文从概述、功能点、背景、业务点、底层原理等多个维度进行解析,并通过Java语言实现多个示例,指出各自实践的优缺点。希望给需要的同学提供一些参考。
45 7
|
21天前
|
人工智能 搜索推荐 Android开发
移动应用与系统:探索开发趋势与操作系统演进####
本文深入剖析了移动应用开发的最新趋势与移动操作系统的演进历程,揭示了技术创新如何不断推动移动互联网生态的变革。通过对比分析不同操作系统的特性及其对应用开发的影响,本文旨在为开发者提供洞察未来技术方向的视角,同时探讨在多样化操作系统环境中实现高效开发的策略。 ####
19 0
|
25天前
|
Java 数据库连接 开发者
Java中的异常处理机制:深入解析与最佳实践####
本文旨在为Java开发者提供一份关于异常处理机制的全面指南,从基础概念到高级技巧,涵盖try-catch结构、自定义异常、异常链分析以及最佳实践策略。不同于传统的摘要概述,本文将以一个实际项目案例为线索,逐步揭示如何高效地管理运行时错误,提升代码的健壮性和可维护性。通过对比常见误区与优化方案,读者将获得编写更加健壮Java应用程序的实用知识。 --- ####
|
27天前
|
缓存 并行计算 Linux
深入解析Linux操作系统的内核优化策略
本文旨在探讨Linux操作系统内核的优化策略,包括内核参数调整、内存管理、CPU调度以及文件系统性能提升等方面。通过对这些关键领域的分析,我们可以理解如何有效地提高Linux系统的性能和稳定性,从而为用户提供更加流畅和高效的计算体验。
30 2
|
20天前
|
5G 数据安全/隐私保护 Android开发
移动应用与系统:探索开发趋势与操作系统革新####
本文深入剖析当前移动应用开发的最新趋势,涵盖跨平台开发框架的兴起、人工智能技术的融合、5G技术对移动应用的影响,以及即时应用的发展现状。随后,文章将探讨主流移动操作系统的最新特性及其对开发者社区的影响,包括Android的持续进化、iOS的创新举措及华为鸿蒙OS的崛起。最后,还将讨论移动应用开发中面临的挑战与未来的发展机遇,为读者提供全面而深入的行业洞察。 ####
|
27天前
|
人工智能 5G 开发工具
移动应用与系统的未来趋势:开发、操作系统创新及挑战###
本文探讨了移动应用开发和移动操作系统的最新发展趋势,包括人工智能的集成、跨平台开发工具的兴起以及5G技术对移动生态的影响。同时,还分析了开发者面临的主要挑战,如安全性问题、性能优化和用户体验提升等。通过具体案例和技术解析,本文旨在为开发者提供前瞻性指导,帮助他们在快速变化的移动科技领域保持竞争力。 ###

推荐镜像

更多