OSR文档:NT中的异步过程调用(APC)

在Windows NT中,APC被无数次地提到,但在标准Microsoft DDK中却没有说明什么是APC以及应该怎么使用。但是理解APC是理解Windows NT怎么工作的本质。
  
   当然,毫无疑问你们一定知道一些完全支持APC的Win32 API(比如QueueUserApc这个Win32 API函数)。Windows NT平台的Win32 APC抽象是建立在内核中的本地APC支持之上的。
   在1997年5月发行的The NT Insider中我们讨了I/O完成。I/O完成的一个关键原理是I/O完成的“第二场景(second stage)”。第二场景必须在开始I/O的线程上下文中完成。I/O管理器通常使用APC来完成——因为APC的一个关键特征就是运行在特定线程上下文中。注意在知识库文章#Q126416种错误地的把APC描述为运行在任意线程上下文中。
  
  异步过程调用有下面几个有趣的特征:
  
  — 一个APC总是运行在特定线程上下文中。
  — 一个APC运行在OS预定的时刻。
  — 一个APC可以导致当前运行线程的抢占(pre-emption)。
  — 一个 APC例程可以被强占。
  
   对于在Windows NT中运行的线程,操作系统用一个叫做“线程结构(thread structure)”的数据结构来表示。在这个结构中有两个APC队列。其中一个用来存放“用户模式”APC对象,另一个队列则存放“内核模式”APC对象。这些队列的APC对象各自都有常规和特殊这两种特性(flavors)。
   在描述用户模式的APC和内核模式APC的区别之前先来描述一下APC对象的控制对象是个不错的注意。虽然没有在DDK中明确地列出,但是APC对象还是在NTDDK。H中定义了。其声明如下:
  
  typedef struct {
  CSHORT Type;
  CSHORT Size;
  ULONG Spare0;
  struct _KTHREAD *Thread;
  LIST_ENTRY ApcListEntry;
  PKKERNEL_ROUTINE KernelRoutine;
  PKRUNDOWN_ROUTINE RundownRoutine;
  PKNORMAL_ROUTINE NormalRoutine;
  PVOID NormalContext;
  PVOID SystemArgument1;
  PVOID SystemArgument2;
  CCHAR ApcStateIndex;
  KPROCESSOR_MODE ApcMode;
  BOOLEAN Inserted;
  } KAPC, *PKAPC;
  
   从APC对象的声明中,它的许多特征是简单地描述的。例如,APC是线程特定的数据结构,因此APC对象包含一个它所关联的线程的指针。和任何标准Windows NT控制对象一样,APC对象包含一个单链表结构LIST_ENTRY,用来排队APC对象。
   一般来说,一个内核模式APC有一个有效KernelRoutine(…)函数指针,而一个用户模式APC会有一个有效NormalRoutine(…)函数指针。这两种APC对象都可以有一个RundownRoutine(…)函数指针,这个函数每当OS需要丢弃APC对列的内容的时候调用(比如线程退出)。没有这个例程的APC就被简单地删除。在这种情况下,KernelRoutine(…)和NormalRoutine(…)都不会被调用,只会调用RundownRoutine(…)。
   我们在前面提到有两个APC队列,每个队列中可以有普通的和特殊的APC对象。对于内核模式操作,特殊APC确实被频繁使用,I/O管理器就是这样执行I/O完成的。I/O管理器创建一个“特殊”内核模式APC并在这个特定内核模式APC上下文中完成线程特定的I/O部分(比如把结果复制到适当的输出缓冲区)。但是特殊的用户模式APC是不常用的。它们使用在比如线程被终止这种情况。不管怎样,特殊APC总是插入到APC队列前面,在任何普通APC对象之前。这样能确保特殊APC在任何普通APC之前运行。
   对于文件系统来说管理APC的投递是保证文件系统正确行为的基本要素。确实,在Windows NT上开发文件系统会碰到的最复杂的问题之一就是在VM系统和文件系统之间交互的复杂锁定模式。一个FSD的危险是一个APC可能导致一个额外的I/O被触发到文件系统中。
   例如,假设对某个文件X执行的I/O刚好完成。如果同一线程的一个内核APC开始对文件Y进行I/O(这是很常见的情况),就很可能导致死锁。实际上,即使FSD开发者定义了在文件X和文件Y之间的锁定顺序,但是在FSD控制之外写成的代码可能不遵守这个规则。
   要处理这种情况,一个典型的Windows NT文件系统将会调用KeEnterCriticalRegion(…)(曾经叫做FsRtlEnterFileSystem(…),但是在当前版本的Windows NT中被定义为KeEnterCriticalRegion(…))来禁止内核模式APC投递。但是还是允许I/O管理器投递特殊内核模式APC。这些APC是安全的是因为他们不会重入文件系统,因此不会引入任何死锁风险。
   禁止APC投递的另一种方式是提高系统的IRQL到APC_LEVEL。这将禁止任何类型的APC投递。例如,Windows NT内存管理器在某些情况下就在APC_LEVEL等级发出I/O操作。这就确保了在它发出新的paging I/O操作的时候任何APC,特别是I/O完成APC都不会投递。
   Windows NT中的某些同步原语为了确保代码不会重入当前运行线程就提高系统的IRQL到APC_LEVEL。这里值得注意的是快速互斥操作。ExAcquireFastMutex(…) 提高系统的IRQL到APC_LEVEL,当驱动程序调用ExReleaseFastMutex(…)的时候降低IRQL。因此,当拥有快速互斥体的时候这个线程的所有APC都不能投递。
   用户模式APC的打开和禁止不能使用和管理内核模式APC一样的方法。相反,内核在某些情况下检查用户APC队列,包括线程等待一个事件发生的时候,比如KeWaitForSingleObject(…)以及它的参数。触发APC投递的另一个一般事件是退出一个系统服务调用。无论如何,任何使用APC的代码不能依赖现在的系统行为。相反,代码对数据结构访问必须进行同步。
   尽管APC对象用在整个操作系统中,怎样创建一个APC对象就从来没有在DDK中文档化。虽然有几个例程说明他们是支持APC函数的,但是在DDK中却没有使用这些例程的示例。例如,函数ZwReadFile(…)接受一个函数指针和上下文参数。名字提示了他们实际上是APC例程。不幸的是,DDK在这一点却相当简单,仅仅声明“设备驱动程序和中间层驱动程序应当把这个指针设置为NULL”。
   虽然这个警告是正确的,但是这也给文件系统或者文件服务器这样的最高层驱动程序怎样使用这些参数一点指导。通常这就导致使用事件而不是使用APC例程,而这样就导致了相应的高负荷和低性能的系统特征。当然,就像平台SDK文档中清晰说明的:“注意ReadFileEx(…), SetWaitableTimer(…), 和 WriteFileEx(…)函数使用APC作为完成通知回调机制”,Win32使用这个功能来实现重叠I/O。
   对于Win32应用程序来说要使用APC比内核层应用程序简单很多。他们可以简单地调用QueueUserApc,传一个函数,一个线程句柄以及上下文参数。假定他们有适当许可,操作系统将构建一个APC对象并插入到目标线程的用户APC队列中。
   尽管内核模式应用程序不能直接创建APC对象,但是Microsoft为文件系统驱动程序提供了一个操作来确保代码执行在一个特定进程上下文中。这个关键的例程是KeAttachProcess(…) 和 KeDetachProcess(…)。尽管这些函数不允许指定一个特定线程上下文,但是他们能确保给定进程的资源可用,特别是地址空间。这些函数原形如下。他们在NTIFS。H中定义了。
  
  NTKERNELAPI VOID KeAttachProcess (IN PRKPROCESS Process);
  NTKERNELAPI VOID KeDetachProcess (VOID);
  
   为了强行切换到特定进程地址空间,文件系统驱动程序可能使用KeAttachProcess(…)。从这个函数返回的时候,线程就执行在在KeAttachProcess(…)中指定进程的地址空间中。文件系统接着可以操作被附加(attached)的进程中的数据。操作完成的时候线程调用KeDetachProcess(…)来恢复进程上下文。
  
   通常应当避免使用KeAttachProcess(…) 和 KeDetachProcess(…)。尽管这些函数允许文件系统进入指定进程上下文,但是却很昂贵。许多文件系统使用这个函数都是不必要的。但是他们能够以直接的方式帮助文件系统在地址空即之间复制数据。
  
   综上,Windows NT使用异步过程调用来在一个已知线程上下文中运行任意过程。通过为每个线程维护一个APC对象队列并周期性检查这个队列来确定是否有工作要做。APC确实是Windows NT处理象I/O完成这样的基本系统操作的基础。对于大多数驱动程序,APC都是不存在的。对于文件系统驱动程序,适当地控制APC投递是控制正确行为的本质。

你可能感兴趣的:(OSR文档:NT中的异步过程调用(APC))