Skip to content

Latest commit

 

History

History
896 lines (675 loc) · 54.6 KB

File metadata and controls

896 lines (675 loc) · 54.6 KB

六、调试多线程代码

理想情况下,一个人的代码第一次就能正常工作,并且不包含等待应用崩溃、数据损坏或导致其他问题的隐藏 bug。实际上,这当然是不可能的。因此,开发的工具使得检查和调试多线程应用变得容易。

在这一章中,我们将研究其中的一些,包括一个常规的调试器,以及一些属于 Valgrind 套件的工具,特别是 Helgrind 和 DRD。我们还将分析多线程应用,以便发现其设计中的热点和潜在问题。

本章涵盖的主题包括:

  • 介绍 Valgrind 工具套件
  • 使用赫尔格林和 DRD 工具
  • 解读赫尔格林和 DRD 的分析结果
  • 分析应用,并分析结果

何时开始调试

理想情况下,每次达到某个里程碑时,无论是单个模块、多个模块还是整个应用,都要测试和验证自己的代码。确定一个人做出的假设与最终的功能相匹配是很重要的。

特别是,对于多线程代码,很大程度上是一致的,因为在应用的每次运行期间,都不能保证达到特定的错误状态。多线程应用实现不当的迹象可能会导致一些症状,如看似随机的崩溃。

很可能你得到的第一个暗示是某个东西不正确是在应用崩溃的时候,剩下的是一个核心转储。这是一个文件,包含应用崩溃时的内存内容,包括堆栈。

这种核心转储的使用方式几乎与在运行进程中运行调试器的方式相同。检查代码中我们崩溃的位置以及在哪个线程中是特别有用的。我们也可以用这种方法检查记忆内容。

处理多线程问题的最佳指标之一是应用从不在同一个位置崩溃(不同的堆栈跟踪),或者总是在执行互斥操作(如操作全局数据结构)的某个点崩溃。

首先,在深入研究 Valgrind 工具套件之前,我们将首先更深入地了解使用调试器进行诊断和调试。

不起眼的调试器

在开发人员可能会有的所有问题中,*为什么我的应用会崩溃?*大概是其中最重要的。这也是调试器最容易回答的问题之一。无论是实时调试进程,还是分析崩溃进程的核心转储,调试器都可以(希望)生成一个回溯,也称为堆栈跟踪。此跟踪包含自应用启动以来调用的所有函数的时间顺序列表,因为人们可以在堆栈上找到它们(有关堆栈如何工作的详细信息,请参见第 2 章处理器和操作系统上的多线程实现)。

因此,这个回溯的最后几个条目将向我们显示代码的哪一部分出错了。如果调试信息被编译成二进制文件,或者被提供给调试器,我们还可以在那一行看到代码以及变量的名称。

更好的是,由于我们正在查看堆栈框架,我们还可以检查该堆栈框架中的变量。这意味着传递给函数的参数以及任何局部变量及其值。

为了获得可用的调试信息(符号),必须使用适当的编译器标志集来编译源代码。对于 GCC,可以选择大量的调试信息级别和类型。最常见的是,使用带有整数的-g标志,指定附加的调试级别,如下所示:

  • -g0:不产生调试信息(否定-g)
  • -g1:关于函数描述和外部变量的最少信息
  • -g3:包括宏定义在内的所有信息

该标志指示 GCC 为操作系统生成本机格式的调试信息。还可以使用不同的标志以特定的格式生成调试信息;但是,这对于 GCC 的调试器(GDB)以及 Valgrind 工具来说并不是必需的。

GDB 和瓦格林都将使用这些调试信息。虽然在没有调试信息的情况下使用两者在技术上是可能的,但这最好留给真正绝望的时候。

基因组数据库

基于 C 和 C++ 的代码最常用的调试器之一是 GNU 调试器,简称 GDB 调试器。在下面的例子中,我们将使用这个调试器,因为它被广泛使用并且免费提供。最初写于 1986 年,现在它被广泛用于各种编程语言,并且已经成为个人和专业使用中最常用的调试器。

GDB 最基本的界面是命令行外壳,但是它可以与图形前端一起使用,图形前端还包括许多 ide,例如 Qt Creator、Dev-C++,以及 Code::Blocks。这些前端和 IDEs 可以使管理断点、设置监视变量和执行其他常见操作变得更加容易和直观。然而,并不要求使用它们。

在 Linux 和 BSD 发行版上,gdb 可以很容易地从一个包中安装,就像它在带有 MSYS2 和类似 UNIX 的环境的 Windows 上一样。对于 OS X/苹果操作系统,你可能不得不使用第三方软件包管理器安装 gdb,比如 Homebrew。

由于 gdb 通常不在 MacOS 上进行代码签名,因此它无法获得正常操作所需的系统级访问权限。在这里,您可以以 root 用户身份运行 gdb(不推荐),也可以遵循与您的 MacOS 版本相关的教程。

调试多线程代码

如前所述,有两种使用调试器的方法,或者通过在调试器中启动应用(或者附加到正在运行的进程),或者通过加载核心转储文件。在调试会话中,可以中断运行过程(通过发送SIGINT信号的 Ctrl + C ,或者为加载的核心转储加载调试符号。之后,我们可以检查这个框架中的活动线程:

Thread 1 received signal SIGINT, Interrupt.
0x00007fff8a3fff72 in mach_msg_trap () from /usr/lib/system/libsystem_kernel.dylib
(gdb) info threads
Id   Target Id         Frame 
* 1    Thread 0x1703 of process 72492 0x00007fff8a3fff72 in mach_msg_trap () from /usr/lib/system/libsystem_kernel.dylib
3    Thread 0x1a03 of process 72492 0x00007fff8a406efa in kevent_qos () from /usr/lib/system/libsystem_kernel.dylib
10   Thread 0x2063 of process 72492 0x00007fff8a3fff72 in mach_msg_trap () from /usr/lib/system/libsystem_kernel.dylibs
14   Thread 0x1e0f of process 72492 0x00007fff8a405d3e in __pselect () from /usr/lib/system/libsystem_kernel.dylib
(gdb) c
Continuing.

在前面的代码中,我们可以看到在向应用(一个运行在 OS X 的基于 Qt 的应用)发送SIGINT信号后,我们如何请求在这个时间点存在的所有线程的列表,以及它们的线程号、标识和它们当前正在执行的功能。这也清楚地显示了哪些线程可能正在基于后一种信息等待,就像这种图形用户界面应用的情况一样。在这里,我们还可以看到应用中当前活动的线程,在其编号(线程 1)前面用星号标记。

我们也可以使用thread <ID>命令在线程之间随意切换,并在线程的堆栈帧之间移动updown。这允许我们检查单个线程的每个方面。

当完整的调试信息可用时,通常还会看到线程正在执行的确切代码行。这意味着,在应用的开发阶段,尽可能多的调试信息可用以使调试变得更加容易是有意义的。

断点

对于我们在第 4 章线程同步和通信中看到的调度程序代码,我们可以设置一个断点来允许我们检查活动线程:

$ gdb dispatcher_demo.exe 
GNU gdb (GDB) 7.9 
Copyright (C) 2015 Free Software Foundation, Inc. 
Reading symbols from dispatcher_demo.exe...done. 
(gdb) break main.cpp:67 
Breakpoint 1 at 0x4017af: file main.cpp, line 67\. 
(gdb) run 
Starting program: dispatcher_demo.exe 
[New Thread 10264.0x2a90] 
[New Thread 10264.0x2bac] 
[New Thread 10264.0x2914] 
[New Thread 10264.0x1b80] 
[New Thread 10264.0x213c] 
[New Thread 10264.0x2228] 
[New Thread 10264.0x2338] 
[New Thread 10264.0x270c] 
[New Thread 10264.0x14ac] 
[New Thread 10264.0x24f8] 
[New Thread 10264.0x1a90] 

正如我们在上面的命令行输出中看到的,我们用我们希望调试的应用的名称作为参数来启动 GDB,这里是从 Windows 下的一个 Bash shell 开始的。在这之后,我们可以在这里设置一个断点,使用源文件的文件名和我们希望在 gdb 命令行输入的(gdb)之后中断的行。我们选择循环后的第一行,其中请求被发送到调度程序,然后运行应用。接下来是调度程序正在创建的新线程列表,如 GDB 所报告的。

接下来,我们等到断点命中:

Breakpoint 1, main () at main.cpp:67 
67              this_thread::sleep_for(chrono::seconds(5)); 
(gdb) info threads 
Id   Target Id         Frame 
11   Thread 10264.0x1a90 0x00000000775ec2ea in ntdll!ZwWaitForMultipleObjects () from /c/Windows/SYSTEM32/ntdll.dll 
10   Thread 10264.0x24f8 0x00000000775ec2ea in ntdll!ZwWaitForMultipleObjects () from /c/Windows/SYSTEM32/ntdll.dll 
9    Thread 10264.0x14ac 0x00000000775ec2ea in ntdll!ZwWaitForMultipleObjects () from /c/Windows/SYSTEM32/ntdll.dll 
8    Thread 10264.0x270c 0x00000000775ec2ea in ntdll!ZwWaitForMultipleObjects () from /c/Windows/SYSTEM32/ntdll.dll 
7    Thread 10264.0x2338 0x00000000775ec2ea in ntdll!ZwWaitForMultipleObjects () from /c/Windows/SYSTEM32/ntdll.dll 
6    Thread 10264.0x2228 0x00000000775ec2ea in ntdll!ZwWaitForMultipleObjects () from /c/Windows/SYSTEM32/ntdll.dll 
5    Thread 10264.0x213c 0x00000000775ec2ea in ntdll!ZwWaitForMultipleObjects () from /c/Windows/SYSTEM32/ntdll.dll 
4    Thread 10264.0x1b80 0x0000000064942eaf in ?? () from /mingw64/bin/libwinpthread-1.dll 
3    Thread 10264.0x2914 0x00000000775c2385 in ntdll!LdrUnloadDll () from /c/Windows/SYSTEM32/ntdll.dll 
2    Thread 10264.0x2bac 0x00000000775c2385 in ntdll!LdrUnloadDll () from /c/Windows/SYSTEM32/ntdll.dll 
* 1    Thread 10264.0x2a90 main () at main.cpp:67 
(gdb) bt 
#0  main () at main.cpp:67 
(gdb) c 
Continuing. 

到达断点后,信息线程命令列出活动线程。这里我们可以清楚地看到条件变量的使用,其中一个线程正在ntdll!ZwWaitForMultipleObjects()中等待。如第 3 章C++ 多线程应用编程接口所述,这是使用本机多线程应用编程接口在 Windows 上实现条件变量的一部分。

当我们创建一个回溯(bt命令)时,我们看到线程 1(当前线程)的当前堆栈只是一个帧,只针对主方法,因为我们没有从这一行的这个起点调入另一个函数。

回溯痕迹

在正常的应用执行过程中,例如使用我们前面看到的图形用户界面应用,向应用发送SIGINT也可以后跟命令来创建如下的回溯:

Thread 1 received signal SIGINT, Interrupt.
0x00007fff8a3fff72 in mach_msg_trap () from /usr/lib/system/libsystem_kernel.dylib
(gdb) bt
#0  0x00007fff8a3fff72 in mach_msg_trap () from /usr/lib/system/libsystem_kernel.dylib
#1  0x00007fff8a3ff3b3 in mach_msg () from /usr/lib/system/libsystem_kernel.dylib
#2  0x00007fff99f37124 in __CFRunLoopServiceMachPort () from /System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation
#3  0x00007fff99f365ec in __CFRunLoopRun () from /System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation
#4  0x00007fff99f35e38 in CFRunLoopRunSpecific () from /System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation
#5  0x00007fff97b73935 in RunCurrentEventLoopInMode ()
from /System/Library/Frameworks/Carbon.framework/Versions/A/Frameworks/HIToolbox.framework/Versions/A/HIToolbox
#6  0x00007fff97b7376f in ReceiveNextEventCommon ()
from /System/Library/Frameworks/Carbon.framework/Versions/A/Frameworks/HIToolbox.framework/Versions/A/HIToolbox
#7  0x00007fff97b735af in _BlockUntilNextEventMatchingListInModeWithFilter ()
from /System/Library/Frameworks/Carbon.framework/Versions/A/Frameworks/HIToolbox.framework/Versions/A/HIToolbox
#8  0x00007fff9ed3cdf6 in _DPSNextEvent () from /System/Library/Frameworks/AppKit.framework/Versions/C/AppKit
#9  0x00007fff9ed3c226 in -[NSApplication _nextEventMatchingEventMask:untilDate:inMode:dequeue:] ()
from /System/Library/Frameworks/AppKit.framework/Versions/C/AppKit
#10 0x00007fff9ed30d80 in -[NSApplication run] () from /System/Library/Frameworks/AppKit.framework/Versions/C/AppKit
#11 0x0000000102a25143 in qt_plugin_instance () from /usr/local/Cellar/qt/5.8.0_1/plugins/platforms/libqcocoa.dylib
#12 0x0000000100cd3811 in QEventLoop::exec(QFlags<QEventLoop::ProcessEventsFlag>) () from /usr/local/opt/qt5/lib/QtCore.framework/Versions/5/QtCore
#13 0x0000000100cd80a7 in QCoreApplication::exec() () from /usr/local/opt/qt5/lib/QtCore.framework/Versions/5/QtCore
#14 0x0000000100003956 in main (argc=<optimized out>, argv=<optimized out>) at main.cpp:10
(gdb) c
Continuing.

在前面的代码中,我们可以通过入口点(main)看到线程 ID 1 的执行。每个后续的函数调用都被添加到堆栈中。当一个函数完成时,它会从堆栈中移除。这既是好处也是坏处。虽然它确实保持了后跟踪的良好和干净,但它也意味着在最后一次函数调用之前发生的历史不再存在。

如果我们用一个核心转储文件创建一个回溯,没有这些历史信息可能会非常烦人,并且当我们试图缩小崩溃的假定原因时,可能会开始徒劳无功。这意味着成功的调试需要一定的经验。

在应用崩溃的情况下,调试器将在遭受崩溃的线程上启动我们。通常,这是代码有问题的线程,但真正的错误可能在于另一个线程执行的代码,甚至是变量的不安全使用。如果一个线程要改变另一个线程当前正在读取的信息,后一个线程可能会以垃圾数据结束。这样做的结果可能是应用的崩溃,甚至更糟的是应用的损坏。

最坏的情况是堆栈被例如一个通配符指针覆盖。在这种情况下,堆栈上的缓冲区或类似区域被写入超过其限制,从而通过用新数据填充来擦除堆栈的部分区域。这是一种缓冲区溢出,可能会导致应用崩溃,或者(恶意)利用应用。

动态分析工具

尽管调试器的价值很难忽视,但有时人们需要不同类型的工具来回答诸如内存使用、泄漏等问题,并诊断或防止线程问题。这就是诸如瓦尔基林动态分析工具套件中的工具可以提供很大帮助的地方。作为构建动态分析工具的框架,Valgrind 发行版目前包含我们感兴趣的以下工具:

  • 梅姆切克
  • 他妈的
  • DRD

Memcheck 是一个内存错误检测器,它允许我们发现内存泄漏、非法读写以及分配、解除分配和类似的内存相关问题。

赫尔格林和 DRD 都是线程错误检测器。这基本上意味着他们将尝试检测任何多线程问题,如数据竞争和互斥锁的不正确使用。它们的不同之处在于,Helgrind 可以检测锁定顺序违规,并且 DRD 支持分离线程,同时使用的内存也比 Helgrind 少。

限制

动态分析工具的一个主要限制是,它们需要与主机操作系统紧密集成。这是 Valgrind 专注于 POSIX 线程的主要原因,目前在 Windows 上不工作。

Valgrind 网站(位于http://valgrind.org/info/platforms.html)对该问题的描述如下:

"Windows is not under consideration because porting to it would require so many changes it would almost be a separate project. (However, Valgrind + Wine can be made to work with some effort.) Also, non-open-source OSes are difficult to deal with; being able to see the OS and associated (libc) source code makes things much easier. However, Valgrind is quite usable in conjunction with Wine, which means that it is possible to run Windows programs under Valgrind with some effort."

基本上,这意味着 Windows 应用可以在 Linux 下用 Valgrind 进行调试,但使用 Windows 作为操作系统不会很快发生。

Valgrind 确实在 OS X/macOS 上工作,从 OS X 10.8(山狮)开始。然而,由于苹果公司所做的改变,对最新版本的苹果电脑的支持可能有些不完整。与 Linux 版本的 Valgrind 一样,通常最好总是使用最新版本的 Valgrind。和 gdb 一样,使用发行版的包管理器,或者像 MacOS 上的 Homebrew 这样的第三方包管理器。

可供选择的事物

Windows 和其他平台上 Valgrind 工具的替代产品包括下表中列出的产品:

| 名称 | 类型 | 平台 | 牌照 | | 记忆博士 | 内存检查器 | 所有主要平台 | 开放源码 | | gperftools (Google) | 堆、中央处理器和调用分析器 | Linux (x86) | 开放源码 | | 视觉检漏仪 | 内存检查器 | 视窗(视觉工作室) | 开放源码 | | 英特尔检查员 | 内存和线程调试器 | Windows、Linux | 所有人 | | 普里菲加 | 内存、性能 | Windows、Linux | 所有人 | | parasoft insurate ++ | 内存和线程调试器 | Windows、Solaris、Linux、AIX | 所有人 |

梅姆切克

当在其可执行文件的参数中没有指定其他工具时,Memcheck 是默认的 Valgrind 工具。Memcheck 本身是一个内存错误检测器,能够检测以下类型的问题:

  • 访问分配边界之外的内存、堆栈溢出以及访问以前释放的内存块
  • 使用未定义的值,这些值是尚未初始化的变量
  • 堆内存释放不当,包括重复释放块
  • 不匹配地使用 C 和 C++ 风格的内存分配以及数组分配器和解除分配器(new[]delete[])
  • 在函数中重叠源指针和目标指针,如memcpy
  • 将无效值(例如负值)作为尺寸参数传递给malloc或类似函数
  • 内存泄漏;也就是说,没有任何有效引用的堆块

使用调试器或简单的任务管理器,几乎不可能检测到前面列表中给出的问题。Memcheck 的价值在于能够在开发的早期检测和修复问题,否则这些问题会导致数据损坏和神秘的崩溃。

基本用途

使用 Memcheck 相当容易。如果我们以我们在第 4 章线程同步和通信中创建的演示应用为例,我们知道通常我们使用以下内容启动它:

$ ./dispatcher_demo

要使用默认的 Memcheck 工具运行 Valgrind,同时将结果输出记录到日志文件中,我们可以按如下方式启动它:

$ valgrind --log-file=dispatcher.log --read-var-info=yes --leak-check=full ./dispatcher_demo

使用前面的命令,我们将把 Memcheck 的输出记录到一个名为dispatcher.log的文件中,并使用二进制文件中的可用调试信息对内存泄漏进行全面检查,包括详细报告这些泄漏发生的位置。同样通过读取变量信息(--read-var-info=yes),我们可以获得更多关于内存泄漏发生位置的详细信息。

一个人不能登录到一个文件,但是除非它是一个非常简单的应用,否则从 Valgrind 产生的输出可能会太多,以至于它可能无法放入终端缓冲区。将输出作为一个文件可以让用户在以后使用它作为参考,也可以使用比终端通常提供的更高级的工具来搜索它。

运行此程序后,我们可以检查生成的日志文件的内容,如下所示:

==5764== Memcheck, a memory error detector
==5764== Copyright (C) 2002-2015, and GNU GPL'd, by Julian Seward et al.
==5764== Using Valgrind-3.11.0 and LibVEX; rerun with -h for copyright info
==5764== Command: ./dispatcher_demo
==5764== Parent PID: 2838
==5764==
==5764==
==5764== HEAP SUMMARY:
==5764==     in use at exit: 75,184 bytes in 71 blocks
==5764==   total heap usage: 260 allocs, 189 frees, 88,678 bytes allocated
==5764==
==5764== 80 bytes in 10 blocks are definitely lost in loss record 1 of 5
==5764==    at 0x4C2E0EF: operator new(unsigned long) (in /usr/lib/valgrind/vgpreload_memcheck-amd64-linux.so)
==5764==    by 0x402EFD: Dispatcher::init(int) (dispatcher.cpp:40)
==5764==    by 0x409300: main (main.cpp:51)
==5764==
==5764== 960 bytes in 40 blocks are definitely lost in loss record 3 of 5
==5764==    at 0x4C2E0EF: operator new(unsigned long) (in /usr/lib/valgrind/vgpreload_memcheck-amd64-linux.so)
==5764==    by 0x409338: main (main.cpp:60)
==5764==
==5764== 1,440 (1,200 direct, 240 indirect) bytes in 10 blocks are definitely lost in loss record 4 of 5
==5764==    at 0x4C2E0EF: operator new(unsigned long) (in /usr/lib/valgrind/vgpreload_memcheck-amd64-linux.so)
==5764==    by 0x402EBB: Dispatcher::init(int) (dispatcher.cpp:38)
==5764==    by 0x409300: main (main.cpp:51)
==5764==
==5764== LEAK SUMMARY:
==5764==    definitely lost: 2,240 bytes in 60 blocks
==5764==    indirectly lost: 240 bytes in 10 blocks
==5764==      possibly lost: 0 bytes in 0 blocks
==5764==    still reachable: 72,704 bytes in 1 blocks
==5764==         suppressed: 0 bytes in 0 blocks
==5764== Reachable blocks (those to which a pointer was found) are not shown.
==5764== To see them, rerun with: --leak-check=full --show-leak-kinds=all
==5764==
==5764== For counts of detected and suppressed errors, rerun with: -v
==5764== ERROR SUMMARY: 3 errors from 3 contexts (suppressed: 0 from 0) 

这里我们可以看到,我们总共有三个内存泄漏。两个来自第 38 行和第 40 行dispatcher类的分配:

w = new Worker; 

另一个是这样的:

t = new thread(&Worker::run, w); 

我们还在第 60 行main.cpp中看到分配的泄漏:

rq = new Request(); 

虽然这些分配本身没有问题,但是如果我们在应用生命周期中跟踪它们,我们会注意到我们从未在这些对象上调用delete。如果我们要修复这些内存泄漏,我们需要删除那些Request实例,并清理dispatcher类析构函数中的Workerthread实例。

因为在这个演示应用中,整个应用在运行结束时被操作系统终止和清理,所以这并不是一个真正的问题。对于一个应用来说,在这个应用中,相同的调度器被用于不断生成和添加新的请求,同时还可能动态地扩展工作线程的数量,然而,这将是一个真正的问题。在这种情况下,必须小心解决这种内存泄漏。

错误类型

Memcheck 可以检测各种内存相关问题。以下各节总结了这些错误及其含义。

非法读取/非法写入错误

这些错误通常以以下格式报告:

Invalid read of size <bytes>
at 0x<memory address>: (location)
by 0x<memory address>: (location)
by 0x<memory address>: (location)
Address 0x<memory address> <error description>

前面错误消息的第一行告诉我们这是无效的读或写访问。接下来的几行将是详细描述执行无效读写的位置(可能还有源文件中的行)以及调用该代码的位置的回溯。

最后,最后一行将详细说明发生的非法访问类型,例如读取已经释放的内存块。

这种类型的错误表示写入或读取不应访问的内存部分。发生这种情况的原因可能是访问了一个通配符指针(即引用了一个随机的内存地址),或者是代码中的早期问题导致计算了一个错误的内存地址,或者内存边界没有得到遵守,并且读取超过了数组或类似对象的边界。

通常,当报告这种类型的错误时,应该高度重视,因为它表明一个基本问题,不仅会导致数据损坏和崩溃,还会导致其他人利用的错误。

使用未初始化的值

简而言之,这就是变量的值在没有被赋值的情况下被使用的问题。在这一点上,这些内容很可能只是内存中刚刚分配的那部分字节。因此,无论何时使用或访问这些内容,都会导致不可预测的行为。

遇到时,Memcheck 将抛出类似以下的错误:

$ valgrind --read-var-info=yes --leak-check=full ./unval
==6822== Memcheck, a memory error detector
==6822== Copyright (C) 2002-2015, and GNU GPL'd, by Julian Seward et al.
==6822== Using Valgrind-3.11.0 and LibVEX; rerun with -h for copyright info
==6822== Command: ./unval
==6822== 
==6822== Conditional jump or move depends on uninitialised value(s)
==6822==    at 0x4E87B83: vfprintf (vfprintf.c:1631)
==6822==    by 0x4E8F898: printf (printf.c:33)
==6822==    by 0x400541: main (unval.cpp:6)
==6822== 
==6822== Use of uninitialised value of size 8
==6822==    at 0x4E8476B: _itoa_word (_itoa.c:179)
==6822==    by 0x4E8812C: vfprintf (vfprintf.c:1631)
==6822==    by 0x4E8F898: printf (printf.c:33)
==6822==    by 0x400541: main (unval.cpp:6)
==6822== 
==6822== Conditional jump or move depends on uninitialised value(s)
==6822==    at 0x4E84775: _itoa_word (_itoa.c:179)
==6822==    by 0x4E8812C: vfprintf (vfprintf.c:1631)
==6822==    by 0x4E8F898: printf (printf.c:33)
==6822==    by 0x400541: main (unval.cpp:6)
==6822== 
==6822== Conditional jump or move depends on uninitialised value(s)
==6822==    at 0x4E881AF: vfprintf (vfprintf.c:1631)
==6822==    by 0x4E8F898: printf (printf.c:33)
==6822==    by 0x400541: main (unval.cpp:6)
==6822== 
==6822== Conditional jump or move depends on uninitialised value(s)
==6822==    at 0x4E87C59: vfprintf (vfprintf.c:1631)
==6822==    by 0x4E8F898: printf (printf.c:33)
==6822==    by 0x400541: main (unval.cpp:6)
==6822== 
==6822== Conditional jump or move depends on uninitialised value(s)
==6822==    at 0x4E8841A: vfprintf (vfprintf.c:1631)
==6822==    by 0x4E8F898: printf (printf.c:33)
==6822==    by 0x400541: main (unval.cpp:6)
==6822== 
==6822== Conditional jump or move depends on uninitialised value(s)
==6822==    at 0x4E87CAB: vfprintf (vfprintf.c:1631)
==6822==    by 0x4E8F898: printf (printf.c:33)
==6822==    by 0x400541: main (unval.cpp:6)
==6822== 
==6822== Conditional jump or move depends on uninitialised value(s)
==6822==    at 0x4E87CE2: vfprintf (vfprintf.c:1631)
==6822==    by 0x4E8F898: printf (printf.c:33)
==6822==    by 0x400541: main (unval.cpp:6)
==6822== 
==6822== 
==6822== HEAP SUMMARY:
==6822==     in use at exit: 0 bytes in 0 blocks
==6822==   total heap usage: 1 allocs, 1 frees, 1,024 bytes allocated
==6822== 
==6822== All heap blocks were freed -- no leaks are possible
==6822== 
==6822== For counts of detected and suppressed errors, rerun with: -v
==6822== Use --track-origins=yes to see where uninitialised values come from
==6822== ERROR SUMMARY: 8 errors from 8 contexts (suppressed: 0 from 0)

这一系列特殊的错误是由以下一小部分代码引起的:

#include <cstring>
 #include <cstdio>

 int main() {
    int x;  
    printf ("x = %d\n", x); 
    return 0;
 } 

正如我们在前面的代码中看到的,我们从不初始化我们的变量,它将被设置为任何随机值。如果幸运的话,它将被设置为零,或者一个同样(希望)无害的值。这段代码展示了我们未初始化的变量是如何进入库代码的。

使用未初始化的变量是否有害很难说,很大程度上取决于变量的类型和受影响的代码。然而,简单地分配一个安全的默认值要比寻找和调试可能由未初始化的变量(随机)引起的神秘问题容易得多。

有关未初始化变量来源的更多信息,可以将-track-origins=yes标志传递给 Memcheck。这将告诉它为每个变量保留更多的信息,这将使跟踪这类问题变得更加容易。

未初始化或不可寻址的系统调用值

每当调用函数时,未初始化的值都有可能作为参数传递,甚至是指向不可寻址缓冲区的指针。无论是哪种情况,Memcheck 都将记录以下内容:

$ valgrind --read-var-info=yes --leak-check=full ./unsyscall
==6848== Memcheck, a memory error detector
==6848== Copyright (C) 2002-2015, and GNU GPL'd, by Julian Seward et al.
==6848== Using Valgrind-3.11.0 and LibVEX; rerun with -h for copyright info
==6848== Command: ./unsyscall
==6848== 
==6848== Syscall param write(buf) points to uninitialised byte(s)
==6848==    at 0x4F306E0: __write_nocancel (syscall-template.S:84)
==6848==    by 0x4005EF: main (unsyscall.cpp:7)
==6848==  Address 0x5203040 is 0 bytes inside a block of size 10 alloc'd
==6848==    at 0x4C2DB8F: malloc (in /usr/lib/valgrind/vgpreload_memcheck-amd64-linux.so)
==6848==    by 0x4005C7: main (unsyscall.cpp:5)
==6848== 
==6848== Syscall param exit_group(status) contains uninitialised byte(s)
==6848==    at 0x4F05B98: _Exit (_exit.c:31)
==6848==    by 0x4E73FAA: __run_exit_handlers (exit.c:97)
==6848==    by 0x4E74044: exit (exit.c:104)
==6848==    by 0x4005FC: main (unsyscall.cpp:8)
==6848== 
==6848== 
==6848== HEAP SUMMARY:
==6848==     in use at exit: 14 bytes in 2 blocks
==6848==   total heap usage: 2 allocs, 0 frees, 14 bytes allocated
==6848== 
==6848== LEAK SUMMARY:
==6848==    definitely lost: 0 bytes in 0 blocks
==6848==    indirectly lost: 0 bytes in 0 blocks
==6848==      possibly lost: 0 bytes in 0 blocks
==6848==    still reachable: 14 bytes in 2 blocks
==6848==         suppressed: 0 bytes in 0 blocks
==6848== Reachable blocks (those to which a pointer was found) are not shown.
==6848== To see them, rerun with: --leak-check=full --show-leak-kinds=all
==6848== 
==6848== For counts of detected and suppressed errors, rerun with: -v
==6848== Use --track-origins=yes to see where uninitialised values come from
==6848== ERROR SUMMARY: 2 errors from 2 contexts (suppressed: 0 from 0)

前面的日志是由以下代码生成的:

#include <cstdlib>
 #include <unistd.h> 

 int main() {  
    char* arr  = (char*) malloc(10);  
    int*  arr2 = (int*) malloc(sizeof(int));  
    write(1, arr, 10 ); 
    exit(arr2[0]);
 } 

就像上一节详细介绍的未初始化值的一般使用一样,传递未初始化的或不可靠的参数至少是有风险的,在最坏的情况下,是崩溃、数据损坏或更糟的来源。

非法免费

非法释放或删除通常是试图在已经释放的内存块上重复调用free()delete()。虽然不一定是有害的,但这可能表明设计不好,绝对需要修复。

当试图使用不指向存储块开头的指针来释放该存储块时,也会发生这种情况。这就是为什么我们不应该对从调用malloc()new()获得的原始指针执行指针运算,而应该使用副本的主要原因之一。

不匹配的解除分配

内存块的分配和解除分配应该始终使用匹配函数来执行。这意味着,当我们使用 C 风格的函数进行分配时,我们从同一个 API 中用匹配的函数进行解除分配。C++ 风格的分配和解除分配也是如此。

简而言之,这意味着:

  • 如果使用malloccallocvallocrealloc,memalign进行分配,则使用free解除分配
  • 如果我们用新的分配,我们用delete解除分配
  • 如果我们用new[]分配,我们就用delete[]解除分配

把这些混在一起不一定会引起问题,但这样做是不明确的行为。后一种类型的分配和解除分配特定于阵列。对于分配了new[]的数组,不使用delete[]可能会导致内存泄漏,甚至更糟。

源和目标重叠

这种类型的错误表示为源和目标内存块传递的指针重叠(基于预期大小)。这种错误的结果通常是某种形式的损坏或系统崩溃。

可疑的论点价值

对于内存分配函数,Memcheck 验证传递给它们的参数是否真正有意义。这方面的一个例子是传递一个负的大小,或者它会远远超过一个合理的分配大小:例如,一个 1pb 内存的分配请求。最有可能的是,这些值可能是代码早期错误计算的结果。

Memcheck 会像本例中的 Memcheck 手册一样报告此错误:

==32233== Argument 'size' of function malloc has a fishy (possibly negative) value: -3
==32233==    at 0x4C2CFA7: malloc (vg_replace_malloc.c:298)
==32233==    by 0x400555: foo (fishy.c:15)
==32233==    by 0x400583: main (fishy.c:23)

这里试图将-3 的值传递给malloc,这显然没有多大意义。因为这显然是一个无意义的操作,它表明代码中有一个严重的错误。

内存泄漏检测

Memcheck 报告内存泄漏时要记住的最重要的一点是,大量报告的泄漏实际上可能不是泄漏。这反映在 Memcheck 报告其发现的任何潜在问题的方式中,如下所示:

  • 肯定输了
  • 间接损失
  • 可能丢失了

在三种可能的报告类型中,肯定丢失了类型是唯一一种绝对确定所讨论的内存块不再可达的类型,没有剩余的指针或引用,这使得应用不可能释放内存。

间接丢失类型的情况下,我们没有丢失指向这些内存块本身的指针,而是丢失了指向引用这些块的结构的指针。例如,当我们直接失去对数据结构(如红/黑或二叉树)的根节点的访问时,就会出现这种情况。结果,我们也失去了访问任何子节点的能力。

最后,可能丢失是一个包罗万象的类型,Memcheck 不能完全确定是否还有对内存块的引用。这可能发生在存在内部指针的地方,例如特定类型的数组分配。也可以通过使用多重继承来实现,其中 C++ 对象使用自引用。

如前所述,在 Memcheck 的基本使用部分,建议总是在指定--leak-check=full的情况下运行 Memcheck,以获得关于内存泄漏的确切位置的详细信息。

他妈的

Helgrind 的目的是检测多线程应用中同步实现的问题。它可以检测 POSIX 线程的错误使用、由于错误的锁定顺序而导致的潜在死锁问题以及数据争用——在没有线程同步的情况下读写数据。

基本用途

我们通过以下方式在应用中启动 Helgrind:

$ valgrind --tool=helgrind --read-var-info=yes --log-file=dispatcher_helgrind.log ./dispatcher_demo

与运行 Memcheck 类似,这将运行应用并将所有生成的输出记录到日志文件中,同时显式使用二进制文件中的所有可用调试信息。

运行应用后,我们检查生成的日志文件:

==6417== Helgrind, a thread error detector
==6417== Copyright (C) 2007-2015, and GNU GPL'd, by OpenWorks LLP et al.
==6417== Using Valgrind-3.11.0 and LibVEX; rerun with -h for copyright info
==6417== Command: ./dispatcher_demo
==6417== Parent PID: 2838
==6417== 
==6417== ---Thread-Announcement------------------------------------------
==6417== 
==6417== Thread #1 is the program's root thread 

在获得关于应用和 Valgrind 版本的初始基本信息后,我们被告知根线程已经创建:

==6417== 
==6417== ---Thread-Announcement------------------------------------------
==6417== 
==6417== Thread #2 was created
==6417==    at 0x56FB7EE: clone (clone.S:74)
==6417==    by 0x53DE149: create_thread (createthread.c:102)
==6417==    by 0x53DFE83: pthread_create@@GLIBC_2.2.5 (pthread_create.c:679)
==6417==    by 0x4C34BB7: ??? (in /usr/lib/valgrind/vgpreload_helgrind-amd64-linux.so)
==6417==    by 0x4EF8DC2: std::thread::_M_start_thread(std::shared_ptr<std::thread::_Impl_base>, void (*)()) (in /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.21)
==6417==    by 0x403AD7: std::thread::thread<void (Worker::*)(), Worker*&>(void (Worker::*&&)(), Worker*&) (thread:137)
==6417==    by 0x4030E6: Dispatcher::init(int) (dispatcher.cpp:40)
==6417==    by 0x4090A0: main (main.cpp:51)
==6417== 
==6417== ----------------------------------------------------------------

第一个线程由调度程序创建并记录。接下来我们得到第一个警告:

==6417== 
==6417==  Lock at 0x60F4A0 was first observed
==6417==    at 0x4C321BC: ??? (in /usr/lib/valgrind/vgpreload_helgrind-amd64-linux.so)
==6417==    by 0x401CD1: __gthread_mutex_lock(pthread_mutex_t*) (gthr-default.h:748)
==6417==    by 0x402103: std::mutex::lock() (mutex:135)
==6417==    by 0x40337E: Dispatcher::addWorker(Worker*) (dispatcher.cpp:108)
==6417==    by 0x401DF9: Worker::run() (worker.cpp:49)
==6417==    by 0x408FA4: void std::_Mem_fn_base<void (Worker::*)(), true>::operator()<, void>(Worker*) const (in /media/sf_Projects/Cerflet/dispatcher/dispatcher_demo)
==6417==    by 0x408F38: void std::_Bind_simple<std::_Mem_fn<void (Worker::*)()> (Worker*)>::_M_invoke<0ul>(std::_Index_tuple<0ul>) (functional:1531)
==6417==    by 0x408E3F: std::_Bind_simple<std::_Mem_fn<void (Worker::*)()> (Worker*)>::operator()() (functional:1520)
==6417==    by 0x408D47: std::thread::_Impl<std::_Bind_simple<std::_Mem_fn<void (Worker::*)()> (Worker*)> >::_M_run() (thread:115)
==6417==    by 0x4EF8C7F: ??? (in /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.21)
==6417==    by 0x4C34DB6: ??? (in /usr/lib/valgrind/vgpreload_helgrind-amd64-linux.so)
==6417==    by 0x53DF6B9: start_thread (pthread_create.c:333)
==6417==  Address 0x60f4a0 is 0 bytes inside data symbol "_ZN10Dispatcher12workersMutexE"
==6417== 
==6417== Possible data race during write of size 1 at 0x5CD9261 by thread #1
==6417== Locks held: 1, at address 0x60F4A0
==6417==    at 0x403650: Worker::setRequest(AbstractRequest*) (worker.h:38)
==6417==    by 0x403253: Dispatcher::addRequest(AbstractRequest*) (dispatcher.cpp:70)
==6417==    by 0x409132: main (main.cpp:63)
==6417== 
==6417== This conflicts with a previous read of size 1 by thread #2
==6417== Locks held: none
==6417==    at 0x401E02: Worker::run() (worker.cpp:51)
==6417==    by 0x408FA4: void std::_Mem_fn_base<void (Worker::*)(), true>::operator()<, void>(Worker*) const (in /media/sf_Projects/Cerflet/dispatcher/dispatcher_demo)
==6417==    by 0x408F38: void std::_Bind_simple<std::_Mem_fn<void (Worker::*)()> (Worker*)>::_M_invoke<0ul>(std::_Index_tuple<0ul>) (functional:1531)
==6417==    by 0x408E3F: std::_Bind_simple<std::_Mem_fn<void (Worker::*)()> (Worker*)>::operator()() (functional:1520)
==6417==    by 0x408D47: std::thread::_Impl<std::_Bind_simple<std::_Mem_fn<void (Worker::*)()> (Worker*)> >::_M_run() (thread:115)
==6417==    by 0x4EF8C7F: ??? (in /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.21)
==6417==    by 0x4C34DB6: ??? (in /usr/lib/valgrind/vgpreload_helgrind-amd64-linux.so)
==6417==    by 0x53DF6B9: start_thread (pthread_create.c:333)
==6417==  Address 0x5cd9261 is 97 bytes inside a block of size 104 alloc'd
==6417==    at 0x4C2F50F: operator new(unsigned long) (in /usr/lib/valgrind/vgpreload_helgrind-amd64-linux.so)
==6417==    by 0x40308F: Dispatcher::init(int) (dispatcher.cpp:38)
==6417==    by 0x4090A0: main (main.cpp:51)
==6417==  Block was alloc'd by thread #1
==6417== 
==6417== ----------------------------------------------------------------

在前面的警告中,Helgrind 告诉我们线程 IDs 1 和 2 之间的大小为 1 的冲突读取。因为 C++ 11 线程应用编程接口使用了大量的模板,所以跟踪可能有些难以阅读。精髓就在这几行字里:

==6417==    at 0x403650: Worker::setRequest(AbstractRequest*) (worker.h:38) ==6417==    at 0x401E02: Worker::run() (worker.cpp:51) 

这对应于以下代码行:

void setRequest(AbstractRequest* request) { this->request = request; ready = true; }
while (!ready && running) { 

在这些代码行中,大小为 1 的唯一变量是布尔变量ready。由于这是一个布尔变量,我们知道它是一个原子操作(详见第 8 章原子操作-使用硬件)。因此,我们可以忽略这个警告。

接下来,我们得到了这个线程的另一个警告:

==6417== Possible data race during write of size 1 at 0x5CD9260 by thread #1
==6417== Locks held: none
==6417==    at 0x40362C: Worker::stop() (worker.h:37)
==6417==    by 0x403184: Dispatcher::stop() (dispatcher.cpp:50)
==6417==    by 0x409163: main (main.cpp:70)
==6417== 
==6417== This conflicts with a previous read of size 1 by thread #2 ==6417== Locks held: none
==6417==    at 0x401E0E: Worker::run() (worker.cpp:51)
==6417==    by 0x408FA4: void std::_Mem_fn_base<void (Worker::*)(), true>::operator()<, void>(Worker*) const (in /media/sf_Projects/Cerflet/dispatcher/dispatcher_demo)
==6417==    by 0x408F38: void std::_Bind_simple<std::_Mem_fn<void (Worker::*)()> (Worker*)>::_M_invoke<0ul>(std::_Index_tuple<0ul>) (functional:1531)
==6417==    by 0x408E3F: std::_Bind_simple<std::_Mem_fn<void (Worker::*)()> (Worker*)>::operator()() (functional:1520)
==6417==    by 0x408D47: std::thread::_Impl<std::_Bind_simple<std::_Mem_fn<void (Worker::*)()> (Worker*)> >::_M_run() (thread:115)
==6417==    by 0x4EF8C7F: ??? (in /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.21)
==6417==    by 0x4C34DB6: ??? (in /usr/lib/valgrind/vgpreload_helgrind-amd64-linux.so)
==6417==    by 0x53DF6B9: start_thread (pthread_create.c:333)
==6417==  Address 0x5cd9260 is 96 bytes inside a block of size 104 alloc'd
==6417==    at 0x4C2F50F: operator new(unsigned long) (in /usr/lib/valgrind/vgpreload_helgrind-amd64-linux.so)
==6417==    by 0x40308F: Dispatcher::init(int) (dispatcher.cpp:38)
==6417==    by 0x4090A0: main (main.cpp:51)
==6417==  Block was alloc'd by thread #1 

与第一个警告类似,这也指的是一个布尔变量——这里是Worker实例中的running变量。由于这也是原子操作,我们可以再次忽略这个警告。

在这个警告之后,我们得到了其他线程的这些警告的重复。我们也看到这个警告重复了很多次:

==6417==  Lock at 0x60F540 was first observed
==6417==    at 0x4C321BC: ??? (in /usr/lib/valgrind/vgpreload_helgrind-amd64-linux.so)
==6417==    by 0x401CD1: __gthread_mutex_lock(pthread_mutex_t*) (gthr-default.h:748)
==6417==    by 0x402103: std::mutex::lock() (mutex:135)
==6417==    by 0x409044: logFnc(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >) (main.cpp:40)
==6417==    by 0x40283E: Request::process() (request.cpp:19)
==6417==    by 0x401DCE: Worker::run() (worker.cpp:44)
==6417==    by 0x408FA4: void std::_Mem_fn_base<void (Worker::*)(), true>::operator()<, void>(Worker*) const (in /media/sf_Projects/Cerflet/dispatcher/dispatcher_demo)
==6417==    by 0x408F38: void std::_Bind_simple<std::_Mem_fn<void (Worker::*)()> (Worker*)>::_M_invoke<0ul>(std::_Index_tuple<0ul>) (functional:1531)
==6417==    by 0x408E3F: std::_Bind_simple<std::_Mem_fn<void (Worker::*)()> (Worker*)>::operator()() (functional:1520)
==6417==    by 0x408D47: std::thread::_Impl<std::_Bind_simple<std::_Mem_fn<void (Worker::*)()> (Worker*)> >::_M_run() (thread:115)
==6417==    by 0x4EF8C7F: ??? (in /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.21)
==6417==    by 0x4C34DB6: ??? (in /usr/lib/valgrind/vgpreload_helgrind-amd64-linux.so)
==6417==  Address 0x60f540 is 0 bytes inside data symbol "logMutex"
==6417== 
==6417== Possible data race during read of size 8 at 0x60F238 by thread #1
==6417== Locks held: none
==6417==    at 0x4F4ED6F: std::basic_ostream<char, std::char_traits<char> >& std::__ostream_insert<char, std::char_traits<char> >(std::basic_ostream<char, std::char_traits<char> >&, char const*, long) (in /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.21)
==6417==    by 0x4F4F236: std::basic_ostream<char, std::char_traits<char> >& std::operator<< <std::char_traits<char> >(std::basic_ostream<char, std::char_traits<char> >&, char const*) (in /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.21)
==6417==    by 0x403199: Dispatcher::stop() (dispatcher.cpp:53)
==6417==    by 0x409163: main (main.cpp:70)
==6417== 
==6417== This conflicts with a previous write of size 8 by thread #7
==6417== Locks held: 1, at address 0x60F540
==6417==    at 0x4F4EE25: std::basic_ostream<char, std::char_traits<char> >& std::__ostream_insert<char, std::char_traits<char> >(std::basic_ostream<char, std::char_traits<char> >&, char const*, long) (in /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.21)
==6417==    by 0x409055: logFnc(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >) (main.cpp:41)
==6417==    by 0x402916: Request::finish() (request.cpp:27)
==6417==    by 0x401DED: Worker::run() (worker.cpp:45)
==6417==    by 0x408FA4: void std::_Mem_fn_base<void (Worker::*)(), true>::operator()<, void>(Worker*) const (in /media/sf_Projects/Cerflet/dispatcher/dispatcher_demo)
==6417==    by 0x408F38: void std::_Bind_simple<std::_Mem_fn<void (Worker::*)()> (Worker*)>::_M_invoke<0ul>(std::_Index_tuple<0ul>) (functional:1531)
==6417==    by 0x408E3F: std::_Bind_simple<std::_Mem_fn<void (Worker::*)()> (Worker*)>::operator()() (functional:1520)
==6417==    by 0x408D47: std::thread::_Impl<std::_Bind_simple<std::_Mem_fn<void (Worker::*)()> (Worker*)> >::_M_run() (thread:115)
==6417==  Address 0x60f238 is 24 bytes inside data symbol "_ZSt4cout@@GLIBCXX_3.4"  

此警告是由于线程之间没有同步使用标准输出而触发的。即使这个演示应用的日志记录功能使用互斥来同步工作线程记录的文本,我们也在一些位置以不安全的方式写入标准输出。

通过使用中央线程安全的日志记录功能,这相对容易修复。尽管它不太可能导致任何稳定性问题,但它很可能会导致任何日志输出最终变成一团乱麻,无法使用。

滥用 pthreads 应用编程接口

Helgrind 检测到大量涉及 pthreads API 的错误,其手册对此进行了总结,下面列出:

  • 解锁无效互斥体
  • 解锁未锁定的互斥体
  • 解锁由不同线程持有的互斥体
  • 销毁无效或锁定的互斥体
  • 递归锁定非递归互斥体
  • 释放包含锁定互斥体的内存
  • 将互斥参数传递给需要读写锁参数的函数,反之亦然
  • POSIX pthread 函数失败,出现必须处理的错误代码
  • 线程退出时仍持有锁定的锁
  • 用未锁定的互斥体、无效互斥体或被不同线程锁定的互斥体调用pthread_cond_wait
  • 条件变量及其关联互斥体之间的绑定不一致
  • pthread 屏障的初始化无效或重复
  • 线程仍在等待的 pthread 屏障的初始化
  • 销毁从未初始化或线程仍在等待的 pthread barrier 对象
  • 等待未初始化的 pthread 屏障

除此之外,如果 Helgrind 本身没有检测到错误,但是 pthreads 库本身为 Helgrind 截获的每个函数返回一个错误,Helgrind 也会报告一个错误。

锁定订单问题

锁顺序检测使用的假设是,一旦一系列锁以特定的顺序被访问,这就是它们将总是被使用的顺序。例如,想象一个由两个锁保护的资源。正如我们在第 4 章线程同步和通信中看到的那样,我们在其 dispatcher 类中使用了两个互斥锁,一个管理对工作线程的访问,另一个管理对请求实例的访问。

在该代码的正确实现中,我们总是确保在尝试获取另一个互斥体之前解锁一个互斥体,因为有可能另一个线程已经获得了对第二个互斥体的访问,并试图获得对第一个互斥体的访问,从而造成死锁情况。

虽然有用,但重要的是要认识到,到目前为止,这种检测算法在某些领域还不完善。这在使用例如条件变量时最为明显,条件变量自然会使用一个锁定顺序,该顺序往往会被 Helgrind 报告为错误

这里的要点是,我们必须检查这些日志消息并判断它们的价值,但与直接滥用多线程应用编程接口不同,报告的问题是否是假阳性远没有那么明确。

数据竞赛

本质上,数据竞争是指两个以上的线程试图在没有任何同步机制的情况下读取或写入同一资源。在这里,只有一次并发读写,或者两次同时写入,实际上是有害的;因此,只报告这两种类型的访问。

在关于 Helgrind 基本用法的前面一节中,我们在日志中看到了一些这类错误的例子。在那里,它涉及变量的同时写入和读取。正如我们在那一节中也谈到的,Helgrind 并不关心写或读是否是原子的,而只是报告一个潜在的问题。

就像锁顺序问题一样,这再次意味着必须根据每个数据竞争报告的优点来判断,因为许多报告可能是误报。

DRD

DRD 与赫尔格林非常相似,因为它也能检测应用中的线程和同步问题。DRD 与赫尔格伦的主要不同之处如下:

  • DRD 使用较少的内存
  • DRD 没有检测到违反锁定命令的情况
  • DRD 支持分离线程

一般来说,我们希望同时运行 DRD 和赫尔格林来比较两者的输出。由于许多潜在的问题是高度不确定的,因此使用这两种工具通常有助于找出最严重的问题。

基本用途

启动 DRD 非常类似于启动其他工具-我们只需要像这样指定我们想要的工具:

$ valgrind --tool=drd --log-file=dispatcher_drd.log --read-var-info=yes ./dispatcher_demo

应用完成后,我们检查生成的日志文件的内容。

==6576== drd, a thread error detector
==6576== Copyright (C) 2006-2015, and GNU GPL'd, by Bart Van Assche.
==6576== Using Valgrind-3.11.0 and LibVEX; rerun with -h for copyright info
==6576== Command: ./dispatcher_demo
==6576== Parent PID: 2838
==6576== 
==6576== Conflicting store by thread 1 at 0x05ce51b1 size 1
==6576==    at 0x403650: Worker::setRequest(AbstractRequest*) (worker.h:38)
==6576==    by 0x403253: Dispatcher::addRequest(AbstractRequest*) (dispatcher.cpp:70)
==6576==    by 0x409132: main (main.cpp:63)
==6576== Address 0x5ce51b1 is at offset 97 from 0x5ce5150\. Allocation context:
==6576==    at 0x4C3150F: operator new(unsigned long) (in /usr/lib/valgrind/vgpreload_drd-amd64-linux.so)
==6576==    by 0x40308F: Dispatcher::init(int) (dispatcher.cpp:38)
==6576==    by 0x4090A0: main (main.cpp:51)
==6576== Other segment start (thread 2)
==6576==    at 0x4C3818C: pthread_mutex_unlock (in /usr/lib/valgrind/vgpreload_drd-amd64-linux.so)
==6576==    by 0x401D00: __gthread_mutex_unlock(pthread_mutex_t*) (gthr-default.h:778)
==6576==    by 0x402131: std::mutex::unlock() (mutex:153)
==6576==    by 0x403399: Dispatcher::addWorker(Worker*) (dispatcher.cpp:110)
==6576==    by 0x401DF9: Worker::run() (worker.cpp:49)
==6576==    by 0x408FA4: void std::_Mem_fn_base<void (Worker::*)(), true>::operator()<, void>(Worker*) const (in /media/sf_Projects/Cerflet/dispatcher/dispatcher_demo)
==6576==    by 0x408F38: void std::_Bind_simple<std::_Mem_fn<void (Worker::*)()> (Worker*)>::_M_invoke<0ul>(std::_Index_tuple<0ul>) (functional:1531)
==6576==    by 0x408E3F: std::_Bind_simple<std::_Mem_fn<void (Worker::*)()> (Worker*)>::operator()() (functional:1520)
==6576==    by 0x408D47: std::thread::_Impl<std::_Bind_simple<std::_Mem_fn<void (Worker::*)()> (Worker*)> >::_M_run() (thread:115)
==6576==    by 0x4F04C7F: ??? (in /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.21)
==6576==    by 0x4C3458B: ??? (in /usr/lib/valgrind/vgpreload_drd-amd64-linux.so)
==6576==    by 0x53EB6B9: start_thread (pthread_create.c:333)
==6576== Other segment end (thread 2)
==6576==    at 0x4C3725B: pthread_mutex_lock (in /usr/lib/valgrind/vgpreload_drd-amd64-linux.so)
==6576==    by 0x401CD1: __gthread_mutex_lock(pthread_mutex_t*) (gthr-default.h:748)
==6576==    by 0x402103: std::mutex::lock() (mutex:135)
==6576==    by 0x4023F8: std::unique_lock<std::mutex>::lock() (mutex:485)
==6576==    by 0x40219D: std::unique_lock<std::mutex>::unique_lock(std::mutex&) (mutex:415)
==6576==    by 0x401E33: Worker::run() (worker.cpp:52)
==6576==    by 0x408FA4: void std::_Mem_fn_base<void (Worker::*)(), true>::operator()<, void>(Worker*) const (in /media/sf_Projects/Cerflet/dispatcher/dispatcher_demo)
==6576==    by 0x408F38: void std::_Bind_simple<std::_Mem_fn<void (Worker::*)()> (Worker*)>::_M_invoke<0ul>(std::_Index_tuple<0ul>) (functional:1531)
==6576==    by 0x408E3F: std::_Bind_simple<std::_Mem_fn<void (Worker::*)()> (Worker*)>::operator()() (functional:1520)
==6576==    by 0x408D47: std::thread::_Impl<std::_Bind_simple<std::_Mem_fn<void (Worker::*)()> (Worker*)> >::_M_run() (thread:115)
==6576==    by 0x4F04C7F: ??? (in /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.21)
==6576==    by 0x4C3458B: ??? (in /usr/lib/valgrind/vgpreload_drd-amd64-linux.so) 

前面的总结基本上重复了我们在 Helgrind 日志中看到的内容。我们看到相同的数据竞争报告(冲突存储),由于原子性,我们可以安全地忽略它。至少对于这个特定的代码,DRD 的使用没有增加任何我们在使用 Helgrind 时不知道的东西。

无论如何,使用两种工具总是一个好主意,以防一种工具发现另一种没有发现的东西。

特征

DRD 将检测到以下错误:

  • 数据竞赛
  • 锁争用(死锁和延迟)
  • 滥用 pthreads 应用编程接口

关于第三点,根据 DRD 的手册,其检测到的错误列表与 Helgrind 的非常相似:

  • 将一种类型的同步对象(例如互斥体)的地址传递给 POSIX API 调用,该调用需要指向另一种类型的同步对象(例如条件变量)的指针
  • 尝试解锁尚未锁定的互斥体
  • 尝试解锁被另一个线程锁定的互斥体
  • 尝试递归锁定类型为PTHREAD_MUTEX_NORMAL或自旋锁的互斥体
  • 锁定互斥体的销毁或解除分配
  • 当与条件变量相关联的互斥体上没有锁时,向条件变量发送信号
  • 在未被锁定的互斥体上调用pthread_cond_wait,也就是说,被另一个线程锁定或者已经被递归锁定
  • 通过pthread_cond_wait将两个不同的互斥体与一个条件变量相关联
  • 正在等待的条件变量的销毁或解除分配
  • 销毁或解除分配锁定的读写同步对象
  • 尝试解锁未被调用线程锁定的读取器-写入器同步对象
  • 试图以独占方式递归锁定读取器-写入器同步对象
  • 尝试将用户定义的读写器同步对象的地址传递给 POSIX 线程函数
  • 尝试将 POSIX 读写同步对象的地址传递给用户定义的读写同步对象的注释之一
  • 互斥体、条件变量、读写锁、信号量或障碍的重新初始化
  • 正在等待的信号量或障碍的销毁或解除分配
  • 屏障等待和屏障破坏之间缺少同步
  • 退出线程而不首先解锁该线程锁定的自旋锁、互斥锁或读写同步对象
  • 将无效的线程标识传递给pthread_joinpthread_cancel

如前所述,DRD 也支持分离线程,这一点很有帮助。锁定顺序检查是否重要取决于个人的应用。

C++ 11 线程支持

DRD 手册包含关于 C++ 11 线程支持的这一部分。

如果您想使用c++ 11std::thread,您需要做以下操作来注释在该类的实现中使用的std::shared_ptr<>对象:

  • 在包含任何 C++ 头文件之前,在公共头文件的开头或每个源文件的开头添加以下代码:
    #include <valgrind/drd.h>
    #define _GLIBCXX_SYNCHRONIZATION_HAPPENS_BEFORE(addr)
    ANNOTATE_HAPPENS_BEFORE(addr)
    #define _GLIBCXX_SYNCHRONIZATION_HAPPENS_AFTER(addr)
    ANNOTATE_HAPPENS_AFTER(addr)
  • 下载 GCC 源代码,从源文件libstdc++-v3/src/c++ 11/thread.cc中,将execute_native_thread_routine()std::thread::_M_start_thread()函数的实现复制到与您的应用链接的源文件中。确保也在这个源文件中正确定义了_GLIBCXX_SYNCHRONIZATION_HAPPENS_*()宏。

当在使用 C++ 11 线程应用编程接口的应用中使用 DRD 时,可能会看到很多误报,这可以通过前面的修复来修复。

然而,当使用 GCC 5.4 和 Valgrind 3.11 时(可能也使用旧版本),这个问题似乎不再存在。然而,当使用 C++ 11 线程应用编程接口时,突然发现自己的 DRD 输出中有很多误报时,需要记住这一点。

摘要

在本章中,我们将了解如何调试多线程应用。我们探讨了在多线程环境中使用调试器的基础知识。接下来,我们看到了如何在 Valgrind 框架中使用三个工具,它们可以帮助我们跟踪多线程和其他关键问题。

在这一点上,我们可以使用前面章节中的信息编写应用,并分析它们是否存在任何应该修复的问题,包括内存泄漏和同步机制的不当使用。

在下一章中,我们将带着我们所学的一切,看看多线程编程和开发的一些最佳实践。