<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:trackback="http://madskills.com/public/xml/rss/module/trackback/" xmlns:wfw="http://wellformedweb.org/CommentAPI/" xmlns:slash="http://purl.org/rss/1.0/modules/slash/"><channel><title>翻译</title><link>http://blog.vckbase.com/localvar/category/1213.html</link><description>翻译</description><managingEditor>局部变量</managingEditor><dc:language>zh-CHS</dc:language><generator>.Text Version 0.958.2004.214</generator><item><dc:creator>局部变量</dc:creator><title>翻译：sqlite中原子提交的实现</title><link>http://blog.vckbase.com/localvar/archive/2008/02/13/32581.html</link><pubDate>Wed, 13 Feb 2008 01:47:00 GMT</pubDate><guid>http://blog.vckbase.com/localvar/archive/2008/02/13/32581.html</guid><wfw:comment>http://blog.vckbase.com/localvar/comments/32581.html</wfw:comment><comments>http://blog.vckbase.com/localvar/archive/2008/02/13/32581.html#Feedback</comments><slash:comments>5</slash:comments><wfw:commentRss>http://blog.vckbase.com/localvar/comments/commentRss/32581.html</wfw:commentRss><trackback:ping>http://blog.vckbase.com/localvar/services/trackbacks/32581.html</trackback:ping><description>最近在实现一个类似数据库事务操作的东西，找到了&lt;A href="http://www.sqlite.org/atomiccommit.html"&gt;这篇关于sqlite事务实现的文章&lt;/A&gt;，觉得还不错。由于网上相关的中文资料很少，所以决定把它翻译过来。不过，等我翻译完了之后，发现有人已经&lt;A href="http://chensheng.net/p/sqlite/auto_commit_zh_cn.html"&gt;先我一步完成了&lt;/A&gt;，我对比了一下这两个译本，自认为我的翻译质量更高一点，故仍有必要把它也发布出来。 
&lt;H3&gt;1. 引言&lt;/H3&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;像SQLITE这样支持事务的数据库的一个重要特性是&amp;#8220;原子提交&amp;#8221;。原子提交意味着，一个事务中的所有修改动作要么全都发生，要么一个都不发生。有了原子提交，对一个数据库文件不同部分的多次写操作，就会像瞬间同时完成了一样。当然，现实中的存储器硬件会把写操作串行化，并且写每个扇区都会花上那么一小段时间，所以，绝对意义上的&amp;#8220;瞬间同时完成&amp;#8221;是不可能的。但SQLITE的原子提交逻辑还是让整个过程看起来像那么回事。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;SQLITE保证，即使事务执行过程中发生了操作系统崩溃或掉电，整个事务也是原子的。本文描述了SQLITE实现原子提交时所采用的技术。 
&lt;H3&gt;2. 对硬件的假设&lt;/H3&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;虽然有的时候会使用闪存，但下文中，我们将把存储设备称为&amp;#8220;磁盘&amp;#8221;。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;我们假设对磁盘的写操作是以&amp;#8220;扇区&amp;#8221;为单位的，也就是说不可能直接对磁盘进行小于一个扇区的修改，要想进行这类修改，你必须把整个扇区读进内存，进行所需的修改，然后再把整个扇区写回去。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;对真正&amp;#8220;磁盘&amp;#8221;来说，读写操作的最小单位都是一个扇区；但闪存有些不同，它们的最小读单位一般远小于最小写单位。SQLITE只关心最小写单位，所以，在本文中，我们说&amp;#8220;扇区&amp;#8221;的时候，指的是向存储器中写数据时的最小数据量。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;3.3.14版之前，SQLITE在任何情况下都认为一个扇区的大小是512字节，有一个编译期选项能改变这个值，但从未有人用更大一些的值测试过相关代码。直到不久以前，把这个值定为512都是合理的，因为所有的磁盘驱动器都在内部使用512字节的扇区。但最近，有人把磁盘扇区的大小提升到了4096字节，而且，闪存的扇区一般也是大于512字节的。由于这些原因，从3.3.14版开始，SQLITE的操作系统接口层提供了一种可以从文件系统获取真实扇区大小的方法。不过，到目前为止（3.5.0版），这一方法仍然只是返回一个硬编码的512字节，因为不论是win32系统还是unix系统，都没有一个标准的机制来获得实际的值。但这种方法给了嵌入式设备的提供商们根据实际情况进行调整的能力，也让我们未来在win32和unix上给出一个更有意义的实现成了可能。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;SQLITE并不假设对扇区的写操作是原子的，它仅假设这种写是&amp;#8220;线性&amp;#8221;的。所谓线性是指：写一个扇区时，硬件总是从扇区一端开始，一个字节一个字节的写到另一端结束，中间不会后退，硬件可以从头向尾写，也可以从尾向头写。如果掉电发生时只写到了扇区的中间，则可能出现扇区一部分修改了而另一部分没被修改的情况。SQLITE在这里做的一个关键假设是：只要扇区被修改了，那么它的第一个字节和最后一个字节中的至少一个会被修改，也就是说，硬件绝不会从中间开始向两端写。我们不清楚这个假设是否总是对的，但它看起来是合理的。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;在上一段中，我们说&amp;#8220;SQLITE没有假设写扇区是原子的&amp;#8221;。默认情况下，这是正确的，但在3.5.0版中，我们增加了一个叫做&amp;#8220;虚拟文件系统（VFS）&amp;#8221;的接口，它是SQLITE和底层文件系统通讯的唯一路径。代码中包含了用于unix和windows的默认VFS实现，同时提供了一种在运行时创建新VFS实现的机制。在这个新的VFS接口中有一个称为&amp;#8220;xDeviceCharacteristics&amp;#8221;的方法，它通过询问文件系统来判断文件系统是否支持某些特性。如果文件系统支持某个特性，SQLITE就会试着利用这个特性进行某种优化。默认的xDeviceCharacteristics不会指出文件系统支持原子的写扇区操作，所以与此相关的优化都是关闭的。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;SQLITE假设操作系统会缓冲写操作，并且写操作会在数据被真正写到磁盘上之前返回。SQLITE还假设写操作会被操作系统记录下来。因此，SQLITE会在关键点上执行&amp;#8220;flush&amp;#8221;或&amp;#8220;fsync&amp;#8221;，并假设&amp;#8220;flush&amp;#8221;和&amp;#8220;fsync&amp;#8221;会等所有正在进行的&amp;#8220;写操作&amp;#8221;真正执行完毕后才返回。在某些版本的windows和unix上，&amp;#8220;flush&amp;#8221;和&amp;#8220;fsync&amp;#8221;原语会被打断，这非常不幸，在这些系统上，如果提交的过程中发生了掉电，SQLITE的数据库有可能崩溃掉，而SQLITE自己则对此无能为力。SQLITE假设操作系统能像广告宣传的那样完美，如果事实并非如此，你只好祈求老天保佑不要经常掉电了。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;SQLITE假设文件增长时，新增加的部分最初包含的是垃圾数据，然后它们会被实际的数据覆盖掉。换句话说，SQLITE假设文件大小的变化发生在文件内容变化之前。这是个悲观的假设，为了保证在从&amp;#8220;文件大小改变&amp;#8221;开始到&amp;#8220;文件内容写完&amp;#8221;为止的这段时间内，系统掉电不会导致数据库崩溃，SQLITE要做一些额外的工作。VFS的xDeviceCharacteristics也可能会指出文件系统总是先写数据后更新文件的大小，这种情况下，SQLITE可以跳过一些过于小心的数据库保护操作，从而减少一次提交所需的磁盘I/O数量。但目前windows和unix上的VFS实现都没有做这个假设。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;SQLITE假设文件删除是原子的，至少从用户程序的角度来看要是这样。也就是说，如果SQLITE要删除一个文件，并且删除的过程中掉电了，那么电力恢复后，文件要么不能从文件系统中找到，要么它的内容和删除之前一模一样。如果文件还能从文件系统中找到，但内容被修改或清空了，那么数据库极有可能会崩溃。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;SQLITE假设检测由宇宙射线、热噪声、驱动程序bug等引起的位错误（bit error）是操作系统和硬件的责任。SQLITE没有在数据库文件中增加任何冗余信息来检测或纠正这类问题。SQLITE假设它所读的数据与它上次所写的数据总是完全相同。 
&lt;H3&gt;3. 单文件提交&lt;/H3&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;我们先来从整体上看看SQLITE在一个单独的数据库文件上操作时，要保证事务提交的原子性需要哪些步骤。为防止掉电时文件被破坏，文件格式在设计时也有相应考虑，相关细节和多数据库提交技术将在后续章节讨论。 
&lt;H4&gt;3.1. 初始状态&lt;/H4&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;下图给出了数据库连接刚刚打开时计算机的状态。图的最右侧是存储在磁盘上的数据，每个小格代表一个扇区，蓝色表示扇区存储的是原始数据；图的中间部分是操作系统的缓存，在当前的例子中，缓存是&amp;#8220;冷&amp;#8221;的，所以它的每个格都没有着色；最左侧是使用SQLITE的进程（译注：本文的作者可能更喜欢unix，所以在windows上，原文中的部分&amp;#8220;进程&amp;#8221;用&amp;#8220;线程&amp;#8221;替换一下会更好，我没有做这种替换，故需要您在阅读过程中结合上下文判断&amp;#8220;进程&amp;#8221;的具体含义）的内存，数据库连接刚刚创建，还没有读任何数据，所以用户的内存空间中什么也没有。 &lt;BR&gt;&lt;IMG src="/images/vckbase_com/localvar/1209/o_commit-0.gif"&gt; 
&lt;H4&gt;3.2. 获取一个&amp;#8220;读锁&amp;#8221;&lt;/H4&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;SQLITE写数据库之前，必须先读，这样它才能知道数据库中已经有些什么了。即使是单纯的追加数据，SQLITE也要先从sqlite_master表中读出数据库的表结构，从而知道如何去解析INSERT语句，以及新数据应该保存到文件的哪个位置。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;读操作的第一步是获取一个数据库文件的&amp;#8220;共享锁&amp;#8221;。这个共享锁允许两个或多个数据库连接同时读数据库文件，但不许其他数据库连接写这个文件。这个锁非常重要，因为，如果在读数据的过程中另一个连接写了数据，我们就可能读到一个新数据和旧数据的混合体，这会让其他连接的写操作失去原子性。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;请注意，共享锁是操作系统的磁盘缓存实现的，而不是磁盘本身。一般来说，文件锁仅仅是操作系统内核中的一些标志（细节取决于具体操作系统的接口层）。所以，当系统崩溃或掉电后，这个锁就自动消失了。并且，通常情况下，创建这个锁的进程退出后，锁也会自动消失。 &lt;BR&gt;&lt;IMG src="/images/vckbase_com/localvar/1209/o_commit-1.gif"&gt; 
&lt;H4&gt;3.3. 从数据库中读数据&lt;/H4&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;获得共享锁后，我们开始从数据库文件中读出数据。在这个例子中，由于我们假设最初的缓存是&amp;#8220;冷&amp;#8221;的，所以要先把数据从磁盘读到操作系统的缓存，再把它们从缓存复制到用户空间。后续的读操作，由于部分或全部数据可能已经在缓存中了，或许就只需要从缓存复制到用户空间这一步了。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;一般情况下，我们不会需要数据库文件的所有页（译注：页是SQLITE对数据进行缓冲的最小单位，但本文中有时它和扇区是一个意思，请注意结合上下文区分），所以我们读的只是它的一个子集。本例中，我们的数据库文件有8个页，而我们需要的是其中的3个。一个真实的数据库可能有数千个页，但每次查询要访问的一般只是其中很小的一部分。 &lt;BR&gt;&lt;IMG src="/images/vckbase_com/localvar/1209/o_commit-2.gif"&gt; 
&lt;H4&gt;3.4. 获取一个预定（Reserved）锁&lt;/H4&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;在对数据库做任何修改之前，SQLITE需要获得一个预定锁。预定锁和共享锁很像，它们都允许其他进程读数据库文件。并且，预定锁也可以和多个共享锁共存。但是，一个数据库文件某一时刻只能有一个预定锁，也就是只允许一个进程有写数据的意图。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;预定锁的目的是告诉整个系统：有一个进程要在不久的将来修改数据库文件了，但它目前还没有任何实际行动。由于仅仅是个&amp;#8220;意图&amp;#8221;，其他进程还可以继续自己的读操作，但是它们不能也有这个意图了。 &lt;BR&gt;&lt;IMG src="/images/vckbase_com/localvar/1209/o_commit-3.gif"&gt; 
&lt;H4&gt;3.5. 创建回滚日志（Journal）文件&lt;/H4&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;在任何实质性的修改之前，SQLITE还需要创建一个独立的回滚日志文件，并把所有要被替换的数据库页的原始内容写到这个文件中去。实际上，日志文件将保存将数据库文件恢复到原始状态所需的全部信息。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;日志文件有一个不大的文件头（图中用绿色表示），它记录了数据库文件的原始大小。如果数据库文件因为修改变大了，我们仍然可以凭它来获得文件的原始大小。数据库页和它们的对应的页号会被放在一起写到日志文件中去。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;创建新文件时，大多数操作系统（windows、linux、macOSX等）并不会立即向磁盘写数据。新文件一开始只存在于操作系统的缓存中，直到操作系统有空闲的时候，它才会真的去在磁盘上创建这个文件。这种方式让用户觉得文件创建非常快，起码比真的去做磁盘I/O快多了。在下图中，为了表示这一情形，我们只在操作系统缓存中画了这个日志文件。 &lt;BR&gt;&lt;IMG src="/images/vckbase_com/localvar/1209/o_commit-4.gif"&gt; 
&lt;H4&gt;3.6. 在用户空间中修改数据库&lt;/H4&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;数据库页的原始内容保存到日志文件后，就可以在用户空间中修改了。每个数据库连接有一份私有的用户空间拷贝，所以这些修改只会被当前的连接看到，其他连接看到的仍然是操作系统缓存中未被修改的内容。在这种情况下，虽然有一个进程正在对数据库进行修改，其他进程仍然可以继续读数据库的原始内容。 &lt;BR&gt;&lt;IMG src="/images/vckbase_com/localvar/1209/o_commit-5.gif"&gt; 
&lt;H4&gt;3.7. 把日志文件&amp;#8220;刷&amp;#8221;到磁盘&lt;/H4&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;下一步是把回滚日志文件的内容刷到具有持久性的存储器上。后面你会看到，这是让数据库能够在掉电情况下存活的关键之一。它可能要花不少时间，因为往持久性存储器上写东西一般是很慢的。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;这一步通常比仅仅把回滚日志刷到磁盘上复杂的多。在大多数平台上，你要刷（flush或fsync）两次才行。第一次是日志文件的基本内容。然后修改日志文件的头部，以反应日志文件中实际的页面数。接着刷第二次，把文件头刷上去。至于为什么要修改文件头并多刷一次，我们将在后续章节讨论。 &lt;BR&gt;&lt;IMG src="/images/vckbase_com/localvar/1209/o_commit-6.gif"&gt; 
&lt;H4&gt;3.8. 获取一个独占锁&lt;/H4&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;为了对数据库文件进行真正的修改，我们需要一个独占锁。获取这个锁需要两步，首先是获取一个待决（Pending）锁，然后再把它提升为独占锁。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;待决锁允许其他已经有了共享锁的进程继续读数据库文件，但它不允许创建新的共享锁。设计它的目的是为了避免一大堆读进程把写进程给饿到。系统中可能会有几十甚至上百个进程想读数据库文件，每个这样的进程都要经历一个&amp;#8220;获得共享锁、读数据、释放锁&amp;#8221;的过程。如果很多进程都想读同一个数据库文件，那么一个极有可能现象是：新进程总是在已有的进程释放共享锁之前获得一个新的共享锁。这样一来，数据库文件就上就总有共享锁了，要写数据的进程可能会一直没有机会得到自己的独占锁。通过禁止创建新的共享锁，待决锁解决了这个问题，已有的共享锁会逐渐被释放，最终，当它们全部被释放后，待决锁就可以升级到独占锁了。 &lt;BR&gt;&lt;IMG src="/images/vckbase_com/localvar/1209/o_commit-7.gif"&gt; 
&lt;H4&gt;3.9. 更新数据库文件&lt;/H4&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;一旦获得独占锁，就可以保证没有其他进程在读这个数据库文件了，这时更新它就是安全的了。一般来说，这里的更新只会影响到操作系统磁盘缓存这一层，而不会影响磁盘上的物理文件。 &lt;BR&gt;&lt;IMG src="/images/vckbase_com/localvar/1209/o_commit-8.gif"&gt; 
&lt;H4&gt;3.10. 把变化刷到存储器&lt;/H4&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;为了把数据库的变化写到持久性存储器，我们还要再刷一次。这也是保证数据库在掉电情况下不崩溃的关键。当然，向磁盘或闪存写数据实在是太慢了，这一步和3.7节中的刷日志文件加在一起会消耗掉SQLITE一次事务提交的绝大部分时间。 &lt;BR&gt;&lt;IMG src="/images/vckbase_com/localvar/1209/o_commit-9.gif"&gt; 
&lt;H4&gt;3.11. 删除日志文件&lt;/H4&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;把所有变化都安全的写到存储器上以后，回滚日志文件就可以删除了。这是提交事务的那个时间点。如果掉电或系统崩溃发生在这之前，后面将要介绍的恢复过程会让数据库文件回到修改之前的状态，就好像什么都没发生过一样。如果掉电或系统崩溃发生在日志文件被删除之后，那么所有的修改都会生效。所以，SQLITE对数据库的修改全部有效还是全部无效，实际上是取决于这个日志文件是否存在。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;删除文件不一定真的是原子操作，但从用户程序的角度来看，它却好像总是原子的。进程总可以询问操作系统&amp;#8220;这个文件存在吗？&amp;#8221;并等到是或否的回答。如果事务提交过程中发生了掉电，SQLITE就会问操作系统是否存在回滚日志文件，存在则事务是不完整的，需要回滚，不存在则说明事务确实成功提交了。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;SQLITE事务的实现依赖于回滚日志文件是否存在和用户程序眼中的原子的文件删除。所以，事务也是一个原子操作。 &lt;BR&gt;&lt;IMG src="/images/vckbase_com/localvar/1209/o_commit-A.gif"&gt; 
&lt;H4&gt;3.12. 释放锁&lt;/H4&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;最后一步是释放独占锁，这样其他进程就又能访问数据库文件了。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;在下图中，我们看到，用户空间中的数据在锁被释放后就清除了。如果是较早版本的SQLITE，这是实际情况。但从最近几版开始，SQLITE不这么做了，因为下个操作可能还会用到它们。比起从操作系统的缓存或磁盘中读数据来，重用这些已经在本地内存中的数据的性能要高得多。再次使用它们之前，我们要先得到一个共享锁，然后再检查一下在我们没有锁的这段时间内是否有别的进程修改了数据库文件。数据库的第一页有一个计数器，每次对数据库进行修改时都会递增它。检查这个计数器，就能知道数据库是否被别的进程修改过了。如果修改过，就必须清除用户空间中的数据并把新数据读进来。但更大的可能是没有任何修改，这样就可以重用原有的数据，从而大幅提高效率。 &lt;BR&gt;&lt;IMG src="/images/vckbase_com/localvar/1209/o_commit-B.gif"&gt; 
&lt;H3&gt;4. 回滚&lt;/H3&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;原子提交看起来是瞬间完成的，但很明显，前面介绍的过程需要一定的时间才能完成。如果在提交过程中电源被切断，为了让整个过程看起来是瞬时的，我们必须回滚那些不完整的修改，并把数据库恢复到事务开始之前的状态。 
&lt;H4&gt;4.1. 如果出了问题&amp;#8230;&lt;/H4&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;假设掉电发生在3.10节所讲的那一步，也就是把数据库变化刷到磁盘中去的时侯。电力恢复后，情况可能会像下图所示的那样。我们要修改三页数据，但只成功完成了一页，有一页只写了一部分，另一页则一点都没写。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;电力恢复后日志文件是完整的，这是个关键。3.7节中的操作就是为了保证在对数据文件做任何改变之前回滚日志的所有内容已经安全的写到持久性存储器中去了。 &lt;BR&gt;&lt;IMG src="/images/vckbase_com/localvar/1209/o_rollback-0.gif"&gt; 
&lt;H4&gt;4.2. &amp;#8220;热的&amp;#8221;回滚日志&lt;/H4&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;任何进程第一次访问数据库文件之前，必须获得一个3.2节中描述的共享锁。然后，如果发现还有一个日志文件，SQLITE就会检查这个回滚日志是不是&amp;#8220;热的&amp;#8221;。我们必须回放热日志文件，从而把数据库恢复到一致的状态。只有在一个程序正在提交事务时发生掉电或崩溃的情况下，才会出现热日志文件。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;日志文件在符合以下所有条件时才是热的： &amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; 
&lt;LI&gt;日志文件是存在的 &amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; 
&lt;LI&gt;日志文件不是空文件 &amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; 
&lt;LI&gt;数据库文件上没有预定锁 &amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; 
&lt;LI&gt;日志文件头中没有主日志文件的文件名，或者，如果有主日志文件名的话，主日志文件是存在的。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;热日志文件告诉我们：之前有进程试图提交一个事务，但由于某种原因，这个提交没有完成。也就是说：数据库处于一种不一致的状态，使用之前必须修复（回滚）。 &lt;BR&gt;&lt;IMG src="/images/vckbase_com/localvar/1209/o_rollback-1.gif"&gt; 
&lt;H4&gt;4.3. 获取数据库上的独占锁&lt;/H4&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;处理热日志的第一步是获得数据库文件上的独占锁，这可以防止两个或更多的进程同时回放一个热日志。 &lt;BR&gt;&lt;IMG src="/images/vckbase_com/localvar/1209/o_rollback-2.gif"&gt; 
&lt;H4&gt;4.4. 回滚不完整的修改&lt;/H4&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;获得了独占锁，进程就有权力修改数据库文件了。它从日志中读出页面的原有内容，然后把它们分别写回到其在数据库文件中的原始位置上去。前面说过，日志文件的头部记录了数据库文件在事务开始前的大小，如果修改让数据库文件变大了，SQLITE会使用这一信息把文件截断到原始大小。这一步结束之后，数据库文件就应该和事务开始前一样大，并且包含和那时完全一样的数据了。 &lt;BR&gt;&lt;IMG src="/images/vckbase_com/localvar/1209/o_rollback-3.gif"&gt; 
&lt;H4&gt;4.5. 删除热日志文件&lt;/H4&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;日志中的所有信息都回放到数据库文件，并将数据库文件刷到磁盘（回滚时可能会再次掉电）以后，就可以删除热日志文件了。 &lt;BR&gt;&lt;IMG src="/images/vckbase_com/localvar/1209/o_rollback-4.gif"&gt; 
&lt;H4&gt;4.6. 继续前进，就像那个中断了的事务根本没发生过一样&lt;/H4&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;回滚的最后一步是把独占锁降级为共享锁。此后，数据库的状态看起来就像那个中断了的事务根本没有开始过一样了。由于整个回滚过程是完全自动、透明的，使用SQLITE的那个程序根本就不会知道有一个事务中断并回滚了。 &lt;BR&gt;&lt;IMG src="/images/vckbase_com/localvar/1209/o_rollback-5.gif"&gt; 
&lt;H3&gt;5. 多文件提交&lt;/H3&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;通过ATTACH DATABASE命令，SQLITE允许一个数据库连接使用多个数据库文件。当在一个事务中修改多个文件时，所有文件都会被原子的更新。换句话说，或者所有文件都会被更新，或者一个也不会被更新。在多个文件上实现原子提交比在单个文件上实现更复杂，本章将解释SQLITE是如何做到这一点的。 
&lt;H4&gt;5.1. 每个数据库一个日志&lt;/H4&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;当一个事务涉及了多个数据库文件时，每个数据库都有自己回滚日志，并且对它们的锁也是各自独立的。下图展示了三个数据库文件在一个事务中被修改的情况，它所描述的状态相当于单文件事务在第3.6节中的状态。每个数据库文件有各自的预定锁，它们将要被修改的那些页的原始内容已经写进回滚日志了，但还没有刷到磁盘上。用户内存中的数据已经被修改了，不过数据库文件本身还没有任何变化。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;相比之前，下图做了一些简化。在这张图上，蓝色仍然代表原始数据，粉红色仍然代表新数据。但上面没有画出回滚日志和数据库的页，并且也没有明确区分操作系统缓存中的数据和磁盘上的数据。所有这些在这张图上仍然适用，不过即使把它们画出来我们也学不到什么新的东西，所以，为了缩小图幅，我们把它们省略掉了。 &lt;BR&gt;&lt;IMG src="/images/vckbase_com/localvar/1209/o_multi-0.gif"&gt; 
&lt;H4&gt;5.2. 主日志文件&lt;/H4&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;多文件提交中的下一步是创建一个&amp;#8220;主日志文件&amp;#8221;。这个文件的名字是最初的数据库文件名（也就是用sqlite3_open()打开的那个数据库，而不是之后附加上来的那些）加上后缀&amp;#8220;-mjHHHHHHHH&amp;#8221;。其中HHHHHHHH是一个32位16进制随机数，每次生成新的主日志文件时，它都会不同。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; （注意：上面一段中用来生成主日志文件名的方法是3.5.0版中使用的方法。这个方法并没有规范化，也不是SQLITE对外接口的一部分，在未来版本中，我们可能会修改它。） &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; 主日志中没有与原始数据库页面内容相关的信息，它里面保存的是所有参与到这个事务中的回滚日志文件的完整路径。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; 主日志生成完毕后，会被立即刷到磁盘上，中间没有任何别的操作。在unix系统上，主日志所在的目录，也会被同步一下，以确保掉电后它也会出现在这个目录下。 &lt;BR&gt;&lt;IMG src="/images/vckbase_com/localvar/1209/o_multi-1.gif"&gt; 
&lt;H4&gt;5.3. 更新回滚日志文件头&lt;/H4&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;下一步是把主日志的路径记录到回滚日志的文件头中去，回滚日志创建时在文件头预留了相应的空间。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;主日志路径写到回滚日志文件头之前和之后，要分别把回滚日志的内容往磁盘上刷一次。这可能有些效率损失，但非常重要，而且，幸运的是，刷第二次时一般只有一页（最开始的那页）数据有变化，所以整个操作可能并没有想象的那么慢。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;这个操作大致相当于单文件提交时的第7步，也就是第3.7节中的内容。 &lt;BR&gt;&lt;IMG src="/images/vckbase_com/localvar/1209/o_multi-2.gif"&gt; 
&lt;H4&gt;5.4. 更新数据库文件&lt;/H4&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;把回滚日志刷到磁盘上后，就可以安全的更新数据库文件了。我们需要获得所有数据库文件上的独占锁，然后写数据，并把这些数据刷到磁盘上去。这一步相当于单文件提交时的第8、9和10步。 &lt;BR&gt;&lt;IMG src="/images/vckbase_com/localvar/1209/o_multi-3.gif"&gt; 
&lt;H4&gt;5.5. 删除主日志文件&lt;/H4&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;下一步是删除主日志文件，这是多文件事务被实际提交的时间点。它相当于单文件提交时的第11步，也就是删除日志文件的那一步。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;如果掉电或系统崩溃发生在这之后，重启时，即使存在回滚日志文件，事务也不会被回滚。这里的区别在于回滚日志的文件头里面有主日志的路径。SQLITE只认为文件头中没有主日志文件路径的回滚日志（单文件提交的情况）或主日志文件仍然存在的回滚日志是&amp;#8220;热的&amp;#8221;，并且只会回放热的回滚日志。 &lt;BR&gt;&lt;IMG src="/images/vckbase_com/localvar/1209/o_multi-4.gif"&gt; 
&lt;H4&gt;5.6. 清理回滚日志文件&lt;/H4&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;最后是删除所有的回滚日志文件，释放独占锁以便其他进程发现数据的变化。这一步对应的是单文件提交时的第12步。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;由于事务已经提交了，所以删除这些文件在时间上并不是非常紧迫。当前的实现是删除一个日志文件，并释放其对应的数据库文件上的独占锁，然后再接着处理下一个。今后，我们可能把它改成先删除所有日志文件，再释放独占锁。这里，只要保证删除日志文件在前，释放其对应的锁在后就行，文件被删除的顺序或锁被释放的顺序并不重要。 &lt;BR&gt;&lt;IMG src="/images/vckbase_com/localvar/1209/o_multi-5.gif"&gt; 
&lt;H3&gt;6. 提交中的更多细节&lt;/H3&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;第3章从总体上介绍了SQLITE原子提交的实现方法，但漏掉了几个重要的细节，本章将对它们进行一些补充说明。 
&lt;H4&gt;6.1. 总是日志中记录整个扇区&lt;/H4&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;在把数据库页面的原始内容写进回滚日志时，即使页面比扇区小，SQLITE也会把完整的扇区写进去。从前，SQLITE中的扇区大小是硬编码的512字节，而最小页面也是512字节，所以不会有什么问题。但从3.3.14版开始，SQLITE也支持扇区大小超过512字节的存储器了，所以，从这一版起，当某个扇区中的任何页面被写进日志时，这个扇区中的其它页面也会被一同写进去。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;掉电可能在写扇区时发生，总是记录整个扇区可以在这种情况下保证数据库不被破坏。例如，我们假设每个扇区有四个页面，现在2号页面被修改了，为了把变化写入这个页面，底层硬件，因为它只能写完整的扇区，也会把1、3、4号页面重新写一遍，如果写操作被打断，这三个页面的数据可能就不对了。为了避免这种情况，必须把扇区中的所有页面写到回滚日志中去。 
&lt;H4&gt;6.2. 日志文件中的垃圾数据&lt;/H4&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;向日志文件末尾追加数据时，SQLITE一般悲观的假设文件系统会先用垃圾数据把文件撑大，再用正确的数据覆盖这些垃圾。换句话说，SQLITE假设文件体积先变大，之后才是写入实际内容。如果掉电发生在文件已经变大但数据还未写入时，回滚日志中就会包含垃圾数据。电力恢复后，另一个SQLITE进程会发现这个日志文件，并试图恢复它，这就有可能把垃圾数据拷贝到数据库文件，进而对其造成破坏。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;为对付这个问题，SQLITE建立了两道防线。首先，SQLITE在回滚日志的文件头中记录了实际的页面数。这个数字一开始是0，所以，在回放一个不完整的回滚日志时，SQLITE会发现文件中没有包含任何页面，也就不会对数据库做任何修改。提交之前，回滚日志会被刷到磁盘上，以保证其中没有任何垃圾。之后，文件头中的页面数才会被改成实际的数值。文件头总是保存在一个单独的扇区去，所以，如果在覆盖它或把它刷到磁盘上时发生掉电，其它页面是不会被破坏的。注意回滚日志要往磁盘上刷两次：第一次是写页面的原始内容，第二次是写文件头中的页面数。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;上一段描述的是同步选项设置为&amp;#8220;full&amp;#8221;（PRAGMA synchronous=FULL）时的情形，这也是默认的设置。不过，当同步选项低于&amp;#8220;normal&amp;#8221;时，SQLITE只会刷一次日志文件，也就是修改完页面数后的那一次。由于（大于0的）页面数可能先于其它数据到达磁盘，这样做有一定的风险。SQLITE假设文件系统会记录写请求，所以即使先写数据后写页面数，页面数也可能会先被磁盘记录下来。所以，作为第二道防线，SQLITE在日志文件中为每页数据都记录了一个32位的校验码。回滚日志文件时，SQLITE会检查这个校验码，一旦发现错误，就会放弃回滚操作。要注意的是，校验码无法完全保证页面数据的正确性，数据有错误但校验码正确的概率虽然极小，却不是零.。不过，校验码机制至少让类似的事情看起来不那么容易发生了。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;在同步选项设置为&amp;#8220;full&amp;#8221;时，就没有必要用校验码了，我们只在同步选项低于&amp;#8220;normal&amp;#8221;时才需要它。然而，鉴于校验码是无害的，故不管同步选项如何设置，它们总是出现在回滚日志中的。 
&lt;H4&gt;6.3. 提交之前的缓存溢出&lt;/H4&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;第三章描述的过程假设提交之前所有的数据库变化都能保存在内存中。一般来说就是这样的，但特殊情况也会出现。这时，数据库变化会在事务提交之前用完用户缓存，需要把缓存中的内容提前写入数据库才行。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;操作之前，数据库连接处于第3.6步时的状态：原始页面的内容已经保存到回滚日志了，修改后的页面位于用户内存中。为了回收缓存，SQLITE执行第3.7到3.9步，也就是把回滚日志刷到磁盘上，获取独占锁，然后把变化写入数据库。但后续步骤在事务真正提交之前都有所不同。SQLITE会在日志文件的最后追加一个文件头（使用一个单独的扇区），独占锁继续保留，而执行流程将跳到第3.6步。当事务提交或再次回收缓存时，将重复执行第3.7和3.9步（由于第一次回收缓存时获得了独占锁且一直没有释放，3.8步将被跳过）。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;把预定锁提升为独占锁将降低并发度，额外的刷磁盘操作也非常慢，所以回收缓存会严重影响系统效率。因此，只要有可能，SQLITE就不会使用它。 
&lt;H3&gt;7. 优化&lt;/H3&gt;&lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;对程序的性能分析显示，在绝大多数系统和绝大多数情况下，SQLITE把绝大部分时间消耗在了磁盘I/O上。所以，减少磁盘I/O的数量是最有可能大幅提升效率的方法。本章将介绍SQLITE在保证原子提交的前提下，为减少磁盘I/O而使用的一些技术。 
&lt;H4&gt;7.1. 在事务之间保持缓存数据&lt;/H4&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;在3.12节中，我们说过当释放共享锁时会丢弃所有已经在用户缓存中的数据库信息。之所以这样做，是因为没有共享锁的时候其他进程能够随意修改数据库文件的内容，从而导致已经缓存的数据过时。所以，每当一个新事务开始时，SQLITE都必须重新读一次以前读过的东西。这个操作并不像大家想象的那么糟糕，因为要重新读的数据极有可能仍在操作系统的缓存中，所谓的&amp;#8220;重读&amp;#8221;一般仅仅是把数据从内核空间拷贝到用户空间而已。不过，即使如此，也是需要一些时间的。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;从3.3.14版开始，我们在SQLITE中增加了一个机制来避免不必要的重读。这些版本中，释放共享锁后，用户缓存的页面继续保留。等到SQLITE启动下一个事务并获得共享锁后，它会检查是否有其他进程修改了数据库文件。如果自上次释放锁后有修改，用户缓存会被清空并重读。但一般不会有任何修改，所以用户缓存仍然有效，这样很多不必要的读操作就被避免了。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;为了判断数据库文件是否被修改，SQLITE在文件头（第24到27字节）中使用了一个计数器，每个修改操作都会递增它。释放数据库锁之前，SQLITE会记下这个计数器的值，等到再次获得锁以后，它比较记录的值和实际的值，相同则重用已有的缓存数据，不同则清空缓存并重读。 
&lt;H4&gt;7.2. 独占访问模式&lt;/H4&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;自3.3.14版开始，SQLITE中增加了&amp;#8220;独占访问模式&amp;#8221;。在这种模式下，SQLITE会在事务提交后继续保留独占锁。这样一来，其他进程就不能访问数据库了。不过，由于大多数的部署方案都只有一个进程访问数据库，所以一般不会有什么问题。独占访问模式让以下三个减少磁盘I/O的方法成为了可能： 
&lt;OL&gt;
&lt;LI&gt;除了第一个事务，不必每次递增数据库文件头中的计数器。这通常意味着在数据库文件和回滚日志中各自少刷一次1号页面。 
&lt;LI&gt;因为没有别的进程能访问数据库，所以没必要每次启动事务时检查计数器和清空用户缓存。 
&lt;LI&gt;事务结束后可以截断（译注：把文件长度设置为0字节）回滚日志文件，而不是删除它。在很多操作系统上，截断比删除快的多。 &lt;/LI&gt;&lt;/OL&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;第三项优化，也就是用截断代替删除，并不要求一直拥有独占锁。理论上说，总是实现它，而不是只在独占访问模式下实现它是可能的，也许我们会在未来版本中让其成为现实。不过，到目前为止（3.5.0版），这项优化仍然只在独占访问模式下有效。 
&lt;H4&gt;7.3. 不记录空闲页面&lt;/H4&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;从数据库中删除数据时，那些不再使用的页面会被加到&amp;#8220;空闲页表&amp;#8221;里去。之后的插入操作将首先使用这些页面，而不是扩大数据库文件。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;一些空闲页面中也有重要数据，比如说其他空闲页面的位置等等。但大多数空闲页面的内容没有用，我们把这些页面称为&amp;#8220;叶页&amp;#8221;。修改叶页的内容对数据库没有任何影响。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;由于叶页的内容没用，SQLITE不会把它们在提交过程的第3.5步中记录到回滚日志里去。也就是说，修改叶页，但不在回滚过程中恢复它们对数据库无害。同样的，一个新叶页的内容既不会在第3.9步中写入数据库也不会在第3.3步中被读出来。在数据库文件有空闲空间时，这项优化大幅减少了磁盘I/O的数量。 
&lt;H4&gt;7.4. .单页更新和原子扇区写&lt;/H4&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;从3.5.0版开始，新的VFS接口包含了一个名叫xDeviceCharacteristics的方法，它可以报告底层存储器是否支持一些特性。这些特性中，有一个是&amp;#8220;原子扇区写&amp;#8221;。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;我们前面说过，SQLITE假设写扇区是线性的，而不是原子的。线性写从扇区的一端开始，逐字节写到另一端结束。如果在线性写的中间发生掉电，则可能扇区的一端被修改了，另一端却保持不变。但在原子写的情况下，扇区或者被完全更新了，或者完全没有变化。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;我们相信大多数现在磁盘驱动器实现了原子扇区写。掉电时，驱动器使用电容中的电能和（或）盘片旋转的动能完成正在进行的操作。然而，在系统写调用与磁盘电子元件之间存在太多的层次，所以我们在Unix和windows的默认VFS实现上做了一个保守的假设，认为写扇区不是原子的。另一方面，能对其使用的文件系统有更多发言权的设备厂商，如果它们的硬件确实支持原子扇区写，也许会选择打开xDeviceCharacteristics中的这个选项。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;当写扇区是原子的、数据库页面和扇区一样大，而且数据库的变化只涉及到一个页面时，SQLITE会跳过整个记日志和同步过程，直接把修改后的页面写到数据库文件上。数据库文件第一页上的修改计数器也会独立修改，因为即使在更新它之前掉电也是无害的。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;译注：个人认为，如果硬件不支持原子扇区写，是无法在软件层次上实现绝对意义上的原子提交的。 
&lt;H4&gt;7.5. 支持安全追加的文件系统&lt;/H4&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;3.5.0版加入的另一项优化措施是基于文件系统的&amp;#8220;安全追加&amp;#8221;功能的。SQLITE假设向文件（特别是回滚日志文件）追加数据时，文件大小的改变早于文件内容增加。所以，如果掉电发生在文件变大之后，数据写完之前，文件中就会包含垃圾数据。也可以通过VFS中的xDeviceCharacteristics方法指出文件系统支持&amp;#8220;安全追加&amp;#8221;功能，这意味着内容的增加早于大小的改变，所以掉电或系统崩溃不可能向日志文件中引入垃圾。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;文件系统支持安全追加时，SQLITE总是在日志文件头的页面数字段中填入-1，表示回滚时要处理的页面数应该根据日志文件的大小自动计算。这个-1不会被修改，所以提交时，我们可以不用单独刷一次日志文件的第一页。而且，当回收缓存时，也没有必要在日志文件末尾再写一个新的文件头了，我们只要继续在已有的日志文件上追加新页面即可。 
&lt;H3&gt;8. 对原子提交的测试&lt;/H3&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;我们作为SQLITE的开发者，对其在掉电和系统崩溃时的健壮性充满自信，因为，我们的自动测试过程在模拟的掉电故障下，对它的恢复能力进行了非常多的检测。我们把这种模拟的故障称为&amp;#8220;崩溃测试&amp;#8221;。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;崩溃测试使用了一个修改过的VFS，以便模拟掉电或崩溃时可能出现的各种文件系统错误。它可以模拟出没有完整写入的扇区、因为写操作没有完成而包含垃圾数据的页面、顺序错误的写操作等，这些错误在测试场景的各个路径点上都会出现。崩溃测试不停地执行事务，让模拟的掉电或系统崩溃发生在各个不同的时刻，造成各种不同的数据损坏。在模拟的崩溃事件发生之后，测试程序重新打开数据库，检测事务是否完全完成或者（看起来）根本没有启动，也就是数据库是否处于一个一致的状态。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;SQLITE的崩溃测试帮助我们发现了恢复机制中的很多小问题（现在都已经修复了）。其中的一部分非常隐晦，单单通过代码检查和分析可能是发现不了的。这些经验让SQLITE的开发者相信：那些没有使用类似崩溃测试的数据库系统，非常有可能包含在系统崩溃或掉电时导致数据库损坏的BUG。 
&lt;H3&gt;9. 可能发生的问题&lt;/H3&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;虽然SQLITE的原子提交机制本身是健壮的，但它却有可能被恶意的对手或不那么完善的操作系统实现给打垮。本章将介绍几个可能在掉电或系统崩溃时导致数据库损坏的情形。 
&lt;H4&gt;9.1. 有问题的锁&lt;/H4&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;SQLITE使用文件系统的锁来保证某一时刻只有一个进程和数据库连接可以修改数据库。文件系统的锁机制是在VFS层实现的，并且在每种操作系统上都有所不同。SQLITE自身的正确性依赖于这个实现的正确性。如果它出了问题，导致两个或更多进程能同时修改一个数据库文件，肯定会严重损坏数据库。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;有人向我们报告说windows的网络文件系统和（Unix的，译注）NFS的锁都有些问题。我们验证不了这些报告，但是考虑到在网络文件系统上实现一个正确的锁的难度，我们也无法否定它们。由于网络文件系统的效率也很低，所以我们建议你最好是避免在其上使用SQLITE。如果一定要这么做的话，请考虑使用一个附加的锁机制来保证即使文件系统自身的锁机制不起作用时，也不会出现多个进程同时写一个数据库文件的情况。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;苹果Mac OS X计算机上预装的SQLITE进行了一个扩展，可以在苹果支持的所有网络文件系统上使用一个替代的加锁策略。只要所有进程使用统一的方式访问数据库文件，这个扩展就工作的很好。但不幸的是，这些加锁机制是相互独立的，如果一个进程用AFP锁，另一个用点文件（dot-file）锁，那这两个进程就可能发生冲突，因为AFP锁并不能禁止点文件锁，反之亦然。 
&lt;H4&gt;9.2. 不完整的刷磁盘操作&lt;/H4&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;在第3.7节和3.10节中你已经看到，SQLITE要把系统缓存刷到磁盘上。在unix系统上，这是用fsync()系统调用来完成的，windows上则是用FlushFileBuffers()。可是，我们收到的报告显示，很多系统上的这些接口没有广告宣传的那么好。我们听说，在一些windows版本上，通过修改注册表，可以完全禁用FlushFileBuffers()；而linux的某些历史版本中的fsync仅仅是个什么也不干的空操作。我们还知道，即使是在FlushFileBuffers()或fsync()可以正常工作的系统上，IDE磁盘控制器也经常会在数据仍处在自己的缓存中时，撒谎说数据已经到达磁盘表面了。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;在苹果的系统上，如果你把fullsync选项打开（PRAGMA fullsync=ON），它可以保证数据确实刷到磁盘上了。Fullsync本身就很慢，而fullsync的实现还需要重置磁盘控制器，这会让其他根本不相关的磁盘I/O也变慢，所以我们不建议你这样做。 
&lt;H4&gt;9.3. 文件删除只完成了一半&lt;/H4&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;SQLITE假设从用户程序的角度看文件删除是原子操作。如果删除文件时掉电，电力恢复后，SQLITE期望这个文件或者不存在，或者是一个完整的、和删除前一模一样的文件。如果操作系统做不到这一点，事务就有可能不是原子的。 
&lt;H4&gt;9.4. 文件中的垃圾&lt;/H4&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;SQLITE的数据库文件是普通的文件，其它用户程序也可以打开它并任意的往里面写数据，一些流氓程序就可能这样做。垃圾数据的来源也可能是操作系统或磁盘控制器的BUG，尤其是那些会在掉电时触发的BUG。对此类问题，SQLITE无能为力。 
&lt;H4&gt;9.5. 删除或重命名热日志文件&lt;/H4&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;如果发生了掉电或崩溃，并且生成了热日志文件，那么，在另一个SQLITE进程打开它和数据库文件并完成回滚之前，这两个文件的名字绝对不能改变。在第4.2步时，SQLITE会在打开的数据库文件所在的目录下，寻找热日志文件，这个文件的名字是从数据库文件名派生而来的。所以，只要这两个文件中的任何一个被移走或改名，就会找不到热日志，也就不会进行回滚。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;我们认为SQLITE恢复过程的失败模式一般是这样的：发生了掉电；电力恢复后，一位好心的用户或者系统管理员开始清点损失；他们发现有一个名为&amp;#8220;important.data&amp;#8221;的文件，他们可能很熟悉这个文件，所以没有对其进行任何操作；但崩溃后，磁盘上还有一个名为&amp;#8220;important.data-journal&amp;#8221;的热日志文件，用户把它删除了，因为他们认为这个文件是系统中的垃圾。防止此类事件的唯一方法可能就是加强用户教育了。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;如果有多个链接（硬链接或符号链接）指向一个数据库文件，那么生成的日志文件会依据打开数据库文件时使用链接名来命名。如果发生了崩溃，并且下次打开数据库时使用了另一个链接，则也会因为找不到热日志文件而不进行回滚。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;某些时候，掉电会导致文件系统出错，以致新更改的文件名无法记录，这时，文件就会被移动到&amp;#8220;/lost+found&amp;#8221;目录下。为防止此类错误，SQLITE会在同步日志文件的同时，打开并同步一下这个文件所在的目录。但是，一些八竿子打不着的程序，在数据库文件所在目录下创建其他文件的操作，也可能会导致文件被移动到&amp;#8220;/lost+found&amp;#8221;里去，这是SQLITE控制不了的，所以SQLITE对它也没什么办法。如果你正在使用此类名字空间易被损坏的文件系统（我们相信大多数现代的日志文件系统没有此问题），我们建议你把SQLITE的数据库文件放在单独的子目录中。 
&lt;H3&gt;10. 总结和展望&lt;/H3&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;不论是过去还是现在，总有人能发现一些SQLITE原子提交机制的失败模式，开发者也不得不为此做一些补丁。但这类事情发生的已经越来越少了，失败模式也变得越来越隐晦。不过，如果藉此认为SQLITE的原子提交逻辑已经无懈可击了，肯定是相当愚蠢的。开发者们能承诺的只是尽量快速的修复新发现的BUG。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;同时，我们也在寻找新的方法来优化这个提交机制。在Linux、MacOSX和windows上，当前的VFS实现都做了悲观的假设。也许在与一些熟悉这些系统工作原理的专家交流之后，我们能放宽一些限制，让它跑得更快些。特别的，我们猜测大部分现代文件系统已经具有了&amp;#8220;安全追加&amp;#8221;和&amp;#8220;原子扇区写&amp;#8221;这两个特性，但在确认之前，我们仍会保守的做最坏假设。&lt;/LI&gt;&lt;img src ="http://blog.vckbase.com/localvar/aggbug/32581.html" width = "1" height = "1" /&gt;</description></item><item><dc:creator>局部变量</dc:creator><title>C++编译器如何实现异常处理(已发表在vckbase文档中心)</title><link>http://blog.vckbase.com/localvar/archive/2005/07/21/9890.html</link><pubDate>Thu, 21 Jul 2005 03:17:00 GMT</pubDate><guid>http://blog.vckbase.com/localvar/archive/2005/07/21/9890.html</guid><wfw:comment>http://blog.vckbase.com/localvar/comments/9890.html</wfw:comment><comments>http://blog.vckbase.com/localvar/archive/2005/07/21/9890.html#Feedback</comments><slash:comments>1</slash:comments><wfw:commentRss>http://blog.vckbase.com/localvar/comments/commentRss/9890.html</wfw:commentRss><trackback:ping>http://blog.vckbase.com/localvar/services/trackbacks/9890.html</trackback:ping><description>&lt;H2 align=center&gt;C++编译器如何实现异常处理&lt;/H2&gt;
&lt;P align=center&gt;&lt;B&gt;作者：&lt;/B&gt;Vishal Kochhar&amp;nbsp;&amp;nbsp; &lt;A href="http://www.codeproject.com/cpp/exceptionhandler.asp"&gt;查看原文&lt;/A&gt; &amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&lt;B&gt;翻译：&lt;/B&gt;&lt;A href="http://blog.vckbase.com/localvar"&gt;局部变量&lt;/A&gt;&lt;/P&gt;
&lt;P align=left&gt;&lt;STRONG&gt;注：&lt;/STRONG&gt;本文在网上已经有几个译本，但都不完整，所以我决定自己把它翻译过来。虽然力求信、雅、达，但鉴于这是我的第一次翻译经历，不足之处敬请谅解并指出。&lt;/P&gt;
&lt;P&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;与传统语言相比，C++的一项革命性创新就是它支持异常处理。传统的错误处理方式经常满足不了要求，而异常处理则是一个极好的替代解决方案。它将正常代码和错误处理代码清晰的划分开来，程序变得非常干净并且容易维护。本文讨论了编译器如何实现异常处理。我将假定你已经熟悉异常处理的语法和机制。本文还提供了一个用于VC++的异常处理库，要用库中的处理程序替换掉VC++提供的那个，你只需要调用下面这个函数： &lt;FONT color=blue&gt;&lt;PRE&gt;install_my_handler();&lt;/FONT&gt;&lt;/PRE&gt;之后，程序中的所有异常，从它们被抛出到堆栈展开（stack unwinding），再到调用catch块，最后到程序恢复正常运行，都将由我的异常处理库来管理。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;与其它C++特性一样，C++标准并没有规定编译器应该如何来实现异常处理。这意味着每一个编译器的提供商都可以用它们认为恰当的方式来实现它。下面我会描述一下VC++是怎么做的，但即使你使用其它的编译器或操作系统①，本文也应该会是一篇很好的学习材料。VC++的实现方式是以windows系统的结构化异常处理（SEH）②为基础的。 
&lt;H3&gt;结构化异常处理&amp;#8212;概述&lt;/H3&gt;
&lt;P&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;在本文的讨论中，我认为异常或者是被明确的抛出的，或者是由于除零溢出、空指针访问等引起的。当它发生时会产生一个中断，接下来控制权就会传递到操作系统的手中。操作系统将调用异常处理程序，检查从异常发生位置开始的函数调用序列，进行堆栈展开和控制权转移。Windows定义了结构&amp;#8220;EXCEPTION_REGISTRATION&amp;#8221;，使我们能够向操作系统注册自己的异常处理程序。&lt;/P&gt;&lt;FONT color=blue&gt;&lt;PRE&gt;struct EXCEPTION_REGISTRATION
{
    EXCEPTION_REGISTRATION* prev;
    DWORD handler;
}; &lt;/FONT&gt;&lt;/PRE&gt;
&lt;P&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;注册时，只需要创建这样一个结构，然后把它的地址放到FS段偏移0的位置上去就行了。下面这句汇编代码演示了这一操作：&lt;/P&gt;&lt;FONT color=blue&gt;&lt;PRE&gt;mov FS:[0], exc_regp&lt;/FONT&gt;&lt;/PRE&gt;
&lt;P&gt;prev字段用于建立一个EXCEPTION_REGISTRATION结构的链表，每次注册新的EXCEPTION_REGISTRATION时，我们都要把原来注册的那个的地址存到prev中。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;那么，那个异常回调函数长什么样呢？在excpt.h中，windows定义了它的原形：&lt;/P&gt;&lt;FONT color=blue&gt;&lt;PRE&gt;EXCEPTION_DISPOSITION (*handler)( 
    _EXCEPTION_RECORD *ExcRecord, 
    void* EstablisherFrame, 
    _CONTEXT *ContextRecord, 
    void* DispatcherContext);  &lt;/FONT&gt;&lt;/PRE&gt;
&lt;P&gt;不要管它的参数和返回值，我们先来看一个简单的例子。下面的程序注册了一个异常处理程序，然后通过除以零产生了一个异常。异常处理程序捕获了它，打印了一条消息就完事大吉并退出了。&lt;/P&gt;&lt;FONT color=blue&gt;&lt;PRE&gt;#include &amp;lt;iostream&amp;gt; 
#include &amp;lt;windows.h&amp;gt; 
using std::cout; 
using std::endl; 
struct EXCEPTION_REGISTRATION 
{ 
    EXCEPTION_REGISTRATION* prev; 
    DWORD handler; 
}; 
EXCEPTION_DISPOSITION myHandler( 
    _EXCEPTION_RECORD *ExcRecord, 
    void * EstablisherFrame, 
    _CONTEXT *ContextRecord, 
    void * DispatcherContext) 
{ 
    cout &amp;lt;&amp;lt; "In the exception handler" &amp;lt;&amp;lt; endl; 
    cout &amp;lt;&amp;lt; "Just a demo. exiting..." &amp;lt;&amp;lt; endl; 
    exit(0); 
    return ExceptionContinueExecution; //不会运行到这 
} 
int  g_div = 0; 
void bar() 
{ 
    //初始化一个EXCEPTION_REGISTRATION结构 
    EXCEPTION_REGISTRATION reg, *preg = &amp;amp;reg;  
    reg.handler = (DWORD)myHandler; 
    //取得当前异常处理链的&amp;#8220;头&amp;#8221; 
    DWORD prev; 
    _asm 
    { 
        mov EAX, FS:[0] 
        mov prev, EAX 
    } 
    reg.prev = (EXCEPTION_REGISTRATION*) prev; 
    //注册！ 
    _asm 
    { 
        mov EAX, preg 
        mov FS:[0], EAX 
    } 
    //产生一个异常 
    int  j = 10 / g_div;  //异常，除零溢出 
} 
int  main() 
{ 
    bar(); 
    return 0; 
} 
/*-------输出------------------- 
In the exception handler 
Just a demo. exiting... 
---------------------------------*/&lt;/FONT&gt;&lt;/PRE&gt;
&lt;P&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;注意EXCEPTION_REGISTRATION必须定义在栈上，并且必须位于比上一个结点更低的内存地址上，windows对此有严格要求，达不到的话，它就会立刻终止进程。&lt;/P&gt;
&lt;H3&gt;函数和堆栈&lt;/H3&gt;
&lt;P&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;堆栈是用来保存局部对象的连续内存区。更明确的说，每个函数都有一个相关的栈桢（stack frame）来保存它所有的局部对象和表达式计算过程中用到的临时对象，至少理论上是这样的。但现实中，编译器经常会把一些对象放到寄存器中以便能以更快的速度访问。堆栈是一个处理器（CPU）层次的概念，为了操纵它，处理器提供了一些专用的寄存器和指令。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;图1是一个典型的堆栈，它示出了函数foo调用bar，bar又调用widget时的情景。请注意堆栈是向下增长的，这意味着新压入的项的地址低于原有项的地址。 &lt;BR&gt;&lt;IMG height=346 alt=图1 hspace=0 src="/images/vckbase_com/localvar/709/o_1.gif" width=703 align=middle&gt; &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;通常编译器使用EBP寄存器来指示当前活动的栈桢。本例中，CPU正在运行widget，所以图中的EBP指向了widget的栈桢。编译器在编译时将所有局部对象解析成相对于栈桢指针（EBP）的固定偏移，函数则通过栈桢指针来间接访问局部对象。举个例子，典型的，widget访问它的局部变量时就是通过访问栈桢指针以下的、有着确定位置的几个字节来实现的，比如说EBP-24。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;上图中也画出了ESP寄存器，它叫栈指针，指向栈的最后一项。在本例中，ESP指着widget的栈桢的末尾，这也是下一个栈桢（如果它被创建的话）的开始位置。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;处理器支持两种类型的栈操作：压栈（push）和弹栈（pop）。比如，&lt;/P&gt;&lt;FONT color=blue&gt;&lt;PRE&gt;pop EAX&lt;/FONT&gt;&lt;/PRE&gt;
&lt;P&gt;的作用是从ESP所指的位置读出4字节放到EAX寄存器中，并把ESP加上（记住，栈是向下增长的）4（在32位处理器上）；类似的，&lt;/P&gt;&lt;FONT color=blue&gt;&lt;PRE&gt;push EBP&lt;/FONT&gt;&lt;/PRE&gt;
&lt;P&gt;的作用是把ESP减去4，然后将EBP的值放到ESP指向的位置中去。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;编译器编译一个函数时，会在它的开头添加一些代码来为其创建并初始化栈桢，这些代码被称为序言（prologue）；同样，它也会在函数的结尾处放上代码来清除栈桢，这些代码叫做尾声（epilogue）。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;一般情况下，序言是这样的：&lt;/P&gt;&lt;FONT color=blue&gt;&lt;PRE&gt;Push EBP     ; 把原来的栈桢指针保存到栈上 
Mov EBP, ESP ; 激活新的栈桢 
Sub ESP, 10  ; 减去一个数字，让ESP指向栈桢的末尾&lt;/FONT&gt;&lt;/PRE&gt;
&lt;P&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;第一条指令把原来的栈桢指针EBP保存到栈上；第二条指令通过让EBP指向主调函数的EBP的保存位置来激活被调函数的栈桢；第三条指令把ESP减去了一个数字，这样ESP就指向了当前栈桢的末尾，而这个数字是函数要用到的所有局部对象和临时对象的大小。编译时，编译器知道函数的所有局部对象的类型和&amp;#8220;体积&amp;#8221;，所以，它能很容易的计算出栈桢的大小。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;尾声所做的正好和序言相反，它必须把当前栈桢从栈上清除掉：&lt;/P&gt;&lt;FONT color=blue&gt;&lt;PRE&gt;Mov ESP, EBP
Pop EBP      ; 激活主调函数的栈桢 
Ret          ; 返回主调函数&lt;/FONT&gt;&lt;/PRE&gt;
&lt;P&gt;它让ESP指向主调函数的栈桢指针的保存位置（也就是被调函数的栈桢指针指向的位置），弹出EBP从而激活主调函数的栈桢，然后返回主调函数。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;一旦CPU遇到返回指令，它就要做以下两件事：把返回地址从栈中弹出，然后跳转到那个地址去。返回地址是主调函数执行call指令调用被调函数时自动压栈的。Call指令执行时，会先把紧随在它后面的那条指令的地址（被调函数的返回地址）压入栈中，然后跳转到被调函数的开始位置。图2更详细的描绘了运行时的堆栈。如图所示，主调函数把被调函数的参数也压进了堆栈，所以参数也是栈桢的一部分。函数返回后，主调函数需要移除这些参数，它通过把所有参数的总体积加到ESP上来达到目的，而这个体积可以在编译时知道： &lt;/P&gt;&lt;FONT color=blue&gt;&lt;PRE&gt;Add ESP, args_size&lt;/FONT&gt;&lt;/PRE&gt;
&lt;P&gt;当然，也可以把参数的总体积写在被调函数的返回指令的后面，让被调函数去移除参数，下面的指令就在返回主调函数前从栈中移去了24个字节：&lt;/P&gt;&lt;FONT color=blue&gt;&lt;PRE&gt;Ret 24&lt;/FONT&gt;&lt;/PRE&gt;
&lt;P&gt;取决于被调函数的调用约定（call convention），这两种方式每次只能用一个。你还要注意的是每个线程都有自己独立的堆栈。 &lt;BR&gt;&lt;IMG alt=图2 src="/images/vckbase_com/localvar/709/o_2.gif" align=middle&gt; &lt;/P&gt;
&lt;H3&gt;C++和异常&lt;/H3&gt;
&lt;P&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;回忆一下我在第一节中介绍的EXCEPTION_REGISTRATION结构，我们曾用它向操作系统注册了发生异常时要被调用的回调函数。VC++也是这么做的，不过它扩展了这个结构的语义，在它的后面添加了两个新字段：&lt;/P&gt;&lt;FONT color=blue&gt;&lt;PRE&gt;struct EXCEPTION_REGISTRATION 
{ 
    EXCEPTION_REGISTRATION* prev; 
    DWORD handler; 
    int id; 
    DWORD ebp; 
}; &lt;/FONT&gt;&lt;/PRE&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;VC++会为绝大部分函数③添加一个EXCEPTION_REGISTRATION类型的局部变量，它的最后一个字段（ebp）与栈桢指针指向的位置重叠。函数的序言创建这个结构并把它注册给操作系统，尾声则恢复主调函数的EXCEPTION_REGISTRATION。id字段的意义我将在下一节介绍。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;VC++编译函数时会为它生成两部分数据： &lt;BR&gt;a）异常回调函数 &lt;BR&gt;b）一个包含函数重要信息的数据结构，这些信息包括catch块、这些块的地址和这些块所关心的异常的类型等等。我把这个结构称为funcinfo，有关它的详细讨论也在下一节。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp; 图3是考虑了异常处理之后的运行时堆栈。widget的异常回调函数位于由FS:[0]指向的异常处理链的开始位置（这是由widget的序言设置的）。异常处理程序把widget的funcinfo结构的地址交给函数__CxxFrameHandler，__CxxFrameHandler会检查这个结构看函数中有没有catch块对当前的异常感兴趣。如果没有的话，它就返回ExceptionContinueSearch给操作系统，于是操作系统会从异常处理链表中取得下一个结点，并调用它的异常处理程序（也就是调用当前函数的那个函数的异常处理程序）。 &lt;BR&gt;&lt;IMG alt=图3 src="/images/vckbase_com/localvar/709/o_3.gif" align=middle&gt; &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;这一过程将一直进行下去&amp;#8212;&amp;#8212;直到处理程序找到一个能处理当前异常的catch块为止，这时它就不再返回操作系统了。但是在调用catch块之前（由于有funcinfo结构，所以知道catch块的入口，参见图3），必须进行堆栈展开，也就是清理掉当前函数的栈桢下面的所有其他的栈桢。这个操作稍微有点复杂，因为：异常处理程序必须找到异常发生时生存在这些栈桢上的所有局部对象，并依次调用它们的析构函数。后面我将对此进行详细介绍。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;异常处理程序把这项工作委托给了各个栈桢自己的异常处理程序。从FS:[0]指向的异常处理链的第一个结点开始，它依次调用每个结点的处理程序，告诉它堆栈正在展开。与之相呼应，这些处理程序会调用每个局部对象的析构函数，然后返回。此过程一直进行到与异常处理程序自身相对应的那个结点为止。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;由于catch块是函数的一部分，所以它使用的也是函数的栈桢。因此，在调用catch块之前，异常处理程序必须激活它所隶属的函数的栈桢。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;其次，每个catch块都只接受一个参数，其类型是它希望捕获的异常的类型。异常处理程序必须把异常对象本身或者是异常对象的引用拷贝到catch块的栈桢上，编译器在funcinfo中记录了相关信息，处理程序根据这些信息就能知道到哪去拷贝异常对象了。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;拷贝完异常并激活栈桢后，处理程序将调用catch块。而catch块将把控制权下一步要转移到的地址返回来。请注意：虽然这时堆栈已经展开，栈桢也都被清除了，但它们占据的内存空间并没有被覆盖，所有的数据都还好好的待在栈上。这是因为异常处理程序仍在执行，象其他函数一样，它也需要栈来存放自己的局部对象，而其栈桢就位于发生异常的那个函数的栈桢的下面。catch块返回以后，异常处理程序需要&amp;#8220;杀掉&amp;#8221;异常对象。此后，它让ESP指向目标函数（控制权要转移到的那个函数）的栈桢的末尾&amp;#8212;&amp;#8212;这样就把（包括它自己的在内的）所有栈桢都删除了，然后再跳转到catch块返回的那个地址去，就胜利的完成整个异常处理任务了。但它怎么知道目标函数的栈桢末尾在哪呢？事实上它没法知道，所以编译器把这个地址保存到了栈桢上（由前言来完成），如图3所示，栈桢指针EBP下面第16个字节就是。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;当然，catch块也可能抛出新异常，或者是将原来的异常重新抛出。处理程序必须对此有所准备。如果是抛出新异常，它必须杀掉原来的那个；而如果是重新抛出原来的异常，它必须能继续传播（propagate）这个异常。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;这里我要特别强调一点：由于每个线程有自己独立的堆栈，所以每个线程也都有自己独立的、由FS:[0]指向的EXCEPTION_REGISTRATION链。 
&lt;H3&gt;C++和异常&amp;#8212;2&lt;/H3&gt;
&lt;P&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;图4是funcinfo的布局，注意这里的字段名可能与VC++编译器实际使用的不完全一致，而且我也只给出了和我们的讨论相关的字段。堆栈展开表（unwind table）的结构留到下节再讨论。 &lt;BR&gt;&lt;IMG alt=图4 src="/images/vckbase_com/localvar/709/o_4.gif" align=middle&gt; &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;异常处理程序在函数中查找catch块时，它首先要判断异常发生的位置是否在当前函数（发生异常的那个函数）的一个try块中。是则查找与此try块相关的catch块表，否则直接返回。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;先来看看它怎样找try块。编译时，编译器给每个try块都分配了start id和end id。通过funcinfo结构，异常处理程序可以访问这两个id，见图4。编译器为函数中的每个try块都生成了相关的数据结构。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;上一节中，我说过VC++给EXCEPTION_REGISTRATION结构加上了一个id字段。回忆一下图3，这个结构位于函数的栈桢上。异常发生时，处理程序读出这个值，看它是否在try块的两个id确定的区间[start id，end id]中。是的话，异常就发生在这个try块中；否则继续查看try块表中的下一个try块。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;谁负责更新id的值，它的值又应该是什么呢？原来，编译器会在函数的多个位置安插代码来更新id的值，以反应程序的实时运行状态。比如说，编译器会在进入try块的地方加上一条语句，把try块的start id写到栈桢上。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;找到try块后，处理程序就遍历与其关联的catch块表，看是否有对当前异常感兴趣的catch块。在try块发生嵌套时，异常将既源于内层try块，也源于外层try块。这种情况下，处理程序应该按先内后外的顺序查找catch块。但它其实没必要关心这些，因为，在try块表中，VC++总是把内层try块放在外层try块的前面。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;异常处理程序还有一个难题就是&amp;#8220;如何根据catch块的相关数据结构判断这个catch块是否愿意处理当前异常&amp;#8221;。这是通过比较异常的类型和catch块的参数的类型来完成的。例如下面这个程序：&lt;/P&gt;&lt;FONT color=blue&gt;&lt;PRE&gt;void foo() 
{ 
    try 
    { 
        throw E(); 
    } 
    catch(H) 
    { 
        //. 
    } 
}  &lt;/FONT&gt;&lt;/PRE&gt;
&lt;P&gt;如果H和E的类型完全相同的话，catch块就要捕获这个异常。这意味着处理程序必须在运行时进行类型比较，对C等语言来说，这是不可能的,因为它们无法在运行时得到对象的类型。C++则不同，它有了运行时类型识别（runtime type identification，RTTI），并提供了运行时类型比较的标准方法。C++在标准头文件&lt;TYPEINFO&gt;中定义了type_info类，它能在运行时代表一个类型。catch块数据结构的第二个字段（ptype_info，见图4）是一个指向type_info结构的指针，它在运行时就代表catch块的参数类型。type_info也重载了==运算符，能够指出两种类型是否完全相同。这样，异常处理程序只要比较（调用==运算符）catch块参数的type_info（可以通过catch块的相关数据结构来访问）和异常的type_info是否相同，就能知道catch块是不是愿意捕获当前异常了。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;catch块的参数类型可以通过funcinfo结构得到，但异常的type_info从哪来呢？当编译器碰到&lt;/P&gt;&lt;FONT color=blue&gt;&lt;PRE&gt;throw E();&lt;/FONT&gt;&lt;/PRE&gt;
&lt;P&gt;这条语句时，它会为异常生成一个excpt_info结构，如图5所示。还是要提醒你注意这里用的名字可能与VC++使用的不一致，而且仍然只有与我们的讨论相关的字段。从图中可以看出，异常的type_info可以通过excpt_info结构得到。由于异常处理程序需要拷贝异常对象（在调用catch块之前），也需要消除掉它（在调用catch块之后），所以编译器在这个结构中同时提供了异常的拷贝构造函数、大小和析构函数的信息。 &lt;BR&gt;&lt;IMG alt=图5 src="/images/vckbase_com/localvar/709/o_5.gif" align=middle&gt; &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;在catch块的参数是基类，而异常是派生类时，异常处理程序也应该调用catch块。然而，这种情况下，比较它们的type_info绝对是不相等，因为它们本来就不是相同的类型。而且，type_info类也没有提供任何其他函数或运算符来指出一个类是另一个类的基类。但异常处理程序还必须得去调用catch块！为了解决这个问题，编译器只能为处理程序提供更多的信息：如果异常是派生类，那么etypeinfo_table（通过excpt_info访问）将包含多个指向etype_info（扩展了type_info，这个名字是我启的）的指针，它们分别指向了各个基类的etype_info。这样，处理程序就可以把catch块的参数和所有这些type_info比较，只要有一个相同，就调用catch块。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;在结束这一部分之前，还有最后一个问题：异常处理程序是怎么知道异常和excpt_info结构的？下面我就要回答这个问题。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;VC++会把throw语句翻译成下面的样子： &lt;/P&gt;&lt;FONT color=blue&gt;&lt;PRE&gt;//throw E(); //编译器会为E生成excpt_info结构 
E e = E(); //在栈上创建异常 
_CxxThrowException(&amp;amp;e, E_EXCPT_INFO_ADDR);&lt;/FONT&gt;&lt;/PRE&gt;
&lt;P&gt;__CxxThrowException会把控制权连带它的两个参数都交给操作系统（控制权转移是通过软件中断实现的，请参见RaiseException）。而操作系统，在为调用异常回调函数做准备时，会把这两个参数打包到一个_EXCEPTION_RECORD结构中。接着，它从EXCEPTION_REGISTRATION链表的头结点（由FS:[0]指向）开始，依次调用各节点的异常处理程序。而且，指向当前EXCEPTION_REGISTRATION结构的指针也会作为异常处理程序的第二个参数出现。前面已经说过，VC++中的每个函数都在栈上创建并注册了EXCEPTION_REGISTRATION结构。所以传递这个参数可以让处理程序知道很多重要信息，比如说：EXCEPTION_REGISTRATION的id字段（用于查找catch块）、函数的栈桢（用于清理栈桢）和EXCEPTION_REGISTRATION结点在异常链表中的位置（用于堆栈展开）等。第一个参数是指向_EXCEPTION_RECORD结构的指针，通过它可以找到异常和它的excpt_info结构。下面是excpt.h中定义的异常回调函数的原型：&lt;/P&gt;&lt;FONT color=blue&gt;&lt;PRE&gt;EXCEPTION_DISPOSITION (*handler)( 
    _EXCEPTION_RECORD* ExcRecord, 
    void* EstablisherFrame, 
    _CONTEXT *ContextRecord, 
    void* DispatcherContext);&lt;/FONT&gt;&lt;/PRE&gt;
&lt;P&gt;后两个参数和我们的讨论关系不大。函数的返回值是一个枚举类型（也在excpt.h中定义），我前面已经说过，如果处理程序找不到catch块，它就会向系统返回ExceptionContinueSearch，对本文而言，我们只要知道这一个返回值就行了。_EXCEPTION_RECORD结构是在winnt.h中定义的：&lt;/P&gt;&lt;FONT color=blue&gt;&lt;PRE&gt;struct _EXCEPTION_RECORD 
{ 
    DWORD ExceptionCode; 
    DWORD ExceptionFlags; 
    _EXCEPTION_RECORD* ExcRecord; 
    PVOID ExceptionAddress; 
    DWORD NumberParameters; 
    DWORD ExceptionInformation[15]; 
}EXCEPTION_RECORD; &lt;/FONT&gt;&lt;/PRE&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;ExceptionInformation数组中元素的个数和类型取决于ExceptionCode字段。如果是C++异常（异常代码是0xe06d7363，源于throw语句），那么数组中将包含指向异常和excpt_info结构的指针；如果是其他异常，那数组中基本上就不会有什么内容，这些异常包括除零溢出、访问违例等，你可以在winnt.h中找到它们的异常代码。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;ExceptionFlags字段用于告诉异常处理程序应该采取什么操作。如果它是EH_UNWINDING（见Except.inc），那是说堆栈正在展开，这时，处理程序要清理栈桢，然后返回。否则处理程序应该在函数中查找catch块并调用它。清理栈桢意味着必须找到异常发生时生存在栈桢上的所有局部对象,并调用其析构函数，下一节我们将就此进行详细讨论。 
&lt;H3&gt;清理栈桢&lt;/H3&gt;
&lt;P&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;C++标准明确指出：堆栈展开工作必须调用异常发生时所有生存的局部对象的析构函数。如下面的代码：&lt;/P&gt;&lt;FONT color=blue&gt;&lt;PRE&gt;int g_i = 0; 
void foo() 
{ 
    T o1, o2; 
    { 
        T o3; 
    } 
    10/g_i; //这里会发生异常 
    T o4; 
    //... 
} &lt;/FONT&gt;&lt;/PRE&gt;
&lt;P&gt;foo有o1、o2、o3、o4四个局部对象，但异常发生时，o3已经&amp;#8220;死亡&amp;#8221;，o4还未&amp;#8220;出生&amp;#8221;，所以异常处理程序应该只调用o1和o2的析构函数。&amp;nbsp; &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;前面已经说过，编译器会在函数的很多地方安插代码来记录当前的运行状态。实际上，编译器在函数中设置了一些关键区域，并为它们分配了id，进入关键区域时要记录它的id，退出时恢复前一个id。try块就是一个例子,其id就是start id。所以，在try块的入口，编译器会把它的start id记到栈桢上去。局部对象从创建到销毁也确定了一个关键区域，或者，换句话说，编译器给每个局部对象分配了唯一的id，例如下面的程序： &lt;/P&gt;&lt;FONT color=blue&gt;&lt;PRE&gt;void foo()
{ 
    T t1; 
    //. 
} &lt;/FONT&gt;&lt;/PRE&gt;
&lt;P&gt;编译器会在t1的定义后面（也就是t1创建以后）,把它的id写到栈桢上：&lt;/P&gt;&lt;FONT color=blue&gt;&lt;PRE&gt;void foo() 
{ 
    T t1; 
   _id = t1_id; //编译器插入的语句 
   //. 
} &lt;/FONT&gt;&lt;/PRE&gt;
&lt;P&gt;上面的_id是编译器偷偷创建的局部变量，它的位置与EXCEPTION_REGISTRATION的id字段重叠。类似的，在调用对象的析构函数前，编译器会恢复前一个关键区域的id。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;清理栈桢时，异常处理程序读出id的值（通过EXCEPTION_REGISTRATION结构的id字段或栈桢指针EBP下面的4个字节来访问）。这个id可以表明，函数在运行到与它相关联的那个点之前没有发生异常。所有在这一点之前定义的对象都已初始化，应该调用这些对象中的一部分或全部对象的析构函数。请注意某些对象是属于子块（如前面代码中的o3）的，发生异常时可能已经销毁了，不应该调用它们的析构函数。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;编译器还为函数生成了另一个数据结构&amp;#8212;&amp;#8212;堆栈展开表（unwindtable，我启的名字），它是一个unwind结构的数组，可通过funcinfo来访问，如图4所示。函数的每个关键区域都有一个unwind结构，这些结构在展开表中出现的次序和它们所对应的区域在函数中的出现次序完全相同。一般unwind结构也会关联一个对象（别忘了，每个对象的定义都开辟了关键区域，并有id与其对应），它里面有如何销毁这个对象的信息。每当编译器碰到对象定义，它就生成一小段代码，这段代码知道对象在栈桢上的地址（就是它相对于栈桢指针的偏移），并能销毁它。unwind结构中有一个字段用于保存这段代码的入口地址： &lt;/P&gt;&lt;FONT color=blue&gt;&lt;PRE&gt;typedef void (*CLEANUP_FUNC)();
struct unwind 
{ 
    int prev; 
    CLEANUP_FUNC cf; 
}; &lt;/FONT&gt;&lt;/PRE&gt;
&lt;P&gt;try块对应的unwind结构的cf字段是空值NULL，因为没有与它对应的对象，所以也没有东西需要它去销毁。通过prev字段，这些unwind结构也形成了一个链表。异常处理程序清理栈桢时，会读取当前的id值，以它为索引取得展开表中对应的项，并调用其第二个字段指向的清理代码，这样，那个与之关联的对象就被销毁了。然后，处理程序将以当前unwind结构的prev字段为索引，继续在展开表中找下一个unwind结构，调用其清理代码。这一过程将一直重复，直到链表的结尾（prev的值是-1）。图6画出了本节开始时提到的那段代码的堆栈展开表。 &lt;BR&gt;&lt;IMG alt=图6 src="/images/vckbase_com/localvar/709/o_6.gif" align=middle&gt; &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;现在把new运算符也加进来，对于下面的代码： &lt;/P&gt;&lt;FONT color=blue&gt;&lt;PRE&gt;T* p = new T();&lt;/FONT&gt;&lt;/PRE&gt;系统会首先为T分配内存，然后调用它的构造函数。所以，如果构造函数抛出了异常，系统就必须释放这些内存。因此，动态创建那些拥有&amp;#8220;有为的构造函数&amp;#8221;的类型时，VC++也为new运算符分配了id，并且堆栈展开表中也有与其对应的项，其清理代码将释放分配的内存空间。调用构造函数前，编译器把new运算符的id存到EXCEPTION_REGISTRATION结构中，构造函数顺利返回后，它再把id恢复成原来的值。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;更进一步说，构造函数抛出异常时，对象可能刚刚构造了一部分，如果它有子成员对象或子基类对象，并且发生异常时它们中的一部分已经构造完成的话，就必须调用这些对象的析构函数。和普通函数一样，编译器也给构造函数生成了相关的数据来帮助完成这个任务。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;展开堆栈时，异常处理程序调用的是用户定义的析构函数，这一点你必须注意，因为它也有可能抛出异常！C++标准规定堆栈展开过程中，析构函数不能抛出异常，否则系统将调用std::terminate。 
&lt;H3&gt;实现&lt;/H3&gt;
&lt;P&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;本节我们讨论其他三个有待详细解释的问题： &lt;BR&gt;a)&amp;nbsp;如何安装异常处理程序 &lt;BR&gt;b)&amp;nbsp;catch块重新抛出异常或抛出新异常时应该如何处理 &lt;BR&gt;c)&amp;nbsp;如何对所有线程提供异常处理支持 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;随同本文，有一个演示项目，查看其中的readme.txt文件可以得到一些编译方面的帮助①。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;第一项任务是安装异常处理程序，也就是把VC++的处理程序替换掉。从前面的讨论中，我们已经清楚地知道__CxxFrameHandler函数是VC++所有异常处理工作的入口。编译器为每个函数都生成一段代码，它们在发生异常时被调用，把相应的funcinfo结构的指针交给__CxxFrameHandler。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;install_my_handler()函数会改写__CxxFrameHandler的入口处的代码，让程序跳转到my_exc_handler()函数。不过，__CxxFrameHandler位于只读的内存页，对它的任何写操作都会导致访问违例，所以必须首先用VirtualProtectEx把该内存页的保护方式改成可读写，等改写完毕后，再改回只读。写入的数据是一个jmp_instr结构。 &lt;/P&gt;&lt;FONT color=blue&gt;&lt;PRE&gt;//install_my_handler.cpp 
#include &amp;lt;windows.h&amp;gt; 
#include "install_my_handler.h" 
//C++默认的异常处理程序 
extern "C" 
EXCEPTION_DISPOSITION __CxxFrameHandler( 
    struct _EXCEPTION_RECORD* ExceptionRecord, 
    void* EstablisherFrame, 
    struct _CONTEXT* ContextRecord, 
    void* DispatcherContext 
    ); 
namespace 
{ 
    char cpp_handler_instructions[5]; 
    bool saved_handler_instructions = false; 
} 
namespace my_handler 
{ 
    //我的异常处理程序 EXCEPTION_DISPOSITION 
    my_exc_handler( 
        struct _EXCEPTION_RECORD *ExceptionRecord, 
        void * EstablisherFrame, 
        struct _CONTEXT *ContextRecord, 
        void * DispatcherContext 
    )  throw(); 
#pragma pack(push, 1) 
    struct jmp_instr 
    { 
        unsigned char jmp; 
        DWORD offset; 
    }; 
#pragma pack(pop) 
    bool WriteMemory(void* loc, void* buffer, int size) 
    { 
        HANDLE hProcess = GetCurrentProcess(); 
        //把包含内存范围[loc，loc+size]的页面的保护方式改成可读写 
        DWORD old_protection; 
        BOOL ret = VirtualProtectEx(hProcess, loc, size, PAGE_READWRITE, &amp;amp;old_protection); 
        if(ret == FALSE) 
            return false; 
        ret = WriteProcessMemory(hProcess, loc, buffer, size, NULL); 
        //恢复原来的保护方式 
        DWORD o2; 
        VirtualProtectEx(hProcess, loc, size, old_protection, &amp;amp;o2); 
        return (ret == TRUE); 
    } 
    bool ReadMemory(void* loc, void* buffer, DWORD size) 
    { 
        HANDLE hProcess = GetCurrentProcess(); 
        DWORD bytes_read = 0; 
        BOOL ret = ReadProcessMemory(hProcess, loc, buffer, size, &amp;amp;bytes_read); 
        return (ret == TRUE &amp;amp;&amp;amp; bytes_read == size); 
    } 
    bool install_my_handler() 
    { 
        void* my_hdlr = my_exc_handler; void* cpp_hdlr = __CxxFrameHandler; 
        jmp_instr jmp_my_hdlr; 
        jmp_my_hdlr.jmp = 0xE9; 
        //从__CxxFrameHandler+5开始计算偏移，因为jmp指令长5字节 
        jmp_my_hdlr.offset = reinterpret_cast&amp;lt;char*&amp;gt;(my_hdlr) - (reinterpret_cast&amp;lt;char*&amp;gt;(cpp_hdlr) + 5); 
        if(!saved_handler_instructions) 
        { 
            if(!ReadMemory(cpp_hdlr, cpp_handler_instructions, sizeof(cpp_handler_instructions))) 
                return false; 
            saved_handler_instructions = true; 
        } 
        return WriteMemory(cpp_hdlr, &amp;amp;jmp_my_hdlr, sizeof(jmp_my_hdlr)); 
    } 
    bool restore_cpp_handler() 
    { 
        if(!saved_handler_instructions) 
            return false; 
        else 
        { 
            void* loc = __CxxFrameHandler; 
            return WriteMemory(loc, cpp_handler_instructions, sizeof(cpp_handler_instructions)); 
        } 
    } 
}&lt;/FONT&gt;&lt;/PRE&gt;
&lt;P&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;编译指令#pragma pack(push, 1)告诉编译器不要在jmp_instr结构中填充任何用于对齐的空间。没有这条指令，jmp_instr的大小将是8字节，而我们需要它是5字节。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;现在重新回到异常处理这个主题上来。调用catch块时，它可能重新抛出异常或抛出新异常。前一种情况下，异常处理程序必须继续传播（propagate）当前异常；后一种情况下，它需要在继续之前销毁原来的异常。此时，处理程序要面对两个难题：&amp;#8220;如何知道异常是源于catch块还是程序的其他部分&amp;#8221;和&amp;#8220;如何跟踪原来的异常&amp;#8221;。我的解决方法是：在调用catch块之前，把当前异常保存在exception_storage对象中，并注册一个专用于catch块的异常处理程序&amp;#8212;&amp;#8212;catch_block_protector。调用get_exception_storage()函数，就能得到exception_storage对象： &lt;/P&gt;&lt;FONT color=blue&gt;&lt;PRE&gt;exception_storage* p = get_exception_storage(); 
p-&amp;gt;set(pexc, pexc_info); 
注册 catch_block_protector; 
调用catch块; //....&lt;/FONT&gt;&lt;/PRE&gt;
&lt;P&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;这样，当catch块（重新）抛出异常时，程序将会执行catch_block_protector。如果是抛出了新异常，这个函数可以从exception_storage对象中分离出前一个异常并销毁它；如果是重新抛出原来的异常（可以通过ExceptionInformation数组的前两个元素知道是新异常还是旧异常，后一种情况下着两个元素都是0，参见下面的代码），就通过拷贝ExceptionInformation数组来继续传播它。下面的代码就是catch_block_protector()函数的实现。&lt;/P&gt;&lt;FONT color=blue&gt;&lt;PRE&gt;//------------------------------------------------------------------- 
// 如果这个处理程序被调用了，可以断定是catch块（重新）抛出了异常。 
// 异常处理程序（my_handler）在调用catch块之前注册了它。其任务是判断 
// catch块抛出了新异常还是重新抛出了原来的异常，并采取相应的操作。 
// 在前一种情况下，它需要销毁传递给catch块的前一个异常对象；在后一种 
// 情况下，它必须找到原来的异常并将其保存到ExceptionRecord中供异常 
// 处理程序使用。 
//------------------------------------------------------------------- 
EXCEPTION_DISPOSITION catch_block_protector( 
        _EXCEPTION_RECORD* ExceptionRecord, 
        void* EstablisherFrame, 
        struct _CONTEXT *ContextRecord, 
        void* DispatcherContext 
        ) throw () 
{ 
    EXCEPTION_REGISTRATION *pFrame; 
    pFrame= reinterpret_cast&amp;lt;EXCEPTION_REGISTRATION*&amp;gt;(EstablisherFrame); 
    if(!(ExceptionRecord-&amp;gt;ExceptionFlags &amp;amp; (_EXCEPTION_UNWINDING | _EXCEPTION_EXIT_UNWIND))) 
    { 
        void *pcur_exc = 0, *pprev_exc = 0; 
        const excpt_info *pexc_info = 0, *pprev_excinfo = 0; 
        exception_storage* p = get_exception_storage(); 
        pprev_exc = p-&amp;gt;get_exception(); 
        pprev_excinfo = p-&amp;gt;get_exception_info(); 
        p-&amp;gt;set(0, 0); 
        bool cpp_exc = ExceptionRecord-&amp;gt;ExceptionCode == MS_CPP_EXC; 
        get_exception(ExceptionRecord, &amp;amp;pcur_exc); 
        get_excpt_info(ExceptionRecord, &amp;amp;pexc_info); 
        if(cpp_exc &amp;amp;&amp;amp; 0 == pcur_exc &amp;amp;&amp;amp; 0 == pexc_info) //重新抛出 
        { 
            ExceptionRecord-&amp;gt;ExceptionInformation[1] = reinterpret_cast&amp;lt;DWORD&amp;gt;(pprev_exc); 
            ExceptionRecord-&amp;gt;ExceptionInformation[2] = reinterpret_cast&amp;lt;DWORD&amp;gt;(pprev_excinfo); 
        } 
        else 
        { 
            exception_helper::destroy(pprev_exc, pprev_excinfo); 
        } 
    } 
    return ExceptionContinueSearch; 
}&lt;/FONT&gt;&lt;/PRE&gt;
&lt;P&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;下面是get_exception_storage()函数的一个实现：&lt;/P&gt;&lt;FONT color=blue&gt;&lt;PRE&gt;exception_storage* get_exception_storage() 
{ 
static exception_storage es; 
return &amp;amp;es; 
} &lt;/FONT&gt;&lt;/PRE&gt;
&lt;P&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;在单线程程序中，这是一个完美的实现。但在多线程中，这就是个灾难了，想象一下多个线程访问它，并把异常对象保存在里面的情景吧。由于每个线程都有自己的堆栈和异常处理链，我们需要一个线程安全的get_exception_storage实现：每个线程都有自己单独的exception_storage，它在线程启动时被创建，并在结束时被销毁。Windows提供的线程局部存储（thread local storage，TLS）可以满足这个要求，它能让每个线程通过一个全局键值来访问为这个线程所私有的对象副本，这是通过TlsGetValue()和TlsSetValue这两个API来完成的。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;Excptstorage.cpp中给出了get_exception_storage()函数的实现。它会被编译成动态链接库，因为我们可以籍此知道线程的创建和退出&amp;#8212;&amp;#8212;系统在这两种情况下都会调用所有（当前进程加载的）dll的DllMain()函数，这让我们有机会创建特定于线程的数据，也就是exception_storage对象。&lt;/P&gt;&lt;FONT color=blue&gt;&lt;PRE&gt;//excptstorage.cpp 
#include "excptstorage.h" 
#include &amp;lt;windows.h&amp;gt; 
namespace 
{ 
    DWORD dwstorage; 
} 
namespace my_handler 
{ 
    __declspec(dllexport) exception_storage* get_exception_storage() throw () 
    { 
        void * p = TlsGetValue(dwstorage); 
        return reinterpret_cast &amp;lt;exception_storage*&amp;gt;(p); 
    } 
} 

BOOL APIENTRY DllMain( HANDLE hModule, DWORD ul_reason_for_call, LPVOID lpReserved ) 
{ 
    using my_handler::exception_storage; 
    exception_storage *p; 
    switch (ul_reason_for_call) 
    { 
    case DLL_PROCESS_ATTACH: 
        //主线程（第一个线程）不会收到DLL_THREAD_ATTACH通知，所以， 
        //与其相关的操作也放在这了 
        dwstorage = TlsAlloc(); 
        if (-1 == dwstorage) 
            return FALSE; 
        p = new exception_storage(); 
        TlsSetValue(dwstorage, p); 
        break ; 
    case DLL_THREAD_ATTACH: 
        p = new exception_storage(); 
        TlsSetValue(dwstorage, p); 
        break;  
    case DLL_THREAD_DETACH: 
        p = my_handler::get_exception_storage(); 
        delete p; 
        break ; 
    case DLL_PROCESS_DETACH: 
        p = my_handler::get_exception_storage(); 
        delete p; 
        break ; 
    } 
    return TRUE; 
}  &lt;/FONT&gt;&lt;/PRE&gt;
&lt;H3&gt;结论&lt;/H3&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;综上所述，异常处理是在操作系统的协助下，由C++编译器和运行时异常处理库共同完成的。 
&lt;H3&gt;注释和参考资料&lt;/H3&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;① 本文写作期间，微软发布了Visual Studio 7.0。本文的异常处理库主要是在运行于奔腾处理器的windows2000上使用VC++6.0编译和测试的。但我也在VC++5.0和VC++7.0 beta版上测试过。6.0和7.0之间有一些差别，6.0先把异常（或其引用）拷贝到catch块的栈桢上，然后在调用catch块之前进行堆栈展开；7.0则先进行堆栈展开。在这方面，我的库代码的行为比较接近6.0版。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;② 参见Matt Pietrek发表在MSDN上的文章《structured exception handling》。 &lt;BR&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;③ 如果一个函数既不含try块，也没有定义任何具有&amp;#8220;有为的析构函数&amp;#8221;的对象，那么编译器将不为它生成用于异常处理的数据。&lt;img src ="http://blog.vckbase.com/localvar/aggbug/9890.html" width = "1" height = "1" /&gt;</description></item></channel></rss>