C++ 死锁排查:Shell + GDB 定位

在 Linux 环境下进行 C++ 编程时,多线程能显著提升程序的并发处理能力,让程序在面对复杂任务时表现得更加高效。但多线程编程并非一帆风顺,死锁问题就像隐藏在暗处的”杀手”,随时可能让程序陷入僵局。

想象一下,你的程序原本运行得好好的,突然就像被施了定身咒一样,毫无反应,所有的线程都被卡住,无法继续推进。这很可能就是死锁在作祟。

死锁一旦发生,程序就像陷入了一个无法自拔的循环,各个线程相互等待对方释放资源,却又都不肯率先放手,最终导致整个程序停滞不前。这种情况不仅会让程序的功能无法正常实现,还可能影响整个系统的稳定性。比如在一个网络服务器程序中,如果发生死锁,可能会导致服务器无法响应新的客户端请求,大量用户的操作被搁置,后果不堪设想。

对于 C++ 开发者而言,掌握排查死锁的技巧至关重要。今天,我们就来深入探讨如何借助 Linux 系统下的 Shell 命令和强大的调试工具 GDB,精准定位并解决死锁问题,让你的程序重新”活”过来。

一、死锁的概述

在多线程编程的领域中,死锁是一个让人头疼不已的问题。简单来说,死锁就是多个线程因为互相争夺资源,而陷入一种无限期的等待状态,导致所有线程都无法继续执行。

就好比两个人过独木桥,独木桥一次只能容纳一个人通过,这两个人同时上了桥,一个从左边往右边走,另一个从右边往左边走,走到桥中间时,谁也不愿意退回去让对方先过,于是就僵持在那里,谁都无法到达对岸,这就是死锁在现实生活中的生动写照。

在程序里,死锁通常发生在多个线程需要获取多个共享资源的时候。比如,线程 A 持有资源 1,并且想要获取资源 2;而线程 B 持有资源 2,却想要获取资源 1。此时,两个线程都在等待对方释放自己需要的资源,谁也不肯先放手,就形成了死锁。

死锁一旦发生,带来的危害是巨大的。程序会陷入无响应状态,无法正常完成任务,这对于那些需要实时响应的系统,如服务器程序、嵌入式系统等来说,是灾难性的。而且,死锁很难被发现和调试,因为它不是代码语法错误,也不是简单的逻辑错误,往往需要借助特定的工具和方法才能排查出来。

所以,学会如何排查死锁就显得尤为重要。在 Linux C++ 编程环境中,利用 shell 命令和强大的调试工具 gdb,我们可以有效地找出死锁的根源,让程序重新回到正常运行的轨道。接下来,就让我们一起深入学习如何使用 shell + gdb 来排查死锁。

二、死锁产生的原因

在实际编写项目的过程中,经常涉及到多线程。多线程的程序编程很大概率会涉及到线程安全的问题,因此往往使用互斥锁来保证线程安全。然而,互斥锁的使用却经常会导致另外一个问题:死锁。

所谓死锁,通俗的讲就是有两个共享资源,一个在你手上,一个在我手上。我等着你用完把另一个给我,你等我用完给你,这样相互等待就形成了死锁。

死锁的产生往往源于对共享资源的不当管理和线程调度的不合理。下面我们来详细分析一下死锁产生的常见原因,并结合具体的代码示例进行解释。

2.1 加锁顺序不当

当多个线程需要获取多个锁时,如果它们获取锁的顺序不一致,就很容易导致死锁。

例如,有两个线程 thread1 和 thread2,它们都需要获取锁 mutex1 和 mutex2。thread1 先获取 mutex1,然后尝试获取 mutex2;而 thread2 先获取 mutex2,然后尝试获取 mutex1。如果在 thread1 获取了 mutex1 之后,thread2 获取了 mutex2,此时两个线程就会互相等待对方释放自己需要的锁,从而陷入死锁。

#include <iostream>
#include <thread>
#include <mutex>

std::mutex mutex1;
std::mutex mutex2;

void thread1Function() {
    mutex1.lock();
    std::cout << "Thread 1: Acquired mutex1" << std::endl;
    std::this_thread::sleep_for(std::chrono::seconds(1));
    mutex2.lock();
    std::cout << "Thread 1: Acquired mutex2" << std::endl;
    mutex2.unlock();
    mutex1.unlock();
}

void thread2Function() {
    mutex2.lock();
    std::cout << "Thread 2: Acquired mutex2" << std::endl;
    std::this_thread::sleep_for(std::chrono::seconds(1));
    mutex1.lock();
    std::cout << "Thread 2: Acquired mutex1" << std::endl;
    mutex1.unlock();
    mutex2.unlock();
}

int main() {
    std::thread thread1(thread1Function);
    std::thread thread2(thread2Function);

    thread1.join();
    thread2.join();

    return 0;
}

在这段代码中,thread1Function 和 thread2Function 函数中获取锁的顺序不同,这就为死锁的发生埋下了隐患。当两个线程同时运行时,很可能会出现死锁的情况。

2.2 重复加锁

如果一个线程在已经持有某个锁的情况下,再次尝试获取该锁,而这个锁又不支持重入(即同一个线程多次获取同一把锁),就会导致死锁。

例如,使用 std::mutex 时,如果一个线程在已经锁定了 std::mutex 的情况下,再次调用 lock 方法,就会陷入死锁,因为它无法再次获取已经持有的锁,而其他线程也无法获取该锁,从而导致所有线程都无法继续执行。

#include <iostream>
#include <thread>
#include <mutex>

std::mutex myMutex;

void recursiveFunction(int count) {
    myMutex.lock();
    std::cout << "Entering recursiveFunction, count: " << count << std::endl;
    if (count > 0) {
        recursiveFunction(count - 1);
    }
    myMutex.unlock();
    std::cout << "Exiting recursiveFunction, count: " << count << std::endl;
}

int main() {
    std::thread myThread(recursiveFunction, 3);
    myThread.join();
    return 0;
}

在这个例子中,recursiveFunction 函数是递归的,每次调用都会尝试获取 myMutex 锁。由于 std::mutex 不支持重入,当递归调用时,第二次获取锁就会导致死锁。

2.3 加锁后未解锁

如果一个线程获取了锁,但由于某些原因(如异常、逻辑错误等)没有释放锁,那么其他需要获取该锁的线程就会一直等待,从而导致死锁。

比如,在下面的代码中,如果 someFunction 函数在执行过程中抛出异常,那么 mutex 锁将不会被释放,后续尝试获取该锁的线程就会陷入死锁。

#include <iostream>
#include <thread>
#include <mutex>

std::mutex mutex;

void someFunction() {
    mutex.lock();
    std::cout << "Locked the mutex" << std::endl;
    // 模拟一些可能抛出异常的操作
    throw std::runtime_error("Something went wrong");
    mutex.unlock();
    std::cout << "Unlocked the mutex" << std::endl;
}

int main() {
    std::thread thread(someFunction);
    thread.join();
    return 0;
}

在实际编程中,为了避免这种情况,通常会使用 try-catch 块来捕获异常,并在 catch 块中释放锁,或者使用 std::lock_guard 等 RAII(Resource Acquisition Is Initialization)机制来自动管理锁的生命周期,确保锁在离开作用域时自动释放。

了解了死锁产生的原因后,我们就可以有针对性地进行排查和解决。接下来,我们将学习如何使用 shell 和 gdb 工具来发现和定位死锁问题。

三、模拟死锁场景

为了更直观地了解死锁,我们先来编写一段会产生死锁的 C++11 代码。这段代码创建了两个线程,它们会尝试获取两个互斥锁,但是获取的顺序不同,从而导致死锁。

#include <iostream>
#include <thread>
#include <mutex>

std::mutex mutex1;
std::mutex mutex2;

void threadFunction1() {
    // 线程1获取mutex1
    mutex1.lock();
    std::cout << "Thread 1 acquired mutex1" << std::endl;
    // 模拟一些工作
    std::this_thread::sleep_for(std::chrono::seconds(1));
    // 线程1尝试获取mutex2
    mutex2.lock();
    std::cout << "Thread 1 acquired mutex2" << std::endl;
    mutex2.unlock();
    mutex1.unlock();
}

void threadFunction2() {
    // 线程2获取mutex2
    mutex2.lock();
    std::cout << "Thread 2 acquired mutex2" << std::endl;
    // 模拟一些工作
    std::this_thread::sleep_for(std::chrono::seconds(1));
    // 线程2尝试获取mutex1
    mutex1.lock();
    std::cout << "Thread 2 acquired mutex1" << std::endl;
    mutex1.unlock();
    mutex2.unlock();
}

int main() {
    std::thread thread1(threadFunction1);
    std::thread thread2(threadFunction2);

    thread1.join();
    thread2.join();

    return 0;
}

在这段代码中:

  • 我们定义了两个全局的互斥锁 mutex1 和 mutex2。
  • threadFunction1 函数中,线程 1 首先获取 mutex1,然后睡眠 1 秒,模拟一些工作,之后尝试获取 mutex2。
  • threadFunction2 函数中,线程 2 首先获取 mutex2,然后睡眠 1 秒,之后尝试获取 mutex1。
  • 在 main 函数中,我们创建了两个线程 thread1 和 thread2,分别执行 threadFunction1 和 threadFunction2。

由于两个线程获取互斥锁的顺序不一致,当线程 1 获取了 mutex1,线程 2 获取了 mutex2 时,它们都会等待对方释放自己需要的锁,从而陷入死锁。

运行这段代码,你会发现程序会卡在某个地方,无法继续执行,这就是死锁发生的表现。接下来,我们就将使用 shell 和 gdb 工具来排查这个死锁问题。

四、使用工具排查——前期准备工作

在开始排查死锁之前,我们需要进行一些前期准备工作。

首先,确保你已经安装了 gdb 调试工具,大多数 Linux 发行版都默认安装了 gdb,如果没有安装,可以使用包管理器进行安装。例如,在 Ubuntu 系统中,可以使用以下命令安装:

sudo apt-get install gdb

然后,我们需要对之前编写的会产生死锁的代码进行编译。在编译时,需要注意使用 g++ 编译时要加上 -pthread 参数,以链接线程库。同时,为了方便调试,最好加上 -g 参数,生成调试信息。编译命令如下:

g++ -g -std=c++11 -pthread deadlock_example.cpp -o deadlock

其中,deadlock_example.cpp 是我们编写的包含死锁场景的代码文件名,deadlock 是生成的可执行文件名称。

编译完成后,运行生成的可执行文件:

./deadlock

运行后,你会发现程序似乎卡住了,没有任何输出,这就是死锁发生的现象。接下来,我们就可以使用 gdb 和一些 shell 命令来排查这个死锁问题。

五、使用 Shell 初步判断

5.1 查看进程状态

当怀疑程序发生死锁时,首先可以使用 ps aux 命令来查看进程状态。ps 是 “Process Status” 的缩写,aux 是常用选项组合,a 表示显示所有用户的进程,u 表示以用户友好的格式显示进程信息,包括进程的用户名、CPU 和内存使用率等,x 表示显示没有关联终端的进程(后台进程)。

假设我们的死锁程序生成的可执行文件名为 deadlock,使用以下命令查找该进程:

ps aux | grep deadlock

命令执行后,会输出包含 deadlock 关键字的进程信息,输出格式如下:

USER       PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
your_user 12345  0.0  0.0  12345  6789?        Ssl  10:00   0:00 /path/to/deadlock

各字段含义如下:

  • USER:进程拥有者。
  • PID:进程 ID。
  • %CPU:占用的 CPU 使用率。
  • %MEM:占用的记忆体使用率。
  • VSZ:占用的虚拟记忆体大小。
  • RSS:占用的实际记忆体大小。
  • TTY:终端的次要装置号码,? 表示不依赖任何终端运行。
  • STAT:该进程的状态,S 表示中断睡眠状态,s 表示该进程是会话的领导进程,l 表示多线程进程。
  • START:进程开始时间。
  • TIME:执行的时间。
  • COMMAND:所执行的指令。

如果进程发生死锁,处于阻塞状态,通常 %CPU 利用率会比较低,因为死锁的线程无法继续执行,不会占用 CPU 资源;%MEM 占用率也可能相对稳定,不会有明显变化。

通过观察这些指标,可以初步判断进程是否存在异常,是否有可能发生了死锁。

5.2 深入查看资源占用

使用 top -Hp 命令可以深入查看进程内线程的 CPU 和内存占用情况。top 命令用于实时监控系统进程,-Hp 选项中,H 表示以线程模式显示,p 表示指定进程 ID。

假设我们找到的死锁进程 ID 为 12345,使用以下命令查看该进程内的线程资源占用:

top -Hp 12345

执行命令后,会显示该进程内所有线程的相关信息,类似于下面这样:

top - 10:10:10 up 1 day,  2:00,  3 users,  load average: 0.00, 0.01, 0.05
Threads:  10 total,   1 running,   9 sleeping,   0 stopped,   0 zombie
%Cpu(s):  0.0 us,  0.0 sy,  0.0 ni,100.0 id,  0.0 wa,  0.0 hi,  0.0 si,  0.0 st
KiB Mem :  16384000 total,  15360000 free,   1024000 used,      0 buff/cache
KiB Swap:        0 total,        0 free,      0 used.  15360000 avail Mem

  PID USER      PR  NI    VIRT    RES    SHR S  %CPU %MEM     TIME+ COMMAND
12345 your_user  20   0  123456  67890  12340 S    0.0  0.0   0:00.00 deadlock
12346 your_user  20   0  123456  67890  12340 S    0.0  0.0   0:00.00 deadlock
12347 your_user  20   0  123456  67890  12340 S    0.0  0.0   0:00.00 deadlock

在死锁情况下,涉及死锁的线程通常会处于阻塞状态,其 %CPU 使用率会趋近于 0,并且这种低 CPU 使用率会持续一段时间。

如果发现多个线程的 CPU 使用率长时间几乎为 0,且进程整体表现出无响应的状态,那么就进一步增加了死锁发生的可能性。同时,观察内存占用情况,如果内存占用没有明显变化,也符合死锁时线程无法继续执行导致资源无动态变化的特征。

通过 ps auxtop -Hp 这两个命令,我们可以初步判断进程是否可能发生了死锁,为后续使用 gdb 进行更深入的调试提供线索。

六、GDB 登场:深入排查

6.1 attach 到进程

在实际项目中,程序往往已经在运行,我们不可能轻易将其停掉再进行调试,这时就需要使用 GDB 的 attach 命令来跟踪正在运行的进程。

假设我们已经通过 ps aux 命令获取到死锁程序的进程 ID 为 12345。首先,我们需要获取超级权限,因为调试其他进程通常需要较高的权限。在终端中输入:

su

输入密码后,即可获得超级权限。然后,使用 GDB 的 attach 命令将 GDB 附加到该进程上:

gdb attach 12345

执行上述命令后,GDB 会连接到指定的进程,此时我们就可以对该进程进行调试操作了。

6.2 查看线程堆栈

成功 attach 到进程后,接下来我们要查看所有线程的堆栈信息,以便了解每个线程的执行状态。在 GDB 中,使用 thread apply all bt 命令可以实现这一目的。

执行该命令后,GDB 会输出所有线程的堆栈跟踪信息,类似于以下内容:

Thread 3 (Thread 0x7ffff7ffb700 (LWP 12347)):
#0  0x00007ffff7b59d8f in pthread_mutex_lock () from /lib64/libpthread.so.0
#1  0x0000000000400934 in threadFunction2 () at deadlock_example.cpp:20
#2  0x00007ffff7b576ba in start_thread () from /lib64/libpthread.so.0
#3  0x00007ffff777188d in clone () from /lib64/libc.so.6

Thread 2 (Thread 0x7ffff87fc700 (LWP 12346)):
#0  0x00007ffff7b59d8f in pthread_mutex_lock () from /lib64/libpthread.so.0
#1  0x0000000000400899 in threadFunction1 () at deadlock_example.cpp:13
#2  0x00007ffff7b576ba in start_thread () from /lib64/libpthread.so.0
#3  0x00007ffff777188d in clone () from /lib64/libc.so.6

Thread 1 (Thread 0x7ffff8e09740 (LWP 12345)):
#0  0x00007ffff778c24d in __GI___poll (fds=0x7fffffffe0c0, nfds=1, timeout=-1) at ../sysdeps/unix/sysv/linux/poll.c:29
#1  0x00007ffff778968d in __GI_ppoll (fds=0x7fffffffe0c0, nfds=1, timeout=0x0, sigmask=0x0) at ../sysdeps/unix/sysv/linux/poll.c:56
#2  0x00007ffff7b59c1d in pthread_join () from /lib64/libpthread.so.0
#3  0x000000000040098b in main () at deadlock_example.cpp:30

在这些输出信息中,每一个 Thread 部分代表一个线程的堆栈信息。#0 表示当前线程的栈顶,即当前正在执行的函数;#1 表示调用 #0 函数的函数,以此类推。

通过分析这些堆栈信息,我们可以看到每个线程正在执行的函数以及函数调用的层级关系。通常,包含 main 函数的线程就是主线程,如上述输出中的 Thread 1;而其他线程则根据其执行的函数来判断,例如 Thread 2 执行的是 threadFunction1,Thread 3 执行的是 threadFunction2,它们就是我们创建的工作线程。

6.3 定位死锁线程

通过查看所有线程的堆栈信息,我们已经对每个线程的执行状态有了初步了解。接下来,要进一步定位死锁线程,需要使用 info threads 命令查看各个线程的索引,然后使用 thread + 线程索引 命令切换到具体的线程,再使用 bt 命令查看该线程的堆栈。

例如,执行 info threads 命令后,会得到类似如下的输出:

  Id   Target Id         Frame
  1    Thread 0x7ffff8e09740 (LWP 12345) "deadlock" 0x00007ffff778c24d in __GI___poll (fds=0x7fffffffe0c0, nfds=1, timeout=-1) at ../sysdeps/unix/sysv/linux/poll.c:29
  2    Thread 0x7ffff87fc700 (LWP 12346) "deadlock" 0x00007ffff7b59d8f in pthread_mutex_lock () from /lib64/libpthread.so.0
  3    Thread 0x7ffff7ffb700 (LWP 12347) "deadlock" 0x00007ffff7b59d8f in pthread_mutex_lock () from /lib64/libpthread.so.0

这里的 Id 列就是线程的索引。假设我们要查看 Id 为 2 的线程堆栈,执行 thread 2 命令切换到该线程,然后执行 bt 命令查看堆栈:

#0  0x00007ffff7b59d8f in pthread_mutex_lock () from /lib64/libpthread.so.0
#1  0x0000000000400899 in threadFunction1 () at deadlock_example.cpp:13
#2  0x00007ffff7b576ba in start_thread () from /lib64/libpthread.so.0
#3  0x00007ffff777188d in clone () from /lib64/libc.so.6

从堆栈信息中,我们可以看到该线程在 deadlock_example.cpp 的第 13 行尝试获取互斥锁,并且当前卡在了 pthread_mutex_lock 函数处,说明该线程可能因为无法获取锁而陷入死锁。

再查看其他线程的堆栈信息,对比分析,就可以确定死锁线程以及死锁发生的具体代码行。通过这种方式,我们就能够准确地定位到死锁的根源,为后续解决死锁问题提供关键线索。

七、全文总结

使用 shell + gdb 排查死锁的过程,就像是一场严谨的侦探推理,每一个步骤都至关重要,环环相扣。

首先,当我们怀疑程序出现死锁时,要运用 ps aux 命令查看进程状态,从宏观层面获取进程的基本信息,像进程的 CPU 利用率、内存占用率等,这是初步判断的关键线索。如果进程处于阻塞状态,CPU 利用率和内存占用率较低,就可能是死锁的信号。

接着,使用 top -Hp 命令深入查看进程内线程的资源占用情况,进一步确认是否存在多个线程长时间处于低 CPU 使用率且无响应的异常情况,这能让我们更接近死锁的真相。

当初步判断可能发生死锁后,就要请出强大的 gdb 工具了。通过 gdb attach 命令将 gdb 附加到运行中的进程,这就像是给我们安装了一个”透视镜”,让我们能够深入进程内部。然后,使用 thread apply all bt 命令查看所有线程的堆栈信息,这是排查死锁的关键一步,它能让我们全面了解每个线程的执行状态和函数调用层级关系。

最后,使用 info threads 命令获取线程索引,再通过 thread + 线程索引 命令切换到具体线程,配合 bt 命令查看线程堆栈,精准定位死锁线程以及死锁发生的具体代码行。这一系列操作就像是在迷宫中找到了正确的出口,让我们能够准确揪出死锁的根源。

在这个排查过程中,每一个命令的使用都有其明确的目的和作用。ps auxtop -Hp 命令帮助我们在系统层面初步判断死锁的可能性,为后续的深入排查提供方向;而 gdb 中的各种命令则是深入进程内部,精准定位死锁的”神兵利器”。掌握了这些方法和工具,我们就能在面对死锁问题时更加从容,迅速找到问题所在,让程序恢复正常运行。


   转载规则


《C++ 死锁排查:Shell + GDB 定位》 吴杭沉 采用 知识共享署名 4.0 国际许可协议 进行许可。
 上一篇
Linux 动态链接与静态链接 Linux 动态链接与静态链接
库是写好的、可复用的代码。现实中每个程序都要依赖很多底层库,不可能从零开始。本质上库是一种可执行代码的二进制形式,可被操作系统载入内存执行。库有两种:静态库(.a/.lib)和动态库(.so/.dll)——Windows 上是 .lib/.
2020-06-21
下一篇 
一道经典 fork 面试题 一道经典 fork 面试题
这是一道关于 Unix fork() 系统调用的经典面试题:每一轮 fork 产生的新进程数量等于当前正在运行的进程数量。题目问——下面这个程序一共输出多少个 -? #include <stdio.h> #include <sy
2020-06-09
  目录