前言

最近发现了 teelebot 构建的 Bot 在长时间运行后会出现 占用 100% CPU 的情况。便乘问题复现的时候用 gdb 分析一波。

工具准备

  • gdb : 由GNU开源组织发布的、UNIX/LINUX操作系统下的、基于命令行的、功能强大的程序调试工具。
  • python3-dbg : CPython 调试符号,用来在调试的时候看到python源代码的call stack。装上这个才能用 gdb 调试 Python程序
sudo apt install gdb python3-dbg -y

使用上面的命令安装它们。

开始调试

一、获取Python程序进程PID

ps -aux | grep -w teelebot | grep -v grep | awk '{ print $2 }'

二、获取占用 100% CPU 的线程PID

top -H -p PID

该命令会动态显示程序所有线程信息,输出如下:

  PID USER      PR  NI    VIRT    RES    SHR S  %CPU  %MEM     TIME+ COMMAND                    
 3208 user      20   0  822964  69152  14252 S   100  3.5   4:21.35 python3                                     
 3437 user      20   0  822964  69152  14252 S   0.0  3.5   0:04.85 python3                                     
 3444 user      20   0  822964  69152  14252 S   0.0  3.5   0:04.33 python3                                     
 3473 user      20   0  822964  69152  14252 S   0.0  3.5   0:02.17 python3                                     
 3474 user      20   0  822964  69152  14252 S   0.0  3.5   0:04.43 python3                                     
 3485 user      20   0  822964  69152  14252 S   0.0  3.5   0:04.37 python3                                     
 3486 user      20   0  822964  69152  14252 S   0.0  3.5   0:03.80 python3  

PID为 3208 的线程即是我们要找的问题线程。

三、进入gdb调试环境

sudo gdb python3 -p PID //此处PID为进程PID

看到下方的输出就代表成功进入调试环境

Reading symbols from /usr/lib/x86_64-linux-gnu/libsqlite3.so.0...Reading symbols from /usr/lib/debug/.build-id/78/5ce90f42d13a60a368533ef.debug...done.

我们可以生成一个 coredump 文件来进行分析,以防程序突然重启导致的问题消失

(gdb) gcore PID //此处PID为进程PID

然后退出 gdb 用以下命令进入文件

gdb python3 core.PID //此处PID为进程PID

四、使用 info threads 查看程序所有线程

(gdb) info threads

输出如下:

Id   Target Id                                  Frame 
  1    Thread 0x7f19b6 (LWP 3208) "python3" 0x00007f19b6 in __GI___poll (fds=0x7f19b6, 
    nfds=1, timeout=500) at ../sysdeps/unix/sysv/linux/poll.c:29
* 2    Thread 0x7f19b4 (LWP 3437) "python3" futex_abstimed_wait_cancelable (private=0, abstime=0x0, 
    expected=0, futex_word=0x18fe750) at ../sysdeps/unix/sysv/linux/futex-internal.h:205
  3    Thread 0x7f19ae (LWP 3444) "python3" futex_abstimed_wait_cancelable (private=0, abstime=0x0, 
    expected=0, futex_word=0x18fe750) at ../sysdeps/unix/sysv/linux/futex-internal.h:205
  4    Thread 0x7f19ad (LWP 3473) "python3" futex_abstimed_wait_cancelable (private=0, abstime=0x0, 
    expected=0, futex_word=0x18fe750) at ../sysdeps/unix/sysv/linux/futex-internal.h:205
  5    Thread 0x7f19ac (LWP 3474) "python3" futex_abstimed_wait_cancelable (private=0, abstime=0x0, 
    expected=0, futex_word=0x18fe750) at ../sysdeps/unix/sysv/linux/futex-internal.h:205
...

* 代表当前线程

五、切换到问题线程

由上一步我们知道了问题线程的编号为 1 ,通过以下命令切换到问题线程:

(gdb) thread 1

输出如下:

[Switching to thread 1 (Thread 0x7f19b6 (LWP 3208))]
#0  futex_abstimed_wait_cancelable (private=0, abstime=0x0, expected=0, futex_word=0x7f19b6)
    at ../sysdeps/unix/sysv/linux/futex-internal.h:205
205     ../sysdeps/unix/sysv/linux/futex-internal.h: No such file or directory.

六、使用 py-list 查看当前应用程序上下文

(gdb) py-list

输出如下:

 348            for i in range(4): #生成答案列表
 349                if  answer == i:
 350                    options.append(captcha_text)
 351                else:
 352                    options.append(shuffle_str(captcha_text))
>353            if len(options) == len(set(options)):
 354                break
 ...

比较幸运的是,第一次使用py-list 就定位到了问题代码。若第一次未找到,则需要结合 py-uppy-down 寻找。

七、使用 py-bt 查看应用程序调用堆栈

初步定位问题代码后,我们还可以用 py-bt 查看程序的调用堆栈

(gdb) py-bt

输出如下:

Traceback (most recent call first):
  File "/teelebot/plugins/Guard/Guard.py", line 353, in reply_markup_dict
    if len(options) == len(set(options)):
  File "/teelebot/plugins/Guard/Guard.py", line 167, in Guard
    reply_markup = reply_markup_dict(captcha_text=captcha_text)
  File "/usr/lib/python3.8/concurrent/futures/thread.py", line 57, in run
    result = self.fn(*self.args, **self.kwargs)
  File "/usr/lib/python3.8/concurrent/futures/thread.py", line 80, in _worker
    work_item.run()
  File "/usr/lib/python3.8/threading.py", line 865, in run
    self._target(*self._args, **self._kwargs)
  File "/usr/lib/python3.8/threading.py", line 917, in _bootstrap_inner
    self.run()

可以看出问题确实出在 Guard插件 的 reply_markup_dict 函数上。

八、使用 py-locals 查看当前scope的变量

我们可以再看看当前使用的变量的值:

(gdb) py-locals

输出如下:

captcha_text = 'bsbmg'
options = ['sbbmg', 'bsgbm', 'bsbmg', 'bsgbm', 'msbgb', 'sbbmg', 'bsbmg', 'gmsbb', 'bsgbm', 'bbgsm', 'bsbmg', 'msbgb', 'sbgmb', 'bbgsm', 'bsbmg', 'bbmgs', 'smbgb', 'gbmsb', 'bsbmg', 'bbgsm', 'bmbgs', 'bmsgb', 'bsbmg', 'gmbbs', 'bmgbs', 'msbgb', 'bsbmg', 'bgmsb', 'sbbmg', 'bbmgs', 'bsbmg', 'msbgb', 'bgsbm', 'bbmsg', 'bsbmg', 'sbgbm', 'bbmsg', 'mgbsb', 'bsbmg', 'bgbms', 'bmgbs', 'bsbmg', 'bsbmg', 'bbgsm', 'sbgbm', 'sgbmb', 'bsbmg', 'bgmsb', 'sbbmg', 'bsgbm', 'bsbmg', 'bbgms', 'sgbbm', 'mbgbs', 'bsbmg', 'mbbgs', 'bmgsb', 'gbsmb', 'bsbmg', 'bmgsb', 'msbbg', 'mgsbb', 'bsbmg', 'gsbmb', 'bsgmb', 'bmbsg', 'bsbmg', 'gsmbb', 'mbsgb', 'gbmbs', 'bsbmg', 'bmgbs', 'bmsbg', 'bbgms', 'bsbmg', 'bbmsg', 'bgmbs', 'bsbmg', 'bsbmg', 'gbsbm', 'smgbb', 'bgmsb', 'bsbmg', 'mbbgs', 'bmgbs', 'mbgbs', 'bsbmg', 'bmgbs', 'mbgbs', 'mbgsb', 'bsbmg', 'mbgsb', 'bsbmg', 'mgsbb', 'bsbmg', 'gbbms', 'sbbgm', 'bmbsg', 'bsbmg', 'bsmbg', 'gbsmb', 'mbbgs', 'bsbmg', 'gbbms', 'bsmbg', 'gmbsb', 'bsbmg', 'smgbb', 'gbmsb', 'bmsgb', 'bsbmg', 'gbmsb', 'sgmbb', 'sgmbb...(truncated)
answer = 2
i = 3

九、解决问题

由上面的步骤分析,可以判断问题是由线程内函数死循环无法退出导致的。接下来在源文件中定位到问题代码:

options = []
while True:
    for i in range(4): #生成答案列表
        if  answer == i:
            options.append(captcha_text)
        else:
            options.append(shuffle_str(captcha_text))
    if len(options) == len(set(options)):
        break

可以看到这是一个死循环,退出条件为当 options 中不存在重复的元素。我们可以试着用文字复述问题出现的整个流程:当遭遇第一次循环后, options 中存在重复的元素,退出条件不会被触发,会继续进行循环,但由于变量 options 定义在循环之外,下一次循环变量 option不会被清空,因而 options 中永远存在重复的元素,退出条件永远不满足,循环永远不会退出,线程也永远等待在这里。

因此解决问题的办法就是: 进入下一次循环之前手动清空 options 的值

options = []
while True:
    for i in range(4): #生成答案列表
        if  answer == i:
            options.append(captcha_text)
        else:
            options.append(shuffle_str(captcha_text))
    if len(options) == len(set(options)):
        break
    else:
        options = [] #手动清空options

奇怪的是内存为什么没有占满。。

最后

感谢

感谢他们的帮助:

参考链接