使用Windbg分析多线程临界区死锁问题分享

Posted dvlinker

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了使用Windbg分析多线程临界区死锁问题分享相关的知识,希望对你有一定的参考价值。

目录

1、多线程死锁场景及多线程锁的类型

1.1、发生死锁的场景说明

1.2、锁的类型

2、问题实例说明

3、使用Windbg初步分析

4、进一步分析死锁

4.1、使用!locks命令查看临界区对象信息

4.2、通过占用临界区锁的线程id找到目标线程

4.3、如何将!locks命令打印出来的临界区对象与发生死锁的线程对应起来

4.4、查看代码,排查死锁的原因

4.5、解决办法

5、最后    


VC++常用功能开发汇总(专栏文章列表,欢迎订阅,持续更新...)https://blog.csdn.net/chenlycly/article/details/124272585C++软件异常排查从入门到精通系列教程(专栏文章列表,欢迎订阅,持续更新...)https://blog.csdn.net/chenlycly/article/details/125529931        为了实现多线程之间能安全地访问一些共享资源(比如内存资源),我们会给共享资源加锁,以保证某个时刻不会出现一个线程在写资源、另一个线程在读资源的冲突情况。但加锁后,如果控制的不好,则可能会出现多线程之间的死锁问题。死锁问题排查起来则比较费时费力,今天我们就通过一个多线程死锁的实例来介绍如何使用Windbg来排查多线程死锁问题。

1、多线程死锁场景及多线程锁的类型

     死锁一般发生在多个线程之间,一般会涉及到两个或两个以上的锁。下面我们先大概地讲述一些发生死锁的场景及用于线程间同步的锁的类型。

1.1、发生死锁的场景说明

        比如当前有两个线程,线程1和线程2;当前有两个锁,锁1和锁2。假设线程1占用了锁1,正在申请锁2,同时线程2占用了锁2,正在申请锁1,两线程都占用了各自的锁,都在申请对方占用的锁,各不相让,如下所示:

这样就导致了死锁,这是个典型的死锁场景。

        还有一个比较典型的场景是,线程1和线程2之间发生了死锁,导致了线程3的死锁。假设线程2占用了线程3要申请的锁3,因为线程1与线程2之间产生了死锁,导致线程2一直在占用锁3,一直没有释放。而线程3的代码进入了要申请锁3的代码中,因为线程2一直在占用锁3不释放,这样也导致了线程3的死锁,如下所示:

本文中的问题实例就属于这个死锁场景。

1.2、锁的类型

        此处我们以Windows平台的多线程锁为例来展开。在Windows平台中可以用多个对象来实现多线程间的锁,比如临界区对象事件对象互斥量对象信号量对象等。

        这些对象主要分用户态对象和内核态对象,其中临界区属于用户态对象,事件、互斥量和信号量则属于内核态对象。使用用户态对象的好处是,不用在用户态与内核态之间切换,在效率上相对高一些,所以在Windows平台上用户态的临界区用的比较多一些。使用内核态对象时,大部分程序代码都运行在用户态的,当操作到这些内核态对象时在底层就需要切换到内核态中,完成对应的操作后再返回到用户态代码中。如果代码在用户态和内核态之间频繁的切换,则执行效率上会有损伤。

        用户态的临界区锁,只能用于一个进程中的多个线程间的同步。而事件、互斥量和信号量都属于内核态的对象,除了可以用于一个进程中的多个线程的同步,还可以跨进程使用。

        使用Windbg去排查用户态的临界区死锁,则相对容易一些,Windbg默认是在用户态中的。如果要排查内核态锁引发的死锁,则要复杂一些,Windbg需要切入到内核态中去分析。

        本案例中主要讲述用户态的临界区锁引发的死锁,对于内核态的死锁实例,我们后面会写一篇专门的文章去介绍。

2、问题实例说明

        平台兄弟项目组维护的某在线视频播放器在测试同事的Win11 PC上运行过程中出现了卡死现象,UI界面没法操作了,如下所示:

这个问题虽然不是必现,但复现的概率挺大,开发人员在自己的PC机倒腾了几下就复现了。

        看现象,估计是播放器UI界面无法操作了,估计大概率是UI主线程卡死了,于是他们到Windows系统的任务管理器中找到出问题的播放器进程,然后将包含进程上下文完整信息导出到dump转储文件中,然后用Windbg去打开这个dump文件去分析。

        但维护该项目的同事都是搞服务器侧开发的,他们对Linux系统上的C++开发比较熟练,对Windows系统上的调试技术不太了解,于是邀请我过去帮他们分析一下。

其实帮兄弟项目组分析问题也挺好,可以了解到相关的代码及编程思想,能够见识更多的异常排查的素材,积累更多的经验!既帮助了被人,也积累了自己!

3、使用Windbg初步分析

        由于目标程序的UI主线程卡住了,所以用Windbg打开dump文件后,先去查看UI主线程(0号线程)的函数调用堆栈。

在Windows GUI界面程序中,UI线程就是程序的主线程,是进程的0号线程。

于是使用~0s命令切换到UI线程,然后输入kn命令查看UI主线程的函数调用堆栈,如下所示:

看到UI主线程中确实卡住了,调用了RtlEnterCriticalSection要获取临界区锁,但一直没获取到,一直卡在那里了。大概率是发生死锁了!应该是要获取的临界区锁,被其他线程占用了,始终没释放,所以UI线程始终获取不到。

        本例中发生死锁的是临界区锁,属于用户态的锁,相对于内核态的锁,排查起来要方便很多。
对于~0s命令,是切换到0号线程中,命令中的数字0就是目标线程的编号。可以输入~命令查看当前进程中的所有线程,如下所示:

可以看到线程的编号,还可以看到线程id。

4、进一步分析死锁

   既然是临界区死锁,我们可以使用!locks命令去查看当前进程中的所有临界区对象信息。

4.1、使用!locks命令查看临界区对象信息

        可以先到Windbg的帮助文档中查看一下!locks命令的说明,如下所示:

该命令会将当前进程中的所有临界区对象信息打印出来,这其中肯定会包含发生死锁的临界区对象。
        但输入!locks命令后却提示:

根据提示信息,!locks命令没法解析Windows系统库ntdll中的RtlCriticalSectionList接口,需要去检查一下系统库ntdll.dll库的pdb文件。估计!locks命令内部用到了ntdll库中的RtlCriticalSectionList接口。
        ntdll.dll是系统库,如何去获取系统库的pdb文件呢?其实,很简单,我们只要设置包含微软在线的pdb符号库下载地址:

http://msdl.microsoft.com/download/symbols

设置好后,Windbg会自动到该服务器上下载对应版本的pdb文件。一般我们在Windbg中设置的pdb路径如下:

C:\\Users\\Administrator\\Desktop\\pdbdir;srv*f:\\mss0616*http://msdl.microsoft.com/download/symbols

关于上述pdb路径的说明,可以参见这篇文章,这里就不再赘述了:
pdb符号库文件详解https://blog.csdn.net/chenlycly/article/details/125508858设置上述pdb路径后,Windbg就会自动去下载其需要的Windows系统库的pdb文件,再次执行!locks命令,就可以看到临界区对象列表了,如下所示

列表中只看到了一个临界区对象,那这个对象肯定是上述函数调用堆栈中要操作的临界区对象。从打印出来的临界区的详细信息中可以看到两点关键信息:

1)临界区对象数据结构的地址0x010ecb7c;
2)拥有临界区所有权(占用临界区)线程id为ac8。

       在Windbg中设置微软在线的pdb符号库下载地址后,Windbg会自动去下载Windows系统库的pdb文件,有了系统库的pdb符号库文件,我们就能在函数调用堆栈中看到更详细的系统接口的调用。有时有没有系统库的pdb符号库文件,看到的函数调用堆栈会有一定的差异,能看到对系统库内部接口的调用,对问题的排查也有很大的指引左右。

4.2、通过占用临界区锁的线程id找到目标线程

        从上面的分析知晓,UI主线程请求的临界区锁,就是!locks命令打印出来的那个临界区对象:

从临界区对象信息得知,该临界区对象正被id为ac8的线程占用。为啥该临界区对象被id为ac8的线程占用没释放呢?这就需要去看看id为ac8线程的函数调用堆栈了!

        那如何才能切换到id为ac8的线程中呢?很简单,使用~命令将进程中的所有线程打印出来,看看id为ac8线程是几号线程。打印出来的线程列表如下所示:

根据线程id为ac8线程在线程列表中对应的线程号为9,即9号线程,于是使用~9s命令切换到该目标线程中,然后使用kn命令将该线程的函数调用堆栈打印出来,看看该线程是如何占用目标临界区锁,以及为啥没有释放锁的。

       9号线程的函数调用堆栈如下所示:

从函数调用堆栈可以看出,9号线程也死锁了,卡在了WaitForSingleObject接口上没有返回,可以多次go多次查看函数调用堆栈,都卡在WaitForSingleObject函数接口上没返回,那基本可以确定9号线程也死锁了。同时9号线程占用了UI主线程要获取的锁,因为9号线程死锁了,一直在占用UI线程的锁,导致UI线程获取不到锁。即9号线程死锁了,导致UI主线程也死锁了。

4.3、如何将!locks命令打印出来的临界区对象与发生死锁的线程对应起来

        如果!locks命令打印出来的临界区对象中有多个,那我们怎么知道当前的UI主线程操作的是哪个临界区对象呢?每个临界区对象后面会显示该临界区对象对应的结构体对象地址,使用kv命令重新打印UI主线程的函数调用堆栈:

使用该命令可以将调用函数时的前三个参数值打印出来,在函数调用堆栈中找到操作临界区的系统函数ntdll!RtlEnterCriticalSection,该接口中应该有个参数传入的是临界区结构体对象地址。

        ntdll!RtlEnterCriticalSection是Windows系统库ntdll.dll的内部函数,不是系统对外公开的API函数,我们如何知道RtlEnterCriticalSection中的哪个参数是临界区结构体对象地址呢?这里有个很重要的技巧,我们可以到ReactOS开源操作系统的代码中查看ntdll!RtlEnterCriticalSection接口的参数。ReactOS开源操作系统系统库中函数名和参数基本和Windows的系统库是一致,所以可以到ReactOS中去查看ntdll!RtlEnterCriticalSection接口都哪些参数,如下:

该函数只有一个参数,即RTL_CRITICAL_SECTION结构体对象的首地址,RTL_CRITICAL_SECTION结构体的定义如下:

 我们可以拷贝RTL_CRITICAL_SECTION字串到VS中,然后Go到该结构体的定义处:

typedef struct _RTL_CRITICAL_SECTION 
    PRTL_CRITICAL_SECTION_DEBUG DebugInfo;

    //
    //  The following three fields control entering and exiting the critical
    //  section for the resource
    //

    LONG LockCount;
    LONG RecursionCount;
    HANDLE OwningThread;        // from the thread's ClientId->UniqueThread
    HANDLE LockSemaphore;
    ULONG_PTR SpinCount;        // force size on 64-bit systems when packed
 RTL_CRITICAL_SECTION, *PRTL_CRITICAL_SECTION;

我们可以对比一下ReactOS开源代码中该结构体的内容与VS下的RTL_CRITICAL_SECTION结构体,两者是完全一样的。

        从函数调用堆栈中看:

调用ntdll!RtlEnterCriticalSection接口的第一个参数值为010ecb7c,即RTL_CRITICAL_SECTION结构体对象的首地址,与!locks命令打印出来的临界区列表中的每个临界区的地址比较一下:

就能确定当前UI线程的要操作的临界区对象是哪个了。然后根据目标临界区对象的OwningThread值,确定目标临界区对象被哪个线程占用了,然后去查看这个线程的函数调用堆栈,这样就找到了继续分析下去的线索了。

         此外,查看开源代码时,推荐大家使用Source Insight工具,该工具小巧且运行速度快,使用起来非常方便。关于如何使用Source Insight,可以参见我之前写的文章:

使用Source Insight查看编辑源代码 https://blog.csdn.net/chenlycly/article/details/124347857

4.4、查看代码,排查死锁的原因

        9号线程发生死锁,占用了UI线程(0号主线程)要申请的临界区锁,于是到9号线程中去查看函数调用堆栈,去查看对应的源码:

9号线程中调用了CHttpsRequset::HttpsGetSMLevel函数,而在CHttpsRequset::HttpsGetSMLevel函数中使用了一个函数范围的锁CAutoWriteLock tWriteLock(m_rwLock),如下:

这个锁就是UI线程要申请的临界区对象锁。而CHttpsRequset::HttpsGetSMLevel函数中调用了CheckUkeyStatus函数,CheckUkeyStatus函数中调用了XXXSecAuthCheckUkeyState函数:

最终在XXXSecAuthCheckUkeyState函数内部发生了死锁,所以导致上层的CHttpsRequset::HttpsGetSMLevel函数始终没有执行完,没有返回,所以导致函数范围的锁CAutoWriteLock tWriteLock(m_rwLock)一直没有释放,导致UI线程一直获取不到锁,导致UI线程也死锁了,导致UI线程卡死了,导致播放器程序没法操作了。

4.5、解决办法

        确定引发死锁的原因后,如何修改代码去避免死锁呢?这就是兄弟项目组需要去解决的问题了。大家很多时候都喜欢使用函数锁(在函数中定义一个局部变量),进入函数时去申请锁,退出函数时该局部变量析构时去释放锁,即这个锁作用于整个函数。但使用锁的一个重要原则是,尽量锁的范围越小越好,所以有时函数锁并不是一个很好的选择。

5、最后    

        很多时候去封装支持跨平台的线程锁类时,在Windows下使用CriticalSection临界区锁,在Linux下使用linux系统中的系统函数去创建锁。在Windows系统中,临界区是用户态对象,而信号量、互斥量等是内核态对象,之所以在Windows下优先选用临界区实现,是因为临界区是用户态对象,在使用时不要像信号量等内核态对象那样要在用户态和内核态之间来会切换,避免了用户态与内核态之间切换的开销。

        此外,Windows曾经开源过部分源码,但后来又取消了开源,ReactOS克隆了WindowsNT内核和系统层。我们可以到ReactOS中查看系统库内部函数的参数以及函数内部的实现,可以查看regsvr32控件注册程序的内部实现,可以查看WindowsSEH结构化异常的处理机制等等。

以上是关于使用Windbg分析多线程临界区死锁问题分享的主要内容,如果未能解决你的问题,请参考以下文章

多线程小结

多线程编程--心得

高并发基础

Java多线程-静态条件与临界区

多线程间的互斥(下)

C语言 pthread_cleanup_push()和pthread_cleanup_pop()函数(用于临界资源程序段中发生终止动作后的资源清理任务,以免造成死锁,临界区资源一般上锁)