理解Windows异步过程调用 (APCs)





5.00/5 (10投票s)
在本文中,我将解释异步过程调用 (APCs),它们的使用方法和潜在的陷阱。
什么是APC?
顾名思义,异步过程调用 (APC) 是一种过程调用——以函数指针的形式——它被安排在特定的线程上执行。调度本身可以在任何线程上完成,因此一个线程可以安排工作在另一个线程上,甚至是在它自己身上。这基本上是一种对线程说“嘿,你有空的时候,我想让你做一些工作”的方式。
APC有两种类型:用户APC和特殊APC。它们之间最大的区别在于对APC执行时间的控制。
用户APC
对于用户APC,目标线程拥有控制权,因为它自己决定何时允许计划好的工作发生。它不能被用户APC抢占。当线程进入“可提醒”状态时,它将开始处理计划好的APC。具体来说,当它执行以下函数之一时:
SleepEx
SignalObjectAndWait
WaitForSingleObjectEx
WaitForMultipleObjectsEx
MsgWaitForMultipleObjectsEx
以上这些API中的每一个都表明一个时刻,此时调用线程决定它将“无事可做”一段时间,现在是处理积压工作的好时机(如果有的话)。前四个选项是显而易见的。第五个,MsgWaitForMultipleObjectsEx
,是专门为基于Windows的应用程序创建的,这些应用程序通常花费大部分时间等待直到收到消息。使用MsgWaitForMultipleObjectsEx
是微软给开发者提供的一种简便方法,可以利用这段时间来处理计划好的工作,而无需实现额外的线程复杂性。
请注意,即使在使用上述函数时,调用线程也始终拥有控制权,因为它指定了等待或睡眠是否是可提醒的。
特殊APC
另一方面,特殊APC的执行方式则相反:目标线程对其执行时间没有发言权。唯一的确定性是它们**不会**在以下情况执行:
- 系统调用正在进行,或者
- 正在执行非可提醒的等待。
在所有其他情况下,当系统认为执行它们的条件有效时,APC就会执行,而不管目标线程当时在做什么。在Windows 10及之前的版本中,特殊APC仅在内核模式下可用。在那里,它们通常用于不依赖于它们碰巧中断的线程中正在发生什么的设备驱动程序的I/O完成。
我不确定为什么微软从Windows 11开始允许在用户模式下使用特殊APC,但如果我必须做出有根据的猜测,我认为这可能与用户模式设备驱动程序有关——顾名思义,它们不但在内核模式下运行,但使用类似的设计原则。就应用程序级别的开发而言,它们的附加价值充其量是边际的,而且是一种以极难复现的方式“搬起石头砸自己的脚”的好方法。稍后将详细介绍。
为什么使用APC?
已经有几种机制可用于提供并行执行和调度。你为什么想要使用APC?
有许多原因可以解释为什么一个人可能想调度APC。最常见的似乎是:
- 启动某事与获得结果之间存在很长的延迟。使用APC来处理结果是一种方便的设计模式。
- 由于历史设计原因以及Windows消息机制的工作方式,用户界面元素只能从属于该窗口的用户界面线程内部进行更新。任何执行操作并希望向用户界面报告的异步进程都可以调度该线程上的APC,从而确保用户界面更新在正确的线程内完成。
- 使用APC机制作为调度工具。虽然我们可以争论它是否合适,但事实是APC机制使用队列来调度需要完成的工作。任何一系列独立的操作都可以分解成一系列按顺序执行的过程调用。
调度APC
普通的APC是使用QueueUserAPC
函数调度的。
DWORD QueueUserAPC(
PAPCFUNC pfnAPC,
HANDLE hThread,
ULONG_PTR dwData );
正如你所见,这个函数与CreateThread
等函数有很多相似之处,它们接受一个函数指针来执行,以及一个数据指针传递给函数。不同的是,它不是创建一个新线程来执行该函数,而是指定一个现有线程来执行该函数。
APC控制台应用程序示例
在本节中,我将解释在控制台应用程序中使用APC的两种方法。我们将使用APC将工作项发送到工作线程,工作线程将通过APC将结果发布回主线程。
准备工作
以下代码片段构成了我们测试应用程序的基础。
我们有不同种类的任务需要分派,因此不同任务有不同的数据集来工作是合理的。由于我们只能向APC传递一个参数,我们将所有任务相关数据放在一个struct
中。如果任务报告回很重要,调用者还需要提供自己的线程句柄。
struct Task1Data
{
HANDLE hCaller;
DWORD Value;
};
struct Task2Data
{
HANDLE hCaller;
FLOAT Value;
};
struct ResponseData
{
HANDLE hTaskThread;
string Task;
};
显然,每个任务都需要自己的函数体,它在那里接收其任务数据并做一些有用的事情。
正如你所说,PerformTask1
只是完成它的工作然后退出,而无需报告。PerformTask2
则完成它的工作,然后将一个响应APC排队到它被分派到的线程。
void ReportBack(void* context)
{
cout << " Reporting back" << endl;
}
void PerformTask1(void* context)
{
Task1Data* data = (Task1Data*)context;
cout << "Thread " << GetCurrentThreadId() <<
" performing Task 1 with value " << data->Value <<
" for thread " << GetThreadId(data->hCaller) << endl;
delete data;
}
void PerformTask2(void* context)
{
Task2Data* data = (Task2Data*)context;
cout << "Thread " << GetCurrentThreadId() <<
" performing Task 2 with value " << fixed << data->Value <<
" for thread " << GetThreadId(data->hCaller) << endl;
QueueUserAPC(
(PAPCFUNC)&ReportBack,
data->hCaller,
(ULONG_PTR)NULL);
delete data;
}
顺便说一句,正如我在这里解释的那样,如果一个线程想将其自身的线程句柄传递给另一个线程,它需要复制其线程令牌。
HANDLE hMainThread = NULL;
if (!DuplicateHandle(
GetCurrentProcess(),
GetCurrentThread(),
GetCurrentProcess(),
&hMainThread,
0,
FALSE,
DUPLICATE_SAME_ACCESS)) {
cout << "Error " << GetLastError() << " cannot duplicate main handle." << endl;
return GetLastError();
}
获取用户输入
由于控制台应用程序通常通过键盘从用户获取输入,因此有两种方法可以实现:阻塞式和非阻塞式。对于阻塞式输入,应用程序会卡在系统调用中,直到用户输入了某些内容。对于非阻塞式输入,应用程序基本上检查是否有新输入,然后无论如何都会返回。
这种区别很重要,因为如果我们使用阻塞式输入,则无法处理APC。应用程序线程不是可提醒的。另一方面,如果我们不使用阻塞式输入方法,APC可以在应用程序等待用户输入时进行处理。
下面是一个这样的输入函数可能是什么样子的示例。请注意,这不是实现此类循环的唯一方法,也不是最好的方法。但为了这个例子的目的,它完成了它需要做的事情:等待输入,输入通过回车键终止。如果选择了非阻塞方法,输入循环会在100毫秒的循环中进行计时,并能够被提醒以处理APC。
string GetChoice(bool blocking)
{
string buffer = "";
if (blocking) {
cin >> buffer;
return buffer;
}
else {
while (1) {
SleepEx(100, TRUE);
if (_kbhit()) {
char c = _getche();
if (c == '\n' || c == '\r') {
cout << "\n";
return buffer;
}
else {
buffer += c;
}
}
}
}
return "";
}
正如我已经提到的,对于实时环境,此输入方法不够健壮。在实时环境中,你需要捕获“Escape”键,并允许通过Ctrl+Break等中断。对输入范式的完整探索超出了本文的范围。
实现APCs
现在我们终于可以做一些有趣的事情了。在做任何事情之前,我们创建了工作线程,我们将向其分派任务,以及一个Windows事件,我们在工作线程需要关闭时使用它来发出信号。当用户输入“q
”表示退出时,就会完成此操作。
HANDLE shutdown = CreateEvent(NULL, TRUE, FALSE, NULL);
HANDLE workerThread = CreateThread(NULL, 0, WorkerThread, shutdown, 0, NULL);
cout << "Make a choice:" << endl;
cout << "==============" << endl;
cout << "q: quit" << endl;
cout << "1: Initiate task 1" << endl;
cout << "2: Initiate task 2" << endl;
if(blockingInput)
cout << "p: Process queue-ed maint thread APCs" << endl;
cout << "Choice: ";
DWORD dwValue = 0;
FLOAT fValue = 0;
do
{
string choice = GetChoice(blockingInput);
if (choice == "q") {
SetEvent(shutdown);
WaitForSingleObject(workerThread, INFINITE);
break;
}
else if (choice == "1") {
auto data = new Task1Data;
data->hCaller = hMainThread;
data->Value = dwValue++;
QueueUserAPC((PAPCFUNC) & PerformTask1, workerThread, (ULONG_PTR)data);
}
else if (choice == "2") {
auto data = new Task2Data;
data->hCaller = hMainThread;
data->Value = fValue++;
QueueUserAPC((PAPCFUNC) &PerformTask2, workerThread, (ULONG_PTR)data);
}
else if (choice == "p" && blockingInput) {
SleepEx(0, TRUE);
}
} while (1);
只有两个与任务相关的命令。每个命令都会创建一个任务数据结构,该结构与任务函数指针一起被分派到工作线程APC队列。正如你所见,这足够简单。
但是为什么还要“P”来处理被安排为响应的APC呢?嗯,还记得我们必须在阻塞和非阻塞输入之间做出选择吗?如果我们使用阻塞输入,将永远没有机会处理那些APC,除非我们通过在某个时间点变得可提醒来明确创建一个处理它们的机会。
那么工作线程APC是如何处理的呢?那部分很简单:
DWORD WorkerThread(void* context)
{
HANDLE shutdown = (HANDLE) context;
DWORD retVal = 0;
while( retVal = WaitForSingleObjectEx
(shutdown, INFINITE, TRUE) == WAIT_IO_COMPLETION)
;
return retVal;
}
与其他任何线程一样,它需要使自己可提醒。由于其唯一目的是处理APC,因此它可以在等待关闭事件的同时无限期地保持可提醒状态。重要的是要记住,当它被提醒并处理了APC时,WaitForSingleObjectEx
将返回。
在我们重新进入等待状态而没有任何改变似乎很麻烦。然而,这是正确的事情,因为APC的执行可能已经改变了工作线程的状况,以至于它在继续等待之前需要做其他事情。例如,主线程没有设置关闭事件,而是可以将关闭功能实现为关闭APC。如果工作线程处理了该APC,它就可以自行关闭。
没有“1个正确的方法”来处理APC。APC是技术。“如何”使用它们是策略、政策和设计。
现在看看工作线程,它看起来异常空。实际工作在哪里完成?答案很简单:看不见,在后台。更确切地说:当线程处于可提醒状态并且APC被排队时,Windows将暂停正常的线程函数(在本例中是“WorkerThread
”函数)。然后它将从队列中取出第一个APC,并在该线程的上下文中执行APC函数指针,就像它是常规线程函数一样,并将数据参数作为函数参数。
完成后,它将从队列中取出下一个并进行处理。它将继续这样做,直到所有APC都被处理。当达到这一点时,Windows将恢复原始线程函数,并让它以它认为合适的方式处理它被提醒的事实。在我们的例子中,这只是恢复等待。
当然,你可以在APC函数中设置断点。但是,你无法通过查看工作线程的代码来推断工作线程中发生了什么。执行的任何内容都是其他线程告诉它执行的结果。这意味着,如果你的应用程序将APC分派到不同位置的工作线程中,那么由你来确保你的代码的内部逻辑能够处理APC以任何它们碰巧到达的顺序执行。
另一件值得强调的事情是,如果APC在线程本身开始执行之前就被调度,那么APC将在线程函数本身开始之前执行。
多线程注意事项
我们已经提到APC只是多线程技术武器库中的另一个工具。然而,它们是一种比常规线程原语需要更多谨慎的工具。
关键在于,APC不会与其目标线程并行执行。它会**中断**目标线程。这一点很重要,因为像互斥锁这样的东西可以在已经持有它们的线程内部递归地获取。这意味着线程不能通过常规手段保护资源。这是一件好事,如果你在想的话。否则,试图获取其目标线程已经持有的互斥锁的APC将永远导致线程死锁。
事实上,线程唯一真正能做的以确保其数据不会因不当的并行访问而损坏的事情是控制APC何时执行。如前所述,只有当线程声明自己是可提醒的时候,APC才能执行。线程完全拥有控制权,可以确定何时其数据对APC安全。请注意,如果APC试图访问与其他线程共享的数据,它仍然可能需要使用互斥锁来保护其他线程的访问。
特殊APC
这也是为什么我之前提到,对于应用程序开发,特殊APC几乎没有用处,而且使用它们极其危险。它们会中断线程,而不管线程在做什么,或者它是否正在进行某项工作。这意味着常规保护不起作用,线程状态很容易被损坏。
情况更糟。而线程一个接一个地执行普通的用户APC,特殊APC则在任何可能的时候执行,即使当时已经有一个APC在执行。特殊APC可以中断一个特殊APC,而这个特殊APC本身又中断了一个用户APC。你看这有多混乱?多么完全不可预测且难以分析?
但等等,它仍然可以变得比这更糟。对于用户APC,程序员可以严格控制APC**何时**执行,并且Windows只会一次处理该特定线程中的1个APC。然而,当你使用互斥锁等锁定原语时,你的应用程序或你的用户APC仍然可能在持有锁时被特殊APC抢占。特殊APC可能会做一些依赖于在另一个线程中获取的锁的事情,如果你有一个复杂的应用程序,这会导致死锁或极难复现的问题。
它们唯一安全的时间是——通过设计——你安排事情,使得当特殊APC执行时,线程没有任何可能与APC冲突的操作。根据定义,这些事情的范围非常有限,而且在几乎所有涉及应用程序开发的案例中,都可以通过用户APC以更安全的方式来处理。除了像用户模式设备驱动程序框架之类的东西,它们处理非常有限、非常特定的I/O包,这些包与它们碰巧执行的线程无关,我看不出有任何用例可以证明它们带来的复杂性和维护噩梦是合理的。如果你知道一个,请在下面的评论区发帖。
运行测试应用程序
测试应用程序演示了我解释的原理。它做的第一件事是询问您是否要使用阻塞式或非阻塞式输入。
如果我们选择阻塞式输入,那么任务2将在每次执行时排队一个响应APC,但是直到我们输入“p”它才会被处理,这将中断程序一次,此时所有排队的APC都将执行。
如果我们选择非阻塞式输入,那么响应APC将在输入循环等待/收集数据时执行。
在这两种情况下,都必须采取行动来确保APC得到执行。一种选择是让用户明确负责。另一种选择是让应用程序负责,在这种情况下意味着在输入循环内。
关注点
APC是并行化和任务卸载的便捷机制。我之前提到过没有“1个正确的方法”。这一切都取决于你如何设计你的应用程序。
代码已根据MIT许可证发布,请尽情使用。
历史
- 2023年3月30日:第1版