调试实战——程序CPU占用率飙升,你知道如何快速定位吗?

前言

如果我们自己的程序的 CPU UsageCPU占用率 )飙升,并且居高不下,很有可能陷入了死循环。你知道怎么快速定位并解决吗?今天跟大家分享几种定位方法,希望对你有所帮助。

如何判断是否有死循环?

  • 通过电脑风扇的声音猜测。

    如果风扇一直响个不停,说明电脑很热。高 CPU占用率 会导致 CPU 发热量增大,从而导致风扇狂响。如果听到风扇响个不停,可以打开任务管理器看看 CPU占用率 是不是很高。如果发现是我们的进程导致的高 CPU占用率 ,那么可以进一步查看是不是有死循环。

  • 通过 CPU占用率 来判断。

    对于多核 CPU (尤其是性能强劲的 CPU ),一个核心的满负荷运转,并不会立刻导致 CPU 发热量明显增大,风扇可能不会有明显响动。这时根据风扇声音不能轻易判断出是否有死循环,但是我们可以通过 CPU占用率 来判断。

    如果 CPU 是单核的,那么当 CPU 处于满负荷运转状态, CPU占用率 会接近 100% 。如果 CPU4 核的,并且这 4 个核心都处于满负荷运转状态,那么 CPU占用率 会接近 100% ,如果只有一个核心是满负荷运转状态,那么 CPU 占用率会在 25%100 / 4 = 25 )左右。如果我们发现某个进程的 CPU占用率 居高不下,有可能是死循环了。

    注意:很多死循环都是 busy 类型的,如果是 idle 类型的死循环,上面的方法不适用。

下面介绍几个我经常使用的工具,可以比较便捷的排查此类问题。

1. process explorer

在前面的文章里跟大家介绍过,使用 process explorer 可以查看线程的 调用栈CPU占用率 。如果程序里的某个功能迟迟不能完成,我的第一反应是,按 Ctrl + Shift + Esc 打开任务管理器(我已经使用 process explorer 替换了系统自带的任务管理器,所以启动的是 process explorer 。如何使用 process explorer 替换系统自带的任务管理器,请参考文章 [原]排错实战——使用process explorer替换任务管理器 )。

启动 process explorer 后,双击我们关心的进程,切换到 Thread 页,在这里我们可以看到当前进程中的所有线程。双击某个线程就可以查看调用栈,在弹出的调用栈界面,点击左下角的 Refresh 按钮可以刷新。

如果每次刷新都能看到某个函数,很有可能是在这个函数中出现了死循环。对照源码,也许能直接能看出原因。

使用process explorer

注意:需要正确加载调试符号才可以看到对应的函数名。

2. windbg

如果不能使用 process explorer 定位到具体的原因,可以使用 windbg 附加到进程中 进行更深入的调查 。我们需要找出哪个线程运行的时间最长,因为一般死循环的线程占用的 CPU 时间会比较长。应该怎么找呢?

  • 使用 .ttime 命令

    .ttime 可以查看当前线程的运行时间(用户态运行时间和内核态运行时间)。但是 .ttime 有个不足之处——没有输出相关的线程标识。我们需要根据其它信息来获取当前线程的标识。

    如果想查看所有线程的运行时间怎么办呢?当然可以手动切换到另外一个线程,然后执行 .ttime 。如果线程数量很多的话,这可是个体力活。不要怕,我们可以通过命令 ~*e .ttime 来获取每个线程的运行时间。因为 .ttime 输出结果中没有线程标识,我们需要执行命令 ~*e ? $tid;.ttime 把对应的 线程ID 一起输出。

    获取所有线程ID和运行时间

    简单向大家解释下这条命令:

    • ~*e 会遍历所有线程并执行后面跟着的命令。其实,
      ~* 就可以遍历所有线程,比如我们在前面的文章里用到的
      ~* kvn 命令来查看所有线程的调用栈。但是对于某些命令,如果不加
      e
      windbg 可能不能正确解析,会报错。
    • ? $tid 评估表达式
      $tid 的值,
      ?
      windbg 中表示
      Evaluate 的意思,会评估后面表达式的值。
      $tid 是伪变量,代表了当前线程的
      线程ID
    • ; 分号是命令分割符。
    • .ttime 查看当前线程的运行时间。

整条命令的效果是: 遍历每个线程,输出其对应的 线程ID 和运行时间。

  • 如果觉得上面的命令太长了,还可以使用更简单的命令 !runaway 查看线程运行时间。

下面是我用 !runaway 命令排查高 CPU占用率 的屏幕录像。

3. visual studio

如果是正在开发的程序在运行过程中出现了死循环,我会考虑用 vs 来附加到进程(如果进程是通过 Ctrl + F5 启动的话,并没有被调试)。然后通过 Parallel Stacks 查看所有线程,并用肉眼查找可能出问题的线程。因为我不知道 vs 中是否有类似 !runaway 的命令。如果哪位小伙伴有更好的办法,请一定要留言告诉我!

下面是我用 Parallel Stacks 功能排查高 CPU占用率 的屏幕录像。

小提示:按 Ctrl + Alt + p 可以快速打开 附加进程 界面。

以上三种工具,我会先使用 process explorer 大体定位下问题,因为可以非常方便的通过 Ctrl + Shift + Esc 启动。如果用 process explorer 解决不了,我会根据情况使用 windbg 或者 vs 。如果 vs 正开着(通常是正在写代码的时候),就顺手用 vs 附加到对应的进程中。如果 vs 没开着,当然会使用 windbg 进行排查了。 :sunglasses:

实战代码

如果你想动手实战,复制下面的代码到工程里就可以实战了。

简单介绍下代码:

  • 示例代码中启动了 8 个线程,是为了增大排查的难度,只有一个线程的情况太简单了。

  • 函数 FindFirstRepeatElementIndex() 的用途是 找到给定的数据中第一次出现重复的数据的索引

  • 除了我们发现的死循环的问题,还有什么地方可以优化呢?命名,效率,各个方面都可以优化哦,欢迎留言交流。

#include 
#include 
#include 

int FindFirstRepeatElementIndex(bool bExcute)
{
  if (!bExcute)
  {
    return -1;
  }

  int idx = -1;
  std::vector datas = { 1 , 3, 5, 7, 9, 11, 11, 13, 14, 15, 16, 17 };
  for (size_t i = 0; i < datas.size(); ++i)
  {
    for (size_t j = i = 1; j < datas.size(); ++j)
    {
      if (datas[j] == datas[i])
      {
        idx = i;
        break;
      }
    }
  }

  return idx;
}

#define THREAD_COUNT 8
int main()
{
  std::future results[THREAD_COUNT];

  int realExcuteIdx = rand() % THREAD_COUNT;
  for (int idx = 0; idx < THREAD_COUNT; ++idx)
  {
    bool bRealExcute = (realExcuteIdx == idx);
    results[idx] = std::async(FindFirstRepeatElementIndex, bRealExcute);
  }

  for (auto& one_result : results)
  {
    std::cout<< one_result.get() << std::endl;
  }

  return 0;
}

总结

  • 使用
    process explorer 的线程相关功能,
    在某些情况下, 我们甚至可以不用调试器,对照源码就可以找出问题所在。
  • visual studio 的并行调用栈可以让我们一次性看到所有线程的调用栈,很是方便。不像
    Call Stack ,每次只能查看一个线程的调用栈。当然除了看所有线程的调用栈,还有更多用途等待大家挖掘。
  • 一般,如果一个线程的运行时间 大于 其它线程,这个线程很有可能是与死循环相关的线程。

  • windbg

    !runaway
    命令可以查看每个线程运行的时间, 运行时间最长的线程会排在第一位。
  • ~*e ? $tid;.ttime 可以查看所有线程的运行时间。
  • ~ N s 切换到第
    N 号线程。
  • ~~[TID]s 切换到
    TID 对应的线程。

参考资料

  • 《格蠹汇编》

  • 《Windows Sysinternals 实战指南》