Windows程序调试----第三部分 调试技术----第10章 调试多线程程序

来源:互联网 发布:软件专业等级证书 编辑:程序博客网 时间:2024/06/05 01:56

10章调试多线程程序

    线程。当一个可怜的灵魂不得不在多线程环境中重现并诊断一个问题时,一个像线程这样优雅的结构所带来的痛苦之大是令人惊奇的。无论设计多么简单而直接,在程序中多引入仅仅一个线程都会带来令人吃惊的调试困难。所以,不管什么时候,做出在程序中使用多线程的决定都必须慎之又慎。

    如果你没有计算多线程将给程序的正确性带来的影响和调试时将耗费的额外时问,最好不要莽撞地引入多线程。在这种情况下,你可以考虑使用另一种技术——Windows的定时器——来完成后台工作。不过,在一些程序中使用多线程确实能带来好处。例如,当一个线程在处理I/O操作或其他需要一段等待时间的工作时,另一个线程就能使用处理器作另一些处理。如果程序是在一个多处理器机器上运行,多个线程可以真正地同时执行。在程序中使用多线程,你还可以根据自己的设计要求对各个线程分别优化。

    多线程环境下的调试工作十分困难,本章介绍的技术能够帮助大家安全、有效地调试多线程。阅读的时候请记住:本章介绍的技术是对本书前面部分介绍的技术的补充。单线程程序易犯的错误,多线程程序中也会出现;诊断一个错误时,只有与棑除了这个错误是单线程内错误的时候,才应该考虑它是否是因为多线程引起的。

10.1 什么是多线程

    在这里我不想复制多线程的完整文本说明(这在很多地方都能够得到),只准备介绍线程的基本概念。我们假定你己经有过一些书写多线程程序的经验,而且读过一些多线程的介绍。如果想对这个主题有更多的了解,可以参阅本章最后“推荐阅读”中所列的书。我发现,虽然很多程序员都有过使用临界区、互斥操作和其他Windows提供的同步原语,他们对最底层的内在实质并没有真正的了解。为了确保我们的概念一致,我先对Windows平台上的线程给出一个定义,并简单解释操作系统对线程是如何操作的。

    线程代表代码的一个执行路径,是Windows内部进行调度的基本单位。Windows为每个线程维护一个数据结构,叫做“线程信息块”(TIB),其中记录了该线程的状态,包括处理器寄存器的拷贝(包括指令和堆栈指针)、进行调度决策时所需的信息(如线程优先级和等待时间等)。每过一定的时间间隔(大概是20-40毫秒〕,Windows会接到一个定时器中断,它就利用这个中断进行调度决策。如果决策者决定不让当前执行的线程继续使用处理器,就会把寄存器的当前内容保存在TIB中,把这个线程放到调度队列的末尾,然后选择一个新的线程来执行。这个被中断的线程就呆在那里,直到下一次被调度器选中

    这样的线程调度是抢占式的,这使得多线程错误很难重现。在一个程序的不同执行中,代码执行的时机和线程执行时的上下文很可能不同。所以,当我收到一个“未处理的异常”消息框时,总是觉得自己很幸运,因为这意味着如果我能诊断并修正问题,则用户会遇到的错误又少了一个。令大多数人恐惧的是,你发现自己正在检査的错误是很多个与时间有关的错误之一——你永远不知道究竟有多少个这样的错误。

10.2 多线程程序的几个要点

    从你的程序由单线程程序变成为多线程程序的那一刻开始,你就要面对一组全新的错误。一个线程安全的程序意味着在多线程的环境中也能正确执行。和很多“程序开发12步”一样,多线程程序员所要经历的第一步是承认自己会遇到问题,更准确地说,是有遇到问题的可能。下面四个小节分别讨沦了多线程程序员必须注意并处理的几个问题。

竞争

    竞争是指这样一种情况:特定操作的正确执行结果与执行时序有关。如果一切尽如人愿,结果是正确的:然而,如果调度器调度的时机不好,结果就会出错。请看下面这段简单的代码,它将一个整数变量加1

    int g_x = 2; // a global variable

    DWORD WINAPI ThreadProc(void*) {

        g_x++;

        return 0;

    }

    如果这个过程是线程安全的,假定g_x初始值为1,有两个线程执行这个过程,那么g_x最后的数值应该为4。如果这个断言不成立,说明你的代码写得不正确。我们最后发现,上面这段代码存在竞争,结果不总是正确的。发生问题的原因是一个C++语句并不一定翻译成一个汇编指令,在调试版本中,增量语句的编译结果很可能是这样的:

    ; Assenbly code for g_x++

    mov eax, dword ptr[g_x] ; read contents of g_x into eax register

    add eax, 1              ; increment contents o£ eax register

    mov dword ptr[g_x],eax  ; store result in variable

    从汇编代码级别看,竞争更明显了。如果线程A先执行完了所有这二句汇编指令,然后线程B才开始执行这个语句序列,结果是正确的。但是,如果线程A已经将g_x读到了自己的EAX寄存器映像中,但还没有将加1后的结果写回,此时处理器被抢占,就会发生错误。这种情况下,很可能线程B会在A更新g_x变量之前从内存中读到g_x,其值仍是2。如果这种情况发生了,两个线程读到的g_x值都是2,在自已的EAX寄存器映像中加1得到3,然后把3存回到内存。因为这段代码的执行结果依赖于执行的时序,我们说存在着竞争。你马上会看到,竞争是绝大部分多线程问题的根源,所有其他问题都是它的变体。

死锁

    如果你写的代码中有可能发生一个线程已经占有了某个资源而又要等待另一个资源的拥有权的情况,你就可能面临死锁的问题。考虑这样的场景:多个线程同时对银行账户进行操作,对应每个账户有一个互斥锁,要对一个账户进行操作必须先获得这个互斥锁。如果一个线程只需要操作一个账户,它只需要简单地调用WaitForSingleObject获得相应的互斥锁、对账户进行操作然后调用ReleseMutex撤销对互斥锁的所有权就可以了。但是,如果一个线程想把资金从一个账户转移到另一个账户上,就需要获得两个互斥锁的所有权,一个是原账户的,一个是目的账户的。只有获取了这两个互斥锁,转移才能安全进行。下面是这段代码的可能形式:

        void TransferFromCheckingToSavings() {

            WaitForSingleObject(g_hCheckingMutex, INFINITE);

            WaitForSingleObject(g_hSavingsMutex, INFINITE);

            // Transfer funds...

            ReleaseMutex(g_hSavingsMutex);

            ReleaseMutex(g_hCheckingMutex);

        }

    单独看这段代码,好像没有问题。但是,如果系统中另外还有一个线程,执行类似的功能,但是在相反的方向上转移资金,会出现什么问题呢?

        void TransferFromSavingsToChecking() {

            WaitForSingleObject(g_hSavingsMutex, INFINITE);

            WaitForSingleObject(g_hCheckingMutex, INFINITE);

            // Transfer funds...

            ReleaseMutex(g_hCheckingMutex);

            ReleaseMutex(g_hSavingsMutex);

        }

    现在,有可能一个线程调用TransferFromCheckingToSavings,获得了g_hCheckingMutex的所有权,然后被抢占了。这个时候如果另个线程恰好调用TransferFromSavingsToChecking,它就会得到g_hSavingsMutex的所有权,然后因为等待g_hCheckingMutex(被第一个线程占有)而被阻塞。当最终轮到第一个线程继续执行时,它又会要求得到g_hSavingsMutex,而这个互斥锁已经被第二个线程拥有了,这时候,两个线程都不能再取得进展——两个人都占有着一个对方需要的资源,而等待第二个己经被对方占有的资源。这导致了没有一个线程能继续执行的僵局。我们说这两个线程陷入了死锁。而且,因为程序中其他的线程很可能最后也要用到这两个资源之一,那时整个程序都会停止。

    当两个线程都占有着一个对方需要的资源,而等待第二个已经被对方占有的资源时,就会发生死锁。

错误的不可重现性

    当有人报告了一个程序错误时,大部分程序员试图做的第一件事是重现这个错误。但如果这个错误和时序有关,重现别人报告的程序行为非常困难。很多因素会影响多线程程序中的执行时序:系统中处理器的速度和数目、内存的大小、可用的硬盘空间大小、使用的是程序的哪个版本(发行版或调试版)、是否与调试器连接、同时运行的还有什么程序,等等,虽然有的看起来好像毫不相干。你经常会发现自己无法重现错误,无论错误报告如何详尽。不幸的是,解决这类不愉快的问题的唯一方法是保证你的程序没有任何错误。既然大部分人都会在某个地方发生错误,知道如何处理难以重现的错误是程序员的必要技能。

异常处理

    如果你在程序中增加了线程,处理未捕捉的异常变得困难多了。这是因为异常的处理是各个线程自己的活动:当一个线程处理异常的时候,进程中的其他线程继续推进。如果一个线程中的异常没有处理,整个进程都会被关闭。你可以使用API函数SetUnhandlcdExceptionFilter引入一个进程范围(processwide)的钩子函数,在这种情况下调用,但是你还是会遇到如何处理的问题。一种选择是记录下来,并悄悄地把崩溃的线程关掉。或者你可以再生成一个新的线程继续处理死线程未完成的工作。有时也许应该通知用户:那么如果用户决定终止整个程序,你怎么处理清理工作?你不能简单地访问数据结构并把缓冲区释放掉,因为程序中的其他线程仍在执行,可能正在操作这些数据结构。

    试图关闭程序的唯一安全方法是通知所有的线程进入关闭过程,并等待所有进程完成这一过程。但是,如果这个过程中又由于最初的异常而发生问题怎么办?如果因为最初的异常,一个或多个数据结构处于非法的状态怎么办?你应该花多长时间等待它们结束?对于这些问题没有绝对的答案,但是通过这些你可以对多线程编程和调试的困难程度有个印象。警惕这些问题并处理它们都是多线程程序员的任务。

10.3 书写线程安全的代码

    Windows提供了多种帮助实现程序的线程安全的机制。知道什么时候使用这些工具是很困难的事。这里我不会重复你在任何一本多线程编程的书上都能找到的资料,但我会总结一些基本的技术。

防止竞争

    防止竞争的第一步是标识被多个线程共享的资源。做完这一步之后,你必须仔细地估计资源被访问的情况,确定是否有发生问题的可能。回忆我们的上一个例子(多个线程同时将一个整数变量加1)。这里有潜在的问题不仅仅是因为其中牵涉到多个线程,而是因为多个线程在读、修改、更新这个变量。如果多个线程都只是读变量,不需要采取任何措施。

    当共享的数据很简单(例如整数)的时候,可以使用加锁API函数,例如InterlockedIncrement。这些函数提供对单32位变量的原子性(atomic)的递加、递减、交换和条件交换操作。在本例中,修改g_x的安全方式是调用;

    InterlockedIncrement(&g_x);

    但是,如果线程需要修改两个整数变量,如下面这段代码所示,简单地两次调用加锁 API函数还不够:

        long g_cAvailableWidgets = MAX_WIDGETS;// # free widgets

        long g_cUsedWidgets = 0;// # used widgets

        void TakeWidget() {

            if(g_cAvailableWidgets > 0) {

                // consume a widget

                InterlockedDecrement(&g_cAvailableWidgets);

                InterlockedIncrement(&g_cUsedWidgets);

            }

            // else there are no widgets available

        }

    虽然对每个整数变量的更新是原子牲的,但不能保证对widget的非零检测以及之后的两个更新操作都作为一个不可中断的操作完成。要解决这个问题,需要引入一些更复杂的东西。这就是引入临界区和互斥锁的用处。

    临界区和互斥 API函数都提供了对共享变量单线程访问的保证机制。这两者的使用模式是相同的:调用一个函数获得锁(临界区或互斥锁)的互斥使用权,访问被锁保护的共享资源,然后释放锁。如果你申请锁的时候锁正在被其他线程使用,Windows会在函数调用中阻塞调用线程。只有当拥有这个锁的线程释放了锁,被阻塞的线程才允许得到锁并继续执行。如果我们用互斥锁重写前面的TakeWidget函数,代码大致如下:

        HANDLE g_hWidgetLock = CreateMutex(0, FALSE, 0);

        long g_cAvailableWidgets = MAX_WIDGETS;// # free widgets

        long g_cUsedWidgets = 0;// # used widgets

        void TakeWidget() {

            // before we do anything, acquire the widget lock

            if( WAIT_OBJECT_0 == WaitForSingleObject(g_hWidgetLock, INFINITE))

            {

                if(g_cAvailableWidgets > 0) {

                    // consume a widget

                    g_cAvailableWidgets--;

                    g_cUsedWidgets++;

                }

                ReleaseMutex(g_hWidgetLock); // release the lock

            }

        }

    临界区与互斥锁的一个差别是它们的性能。为了取得互斥锁的所有权,线程调用 API函数WaitForSingleObject。这是一个进入内核的系统调用,需要将处理器模式从程序执行的用户模式切换到Windows和设备驱动程序执行的内核模式。如果当前互斥锁是可用的,Windows会记录调用线程是该锁的所有者,并允许线程立刻从系统调用返回。如果锁被别的线程使用,调用线程会在内核中被阻塞。如果运行的时候对互斥锁的真正竞争并不多,即线程一般不需要阻塞就能获得锁的使用权,那么切换到内核模式再切换回来的开销相对就会显得很大,而且并不是严格需要的

    这种情况下,临界区就可以发挥作用了。某种意义下说临界区就是优化的互斥锁。当你调用API函数EnterCriticalSection请求对临界区的使用权时,内核只是简单地对CRITICAL_SECTION数据结构中的一个变量执行一个加锁更新操作,不需要切换到内核模式。这个更新操作的目的是声明对临界区的所有权,如果发现临界区没有被其他线程占有,就不必再切换到内核模式,函数简单地返回到调用者程序。如果运行的时候对临界区的竞争很少,这一优化措施将带来巨大的性能提高。但是,如果临界区已经被其他线程占有,临界区会使用一个内部事件阻塞调用线程,直到获取所有权才允许线程继续执行,这时就必须发生一次到内核模式的切换。使用临界区的一个限制是它只能用于同一进程空间内的线程的同步。这是因为它使用的是普通的C++数据结构,属于进程级别的资源,就像事件的HANDLE—样。另外,请求临界区不允许指定超时参数,也不允许同时请求多个临界区的所有权。相反地,互斥锁是内核对象,可以被同一系统中的不同进程的线程共享。用于申请互斥锁所有权的 API同步函数WaitForSingleObjectWaitForMuItipleObjects允许指定超时参数,也允许通过一个函数调用同时申请多个锁。这两个技术在避免死锁上都有着很重要的作用。

    当对锁的竞争不多时,临界区比互斥锁更有效,但临界区只限于同一进程中,不允许用户指定超时参数,也不允许同时申请多个锁。

防止死锁

    一般来说,有两种对付死锁的方法:采取步骤预防死锁,或者检测并破坏死锁。检测死锁是可以做到的,但通常消耗较多的精力,程序员觉得不太值得,因为死锁的情形一般很少发生。即使检测的代价不是很大,一旦检测到死锁后应该怎么处理仍旧是个问题。是否由Windows随机指定一个线程,允许它继续执行,而给其他线程一个某种错误代码,例如WAI_DEADLOCK_DETECTED返回值?或者,是否让Windows通知所有线程,告诉它们陷入了死锁?即使如此,当线程收到通知之后应该怎么做?检测的方式需要考虑这么多问题,而预防死锁只需要很小的开销,所以预防是我们最常采用的策略。和很多操作系统一样,Windows不主动检测并破坏死锁,但它提供了一些机制允许你预防死锁。

    当涉及到多个锁的时候,例如我们前面讲过的银行账户转移的例子,有二种预防死锁发生的方法可供选择:

l  在调用 API函数WaitForSingleObject时不要使用INFINITE作为超时参数;

l  按事先约定的顺序申请锁;

l  使用 API函数WaitForMuItipleObjects

    在这个例子中,如果在WaitForSingleObject调用中使用的超时参数不是INFINITE,那么即使发生了死锁,也不会一直持续。一旦过了指定的时间,其中一个线程或两个线程就会接到WAIT_TlMEOUT作为WaitForSingIeObject调用的返回值。选择超时参数其实就是决定你能容受多长时间的死锁(如果发生死锁的话)。如果这个时间段很短,例如1毫秒,线程将不能承受对锁的任何竞争;如果这个参数设置得很大,例如几分钟,用户可能会感到程序挂起了,从而把把程序结束掉。显然,选择合适的超时参数是关键。我们最好选择这样一个值:以计算机的时间来衡量几乎是“永远”,而对用户来说又很小,几乎不可觉察。

    预防死销的一个简单办法是在调用 API函数WaitForSingIeObject时使用适当的超时参数。

    第二个选择是建立一种规则,规定多个锁被获得的顺序。这个方法也被称为层次式锁申请。在上面这个例子中,问题的发生是因为TransferFromCheckingToSavings函数和TransferFromSavingsToChecking函数试图以相反的顺序获得两个互斥锁,如果两个函数都先申请Checking账户的互斥锁,再申请Savings账户的锁,即使我们使用INFINITE作为超时参数,也不会发生死锁。不过,这个方法虽然概念上很简单,但在实际程序中有时候并不现实。在这段代码中,你可以很快发现可能会产生死锁,因为这两个函数都很短,互相直接相关,而且所有的锁都在一个函数中集中中请。如果涉及到更多的锁,而且对这些锁的申请分散在多个函数中,发现错误会困难得多。

    例如,假设你的程序有四个共享资源,每个资源有一个锁保护,名字分别是ABCD,我们规定申请的顺序必须按照字母序。请看如下代码。

        void UseAD() {

            Lock(A);

            Lock(D);

            // ... use resources A and D ...

            UseBCD();

            // ... use resources A and D some more ...

            Unlock(D);

            Unlock(A);

        }

        void UseBCD() {

            Lock(B);

            Lock(C);

            Lock(D);

            // ... use resources B, C and D ...

            Unlock(D);

            Unlock(C);

            Unlock(B);

        }

    UseADUseBCD都遵守了申请锁的协议,按照锁的名字的字母顺序申请锁,但发生死锁的可能性仍存在。假设程序中有两个线程,线程1调用UseAD,线程2调用UseBCD,则有可能发生如下的动作序列:

    1.线程1调用UseAD

    2.线程1获得锁A

    3.线程2调用UseBCD

    4.线程2获得锁B

    5.线程2获得锁C

    6线程1获得锁D

    7.线程1调用UseBCD

    8.线程1试图获得锁B(当前被线程2占有)时被阻塞

    9.线程2试图获得锁D(当前被线程1占有)时被阻塞

    在第8步中,线程1已经占有了锁AD,等待获得锁B;在第9步中,线程2已经占有了锁BC,等待获得锁D。在这一点上,线程12陷入了死锁。错误出在第8步,线程1申请锁B时破坏了顺序。一个解决方案是重新编码UseAD,让它在调用UseBCD之前先释放锁AD,从UseBCD返回之后再重新申请。但是,这意味者UseAD对于资源AD的两个阶段的使用不是原子性(atomical)的,这有可能是不正确的。

    如果在占有锁的情况下需要调用的函数更多,这样的情况更难预防。要在釆用层次式锁申请下完全防止这一类的代码错误,唯一的方法是申请额外的资源:如果你想使用锁AD,你必须按顺序申请ABCD的所有权。这样的要求降低了多线程可以获得的并发度。因为人们并不能很好地遵守这样的规则,而且最安全的策略以牺牲性能为代价,所以展次式锁申请的方法并不总是适用

    层次式锁申请能够预防死锁,但使用时必须小心。

    层次式锁申请的方法适用于不提供其他互斥机制的平台,适用于临界区(下面我要介绍的方法只能用于互斥锁,不能用于临界区)。不过,Windows向程序员提供了另外一种选择:使用 API函数WaitForMultipleObjects。这个函数的输入参数包括:一个数组(数组的元素是内核对象的句柄)、一个布尔标识(fWaitAll),告诉Windows获得一个资源的所有权就返回,还是获得所有资源才返回),还有一个超时参数。如果在前面的银行账户的例子中使用这个函数,Windows就能保证只有一个线程能安全地获得互斥锁,不会发生占有-等待的局面。在UseADUseBCD中使用WaitForMultipleObjects也能成功地避免死锁,因为WaitForMultipleObjects不会内在地占有任何锁,除非你能得到所有请求的锁。也许这个函数唯一的缺点就是它只能对内核对象进行操作,例如互斥锁,而对临界区没有等价的机制。对临界区进行操作的APl函数中没有一个类似于EnterMultipleCriticalSections的函数,也没有什么方法能从CRITICAL_SECTION数据结构中提取出什么东西在WaitForMultipleObjects函数中使用,因为当没有竞争的时候只对一个整数进行操作。

    当使用互斥锁时,借助 API函数WaitForMultipleObjects来预防死锁是最实用而且有效的

处理不可再现的错误

    很不幸,且不说零错误程序的理想目标,即使用户报告的错误,我们也并不能保证总是能够足够重现。如果程序没有与时序有关的错误,重现错误通常是比较直接的。如果在单线程程序中用户在对话框中输入了什么非法的数据,程序因此而崩溃了,你可以很容易地再现这个错误只需要简单地重复用户的动作,就能导致错误,然后你就可以检査了。

    但是,在多线程程序中,用户对导致问题发生的动作的描述可能不是错误的唯一诱因;时序在其中也插了一脚。更糟糕的是,用户的动作可能与问题完全无关。有一次,一个用户使用我参与工作的一个视频会议产品,报告了一个导致程序崩溃的未处理的异常。他在错误报告中详尽地描述了导致这个异常产生的动作。这个用户非常细心,在书写详细错误报告方面达到了开发人员所希望的理想境界。但不幸的是,那个崩溃与他的动作根本无关,实际原因是通过网络传来了一些非法数据,在很底层的通信子系统中产生了一个异常。

    既然你不能预测自己的程序会遇到什么样的与时序有关的问题,那么,处理不可再现问题的最好方法是促使潜在错误的发生。这意味着你应该尽量早地测试你的程序,最好在整个编码过程中经常测试,尤其是在容易影响程序时序的时候。特别地,你的测试环境应该包括以下场景:

    不仅在单处理器上,更要在多处理器系统上进行测试。在一个多处理器系统上运行你认为线程安全的程序,是很好的发现隐藏的同步问题的方法。如果没有这样的条件,退而求其次,在不同速度的单处理器系统上进行测试也是很有用的。

    •在内存大小和可用硬盘空间大小不同的系统上进行测试。物理内存和可以用作交换文件的可用硬盘空间结合起来,会影响程序可以使用的总的虚拟内存的大小。系统拥有的物理内存越少,Windows为处理缺页中断而需要做的存储管理的工作就越多,这会导致运行速度的降低。大量的物理内存可以降低缺页中断,从而提高速度。

    •测试时使环境的工作负载因为其他程序的影响而变化。这句话是说,你应该既在没有其他程序运行的时候测试你的程序,也应该在同时有另外几个程序运行的情况下进行测试。你所选择的其他程序应该具有不同的性质。消耗大量内存的程序可以帮助测试与虚拟内存管理器有关的问题。计算集中的程序可以使处理器一直忙于处理,从而测试调度器;I/O集中的程序,例如磁盘分块工具,或者视频集中的程序,例如电影播放器,有助于测试Windows的其他方面。在不同的环境中测试你的程序,你能够更精确地模拟你的程序在用户机器上操作时的不同环境。

    •测试程序的调试版本和发行版本。我曾经见过这样一个组织,他们花了18个月来开发并测试一个很大的产品,但没有建立代码的一个发行版本:一直到“QA阶段”,也就是发布日期之前的最后4个星期才建立发行版本。当他们第一次建立发行版本时,遇到了大量的编译错误:花了很大功夫解决这些编译问题之后,才开始真正的测试。不可避免地,QA部门抱怨产品很不稳定,只能实现一小部分的测试计划。不对产品的发行版本进行测试是最典型的“自找麻烦”。测试版本中一般禁止了很多的编译优化选项,而且在程序代码中还包含了很多的断言、跟踪和其他沴断信息,另外还有一些第三方构件,例如C运行时刻函数库和MFCATL基本结构库等。虽然这样的调试代码非常有用,但它确实会影响程序的时序。所以,我们一定要注意在整个开发周期中都要测试发行版本(即你最终要交给客户使用的代码)

    处理与时序有关的问题的最好方法是促使潜在错误的发生。

处理编译器的优化

    上面提出的最后一个建议与在一个多线程开发中更普遍的问题有关:编译器的代码生成。有人曾说过:如果你在调试工具中看到一些编译代码,而且能清楚地知道这些代码在做什么,它一定没有经过编译器的优化。或者这些代码是人手写的,或者编译器的所有优化选项都被禁止了。当程序员需要实现一些程序设计语言不支持的功能时,他们会使用汇编语言:有一些尽责的程序员会书写汇编语言,以使得以后要读这些代码的人能看懂。虽然这样的做法对程序维护人员来说是件好事,但这样的代码可能并没有得到很好的优化。另一方面,编译器的优化器能够利用底层处理器体系结构的细节来产生指令序列,使之最好地利用特定处理器的指令流水、转移预测以及数据缓存等机制。当然,优化器是在不改变代码的预期行为的前提下做这些事情的。

    优化器面临的一个困难是它只能处理一个小窗口内的代码。要求优化器在优化一个for循环的时候扫描所有的代码并考虑所有的因素,是不现实的。但是,通过减少所查看代码的范围,优化器有的时候的确会产生不能完成程序员预期功能的代码。请看下面这段代码:

        BOOL g_fExit = FALSE; // a flag thart indicates it's tome to exit

        // other variable and code here ...

 

        DWORD WINAPI ThreadProc(void*) {

            while(!g_fExit) { // see if it's time to exit

                // do something exciting ...

            }

        }

    在这段代码中,只要全局变量g_Exit没有被设置成TRUE(TRUE意味着到了应该退出的时候),一个或多个线程就会一直循环进行一些有用的处理。到了线程应该结束循环的时候,在程序的其他地方会有另外一个线程将这个变量设置成TRUE。这段代码在调试版本中工作正常,因为调试版本中,检查变量的值的代码总是指向内存中该变量的内容,如下面的代码所示;但是,在发行版本中,就有可能发生问题。

        ; unoptimized code generated for while loop

        LoopStart:

            cmp dword ptr[g_fExit], 0;compare the variable to FALSE

            jne ExitLoop; exit loop if flag variable != 0

            ; do something exciting ...

        ExitLoop:

    但是,当打开优化选项的时候,编译器生成的代码可能是这个样子的:

        ; optimized code generated for while loop

        mov esi, dword ptr[g_fExit]; fetch variable from memory into a register

        LoopStart:

            cmp esi, 0;compare the variable to FALSE

            jne ExitLoop; exit loop if flag variable != 0

            ; do something exciting ...

            jmp LoopStart; go back to the start of the loop

        ExitLoop:

    在“优化”后的代码中,编译器在循环开始的地方把变量的值从内存中取到了ESI寄存器里。这个措施能够加快对变量的访问,因为对寄存器内容的读、写和测试要比对内存内容做同样的操作要快。如果循环体中的代码要设置g_fExitTRUE,编译器会产生代码修改ESI寄存器,从而导致线程退出循环,正如我们所希望的。但是,这段代码中while循环体的内部永远不会修改g_fExit——这个工作由程序中的其他线程完成。现在,当那个线程将g_fExit设置成TRUE时,它将修改g_fExit所在的内存地址的内容。因为这里的代码测试的是寄存器ESI中的值,它不能感受到g_fExit变量的值的变化,所以while循环永远不能停止。在运行的时候,这个程序看起来好像被挂起了,执行这段“优化”代码的线程永远接不到退出的请求。

    解决这个问题的方法很简单,困难的是发现潜在的问题。在这个问题中,我们可以将g_fExit变量声明成volatile的关键词,语句如下;

    BOOL volatile g_fExit = FALSE;

    volatile关键词告诉编译器:在程序的某个地方,可能有另一个线程(或者进程,如果这个变量恰好在共享内存中)会修改这个变量。对于这样的变量,编译器产生的代码总是直接访问内存,而不把变量的值缓存到寄存器里。这个关键词相对任何打开的优化选项都有优先级。随着编译优化器的不断智能化,这样的漏洞已经很少,但是不排除出错的可能性。

    使用volatile关键词防止多线程程序中的编译优化错误。

防止资源泄露

    在预防多线程错误方面,C++语言能给我们提供巨大的帮助。在书写线程安全的代码时,它的一个语言持点给我们的开发过程带来很大的便利,那就是析构函数。C++语言中,当一个经过完整构造的自动本地对象超出其作用域的时候,其析构函数一定会被调用;即使当异常发生的时候,这个保证仍有效。在第9章“内存调试”中,我们己经看到了析构函数和智能指针在防止资源泄漏方面的用途。在编写线程安全的代码中,析构函数也发挥着强大的作用。这里的基本思想是使用构造函数和析构函数提供自动的、异常安全的方法来获得和释放锁(包括临界区和互斥锁),请看下面这段代码:

        void DoSomething() {

            EnterCriticalSection(&g_cs);

            UseSharedResource();

            DoSomethingElse();

            LeaveCriticalSection(&g_cs);

        }

    在这个例子中,使用了一个CRITICAL_SECTION结构的变量g_cs来串行化对共享资源的访问。一旦获得了临界区,下面的代码使用这个共享资源,然后进行一些其他处理,在DoSomething函数返回之前将临界区释放,我们必须确保临界区会被释放,即使在异常发生的情况下。但是按照这段代码的书写逻辑,只有当没有任何异常发生的时候临界区才会被释放。当然,你可以在这段代码外加一段guard代码,但这样做你会把事情弄得很复杂,因为你要在程序中所有类似的地方都加上guard,而实际上,编译器可以自动地解决这个问题。

    借助C++语言的一些特性,你可以用一个“自动锁”类很较松地解决这个问题。一个这样的类至少提供:一个用来获得某种特定的锁的构造函数,一个用来释放这个锁的析构函数。下面是一个管理临界区的自动锁类的代码:

        class CAutoCritSec {

        public:

            CAutoCritSec(CRITICAL_SECTION* pcs):m_pcs(pcs) {

                EnterCriticalSection(m_pcs);

            }

            ~CAutoCritSec(){

                LeaveCriticalSection(m_pcs);

            }

 

        private:

            CRITICAL_SECTION* m_pcs;

        };

    借助这个类,你可以很方便地改写DoSomething函数,使函数具有线程安全性和异常安伞性:_

        void DoSomething() {

            CAutoCritSec Lock(&g_cs);

            UseSharedResource();

            DoSomethingElse();

        }

    现在,当进入DoSomething函数的时候会调用自动锁类的构造函数,自动获得临界区g_cs。类似地,当离开这一基本块的时候,自动锁类的析构函数会被自动调用,保证临界区被释放。在这个例子中,这个基本块的结束也是DoSomething函数的结束。无论DoSomething函数是正常返回的还是抛出异常,这个性质都能够得到保证。你也可以使用块作用域显式地界定需要占有锁的代码区段。

        void DoSomething() {

            // ... statements thart do not need to be protected by a lock

            {

                CAutoCritSec Lock(&g_cs); // lock claimed here

                UseSharedResource();

            }// lock released

            DoSomethingElse();

        }

    使用“自动锁”类保证当函数退出时锁总是被释放,即使有异常发生。

10.4 线程的创建和终止

    在线程的启动和关闭过程中涉及的几个方面,可能导致令人头疼的调试问题和其他难以检测的小错误。不过,这些问题本身都不难对付。你只需要警惕这些隐藏的问题和代码就可以了。

谨慎使用TerminateThread

    调用API函数TerminateThread应该是你试图关闭一个线程时应该考虑采用的最后一种手段。不幸的是,很多程序员认为TerminateThread API函数CreateThread的对应函数。他们的想法是:“我创建了一个线程,现在我要把它关掉。CreateThread函数返回给我一个句柄;TerminateThread可吸收一个线程的句柄。所以,我应该使用TerminateThread终止并清理我的线程。”很难说清楚这个逻辑到底错在什么地力,但是调用TerminateThread的确会引起一些负效应。TerminateThread函数的第一个问题是它不释放线程的堆栈(CreateThread函数中分配),所以调用TerminateThread会导致程序泄漏资源

    TerminateThread的第二个问题与DLL有关。在进程中创建一个线程的时候,Windows会通知进程中的所有DLL有一个新线程创建了(使用一个DLL_THREAD_ATTACH码调用DllMain)。很多DLL会利用这个通知做一些工作,例如分配每个线程独有的资源以供线程调用DLL时使用。相对地,当一个线程退出的时候,Windows也会通知进程中的所有DLL(用另一个DLL_THREAD_ATTACH码调用DllMain)。需要为每个线程维护状态的DLL使用这个通知冲掉硬盘或数据库中有关的永久信息,然后释放为这个线程申请的资源。但是,如果使用了TerminateThread,线程立刻被终止,DLL不会得到通知,所以会造成资源的泄漏

    TerminateThread具有这样的性质,是因为Windows认为你只有在正常的终止和清理过程不能工作时才会使用这个函数,例如当线程陷入死循环,不能再响应正常的命令。在这样的情况下,Windows不太可能成功地使线程修改上下文并调用一系列的DllMain入口点,因此这个函数只好牺牲进程的稳定性。

    当需要终止一个线程的时候,适当的动作是请求线程在其自身的控制下退出,并在退出过程中执行必需的清理工作。这个请求的形式可以是发送给该线程的一个消息,或者是使用API函数SetEvent发出的一个事件,甚至可以是简单地设置一个布尔变量。然后,发出请求的控制代码可以调用WaitForSingleObject,参数为这个线程的句柄,等待Windows确认该线程已经退出执行。如果你使用的超时参数是INFINITE,你还能标识不可响应的zombie线程。假设你已经等待了一段比较长的时间,WaitForSingleObject调用超时了,这个时候你再调用TerminateThread才是正确的选择。我经常建议别人使用我所谓的“线程终止的陪审团经验法则”:如果你想调用TerminateThread,最好先征得你的陪审团和你一起工作的开发人员的同意。如果大家同意了,你可以这样做;但是如果你不能说服所有人你己经采取了合理的措施,你就会有麻烦。

    不到万不得已的时候不要使用TerminateThread,因为它会造成资源的泄露。

创建线程

    与线程初始化和清理有关的一个更普遍的问题是因为使用C运行时刻函数库,MFC或者其他第三方库而引起的。C运行时刻函数库和MFC都提供了创建线程的函数,而很多程序员或者根本不知道这些函数的存在,或者忽略了它们。如果你属于其中的一类人,你注定会碰到一些麻烦。图10.1展示了程序员可能使用的一些线程创建函数的地位。

10.1线程创建函数的选择

    所有的线程创建函数最终都调用了CreateThread,这是Windows提供的一个API函数。各种变体都在此之上提供了一层重要的“增值层”。例如,C运行时刻函数库中的_beginthreadexC运行时刻函数库中还有一个名叫_beginthread的函数,但它的用法和CreateThread函数大不一样。例如,我们经常记录返回的线程句柄,之后使用WaitForSingleObject等待线程关闭,然后很可能接着检查线程的退出码。这个技术在使用_beginthreadex时是被支持。但如果使用的是_beginthread,当线程退出的时候线程句柄自动被关闭,所以试图访问保存的线程句柄是非法的(一般来说,我们更倾向于使用_beginthreadex)函数分配了一些为该线程服务的数据(per-thread data),然后调用了CreateThread。但是,_beginthreadex没有将程序中定义的线程过程地址传递给CreateThread:实际上它传的是C运行时刻函数库自己实现的一个线程过程的地址。当Windows创建并启动一个新线程的时候,运行时刻函数库的线程过程被激活。这个函数把该指针存放在线程本地存储中的之前分配了的该线程数据里,并在调用程序定义线程过程的地方建立一个保护体(使用_try/_except语句)。当线程过程返回的时候,运行时刻函数库调用_endthreadex,清理并释放该线程数据。

    如果你直接调用CreateThread,然后新的线程又调用C运行时刻函数库提供的函数,当线程第一次调用运行时刻函数库函数的时候,运行时刻函数库就必须为它分配该线程数据。如果你使用的是静态链接的C运行时刻函数库,就会绕过必要的清理过程,因为线程退出时真接返回到了Windows。如果使用DLL版本的运行时刻函数库,它可以完成清理工作,因为它和一般DLL一样会接收到DLL_THREAD_DETACH通知。现在你应该明白为什么如果使用了TerminateThread,这个清理过程永远不会被调用到了。还要指出一点,如果一个线程想“自杀",不想再往下执行了,它应该调用_endthreadex,而不是 API函数ExitThread。如果你调用了ExitThread就会绕过C运行时刻函数库为该线程所作的清理过程(除非你使用的是DLL版本的运行时刻函数库,它能够自动处理这种情况)

    除了防止资源泄漏,如果你要调用C运行时刻函数库的signalraise函数,也一定要使用_beginthreadex,不能使用CreateThread。如果你在一个用CreateThread创建的函数中调用了raise,线程会发生一个未捕捉的异常。这是因为在C运行时刻函数库中,对信号的支持是使用Windows构造的异常处理实现的。

    MFC中也有类似的情况。MFCAfxBeginThread提供了两个重载的版本。其中一个接受指向线程过程的指针,另一个接受一个派生自CWinThread类的MFC RTTI(运行时刻类型信息)类结构。除了这个区别,AfxBeginThread还为使用MFC的线程提供了一些必要的初始化和清理服务。和C运行时刻函数库一样,很多MFC函数和类都假设存在该线程的状态。如果没有这个状态(例如因为没有使用AfxBeginThread),你就很可能会遇到麻烦。仍和C运行时刻函数库一样,用AfxBeginThread创建的线程必须在退出时返回到MFC,这样MFC就可以做一些必要的清理工作,然后再交给C运行时刻函数库继续作它的清理。如果一个使用AfxBeginThread创建的线程想“自杀”,必须使用AfxEndThread函数,而不能用_endthreadex或者ExitThread。你会绕过MFC的清理过程,如果你不使用AfxEndThread的话。

    在线程创建的时候,我们的经验准则是:“入乡随俗”。如果你使用了一个库或框架,最好看看这个库提供了什么函数可以用来创建线程。如果你使用这些函数,在以后的调试过程中会给你减少很多麻烦。如果你使用了多个库,请使用最高层次的功能。

    使用最高层次的线程创建函教,以避免资源泄漏和未捕捉的异常。

终止MFC线程

    下面是我在使用MFCCWinThread类和线程关闭过程上的最后一个建议。AfxBeginThread函数的一个版本接受一个指向你实现的线程过程的指针。这个函数的行为和_beginethreadex很像,因为它也要先建立MFC特有的线程本地数据,然后调用线程过程。当线程过程返回的时候,MFC要执行一些关闭过程(这些过程我在下面马上会讲到)。这个版本的函数还会创建一个CWinThread类的对象,并将它的指针返回给AfxBeginThreadAfxBeginThread的另一个版本接受一个派生自CWinThread类的RTTI类结构,使用new对它实例化,然后调用这个对象的InitInstance方法(你必须重载InitInstance方法,否则线程会立即退出)。如果InitInstance返回TRUEMFC接着激活Run方法(这个函数一般不需要重载)Run函数的默认实现是执行一个消息循环,直到接到WM_QUIT消息。当这个事件发生的时候,Run激活ExitInstance方法,进入MFC的线程关闭过程。

    很多MFC程序员在这方面遇到过麻烦,它们无法将线程彻底地关闭,也不知道关闭过程什么时候结束。如果你使用的不是MFC,你可以得到CreateThread_beginthreadex返回的线程句柄;当你希望关闭一个非MFC线程的时候,你可以用你自己喜欢的技术要求它结束,然后对这个线程句柄调用WaitForSingleObject,直到Windows确认这个线程己经退出了。当你使用MFC线程创建机制的时候,应该执行相同的基本过程,但是具体实现会更复杂一点,因为MFC在上层又增加了一抽象层。关闭一个MFC线程需要解决以下问题:

    •当希望结束一个线程时,通知它的最好方法是什么?

    •如何确定什么时候Windows真正完成了线程的退出?

    •线程退出之后,如何获得线程的退出码?

    •由谁删除CWinThread对象?

    通知一个MFC线程退出的最好方式依赖于你使用的AfxBeginThread函数的版本。如果传递给AfxBeginThread的是线程过程的地址,你可以使用任何你喜欢的关闭技术。你可以设计你的线程过程,让它监控一个Windows事件对象,或者查看一个布尔标识是否被设置。或者你也可以让你的线程过程处理消息循环和查看一个特殊的消息,这个特殊信息是你在别的地方用PostThreadMessage发送的(不过如果你希望线程实现消息循环,最好还是使用另一个版本的AfxBeginThread)。如果你使用的AfxBeginThread版本接受派生自CWifiThread类的RUNTIME_CLASS类结构,而且你没有重载Run成员函数,通知线程结束的正确方法是向这个线程发送一个WM_QUIT消息。可以在另一个线程中调用如下代码完成这个工作:

    pThread->PostThreadMessage(WM_QUIT, 0, 0);

    如果线程希望强迫自己结束,它应该调用PostQuitMeseage。无论是哪种情况,WM_QUIT消息都会使得PeekMessageGetMessage返回FALSE,于是MFC实现的Run函数结束消息循环,调用你的ExitInstance方法,开始关闭过程。

    确定一个MFC线程是否己经退出的方法和非MFC线程相似:使用WaitForSingleObject等待该线程的句柄。差别是这里的线程句柄隐藏在CWinThread对象中。因为这个句柄是公共的数据成员,所以你可以直接访问它;这里并没有问题。唯一的问题在于CWinThread的析构函数,这个函数将在关闭线程的上下文时被调用,在这个函数中又会调用CloseHandle关闭线程的句柄。如果你在WaitForSingleObject的调用中试图使用线程句柄,这个时候该句柄已经无效了,这将使得WaitForSingleObject返回一个WAIT_FAILED错误值。此时线程可能已经结束,返回到了Windows,也有可能还没有返回——这与时序有关。因为你己经不再拥有有效的线程句柄,你无法再得到它的退出码。

    下面这段代码说明了关闭一个MFC线程的正确方法

        DWORD StopWinThread(CWinThread* pThread, DWORD dwTimeout = INFINITE) {

            HANDLE hThread = 0;

            ::DuplicateHandle(GetCurrentProcess(), pThread->m_hThread,

                GetCurrentProcess(), &hThread, 0, FALSE,

                DUPLICATE_SAME_ACCESS);

            pThread->PostThreadMessage(WM_QUIT, 0, 0);

            ::WaitForSingleObject(hThread, dwTimeout);

            DWORD nExitCode = 0;

            ::GetExitCodeThread(hThread, &nExitCode);

            return nExitCode;

        }

    为了使代码简洁,这里省略了错误检查。这个函数演示了安全地请求线程结束的必要步骤,等待直到Windows确认线程已经退出,并且返回线程的退出码。因为CWinThread析构函数调用CloseHandle此释放了m_hThread,先调用了DuplicateHandle函数得到一个新的句柄,指向同一个底层的线程对象;然后调用该线程对象的PostThreadMessage方法激活该线程的MFC关闭过程;下一步,调用WaitForSingleObject,参数为刚才复制出来的新句柄,挂起,直到线程的退出过程结束;获得确认之后,调用GetExitCodeThread获得退出码,关闭复制的线程句柄,并将退出码返回给调用者。

    删除线程对象的方法很直接,虽然这一点在MFC文档中没有明确的表述。默认情况下,AfxEndThread函数调用一个CWinThread类中定义的虚成员函数Delete。这个函数的默认实现是:如果数据成员m_bAutoDeleteTRUE,则调用delete this。如果你没有改变默认设置,那么当AfxEndThread被调用时(无论是被你直接调用还是被MFC的清理代码调用),你的所有的CWinThread对象都会自析构(self_destruct)。所以,在StopWinThread函数中,一旦WaitForSingleObject保证了线程已经退出,包装这个线程的CWinThread对象也一定已经被删除了,你不必(也不应该)再刪除这个对象。

    如果你不希望CWinThread对象是自析构的,你也还可以有裉多选择。最简单的办法是将m_bAutoDelete设置成FALSE。在你发出线程退出请求之前的任何时间都可以进行此操作。这个工作可以在CWinThread派生类的构造函数中完成,也可以在另个线程中讲行(因为这是一个公共数据成员)。举个例子,利用这个技术,你就不必在StopWinThread函数中调用DuplicateHande复制句柄。下面这段代码演示了另一种实现方法

        DWORD StopWinThread(CWinThread* pThread, DWORD dwTimeout = INFINITE) {

            pThread->m_bAutoDelete = FALSE; // disable self-destruction

            pThread->PostThreadMessage(WM_QUIT, 0, 0);

            ::WaitForSingleObject(pThread->m_hThread, dwTimeout);

            DWORD nExitCode = 0;

            ::GetExitCodeThread(pThread->m_hThread, &nExitCode);

            delete pThread; // delete the MFC thread object

            return nExitCode;

        }

    在清求线程结束之前,我们把m_bAutoDelete设置成FALSE。这使得成员函数Delete跳过delete线程对象的步骤。因为CWinThread析构函数是调用CloseHandk释放线程句柄的地方,现在我们就可以在WaitForSingleObjectGetExitCodeThread调用时放心地使用m_hThread了。当你确认线程已经结束,并且获得了它的退出码,那么只是简单地调用delte删除这个线程对象就可以了。

    如果你不希望CWinThread对象自析构,第二个选择就是在线程的上下文中调用AfxEndThread(这个函数使线程“自杀”),并且传入的第二个参数为TRUEAfxEndThread的文裆中显示这个函数只需要一个参数nExitCode。但如果你在头文件中看AfxEndThread的函数原型,就会发现还有第二个参数bDelete,默认值为TRUE。如果你希望线程立即退出,不删除CWinThread对象,可以在线程中简单地加入如下代码:

    AfxEndThread(exitCode, FALSE);

    这里的第一个参数是线程的退出码,可以是任意值。

10.5 理解调试器

    要开发多线程的程序,程序员除了应该了解开发有关的问题,还有一点很重要,就是要准确地知道调试器是如何与多线程程序以及Windows交互的。很多时候,知道调试器工作原理可以使你的调试过程很轻松,而缺少这方面的知识则会使你忙碌整个晚上都一无进展。对于调试单线程程序而言,对这个交互过程的了解并不是那么重要,但是当涉及多线程的时候,调试的情况就大不一样了。如果你清楚所有的参与者是怎么交互的,就不需要在调试器上消耗大量的时间来做不正确的结论或者追逐一些幻影了。

    了解调试器与多线程程序的交互原理可以使你少经受几次挫折,少掉一些头发。

    我们先介绍一些基本的概念。一般来说有两类调试器:应用程序级的调试器和系统级的调试器。应用程序级的调试器是普通的Windows应用程序,只能用来调试应用程序(这里所说的应用程序是相对于内核级别的驱动程序而言)。它们之所以成为调试器是因为它们使用了一些Windows的专门用于调试的API函数。一个应用程序级别的调试器的最大特点是,当你中断调试器的时候,只有正在被调试的进程和它spawn的进程被暂停,机器上的其他进程不受影响。Visual C++有一个应用程序级的调试器,也就是我在这里要分析的调试器。但是,系统级的调试器和内核有着更紧密的联系,一般包括至少一个内核模式的驱动程序组件。你可以使用系统级的调试器调试应用程序和驱动程序。当你中断一个系统调试器的时候,机器上的所有进程都被挂起,包括所有的内核线程,而不仅仅是调试器本身。这样的调试器提供了更高的可见性,可以便你看到系统行为的各个方面,但对整个系统的影响更大,微软的WinDbgCompuware NuMegaSoftICE是系统级调试器的典型。

调试器、操作系统和程序的交互

    Visual C++中有三种方式可以启动一个调试会话过程。第一种,可能也是最常用的一种技术,就是载入一个工程workspace,然后在Build/Start Debug菜单中选择Go命令。当你这么做的时候,实际上Visual C++使用了APl函数CreateProcess发射你的程序,其中设置参数dwCreationFlags包含DEBUG_PROCESS标志,第二种方法是附着到一个己经在运行的进程上,方法是在Build/Start Debug菜单中选择Attach to Process命令。这个命令使得Visual C++列出一些进程,你可以在其中进行选择。你选择了之后,Visual C++调用API函数DebugActiveProcess,将你选中的进程的ID传递给它。第三种方法,在Windows 2000下,你可以运行任务管理器,选择你想要调试的进程,然后在上下文菜单中选择Debug命令。无论你采用何种方式将调试器附着到程序上,在所有这三种情形中,有一件事是相同的:其结果是将调试器与被调试者“焊接”起来了。没有什么方法可以将调试器从一个进程上分离出来。也就是说,一旦附着上去了,我们不能停止调试会话而不终止程序。这是在Windows中调试中调试API的一个局限性(PS:现在应当已经可以使用DebugActiveProcessStop停止调试而不终止程序)。

    Windows调试支持使用事件驱动模式。一过一个调试器附着到了一个程序上,它就使用APl函数WaitForDebugEvent将自己挂起,直到程序中发生调试事件。无论何时,只要调试事件发生,Windows就会把正在被调试程序中的所有线程挂起,把事件传递给调试器。这时,调试器会处理相应的事件(例如,如果到了一个断点,调试器会显示有关的源代码)。事件被处理完之后,调试器使用ContinueDebugEvent函数通知Windows继续执行程序,于是程序线程就继续执行。这个过程不断重复,直到调试会话结束。图10.2演示了主要的交互过程。

10.2调试器、操作系统和程序的交互

    从程序中调试事件发生这一时刻开始,一直到调试器调用ContinueDebugEvent继续程序的执行,这个程序完全被挂起。那么,如何定义一个调试事件?Windows定义了一组调试事件,如表10.1所示,它们中的任何一个都能使程序挂起,并通知调试器。也就是说,如果在一个进程或它的spawn进程中发生了一个表10.1中所列的事件,整个程序都会挂起,并且会通知调试器。只有当调试器处理完了这个事件,并且调用ContinueDebugEvent的时候,程序才能继续执行。对于调试多线程程序的程序员来说,这个过程意味着一些很重要的问题,其中很多与海森堡不确定原理有关。

    10.1 WaitForDebugEvent接收的调试事件

事件

描述

典型的调试反应

异常

产生一个异常。任何一个处理器产生的异常,例如非法访问内存,或者程序员产生的异常(RaiseExceptionthrow或者DebugBreak),都会使用这个事件类型传递给调试器。断点是使用一个断点异常来实现的,所以,执行到断点的时候调试器接受到的事件就是这个类型

如果是first chance通知,在输出窗口中记录并返回程序;如果是last chance通知,中断

线程开始/结束

一个线程开始或结束

更新内部的线程列表,然后继续

进程开始/结束

一个进程被发射或者结来

更新内部的进程列表,然后继续

DLL载入/卸载

一个动态链接库被载入或者卸载

载入DLL的调试符号,更新内部的载入模块列表,在载入之前将“虚”断点转化成真正的断点,然后继续

OutputDebugString

调用OutputDebugString函数在调试器的输出窗口中记录一些文本

在输出窗口中记录字符串,然后继续

不希望的线程串行化

    如果你成功地完成过一个多线程工程,你就会知道,测试线程同步代码的正确性的唯一方法就是在不同的平台上运行你的发行版本程序。很多人知道在程序的发行版本和调试版本之间,时序的差异是很大的,这是因为发行版本中涉及编译器的优化,并且因条件编译而缺失了调试代码。但是,为什么同样的调试版本,在调试器内部运行和外部运行行为也会相差很大呢?答案与调试器、Windows和程序的基本交互过程有关。任何时候发生一个调试事件,被调试的程序都会挂起,并且控制权被转交给调试器。

    最常见的调试版本特有的调试事件是由OutputDebugString API函数产生的。很多程序员使用某种类型的跟踪语句打印诊断消息到输出窗口的Debug标签中。但是,当一个多线程程序中的线程调用OutputDebugString的时候,它们就是在访问同一个并亨资源(就是调试器),所以必须串行化。如果对OutputDebugString的调用不被串行化,来自不同线程的调试输出就会互相干扰,绞成一闭。因此,一旦有多个线程同时调用OutputDebugString这些在调试器外本来应该互相独立地运行的线程现在就不得不以锁步(lock_step)方式执行。如果你想诊断一个因时序而引起的多线程问题,在调试器中运行程序几乎肯定会严重地影响时序,从而使得问题不再出现,或者要花很长很长的时间才能再现。

    调用OutputDebugString或者其他跟踪语句会产生使线程串行化的副作用,不过只有当程序在调试器内部运行时才会有这个问题。

    一个解决方案是你自己定义自定义跟踪宏,根据一个非_DEBUG的预处理符号对它进行条件编译,你不必从头开始自己实现一个跟踪宏,只要简单地定义一个己存在跟踪宏的异名就行了。下面的代码说明了基本思想:

        #ifdef DBGTRACEON

        #define  DBGTRACE(_exp) ATLTRACE(_exp)// just use ATL's trace mechanism

        #else

        #define DBGTRACE(_msg)

        #endif

    这个宏允许你使用跟踪输出创建程序的调试版本,它消除了因调用OutputDebugString引起的我们不希望的串行化,而不牺牲使用调试版本的其他好处,例如调试符号、断言语句等。当然,如果你依赖跟踪消息来诊断问题。这样你就失去了你最主要的调试辅助工具。所以,你最好不要使用跟踪语句作为多线程程序的调试工具。

    _DEBUG以外的符号有条件地消除跟踪语句。而保留其他所有调试支持,你可以提高你调试多线程程序的能力。

    另一个方法在某些环境中很有用,例如跟踪输出对问题的诊断很有帮助的情况:为每个线程使用独立的缓冲区,而不使用OutputDebugString,每个线程在内存中都有一个环形缓冲区,跟踪消息就储存在那里。因为每个线程都有自己的缓冲区,所以在将跟踪字符串写入缓冲区的时候不需要加锁。因为在与调试器交互的时候没有使用OutputDebugString函数,程序的时序不会因为跟踪的动作而受到影响。一旦问题出现,就可以在调试器中显示所有线程的跟踪缓冲区,或者保存到磁盘,以后再看并分析。

线程上下文的迷惑

    现在让我们看看当断点异常发生,你继续跟踪指令时发生了些什么。当到达一个断点的时候,会产生个异常调试事件,进程中的所有线程都被挂起。你可以四处看看,然后使用Go命令使程序继续执行,直到下一个断点:或者你可以用Step IntoStep Over命令单步跟踪。当你单步跟踪的时候,调试器其实是在下一行代码上设置了一个临时断点,然后继续执行程序。在这个时候,程序中的所有线程全速运行,直到遇到下一个断点。这下一个断点可能是下一行代码上的临时断点,也可能不是。如果你很幸运,当调试器重新获得控制权的时候,你发现自己在代码的某一个完全不同的位置,这时你知道自己的上下文己经变了。但如果你不是那么幸运,当调试器重新获得控制权的时候,你可能发现自己恰是在下一行代码上,或者在几行以外的下一个断点上,这个情况就很迷惑人了。

    如果你在调试一个多线程程序并且跟踪一段可能被多个线程调用的代码,你不能这样假设:你现在所处的上下文就是你刚才所处的环境。如果多个线程可以调用你正在跟踪的函数,其中的任何一个都可能触发断点。依赖于你跟踪时使用的命令,具体行为可能不同。例如,如果你设置了断点并使用Go命令运行程序到下一个断点,任何线程都可能触发任何一个断点。如果你使用的是Step Info或者Step Over命令,Visual C++在下一行语句上设置一个临时断点,只有当现在这个线程执行到这行代码的时候才会触发断点异常事件。但是,即使是在这个情况下,如果你使用的是Step Over命令,只有你当前所在的线程能触发“这个”断点:但这并不妨碍其他线程执行,并且可能触发其他的断点,如果你想避免线程上下文切换带来的迷惑,你必须意识到这个情况

    当跟踪一段可以被多个线程调用的代码时,你不能假设你现在所处的上下文就是你刚才所处的环境。

    怎么样才能不受到线程上下文切换的困感?你可以学习飞行员避免迷惑的办法:始终盯住刻度盘。对我们来说,刻度盘就是Visual C++的调试窗口。下面这些调试窗口显示的信息总是当前线程的,尤论什么时候你进入调试器:

    •观察窗口;

    •调用堆栈窗口;

    •变量窗口;

    •寄存器窗口。

    其中最好用的可能变量窗口,因为当跟踪代码的时候,你很可能已经打开了这个窗口来监控局部变量的值。想像最坏的情况:你在一个可以从很多线程上下文中调用的函数里设置了一些断点。如果这个断点是在个线程过程里,调用堆栈窗口不能给你很大的帮助,因为无论是哪个线程激发了断点,调用堆栈者起来都差小多。但是,堆栈中局部变量却很可能每个线程都不一样,所以如果你发现一个变量的值忽然改变了,而你又没有修改过这个变量的代码,你就可以知道发生了上下文切换。调试器还提供了一个额外的线索,它会把新近修改过的变量值用红色显示,所以你真正要做的事就是注意这些红色的值。从而可以知道在跟踪的过程中有没有发生上下文切换了。如果断点不是在一个线程过程里面,而是在一个可以被很多线程调用的函数中,每个线程的调用堆栈窗口就很可能互不相同,这样可以反应出这些线程到达断点的代码路径。

    要一直注意变量窗口的值。如果有的值忽然出乎意料地变成红色,说明你的线程上下文很可能切换了。

    然而,这个技术可能并不总是有效。有时候不同线程的局部变量值并没有区别,所以不能用来决定线程上下文。这种情况下,你唯一的办法是使用Debug菜单中的Threads命令。这个命令用来显示线程对话框,列出被调试进程中所有正在执行的线程。当前的线程最左面会有一个星号,而且在对话框出现的时候是高亮的。不幸的是,这个对话框是有模式的,所以你不能让它一直浮在源窗口上,或者把它拉到工作区的哪个角落里。所以,在你继续调试之前,你必须关闭这个对话框。

    可以使用线程对话框确定当前线程,不过这是件麻烦事。

不希望的超时

    使用调试器的另一个副作用的根源是,当调试器中的程序停止的时候,时间并没有静止不动,这个现象会给进行阻塞系统调用而且指定的超时参数不为INFINITE的线程带来不好的影响。如果当你中断调试器的时候,恰好有一个这样的钱程在内核中被阻塞,等待某个事件发生或者等待超时,很可能当你继续执行程序的时候线程已经超时了。会发生这种现象,是因为你在调试器中查看变量、分析调用堆栈连至搔头皮的时间都被计入了线程的挂起时间。超时时间是用真实时间来衡量的,而不是有效的处理器时问,所以这个时钟一直在运行。如果你进入调试器的时候有一个线程被阻塞,很可能会导致这个阻塞的超时,依赖于指定的超时时段和你在调试器浏览中所花的时间。造成的后果是,正常情况下不太可能发生的超时,在调试的时候很容易发生。

    超时时段是用真实时间来衡量的,而不是有效的处理器时间,所以这个时钟一直在前进。在调试器中浏览所花的所有时间都会计入超时时间。

10.6 调试技术

    现在,我们己经看到了与多线程开发有关的最常见问题。了解了多线程程序如何与调试器进行交互,接下来让我们看一些有效的调试技术。

评估错误的情况

    当你处理一个很容易再现的错误时,定位出问题的代码不是特别困难。如果错误是因为一个未捕捉的异常产生的,问题就更容易了,因为调试器会告诉你应该检查什么地方。但是,有的时候程序很大,要找的错误似乎总是在你不注意的地方出现。这种情况下,在开始检查调试器的调用堆栈和寄存器之前,对问题进行谨慎的高层评估就很必要了。你的目标是确定要调试的问题是与多线程有关的,还是因为其他一些问题,例如非法的用户输入。如果问题看起来和时序有关,你就可以集中注意力在小范围的代码中。下面是你应该问自己的一些问题,以及这些问题的答案可以向你提供的线索:

    是不是错误在调试版本和发行版本中都能再现?如果不是,你要处理的问题可能是竞争,而且受到调试代码的影响,例如断言、跟踪语句或其他调试代码

    •错误在调试器中能否再现?因为调试器和程序之间的交反经常会造成干扰,只在调试器内部发生的错误或只在调试器外面发生的错误都意味有问题可能与时序有关,而且因为调试器的存在或缺席而更严重了。有时在调试器外运行一个程序使得运行速度加快,从而揭示了错误。如果是这个情况,可以在调试器以外运行你的程序,然后在错误发生以后把调试器附着到程序上去。相反地,另一些时候在调试器中运行程序能够使速度慢下来,从而暴露了问题。这样的情况下,直接在调试器中测试你的程序就行了。

    错误是不是只在一台特定的计算机上发生?如果一个错误只在一台多处理器机器上发生,这就是多线程错误的一个明确标志

    •错误的症状是什么?是被抛出来的一个异常,例如非法汸问,还是系统挂起?错误发生之前是不是在输出窗口的Debug标签中出现过一个或多个first chance异常通知?

    在具体调试之前回答这些问题,你可能不会得出导致错误的源代码的精确位置。但是这些智力练习可以使你把注意力集中在一些可能的原因上。你的答案也可以让你知道当开始使用调试器的时候应该先试些什么。例如,如果一个错误发生之前出现过一个或多个first chance异常通知,你可以先检查这些异常,使用第5章“使用异常和返回值”中介绍的过程。这些异常可能促使最终导致错误的事件的发生。

    在调试多线程代码之前,先确定这个问题是否与版本、调试器、系统和处理器有关。从这些信息中你可以知道当开始使用调试器的时候应该先试些什么。

确定你的方向

    如果一个错误的实质不太明显,当开始使用调试器的时候,在你开始盲目地在代码中“跋涉”之前,很重要的一点是确定你的方向。我喜欢像一个侦探刚进入犯罪现场那样处理这个问题:观察这个场景,但不触动任何东西,在脑子里建立刚刚发生的事情的全貌。在一个单线程的程序里,这个意味着査看堆栈、检査变量和寄存器的值、查看源代码。在多线程程序中,你还得考虑其他的线程。因为很多你可以获得的信息是单个线程的数据,例如调用堆栈等,从当前线程出发理解每个线程的行为经常可以给你一些启示。当前线程的堆栈是怎么样的?是不是有异常发生,或者这个线程是不是阻塞等待什么东西?如果这个线程崩溃了,当时它在做什么?

    如果线程正在处理被多个线程共享的数据,你必须确定当时那些线程在做些什么。使用线程对话框切换到其他线程,并查看每个线程的当前代码位置、调用堆栈、变量和寄存器等。也许你可以找到另一个与出错线程同时处理相同数据的线程。如果你找到了这样一个线程,就表明这是一个线程同步问题,并且可以帮助你确定应该分析哪一部分源代码。也许是你没有使用临界区或互斥锁,或者其中一个线程没有获得应该使用的临界区/互斥锁。对这些问题的回答可能告诉你应该注意那些线程安全的代码。

    使用线程对话框检查其他线程,看是否有线程正与出错线程同时处理相同的数据。

Windows 2000下查看线程ID

    如果你在Windows 2000Intel体系结构平台下进行调试,可以使用一种很有用的线程识别技术,第7章“使用Visual C++调试器调试”中提到过,Visual C++调试器带有很多伪寄存器。其中伪寄存器@TIB是一个指向线程信息块的指针,19965月,Matt Pietrek在他的《Under the hood》中说明了这个数据结构的前几个域。对多线程程序的调试人员来说,TlB域中最有意思的就是threadlD,这个域相对于TIB数据结构的首地址的偏移量是0x24字节。这个域包含了当前线程的线程ID,该ID在整个系统范围是唯一的,这个值和线程调用GetCurrentThreadID得到的返回值相同。知道了这些,我们可以很容易地得到当前线程ID了——只要在Watch窗口中输入表达式“dw(@TIB+0x24)”即可。这个表达式告诉Watch窗口显示从当前线程TIB首地址开始的第24个字节中的DWORD内容。如果不使用这个观察表达式,毎次你想要查看当前所在的线程上下文,就得打开线程对话框,查到当前线程,再释放对话框。这个技术也意味着当你调试的时候,只滞要注意Watch窗口中的一个表达式就可以了。

Windows 98下査看线程ID

    如果在Windows 98下调试,也可以使用TIB伪寄存器,但是不像在Windows 2000下这么直接,在Windows98中,数据结构TIB中不像Windows 2000中那样包含threadID域。Windows 98中的GetCurrentThreadID函数返回一个指针,指向调用线程的TIB与一个“模糊子(obfucator)”异或的结果,这个模糊子的值在每次机器启动的时候确定。这是一项保护性措施,避免程序员直接对这样一个重要的数据结构进行操作。因为每个线程都有自己的TIB,所以TIB指针是一个全系统范围内唯一的数,可以用作线程ID。将一个唯一的数与一个常数进行异或,得到的结果仍然是唯一的,但不再是一个指针。

    现在你己经知道伪寄存器@TIB指向当前线程的信息块,你只需要知道本次机器启动的模糊子的值就行了。如果可以确定模糊子的值,就可以在任何时候使用观察表达式“@TIB ^模糊子”打印出当前线程的ID了。有两种方法可以确定模糊子的值。①跟踪GetCurrentThreadId函数的反汇编代码,确定模糊子的值存储在什么地方,然后访问这个地址得到它的值;②利用异或运算的数学性质。如果你对汇编代码比较熟悉,第一种方法对你来说就很简单了(你不需要跟踪GetCurrentThreadId很长时间就可以找到模糊子)。实际上,—开始我就是用这种方法知道Windows 98怎么确定当前线程ID的。不过一旦你知道了Windows 98就是用TIB的地址和一个常数值异或(XOR)的结果作为线程ID的,第二种方法更为优秀,它需要的时间和精力更少,而且不需要任何汇编语言的知识。

    异或(XOR)操作有这样的性质:如果你计算A XOR B = C,那么计算C XOR B的结果就是A,而C XOR A的结果是B。既然你知道当前线程ID是用@TIB异或模糊子计算得到的,如果你知道了线程ID,就可以通过计算@TIB XOR 线程ID得到模糊子的值了。Debug菜单的线程对话框可以列出程序中所有线程的线程lD,所以你可以选择其中的任何一个ID施行这个计算;具体地说,你可以打开线程对话框,选择结果列表中的任意一个线程ID,然后在观察窗口中输入表达式“@TIB ^ ThreadId”计算出模糊子的值。不幸的是,在Windows 98运行的时候,Visual C++认为TIB的位置要偏移8个字节,所以,要看当前线程ID。在观察窗口中实际应该输入的表达式应该依次为:

    1.打开线程对话框,记录其中任意一个线程的ID

    2.在观察窗口中输入表达式“(@TIB - 8) ^ ThreadId”。得到的结果是Windows本次启动指定的模糊子的值。

    3.在观察窗口中输入表达式“(@TIB - 8) ^ Obfuscator”。调试器每次更新观察窗口时,这个表达式就会产生当前线程的ID

    因为模糊子是在每次Windows 98启动时计算的,所以每次Windows重启时应该重新进行一遍上述快速过程。不过这个过程很简单,不应该有什么麻烦。

设置特定于线程的断点

    在多线程程序的调试方面,Visual C++调试器的一个最严重的疏忽就是设置只有指定线程才能激活的断点的能力。很多其他的调试器,例如WinDbgSoftICE,都支持这个特性,允许程序员在某一行代码上设置断点,然后弹出一个断点对话框,允许指定断点的条件,只为某一个特定的线程中断。

    虽然调试器没有提供一个这样的用户界面直接设置此类断点,但我们可以使用伪寄存器@TIB进行设置,使得该断点仅在某个指定线程(或一组指定线程)的上下文中才中断。为了设置一个特定于线程的断点,在你关心的线程的一过程窗口中输入表达式“@TIB”以确定线程的TIB地址;然后打开断点对话框,选择你要设置的断点,然后单击Condition按钮。现在,输入表达式“@TIB = TIBAddress”,其中TIBAddress是伪寄存器@TIB的值。从现在开始,在这个调试会话期间,只有当指定的线程执行到这个断点才会中断。你可以很容易地使用更复杂的条件指定一组感兴趣的线程的TIB地址。但是,因为TIB地址在不同的调试会话中会不同,所以你不得不在每次重新启动调试器的时候都重新设置这些断点。

    现在告诉大家一个不幸的消息。这个技术只能在Windows 2000下工作;在Windows 98下,从原理上说也应该适用,但是我的试验结果发现结果性质不稳定。Windows 98可以选择的一个技术是检查FS寄存器的特殊值,这里为每个运行中的线程提供了一个独特的选择子(se]ector)。然而我的测试又发现,在Windows 98中使用@FS代替@TIB也是不可靠的。幸运的是,这个问题将在将来的Windows 98Visual C++服务包中解决,所以你可以放心地在你的Windows 98平台上进行试验。

命名线程

    如果你的程序中有多个线程,要想记住线程0x0000073C是进行视频解码的,线程0x0000071C是捕捉视频的,是一件非常困难的事。要在线程对话框中列出的几十个其他线程中找到这两个线程会很费时间,而且容易出错。虽然在Watch窗口中创建一个表达式显示当前线程ID能够帮助你在跟踪的过程中监测线程的切换,但是线程ID本身并不表达任何含义。有两种技术可以帮助我们为线程指定名字。这两个技术都没有正式地被支持,也没有文档讲述,但是在Windows 200098下都能可靠地工作。

    第一种技术是利用TIB的一个名叫pvArbitary的域,这个域的偏移为0x14。这个域没有被Windows使用,程序员可以使用。把这个域设置成指向一个字符串的指针,你在Watch窗口中输入表达式“(char*)(dw(@TIB+0x14))”,就可以显示当前线程的名字了。

    第二种为线程指定名字的技术是Jay Bazuzi——Visual C++调试器组的一个开发人员,在1999年的微软TechEd会议上提出来的。这个技术使用API函数RaiseException抛出一个会被调试器捕捉到的结构化的异常,这个异常将一个字符串与指定的线程联系起来,所以在打开线程对话框的时候,这个线程的名字会和线程ID以及在代码中的位置一起显示出来,这个方法使得你能够在线程对话框的一大堆线程中很快定位感兴趣的线程,而不必在程序中写出线程ID和对应的行为。

    下面的SetThreadName函数将上述两种技术结合在一个函数中,可以在任何线程的上下文中调用本函数为线程指定名字:

        struct THREADNAME_INFO{

            DWORD dwType;

            char* pszName;

            DWORD dwThreadID;

            DWORD dwFlags;

        };

 

        BOOL SetThreadName(char* pszName) {

            BOOL fOkay = FALSE;

            // set the thread name in the TIB

            char **ppszThreadName = 0;

            __asm {

                mov eax, fs:[0x18]; //locate calling thread's TIB

                add eax, 0x14; // pvArabitrary is at offset 0x14 in TIB

 

                // ppszThreadName & pTIB->pvArbitrary

                mov [ppszThreadName], eax;

            }

            if(*ppszThreadName == 0) {// verify pvArbitraty

                *ppszThreadName = pszName; // set thread name

                fOkay = TRUE;

            }

 

            // set the thread name so that it shows in Threads dialog box

            THREADNAME_INFO tni = {0};

            tni.dwType = 0x1000;

            tni.pszName = pszName; // name to assign to thread

            tni.dwThreadID = -1; // -1 indicates calling thread can use any ID here

 

            __try {

                RaiseException(0x406d1388, 0, sizeof(tni)/sizeof(DWORD), (DWORD*)&tni);

            }

            __except(EXCEPTION_EXECUTE_HANDLER) {

                fOkay = FALSE; // the exception wasn't handled by the debugger

            }

            return fOkay;

        }

    本函数的第一部分使用内嵌汇编语句在调用线程的TIB中定位pvArbitrary成员的位罝。这个域的地址就存储在局部变量ppszThreadName里。找到了这个域之后,检查它是否己经被用作其使用途,如果这个域没有被使用,就用ppszThreadName指针将pvArbitrary域设置成指向线程的名字,以后就一直可以用前面介绍的Watch窗口表达式显示这个名字了,因为传递给SetThreadName函数的字符串指针被直接保存在TlB里了,只要线程还存在,就要保证这个字符串的存储空间不能被释放。这个字符串可以是常数、全局变量,也可以放在堆中,只要你在线程退出之前不释放这个字符串。

    修改了TIB之后,初始化一个THREADNAME_INFO数据结构,使它指向一个字符串名字,并初始化其他的域以告诉调试器,你想要给调用本函数的线程指定一个名字。这个结构初始化完了之后,用它作为参数调用函数RaiseException,其中异常码指定为0x406D1388。这个异常通知调试器将给定的字符串名字分配给指定的线程。和其它异常不同,Visual C++不在Output窗口中报告这个异常,异常过诡器也不不工作。RaiseExceplion返回之后,线程名字会在线程对话框的Location栏中显示。由于线程对话框的空间限制,只能看到9个字符(不包括结束字符0)。如果你的程序不是正在被调试,或者Visual C++取消了对这个特性的支持,无条件异常过滤器EXCEPTIOM_EXECUTE_HANDLER保证这个异常不会使程序崩溃。这种情况下,会捕捉到这个异常,并且返回一个FALSE给调用者。

    要给线程指定个名字,只要在每个线程过程开始的地方调用SetThreadName函数就可以了。如果想自动使用SelThreadName,可以写一个线程创逮包装(wrapper)函数,在你的环境中使用。将线程的名字和ID一起显示大大减少了跟踪时线程上下文迷感的危险。另一方面,它也可以为你在线程对话框中选择你感兴趣的线程节省时间,使你不会在找到正确的线程之前切换到几个错误的线程。

    给线程指定名字可以减少了跟踪代码时线程上下文迷惑的危险,而且使你能更容易地寻找想找的线程

    将一个线程和一个名字联系起来还可以提高多线程程序的跟踪消息和事件日志的质量。很多程序员扩展跟踪输出,使之包含打印消息的线程的ID信息。为线程指定名字之后,你的跟踪宏除了打印线程ID之外,还可以调用下面实现的GetThreadName函数打印线程的名字:

        char* GetThreadName() {

            char* pszName;

            __asm{

                mov eax, fs:[0x18];    // locate the caller's TIB

                mov eax, [eax + 0x14]; // read the pvArbitrary field in the TIB

                mov [pszName], eax;    // pszName = pTIB->pvArbitrary

            }

            return pszName ? pszName :"unknown";

        }

    这个函数找到调用线程的TIB,从中提取出pvArbitrary成员。如果这个域是空的,就返回"unknown",所以你的跟踪宏不必测试这个函数是否成功返回。

调査并诊断挂起

    如果你需要分析程序的一个莫名其妙的挂起的原因,这个过程可能会十分痛苦。如果你的程序已经动不了了,你怎么在调试器中确定挂起的原因呢?假设你知道每个线程的行为。答案是先看可以得到的证据——你可以从查看程序中所有线程的调用堆栈和当前代码位置入手。例如,如果它们都在一个等待某个事件信号的WaitForSingleObject函数中阻塞,你就应该查看那个负责发出事件的线程,为什么这个事件没有发生。在Windows 2000中,大部分死锁的情况所有线程都阻塞是在Ntdll.dll,下面是一个调用了WaitForSingleObject的线程的情况:

        NTDLL!_ZwWaitForSingleObject@12

            mov eax, 005h; eax loaded with function # of system call

            lea edx, [esp+4] ;// eds points to the arguments on the call stack

            int 2Eh ; // Transition into kernel mode.

            ret 0CH ; <- thread's location shown here by debugger

    这表明线程己经切换到了内核模式,作为用int 2Eh指令进行系统调用的结果。这意味着出问题的线程在内核的什么地方被阻塞了,无法返回。査看调用堆栈,你可以在源代码中找到导致这个情况的位置。知道了这个线程正在等待哪个内核对象,并考虑这个内核对象如何影响其他线程,你就可以定位这个问题了。不幸的是,并没有通用的步骤可以告诉你如何从一个被阻塞的线程开始找到导致死锁的根源。死锁渉及的线程越少,对程序的整体结构越熟悉,能诊断出挂起的原因的可能性越大。除了深刻了解程序中的线程彼此交互的结构,没有什么别的办法。

受控制的步进

    有的时候你不能仅仅通过查看调用堆栈就辨别出问题的根源。有的线程可能是在中断调试器的那一刻恰好在内核中,这并不意味着它们一定不会再返回。一个简单的实验是发出Go命令,让程序再运行一会儿,然后发出Break命令再次进入调试器。现在你可以很快地扫描一遍所有的调用堆栈,看这些线程是否还阻塞在相同的位置,还是有线程到了别的位置。重复几次这个过程,你就可以了解到哪些线程是真的阻塞了,哪些还在继续运行。

    使用Break命令帮助你确定哪些线程真的阻塞了。

    很多时候,多线程的存在会给调试带来很多干扰,例如当跟踪代码时发生的上下文切换。如果你只想跟踪某一个特定的线程,你可以使用线程对话框跟踪这个线程,不允许其它线程运行。先找到你想单独调试的那个线程,然后打开线程对话框,如果目标线程就是当前线程,选中对话框中的所有其他线程,然后单击suspend。不幸的是,线程对话框不是一个多选列表,所以你只能每次选中并暂停一个线程。现在,你就可以跟踪或者运行你的程序了,只有那个没有被暂停的线程被允许执行。

    如果你想单独调试某一个线程,使用线程对话框暂停所有其他线程

    在使用这个技术之前最好注意一个问题:不要暂停调以器的当前线程(在线程列表的最左栏有一个星号)。这样做有时会造成一个假死锁,在输出窗口的Debug标签下打印如下消息:

        DBG: break command failed within 3 seconds.

        Dbg: potential deadlock. Soft broken.

    不幸的是,你很可能使自己陷入这种境地。例如。假定程序中有四个线程,你想要跟踪线程B,但是在线程对话框中显示线程C是当前线程。你选择并暂停了线程ACD,希望能跟踪线程。而不受到其它线程的打扰。这样做可能成功,也可能导致一个调试器死锁。死锁到底是不是会发生很难预测,因为这是由调试器依赖什么调试事件以及当前线程在激发这个事件上起着什么样的作用决定的。避免这个问题很容易:选择目标线程,单击Set Focus,然后就可以暂停所有其他线程了。这个问题很容易避免,但是可你正热火朝天地进行调试的时候,因为犯这样的低级错误而打断你的检查过程实在是很不爽的。

    当用线程对话框暂停线程的时候,如果目标线程不是当前线程,使用Set Focus按钮使目标线程成为当前线程。

    调试多线程程序是Windows程序员可能面临的最具挑战性的工作之一。成功地调试多线程程序的关键不仅仅是知道使用什么调试命令。在多线程程序的调试方面有过成功经验的程序员一般都拥有以下能力:

    •对多线程开发的要点有深刻的理解,知道如何开发线程安全的代码;

    •知道调试器的工作原理,以及它对多线程程序的影响;

    •警惕各种可能干扰你并把你引向错误方向的陷阱。

    希望本章所介绍的信息和技术能够帮助你走上多线程调试的成功之路。

10.7 推荐阅读

    Asche, Ruediger.Detecting Deadlocks in Multithreaded Win32 Applications.MSDN, January 111994.

    死锁检测三步曲的第一部分。这篇文章介绍了分析程序中的死锁可能的策略。

    Beveridge, Jim, and Robert Weiner. Multithreading Applications in Win32: The Complete Guide to Threads. Reading, MA: Addison-Wesley, 1996.

    介绍了Win32下的多线程。

    Burger, John,"A Lesson in Multithreaded Bugs." Windows Developer Journal. April 1998.

    从一个开发人员的经历的角度,概要地介绍了如何诊断在多处理器平台上运行的多线程程序的错误。

    Cohen, Aaron, and Mike Woodring. Win32 Multithreaded Programming. Sebastopol. CA: O'Reilly, 1998.

    主要关注Win32下的多线程,包括Win32线程和同步API的介绍、多线程程序中的异常处理、多线程设计建议,以及如何应用C语言进行多线程开发。

    ...

    Richter, Jeffrey. Programming Applications for Microsoft Windows: Master the Critical Building Blocks of 32-Bit and 64-Bit Windows-based Applications, 4th ed. Redmond. WA: Microsoft Press, 1999.

    介绍有关Win32 API的内容,包括线程和线程同步。

    Robbins, John''Bugslayer." Microsoft Systems Journal, October 1998.

    介绍一个死锁检测功能库,可以在应用程序中使用这个库记录程序中的线程同步活动,当死锁发生后可以离线浏览日志。

原创粉丝点击