23  终止处理程序[1]

本章概览:

通过实例理解终止处理程序... 2

 

闭上眼睛,想象一下要写一个不会因异常而终止的程序。没错——有足够的内存,不会有非法的指针,并且要访问的文件也始终存在。在这样的条件下写代码,那一定是一件愉快的事情。代码将会因此而容易编写,容易阅读,并且容易理解。再也不需要为代码里到处都是的if语句和 goto语句而烦恼——对每个函数,我们都只需要从头到尾把代码写完。

 

如果这种直接的编程方法对你来说是一个美梦的话,那么你一定会喜欢上结构化异常处理(structured exception handling, 后面简称为SEH)。SEH带来的好处是当我们在写代码时,可以先集中精力完成软件的正常工作流程。如果在运行的时候出现了什么问题,系统会捕获这个问题,并且通知我们。

 

使用SEH,并不意味着可以完全忽略代码中可能出现的错误,但是我们可以将软件主要功能编写和软件异常情况处理这两个任务分离开。这样,就可以先集中注意力完成手头上的工作,稍后再去处理软件可能会遇到的各种错误情况。

 

促使MicrosoftSEH加入Windows系统的因素之一是它可以简化操作系统本身的开发工作。操作系统的开发人员使用SEH来让系统更加健壮,而我们也可以使用SEH来让应用程序更为健壮。

 

为了让SEH运作起来,编译器的工作量要大于操作系统。在进入和离开异常处理代码块时,编译器必须生成一些特殊的代码,以及产生一些关于支持SEH的数据结构表,还必须提供回调函数给操作系统调用,以便系统遍历异常代码块。编译器还负责准备进程的栈框架[2]和其他一些内部信息,这些信息都是操作系统需要使用或者引用的。让编译器支持SEH不是一个简单的任务,就算不同的编译器厂商以不同的方式来实现它,也不是什么奇怪的事情。幸运的是,我们可以直接利用编译器对SEH的支持,而不需要理会编译器如何支持它的细节。

 

但是,不同编译器针对SEH的实现不尽相同,这给我们用具体的方法和具体的例子来讨论SEH的优点带来了困难。所幸的是,大部分的编译厂商都遵循了Microsoft建议的语法。本书例子中使用的语法或者关键字可能与其他公司的编译器所采用的并不一致,但是SEH的基本概念是一样的。本章采用Microsoft Visual C++编译器规定的语法。

 

注意 不要混淆结构化异常处理与C++异常处理。C++异常处理在形式上表现为使用关键字catchthrow,这和结构化异常处理的形式不同。Microsoft Visual C++ 支持异常处理,它在内部实现上其实就是利用了编译器和Windows操作系统的结构化异常处理功能。

 

SEH实际上包含两方面的功能:终止处理[3]和异常处理[4]。在这一章中我们讨论终止处理,下一章才讨论异常处理。

 

终止处理程序确保不管一个代码块(被保护代码[5]是如何退出的,另一个代码块(终止处理程序)总能被调用和执行。终止处理的语法(当使用Microsoft Visual C++编译器时)如下所示:

 

__try {

// Guarded body

...

}

__finally {

// Termination handler

...

}

 

__try__finally关键字标记了终止处理程序的两个部分(被保护代码和终止处理程序)。在前面这段代码中,操作系统和编译器的协同工作保证了不管被保护代码部分是如何退出的——无论我们在被保护代码中使用了return,还是goto,又或者longjump语句(除非调用ExitProcessExitThreadTerminateProcessTerminateThread来终止进程或线程)——终止处理程序都会被调用,即__finally代码块都能执行。笔者将通过几个例子来说明这一点。

 

通过实例理解终止处理程序

当我们使用SEH时,我们的代码执行与操作系统和编译器紧密相关。因此笔者认为阐述SEH如何工作的最佳途径是分析实例的源代码,并讨论例子中代码的执行顺序。

 

下面几个小节将分别展示一些不同的代码,并解释编译器和操作系统如何调整代码的执行顺序。

Funcenstein1

在分析终止处理程序的各种不同情况之前,让我们首先看一个更为具体的例子:

 

DWORD Funcenstein1() {

DWORD dwTemp;

   

// 1. Do any processing here.

...

__try {

// 2. Request permission to access

// protected data, and then use it.

WaitForSingleObject(g_hSem, INFINITE);

 

g_dwProtectedData = 5;

dwTemp = g_dwProtectedData;

}

__finally {

// 3. Allow others to use protected data.

ReleaseSemaphore(g_hSem, 1, NULL);

}

 

// 4. Continue processing.

return(dwTemp);

}

 

代码注释里的数字表示相关代码的执行顺序。在函数Funcenstein1使用try-finally代码块并没有为我们带来什么好处。这段代码等待一个信号量,修改一个受保护变量的值,然后将这个值保存在一个局部变量dwTemp里,接着释放信号量资源,最后返回改变后的值。

Funcenstein2

现在,让我们稍微改动一下这个函数,看看会发生什么:

 

DWORD Funcenstein2() {

DWORD dwTemp;

 

     // 1. Do any processing here.

     ...

     __try {

         // 2. Request permission to access

         // protected data, and then use it.

         WaitForSingleObject(g_hSem, INFINITE);

 

         g_dwProtectedData = 5;

         dwTemp = g_dwProtectedData;

 

         // Return the new value.

         return(dwTemp);

     }

     __finally {

         // 3. Allow others to use protected data.

         ReleaseSemaphore(g_hSem, 1, NULL);

     }

 

     // Continue processing--this code

     // will never execute in this version.

     dwTemp = 9;

     return(dwTemp);

}

 

Funcenstein2中的try代码块结尾有一个return语句。这个return语句等于告诉编译器,在这里要退出当前函数并返回dwTemp变量的值,现在它的值是5。如果没有使用终止处理,因为return语句在信号量释放语句的前面,return语句被执行,线程便没有机会释放信号量资源,其他线程当然也不会再得到对这个信号量的控制权。不难想象,在等待同一个信号量的其他线程因此再也没有机会运行,这样的执行顺序带来了很严重的问题。

 

幸运的是,通过使用终止处理程序可以防止过早地执行return语句。当return语句试图退出try块的时候,编译器会让finally代码块在它之前执行。即编译器保证finally代码块在try块中的函数退出语句return之前执行。在Funcenstein2中,将ReleaseSemaphore置于终止处理程序中可以保证信号量会被释放。这样,一个线程便不会在无意中一直占有一个信号量,也就意味着其他等待着同一个信号量的线程不会因此而始终处于等待状态。

 

finally代码块执行完以后,函数就可以返回了。因为try语句块中包含一个return语句,所以finally块之后的代码都没有机会执行,因此这个函数的返回值是5,而不是9

 

读者可能想知道,编译器如何保证finally块可以在try代码块退出前被执行。原来当编译器检查程序代码时,会发现在try代码块里有一个return语句。于是,编译器就会生成一些代码先将返回值(在我们的例子中,这个值为5 )保存在一个由它创建的临时变量里,然后再执行finally代码块,这个过程被称之为局部展开[6]。更确切地说,当系统因为try代码块中的代码提前退出而执行finally代码块时,局部展开就会发生。一旦finally代码块执行完毕,编译器所创建的临时变量的值就会返回给函数的调用者。

 

如你所见,为了让整个机制运行起来,编译器必须生成一些额外代码,而系统也必须执行一些额外工作。在不同的CPU体系结构上,让终止处理工作起来的步骤也不同。需要注意的是:应该避免在try代码块中使用return语句,因为这对应用程序性能是有害的。我们将在本章稍后部分讨论__leave关键字,它可以帮助我们发现那些有局部展开开销的代码。

 

异常处理是用来捕获那些本不应该经常发生的异常(在我们的例子中,即try代码块中提前调用的return语句)。如果是常见的问题,应该显式地检查这些问题以提高运行效率,而不是依赖于操作系统和编译器的SEH机制来捕捉这些问题。

 

如果代码控制流正常地离开try代码块进入finally代码块(如我们在Funcenstein1中演示的那样),那么进入finally代码块的额外开销就是最小的。若是使用Microsoft的编译器,而应用程序又运行在x86体系结构的CPU上,离开try代码块进入finally代码块只需要执行一条机器指令——笔者怀疑你甚至不会觉察到这种开销。当编译器需要生成额外代码,而系统也必须作一些额外工作时,开销才会更加明显。

 

Funcenstein3

现在,让我们再修改一下这个函数,看看会发生什么:

 

DWORD Funcenstein3() {

DWORD dwTemp;

 

// 1. Do any processing here.

...

__try {

// 2. Request permission to access

// protected data, and then use it.

WaitForSingleObject(g_hSem, INFINITE);

 

g_dwProtectedData = 5;

dwTemp = g_dwProtectedData;

 

// Try to jump over the finally block.

goto ReturnValue;

}

 

__finally {

           // 3. Allow others to use protected data.

ReleaseSemaphore(g_hSem, 1, NULL);

}

 

dwTemp = 9;

// 4. Continue processing.

ReturnValue:

return(dwTemp);

}

 

当编译器看到函数Funcenstein3try块的goto语句时,就会产生局部展开以执行finally代码块。这一次,当finally代码块执行完毕后,因为tryfinally代码块中都没有函数返回语句,所以ReturnValue标签后的代码也会被执行。因此这个函数的返回值为5。但是因为破坏了代码从try块到finally块的正常执行流程,可能有比较大的性能损失,其程度取决于运行程序的CPU体系结构。

Funcfurter1

现在让我们再观察一个例子,在这个例子中,终止处理将真正证明它的价值。首先看一下这个函数:

 

DWORD Funcfurter1() {

DWORD dwTemp;

 

// 1. Do any processing here.

...

__try {

// 2. Request permission to access

// protected data, and then use it.

WaitForSingleObject(g_hSem, INFINITE);

dwTemp = Funcinator(g_dwProtectedData);

}

__finally {

// 3. Allow others to use protected data.

ReleaseSemaphore(g_hSem, 1, NULL);

}

 

// 4. Continue processing.

return(dwTemp);

}

 

假设try代码块中的Funcinator函数存在一个缺陷会导致程序访问非法的内存。如果没有SEH,这种情况下最终导致Windows错误报告(Windows Error Reporting,后面简称为WER)弹出一个对话框:“Application has stopped working”。这个对话框在Windows上经常可以见到。我们将在第25章详细讨论它。一旦用户取消这个对话框,进程就会终止(因为非法的内存访问),但信号量将依然被占用并再也得不到释放。其他进程中的线程就会因为无休止地等待这个信号量而得不到CPU时间片。如果把释放信号量的语句置于finally块中,即使try中调用的函数发生了内存访问违规这样的异常,这个信号量也可以被释放。但是请注意,从Windows Vista系统开始,必须显式地保护try/finally框架,以确保在异常抛出时,finally代码块会执行。读者可以在第673页的“SEH 终止示例程序”一节中找到相关解释。我们在下一章还会深入探讨使用try/except保护代码的细节。

 

然而,就算在早期的Windows系统里,在异常发生时,finally块也不能保证绝对能得到执行。例如,在windows XP系统里,如果一个“栈耗尽异常[7]”发生在try代码块里,finally块就很有可能得不到运行机会,因为运行在出错进程里的WER代码都可能没有足够的栈空间去报告错误。所以在这种情况下,进程往往是不加任何提示地被终止。还有,如果异常导致SEH[8]的中断,终止处理程序也不会得到执行。最后,如果异常发生在异常过滤程序里,终止处理程序也不会被执行。一条经验法则是尽量限制在catch或者finally块中代码所做的工作,否则进程很有可能会在finally块执行前突然终止。这也就是为什么在Windows Vista系统上,错误报告过程(WER)运行在另一个独立的进程里(详见第25章)。

 

如果终止处理程序强大到能捕获非法的内存访问引起的进程终止,我们没有理由怀疑它也能捕获setjumplongjump的结合,当然更不用说类似于breakcontinue这样的简单语句。

 



[1] 译注:Termination Handlers

[2] 译注:stack frame

[3] 译注:termination handling

[4] 译注:exception handling

[5] 译注:the guarded body

[6] 译注:local unwind

[7] 译注:stack exhaustion exception

[8] 译注:SEH