异常注入:在另一个线程中抛出异常
如何通过异常中止不合作的线程
引言
本文介绍了一种“中止”一个不合作线程的方法。更准确地说,它可以用于中止从另一个线程调用的某个不合作函数,并将执行权返回到该线程内的某个“友好”点。本文描述的方法会导致在目标线程中引发异常(类似于 .NET 中的 Thread.Abort
)。
方法
首先,我们约定一个可能要抛出的异常类型。我们称之为 ThreadAbort
并进行定义
class ThreadAbort
{
__declspec (noreturn) static void Throw();
public:
static bool RaiseInThread(HANDLE hThread);
static void DontOptimize() throw (...);
};
正如你所见,ThreadAbort
没有成员变量。这意味着我们不会用异常传递任何参数。实际上也可以添加参数,但我们这里不讨论。static
成员函数的作用如下
RaiseInThread
导致指定线程抛出ThreadAbort
异常。DontOptimize
没有任何作用。但是,它应该在目标线程的适当try/catch
块中调用。这与编译器在编译时看不到我们的异常在那里发生的可能性有关,因此在优化过程中可能会省略try/catch
块,从而导致我们的异常未被处理。解决此问题的另一种方法是设置异步异常处理模型(稍后会详细介绍)。
我们可能在该线程中调用的不合作函数应该被适当的 try/catch
块包装
try {
ThreadAbort::DontOptimize();
// pass control to the func
SomeNonResponsiveFunc();
} catch (ThreadAbort&) {
// process abortion
}
现在让我们深入了解 ThreadAbort
的实现
__declspec (noreturn) void ThreadAbort::Throw()
{
// just throw
throw ThreadAbort();
}
void ThreadAbort::DontOptimize() throw (...)
{
// By this awkward method we may convince the compiler that during the runtime
// the exception *may* be thrown.
// However it may not actually.
volatile int i=0;
if (i)
Throw();
}
bool ThreadAbort::RaiseInThread(HANDLE hThread)
{
bool ok = false;
// Suspend the thread, so that we won't have surprises
DWORD dwVal = SuspendThread(hThread);
if (INFINITE != dwVal)
{
// Get its context (processor registers)
CONTEXT ctx;
ctx.ContextFlags = CONTEXT_CONTROL;
if (GetThreadContext(hThread, &ctx))
{
// Jump into our Throw() function
ctx.Eip = (DWORD) (DWORD_PTR) Throw;
if (SetThreadContext(hThread, &ctx))
ok = true;
}
// go ahead
VERIFY(ResumeThread(hThread));
}
return ok;
}
从上面的代码可以看出,为了让线程抛出异常,我们挂起它,修改它的 EIP
寄存器(指令指针),使其指向 ThreadAbort::Throw
,然后恢复它,使其愉快地进入深渊。这是一种暴力方法:我们不知道该线程实际上在做什么,它可能正处于某个操作的中间。所以我们可以在任何时候中断它。
与其他方法相比
还有其他方法可以中断线程正在进行的操作,但它们不如异常方法灵活
- 可以调用
TerminateThread
来立即终止线程。但是,这不允许将控制权传递到线程内的“友好”代码。也就是说,我们可能不想终止该线程,只想将控制权传递到该线程内的其他代码。此外,当一个线程被TerminateThread
终止时,其堆栈内存不会被操作系统释放。所以我们有内存泄漏(以及被中止代码分配的泄漏)。 - 我们可以修改
EIP
直接指向“友好”代码,而不是涉及异常处理机制(该机制将(希望)在我们的友好代码中完成)。这里的问题是我们没有给被中止的代码执行清理的机会。因此,它分配的所有资源都会丢失,我们很可能会出现资源/内存泄漏。另一方面,如果代码是以支持异常的方式编写的,则可以优雅地进行清理。
也就是说,异常方法允许优雅地中止。然而,不幸的是,它不能保证我们不会有任何泄漏。这有几个原因
- 并非所有代码都以支持异常的方式编写(意味着没有“悬空”的已分配资源,所有资源都受到自动变量的析构函数或
__try/__finally
SEH 块的保护)。有些代码块的编写方式假设异常不会在其中发生。 - 即使所有代码都以支持异常的方式编写,编译器也可以自由地优化代码。如果它没有看到异常发生的可能性,它可能会省略所需的异常处理记录。幸运的是,这可以通过选择所谓的异步异常处理模型来防止(有关更多信息,请阅读本文)。
- 如果所有代码都以支持异常的方式编写,甚至如果我们选择了异步异常处理模型——我们仍然可能遇到问题。
根据 C++ 规则,自动对象的生命周期在构造函数完成后正式开始。例如,如果对象在构造函数中抛出异常,则不会调用其析构函数。
现在,由于我们盲目地在任何线程正在执行的操作的中间引起异常,我们可能会在对象的构造函数结束时抛出异常。在分配了资源之后,但之前它正式“出生”。在这种情况下,不会调用其析构函数,我们将出现泄漏。
幸运的是,这也有一个变通方法:我们可以避免在构造函数中进行分配。也就是说,不要在构造函数中分配任何内容,只需“零初始化”您的变量。然后实际的分配可以在另一个方法中完成,该方法应该在构造函数之后立即调用。这称为两阶段对象构造。
- 最后,如果我们以最谨慎的方式进行所有操作——我们仍然可能遇到问题。这次是析构函数。在正常的程序流程中,当对象的生命周期结束时,编译器生成的代码会移除该对象的异常处理信息,然后立即调用其析构函数。但是,如果我们恰好在移除异常处理信息之后,但在对象析构函数调用之前抛出异常呢?或者在析构函数执行过程中,当它尚未完成清理时?
不幸的是,在所有可能的情况下,都无法保证对非合作代码的正确清理。这就是为什么我们的方法是暴力方法。但是,与其他方法相比,它更优雅。实际上,您很有可能正确清理,而在最坏的情况下(如果一切都仔细编写),您最多只会遇到一个泄漏。
内核模式调用
我们的方法在目标线程处于用户模式运行时在其内部引发异常。然而,如果该线程当前正在执行系统(内核模式)调用,它不会立即中止。中止将被推迟到它从系统调用返回为止。如果我们谈论的是持续时间较短的系统调用(例如调用 SetEvent
、CreateMutex
等),那么没有问题。但是,如果有人调用可等待函数(例如 Sleep
、WaitForSingleObject
等),它们可能需要很长时间才能完成,甚至永远不会完成。
据我所知,从用户模式代码中无法中止系统调用。这只能通过深入操作系统内部来实现。中止此类调用的唯一方法是使用 TerminateThread
。
结论
最重要的结论是,您不应该使用非合作代码。任何可能耗时长的代码必须提供常规的中止方式。
接下来,无法中止未知代码并确信所有内容都已清理。然而,注入异常的方法提供了优雅清理的最佳机会。
从用户模式代码无法中止系统调用。在某些情况下,别无选择,只能调用 TerminateThread
函数。
我很感激评论。欢迎批评和新想法。
历史
- 2010年4月8日:初始发布