The missing semester of your CS education--调试及性能分析

课程结构

01.课程概览与 shell
02.Shell 工具和脚本
03.编辑器 (Vim)
04.数据整理
05.命令行环境
06.版本控制(Git)
07.调试及性能分析
08.元编程
09.安全和密码学
10.大杂烩
11.提问&回答

本文档修改自这里,补充了一些视频中展示但配套文档中未提供的代码,以及一些注释,另外,本节中涉及的相关文件可在百度云链接中获取。

调试与性能分析--目录

    • 调试代码
      • 打印调试法与日志
      • 第三方日志系统
      • 调试器
      • 专门工具
      • 静态分析
    • 性能分析
      • 计时
      • CPU
      • 内存
      • 事件分析
      • 可视化
      • 资源监控
      • 专用工具
    • 课后练习
      • 调试
      • 性能分析
    • 习题解答
      • 调试
      • 性能分析

调试代码

打印调试法与日志

“最有效的 debug 工具就是细致的分析,配合恰当位置的打印语句” — Brian Kernighan, Unix 新手入门

调试代码的第一种方法往往是在您发现问题的地方添加一些打印语句,然后不断重复此过程直到您获取了足够的信息并找到问题的根本原因。

另外一个方法是使用日志,而不是临时添加打印语句。日志较普通的打印语句有如下的一些优势:

  • 您可以将日志写入文件、socket 或者甚至是发送到远端服务器而不仅仅是标准输出;
  • 日志可以支持严重等级(例如 INFO, DEBUG, WARN, ERROR等),这使您可以根据需要过滤日志;
  • 对于新发现的问题,很可能您的日志中已经包含了可以帮助您定位问题的足够的信息。

logger.py 是一个包含日志的例程序:

$ python3 logger.py # 简单的输出
Value is 8 - Dangerous region   
...
$ python3 logger.py log # 带有格式化的输出
2023-05-05 09:15:52,716 : INFO : Sample : Value is 1 - Everything is fine
...
$ python3 logger.py log ERROR   # 格式化的输出ERROR层级以上的内容:ERROR&CRITICAL
2023-05-05 09:16:05,641 : CRITICAL : Sample : Maximum value reached
...
$ python3 logger.py color   # 带有颜色的格式化输出
2023-05-05 09:16:28,727 - Sample - CRITICAL - Maximum value reached (logger.py:64)  
...

有很多技巧可以使日志的可读性变得更好,例如着色。

lsgrep 这样的程序会使用 ANSI escape codes,它是一系列的特殊字符,可以使您的 shell 改变输出结果的颜色。

~ $ echo -e "\e[38;2;255;0;0mThis is red\e[0m"
This is red

 # 如果你的终端不支持真彩色,可以使用更广泛的 16色
~ $ echo -e "\e[31;1mThis is red\e[0m"
This is red

下面这个脚本向您展示了如何在终端中打印多种颜色(只要您的终端支持真彩色)

~ $ vim color.sh
~ $ cat color.sh

#!/usr/bin/env bash
for R in $(seq 0 20 255); do
    for G in $(seq 0 20 255); do
        for B in $(seq 0 20 255); do
            printf "\e[38;2;${R};${G};${B}m█\e[0m";
        done
    done
done

~ $ source color.sh

第三方日志系统

如果您正在构建大型软件系统,您很可能会使用到一些依赖,有些依赖会作为程序单独运行。如 Web 服务器、数据库或消息代理都是此类常见的第三方依赖。

和这些系统交互的时候,阅读它们的日志是非常必要的,因为仅靠客户端侧的错误信息可能并不足以定位问题。

幸运的是,大多数的程序都会将日志保存在您的系统中的某个地方。对于 UNIX 系统来说,程序的日志通常存放在 /var/log。例如, NGINX web 服务器就将其日志存放于/var/log/nginx

目前,系统开始使用 system log,您所有的日志都会保存在这里。大多数(但不是全部的)Linux 系统都会使用 systemd,这是一个系统守护进程,它会控制您系统中的很多东西,例如哪些服务应该启动并运行。systemd 会将日志以某种特殊格式存放于/var/log/journal,您可以使用 journalctl 命令显示这些消息。

类似地,在 macOS 系统中是 /var/log/system.log,但是有更多的工具会使用系统日志,它的内容可以使用 log show 显示。

对于大多数的 UNIX 系统,您也可以使用dmesg 命令来读取内核的日志。

如果您希望将日志加入到系统日志中,您可以使用 logger 这个 shell 程序。

logger "Hello Logs"

# On macOS
log show --last 1m | grep Hello
# On Linux
journalctl --since "1m ago" | grep Hello

视频中提到一个应用场景:你可以编写一个bash脚本,监控wifi,当wifi切换连上另一个网络时,向系统日志写入内容,类似"It’s XXX time now, we’ve changed our connection to XXX!",稍后,可以浏览系统日志,用以研究wifi的切换是否导致了某些系统问题。

日志的内容可以非常的多,需要对其进行处理和过滤才能得到想要的信息。

如果您发现您需要对 journalctllog show 的结果进行大量的过滤,那么此时可以考虑使用它们自带的选项对其结果先过滤一遍再输出。还有一些像 lnav 这样的工具,它为日志文件提供了更好的展现和浏览方式,如执行cat /var/log/syslog | lnav

调试器

当通过打印已经不能满足您的调试需求时,您应该使用调试器。

调试器是一种可以允许我们和正在执行的程序进行交互的程序,它可以做到:

  • 当到达某一行时将程序暂停;
  • 一次一条指令地逐步执行程序;
  • 程序崩溃后查看变量的值;
  • 满足特定条件时暂停程序;
  • 其他高级功能。

很多编程语言都有自己的调试器。Python 自带的调试器是pdb.

下面对pdb 支持的命令进行简单的介绍:

  • l(ist) - 显示当前行附近的11行或继续执行之前的显示;
  • s(tep) - 执行当前行,并在第一个可能的地方停止;
  • n(ext) - 继续执行直到当前函数的下一条语句或者 return 语句;
  • b(reak) - 设置断点(基于传入的参数);
  • p(rint) - 在当前上下文对表达式求值并打印结果。还有一个命令是pp ,它使用 pprint 打印;
  • r(eturn) - 继续执行直到当前函数返回;
  • q(uit) - 退出调试器。

让我们修复下面的 Python 代码(bubble.py

def bubble_sort(arr):
    n = len(arr)
    for i in range(n):
        for j in range(n):
            if arr[j] > arr[j+1]:
                arr[j] = arr[j+1]
                arr[j+1] = arr[j]
    return arr

print(bubble_sort([4, 2, 1, 8, 7, 6]))
  • 下面使用ipdb来调试代码:
    ~ $ sudo apt install pip
    ~ $ pip install ipdb
    ~ $ mkdir debug; cd debug
    ~/debug $ vim bubble.py   # 输入上面的python的代码
    ~/debug $ python3 bubble.py
     # 执行代码,报错:IndexError
    
    ~/debug $ python3 -m ipdb bubble.py
    /usr/lib/python3.10/runpy.py:126: RuntimeWarning: 'ipdb.__main__' found in sys.modules after import of package 'ipdb', but prior to execution of 'ipdb.__main__'; this may result in unpredictable behaviour
    warn(RuntimeWarning(msg))
    > /home/laihj/debug/bubble.py(1)<module>()
    ----> 1 def bubble_sort(arr):
        2     n = len(arr)
        3     for i in range(n):
    
  • 展示当前行(第一行)附近的11行
    ipdb> l 
    ----> 1 def bubble_sort(arr):
        2     n = len(arr)
        3     for i in range(n):
        4         for j in range(n):
        5             if arr[j] > arr[j+1]:
        6                 arr[j] = arr[j+1]
        7                 arr[j+1] = arr[j]
        8     return arr
        9
        10 print(bubble_sort([4, 2, 1, 8, 7, 6]))
    
  • 逐行执行代码,在第一个可能出错的地方停下
    ipdb> s
    > /home/laihj/debug/bubble.py(10)<module>()
        8     return arr
        9
    ---> 10 print(bubble_sort([4, 2, 1, 8, 7, 6]))
    
  • 使用s后,只要输入键,就可继续逐行运行代码,直至出错的地方
    ipdb> 
    --Call--
    > /home/laihj/debug/bubble.py(1)bubble_sort()
    ----> 1 def bubble_sort(arr):
        2     n = len(arr)
        3     for i in range(n):
    
    ipdb>
    > /home/laihj/debug/bubble.py(2)bubble_sort()
        1 def bubble_sort(arr):
    ----> 2     n = len(arr)
        3     for i in range(n):
    
  • 使用 s 逐行运行,到达出错的地方,效率可能太慢,重新开始调试
    ipdb> restart
    Restarting bubble.py with arguments:
    
    > /home/laihj/debug/bubble.py(1)<module>()
    ----> 1 def bubble_sort(arr):
        2     n = len(arr)
        3     for i in range(n):
    
  • 使用 c ,直接运行至出错的地方
    ipdb> c
    Traceback (most recent call last):
    File "/home/laihj/.local/lib/python3.10/site-packages/ipdb/__main__.py", line 323, in main
        pdb._runscript(mainpyfile)
    File "/usr/lib/python3.10/pdb.py", line 1586, in _runscript
        self.run(statement)
    File "/usr/lib/python3.10/bdb.py", line 597, in run
        exec(cmd, globals, locals)
    File "", line 1, in <module>
    File "/home/laihj/debug/bubble.py", line 10, in <module>
        print(bubble_sort([4, 2, 1, 8, 7, 6]))
    File "/home/laihj/debug/bubble.py", line 5, in bubble_sort
        if arr[j] > arr[j+1]:
    IndexError: list index out of range
    Uncaught exception. Entering post mortem debugging
    Running 'cont' or 'step' will restart the program
    > /home/laihj/debug/bubble.py(5)bubble_sort()
        4         for j in range(n):
    ----> 5             if arr[j] > arr[j+1]:
        6                 arr[j] = arr[j+1]
    
  • 此时,报出IndexError。使用 p 打印变量的当前值进行检查。
    ipdb> p arr
    [2, 1, 1, 7, 6, 6]
    ipdb> p j
    5
    
  • 此时,j+1=6,arr[6]不存在,需要修改for j in range(n):中的nn-1。退出调试,并直接修改bubble.py
    ipdb> q
    ipdb> q
    ~/debug $ vim bubble.py
    
  • 重新执行bubble.py,发现还有错误,再次进行调试。
    ~/debug $ python3 bubble.py
    [1, 1, 1, 6, 6, 6]
    ~/debug $ python3 -m ipdb bubble.py
    /usr/lib/python3.10/runpy.py:126: RuntimeWarning: 'ipdb.__main__' found in sys.modules after import of package 'ipdb', but prior to execution of 'ipdb.__main__'; this may result in unpredictable behaviour
    warn(RuntimeWarning(msg))
    > /home/laihj/debug/bubble.py(1)<module>()
    ----> 1 def bubble_sort(arr):
        2     n = len(arr)
        3     for i in range(n):
    
    ipdb> l
    ----> 1 def bubble_sort(arr):
        2     n = len(arr)
        3     for i in range(n):
        4         for j in range(n-1):
        5             if arr[j] > arr[j+1]:
        6                 arr[j] = arr[j+1]
        7                 arr[j+1] = arr[j]
        8     return arr
        9
        10 print(bubble_sort([4, 2, 1, 8, 7, 6]))
    
  • 我们现在要找出代码中哪里修改了arr中的值,从代码来看,只有if结构中的语句有可能。为此,在这里设置断点breakpoint
    ipdb> b 6
    Breakpoint 1 at /home/laihj/debug/bubble.py:6
    ipdb> c
    > /home/laihj/debug/bubble.py(6)bubble_sort()
        5             if arr[j] > arr[j+1]:
    1---> 6                 arr[j] = arr[j+1]
        7                 arr[j+1] = arr[j]
    
    ipdb> p locals()
    {'arr': [4, 2, 1, 8, 7, 6], 'n': 6, 'i': 0, 'j': 0}
     # 打印当前的所有变量值
     # 此时的arr还没问题
    ipdb> s
    > /home/laihj/debug/bubble.py(7)bubble_sort()
    1     6                 arr[j] = arr[j+1]
    ----> 7                 arr[j+1] = arr[j]
        8     return arr
    
    ipdb> p locals()
    {'arr': [2, 2, 1, 8, 7, 6], 'n': 6, 'i': 0, 'j': 0}
    
  • 可以看到,这一步出问题了,arr中原来的4被改为了2。第6、7步需要修改,否则执行这两步后,arr[j]arr[j+1]的值就相等了。
    ipdb> q
    ~/debug $ vim bubble.py
    

注意,因为 Python 是一种解释型语言,所以我们可以通过 pdb shell 执行命令。 ipdb 是一种增强型的 pdb ,它使用IPython 作为 REPL并开启了 tab 补全、语法高亮、更好的回溯和更好的内省,同时还保留了pdb 模块相同的接口。

对于更底层的编程语言,您可能需要了解一下 gdb ( 以及它的改进版 pwndbg) 和 lldb。

~/gits $ git clone https://github.com/pwndbg/pwndbg
~/gits $ cd pwndbg
~/gits/pwndbg $ ./setup.sh
 # 安装时间会比较久,确保网络连接顺畅,如果安装不成功,可以再执行一次./setup.sh
~/gits/pwndbg (dev) $ pwndbg
~ $ gdb --args sleep 20 # 使用gdb调试命令
 # 显示加载内容,键入"Enter"进行输入
pwndbg> run
 # 执行sleep 20
^C  # 键入Ctrl+c,中断程序
Program received signal SIGINT, Interrupt.
 # 下面还会显示运行sleep 20时调用的底层的信息

The missing semester of your CS education--调试及性能分析_第1张图片

它们都对类 C 语言的调试进行了优化,它允许您探索任意进程及其机器状态:寄存器、堆栈、程序计数器等。

专门工具

即使您需要调试的程序是一个二进制的黑盒程序,仍然有一些工具可以帮助到您。当您的程序需要执行一些只有操作系统内核才能完成的操作时,它需要使用 系统调用。有一些命令可以帮助您追踪您的程序执行的系统调用。在 Linux 中可以使用strace ,在 macOS 和 BSD 中可以使用 dtrace。dtrace 用起来可能有些别扭,因为它使用的是它自有的 D 语言,但是我们可以使用一个叫做 dtruss 的封装使其具有和 strace (更多信息参考 dtrace-even-better-than-strace-for-osx)类似的接口

下面的例子展现来如何使用 stracedtruss 来显示ls 执行时,对stat 系统调用进行追踪对结果。若需要深入了解 strace,strace-the-sysadmins-microscope 值得一读。

# On Linux
sudo strace -e lstat ls -l > /dev/null

# On macOS
sudo dtruss -t lstat64_extended ls -l > /dev/null

有些情况下,我们需要查看网络数据包才能定位问题。像 tcpdump 和 Wireshark 这样的网络数据包分析工具可以帮助您获取网络数据包的内容并基于不同的条件进行过滤。

对于 web 开发, Chrome/Firefox 的开发者工具非常方便,功能也很强大:

  • 源码 -查看任意站点的 HTML/CSS/JS 源码;
  • 实时地修改 HTML, CSS, JS 代码 - 修改网站的内容、样式和行为用于测试(从这一点您也能看出来,网页截图是不可靠的);
  • Javascript shell - 在 JS REPL中执行命令;
  • 网络 - 分析请求的时间线;
  • 存储 - 查看 Cookies 和本地应用存储。

静态分析

有些问题是您不需要执行代码就能发现的。例如,仔细观察一段代码,您就能发现某个循环变量覆盖了某个已经存在的变量或函数名;或是有个变量在被读取之前并没有被定义。 这种情况下 静态分析 工具就可以帮我们找到问题。静态分析会将程序的源码作为输入然后基于编码规则对其进行分析并对代码的正确性进行推理。

下面这段 Python 代码中存在几个问题。 首先,我们的循环变量foo 覆盖了之前定义的函数foo。最后一行,我们还把 bar 错写成了baz,因此当程序完成sleep (一分钟)后,执行到这一行的时候便会崩溃。

import time

def foo():
    return 42

for foo in range(5):
    print(foo)
bar = 1
bar *= 0.2
time.sleep(60)
print(baz)

静态分析工具可以发现此类的问题。当我们使用pyflakes 分析代码的时候,我们会得到与这两处 bug 相关的错误信息。mypy 则是另外一个工具,它可以对代码进行类型检查。这里,mypy 会发现bar 起初是一个 int ,然后变成了 float。这些问题都可以在不运行代码的情况下被发现。

$ pyflakes foobar.py
foobar.py:6: redefinition of unused 'foo' from line 3
foobar.py:11: undefined name 'baz'

$ mypy foobar.py
foobar.py:6: error: Incompatible types in assignment (expression has type "int", variable has type "Callable[[], Any]")
foobar.py:9: error: Incompatible types in assignment (expression has type "float", variable has type "int")
foobar.py:11: error: Name 'baz' is not defined
Found 3 errors in 1 file (checked 1 source file)

在 shell 工具那一节课的时候,我们介绍了 shellcheck,这是一个类似的工具,但它是应用于 shell 脚本的。

大多数的编辑器和 IDE 都支持在编辑界面显示这些工具的分析结果、高亮有警告和错误的位置。 这个过程通常称为 code linting 。风格检查或安全检查的结果同样也可以进行相应的显示。

在 vim 中,有 ale 或 syntastic 可以帮助您做同样的事情。 在 Python 中, pylint 和 pep8 是两种用于进行风格检查的工具,而 bandit 工具则用于检查安全相关的问题。

对于其他语言的开发者来说,静态分析工具可以参考这个列表:Awesome Static Analysis (您也许会对 Writing 一节感兴趣) 。对于 linters 则可以参考这个列表: Awesome Linters。

对于风格检查和代码格式化,还有以下一些工具可以作为补充:用于 Python 的 black、用于 Go 语言的 gofmt、用于 Rust 的 rustfmt 或是用于 JavaScript, HTML 和 CSS 的 prettier 。这些工具可以自动格式化您的代码,这样代码风格就可以与常见的风格保持一致。 尽管您可能并不想对代码进行风格控制,标准的代码风格有助于方便别人阅读您的代码,也可以方便您阅读它的代码。

性能分析

即使您的代码能够像您期望的一样运行,但是如果它消耗了您全部的 CPU 和内存,那么它显然也不是个好程序。算法课上我们通常会介绍大O标记法,但却没教给我们如何找到程序中的热点。 鉴于过早的优化是万恶之源(参考PrematureOptimization),您需要学习性能分析和监控工具,它们会帮助您找到程序中最耗时、最耗资源的部分,这样您就可以有针对性的进行性能优化。

计时

和调试代码类似,大多数情况下我们只需要打印两处代码之间的时间即可发现问题。下面这个例子中,我们使用了 Python 的 time模块。

import time, random
n = random.randint(1, 10) * 100

# 获取当前时间 
start = time.time()

# 执行一些操作
print("Sleeping for {} ms".format(n))
time.sleep(n/1000)

# 比较当前时间和起始时间
print(time.time() - start)

# Output
# Sleeping for 500 ms
# 0.5713930130004883

不过,执行时间(wall clock time)也可能会误导您,因为您的电脑可能也在同时运行其他进程,也可能在此期间发生了等待。 对于工具来说,需要区分真实时间、用户时间和系统时间。通常来说,用户时间+系统时间代表了您的进程所消耗的实际 CPU (更详细的解释可以参照what-do-real-user-and-sys-mean-in-the-output-of-time)。

  • 真实时间 - 从程序开始到结束流失掉的真实时间,包括其他进程的执行时间以及阻塞消耗的时间(例如等待 I/O或网络);
  • User - CPU 执行用户代码所花费的时间;
  • Sys - CPU 执行系统内核代码所花费的时间。

例如,试着执行一个用于发起 HTTP 请求的命令并在其前面添加 time 前缀。网络不好的情况下您可能会看到下面的输出结果。请求花费了 2s 才完成,但是进程仅花费了 15ms 的 CPU 用户时间和 12ms 的 CPU 内核时间。

$ time curl https://missing.csail.mit.edu &> /dev/null
real    0m2.561s
user    0m0.015s
sys     0m0.012s

CPU

大多数情况下,当人们提及性能分析工具的时候,通常指的是 CPU 性能分析工具。 CPU 性能分析工具有两种: 追踪分析器(tracing)及采样分析器(sampling)。 追踪分析器 会记录程序的每一次函数调用,而采样分析器则只会周期性的监测(通常为每毫秒)您的程序并记录程序堆栈。它们使用这些记录来生成统计信息,显示程序在哪些事情上花费了最多的时间。如果您希望了解更多相关信息,可以参考how-do-ruby—python-profilers-work。

大多数的编程语言都有一些基于命令行的分析器,我们可以使用它们来分析代码。它们通常可以集成在 IDE 中,但是本节课我们会专注于这些命令行工具本身。

在 Python 中,我们使用 cProfile 模块来分析每次函数调用所消耗的时间。 在下面的例子中,我们实现了一个基础的 grep 命令:

#!/usr/bin/env python3

import sys, re

def grep(pattern, file):
    with open(file, 'r') as f:
        print(file)
        for i, line in enumerate(f.readlines()):
            pattern = re.compile(pattern)
            match = pattern.search(line)
            if match is not None:
                print("{}: {}".format(i, line), end="")

if __name__ == '__main__':
    times = int(sys.argv[1])
    pattern = sys.argv[2]
    for i in range(times):
        for file in sys.argv[3:]:
            grep(pattern, file)

我们可以使用下面的命令来对这段代码进行分析。通过它的输出我们可以知道,IO 消耗了大量的时间,编译正则表达式也比较耗费时间。因为正则表达式只需要编译一次,我们可以将其移动到 for 循环外面来改进性能。

$ python -m cProfile -s tottime grep.py 1000 '^(import|\s*def)[^,]*$' *.py

[omitted program output]

 ncalls  tottime  percall  cumtime  percall filename:lineno(function)
   8000    0.266    0.000    0.292    0.000 {built-in method io.open}
   8000    0.153    0.000    0.894    0.000 grep.py:5(grep)
  17000    0.101    0.000    0.101    0.000 {built-in method builtins.print}
   8000    0.100    0.000    0.129    0.000 {method 'readlines' of '_io._IOBase' objects}
  93000    0.097    0.000    0.111    0.000 re.py:286(_compile)
  93000    0.069    0.000    0.069    0.000 {method 'search' of '_sre.SRE_Pattern' objects}
  93000    0.030    0.000    0.141    0.000 re.py:231(compile)
  17000    0.019    0.000    0.029    0.000 codecs.py:318(decode)
      1    0.017    0.017    0.911    0.911 grep.py:3(<module>)

[omitted lines]
  • -s tottime: 按照tottime进行排序
  • grep.py 1000 'regex': 执行1000次grep.py,后面的正则表达式是grep.py的匹配参数

关于 Python 的 cProfile 分析器(以及其他一些类似的分析器),需要注意的是它显示的是每次函数调用的时间。看上去可能快到反直觉,尤其是如果您在代码里面使用了第三方的函数库,因为内部函数调用也会被看作函数调用。

更加符合直觉的显示分析信息的方式是包括每行代码的执行时间,这也是 行分析器 的工作。例如,下面这段 Python 代码会向本课程的网站发起一个请求,然后解析响应返回的页面中的全部 URL:

#!/usr/bin/env python
import requests
from bs4 import BeautifulSoup

# 这个装饰器会告诉行分析器 
# 我们想要分析这个函数
@profile
def get_urls():
    response = requests.get('https://missing.csail.mit.edu')
    s = BeautifulSoup(response.content, 'lxml')
    urls = []
    for url in s.find_all('a'):
        urls.append(url['href'])

if __name__ == '__main__':
    get_urls()
~/debug $ pip install lxml  # urls.py中使用到的分析程序
~/debug $ vim urls.py   # 需要将urls.py中的行"@profile"注释掉
~/debug $ python3 -m cProfile -s tottime urls.py | tac | wc -l
1859
# tac刚好与cat相反,会把读取的内容反向输出
# 总的行数达到1859!!

如果我们使用 Python 的 cProfile 分析器,我们会得到超过1000行的输出结果,即使对其进行排序,我仍然搞不懂时间到底都花在哪了。如果我们使用 line_profiler,它会基于行来显示时间:

$ pip install line_profiler
# 可能会提示:将line_profiler的安装路径添加到$PATH
$ echo PATH="$HOME/.local/bin:$PATH" | sudo tee -a /etc/environment
$ sudo vim /etc/environment
# 可能会看到两行路径,其中第二行为添加了新路径的$PATH,将第一行的路径删除
$ source /etc/environment
$ echo $PATH

$ vim urls.py
# 重新将"@profile"行解除注释
$ kernprof -l -v urls.py
Wrote profile results to urls.py.lprof
Timer unit: 1e-06 s

Total time: 0.636188 s
File: urls.py
Function: get_urls at line 5

Line #  Hits         Time  Per Hit   % Time  Line Contents
==============================================================
 5                                           @profile
 6                                           def get_urls():
 7         1     613909.0 613909.0     96.5      response = requests.get('https://missing.csail.mit.edu')
 8         1      21559.0  21559.0      3.4      s = BeautifulSoup(response.content, 'lxml')
 9         1          2.0      2.0      0.0      urls = []
10        25        685.0     27.4      0.1      for url in s.find_all('a'):
11        24         33.0      1.4      0.0          urls.append(url['href'])

内存

像 C 或者 C++ 这样的语言,内存泄漏会导致您的程序在使用完内存后不去释放它。为了应对内存类的 Bug,我们可以使用类似 Valgrind 这样的工具来检查内存泄漏问题。

对于 Python 这类具有垃圾回收机制的语言,内存分析器也是很有用的,因为对于某个对象来说,只要有指针还指向它,那它就不会被回收。

下面这个例子及其输出,展示了 memory-profiler 是如何工作的(注意装饰器和 line-profiler 类似)。

@profile
def my_func():
    a = [1] * (10 ** 6)
    b = [2] * (2 * 10 ** 7)
    del b
    return a

if __name__ == '__main__':
    my_func()


$ python -m memory_profiler example.py
Line #    Mem usage  Increment   Line Contents
==============================================
     3                           @profile
     4      5.97 MB    0.00 MB   def my_func():
     5     13.61 MB    7.64 MB       a = [1] * (10 ** 6)
     6    166.20 MB  152.59 MB       b = [2] * (2 * 10 ** 7)
     7     13.61 MB -152.59 MB       del b
     8     13.61 MB    0.00 MB       return a

事件分析

在我们使用strace调试代码的时候,您可能会希望忽略一些特殊的代码并希望在分析时将其当作黑盒处理。perf 命令将 CPU 的区别进行了抽象,它不会报告时间和内存的消耗,而是报告与您的程序相关的系统事件。

例如,perf 可以报告不佳的缓存局部性(poor cache locality)、大量的页错误(page faults)或活锁(livelocks)。下面是关于常见命令的简介:

  • perf list - 列出可以被 pref 追踪的事件;
  • perf stat COMMAND ARG1 ARG2 - 收集与某个进程或指令相关的事件;
  • perf record COMMAND ARG1 ARG2 - 记录命令执行的采样信息并将统计数据储存在perf.data中;
  • perf report - 格式化并打印 perf.data 中的数据。

perf内置在linux-tools包中:

~ $ sudo apt install linux-tools-common linux-tools-generic linux-tools-`uname -r`

可视化

使用分析器来分析真实的程序时,由于软件的复杂性,其输出结果中将包含大量的信息。人类是一种视觉动物,非常不善于阅读大量的文字。因此很多工具都提供了可视化分析器输出结果的功能。

对于采样分析器来说,常见的显示 CPU 分析数据的形式是 火焰图,火焰图会在 Y 轴显示函数调用关系,并在 X 轴显示其耗时的比例。火焰图同时还是可交互的,您可以深入程序的某一具体部分,并查看其栈追踪(您可以尝试点击下面的图片)。

FlameGraph

调用图和控制流图可以显示子程序之间的关系,它将函数作为节点并把函数调用作为边。将它们和分析器的信息(例如调用次数、耗时等)放在一起使用时,调用图会变得非常有用,它可以帮助我们分析程序的流程。 在 Python 中您可以使用 pycallgraph 来生成这些图片。

The missing semester of your CS education--调试及性能分析_第2张图片

资源监控

有时候,分析程序性能的第一步是搞清楚它所消耗的资源。程序变慢通常是因为它所需要的资源不够了。例如,没有足够的内存或者网络连接变慢的时候。

有很多很多的工具可以被用来显示不同的系统资源,例如 CPU 占用、内存使用、网络、磁盘使用等。

  • 通用监控 - 最流行的工具要数 htop,了,它是 top的改进版。htop 可以显示当前运行进程的多种统计信息。htop 有很多选项和快捷键,常见的有: 进程排序、 t 显示树状结构和 h 打开或折叠线程。 还可以留意一下 glances ,它的实现类似但是用户界面更好。如果需要合并测量全部的进程, dstat 是也是一个非常好用的工具,它可以实时地计算不同子系统资源的度量数据,例如 I/O、网络、 CPU 利用率、上下文切换等等;
  • I/O 操作 - iotop 可以显示实时 I/O 占用信息而且可以非常方便地检查某个进程是否正在执行大量的磁盘读写操作;
  • 磁盘使用 - df 可以显示每个分区的信息,而 du 则可以显示当前目录下每个文件的磁盘使用情况( disk usage)。-h 选项可以使命令以对人类(human)更加友好的格式显示数据;ncdu是一个交互性更好的 du ,它可以让您在不同目录下导航、删除文件和文件夹;
  • 内存使用 - free 可以显示系统当前空闲的内存。内存也可以使用 htop 这样的工具来显示;
  • 打开文件 - lsof 可以列出被进程打开的文件信息。 当我们需要查看某个文件是被哪个进程打开的时候,这个命令非常有用;
  • 网络连接和配置 - ss 能帮助我们监控网络包的收发情况以及网络接口的显示信息。ss 常见的一个使用场景是找到端口被进程占用的信息。如果要显示路由、网络设备和接口信息,您可以使用 ip 命令。注意,netstatifconfig 这两个命令已经被前面那些工具所代替了。
  • 网络使用 - nethogs 和 iftop 是非常好的用于对网络占用进行监控的交互式命令行工具。

如果您希望测试一下这些工具,您可以使用 stress 命令来为系统人为地增加负载。

专用工具

有时候,您只需要对黑盒程序进行基准测试,并依此对软件选择进行评估。 类似 hyperfine 这样的命令行可以帮您快速进行基准测试。例如,我们在 shell 工具和脚本那一节课中我们推荐使用 fd 来代替 find。我们这里可以用hyperfine来比较一下它们。

例如,下面的例子中,我们可以看到fdfind 要快20倍。

$ hyperfine --warmup 3 'fd -e jpg' 'find . -iname "*.jpg"'
Benchmark #1: fd -e jpg
  Time (mean ± σ):      51.4 ms ±   2.9 ms    [User: 121.0 ms, System: 160.5 ms]
  Range (min … max):    44.2 ms …  60.1 ms    56 runs

Benchmark #2: find . -iname "*.jpg"
  Time (mean ± σ):      1.126 s ±  0.101 s    [User: 141.1 ms, System: 956.1 ms]
  Range (min … max):    0.975 s …  1.287 s    10 runs

Summary
  'fd -e jpg' ran
   21.89 ± 2.33 times faster than 'find . -iname "*.jpg"'

和 debug 一样,浏览器也包含了很多不错的性能分析工具,可以用来分析页面加载,让我们可以搞清楚时间都消耗在什么地方(加载、渲染、脚本等等)。 更多关于 Firefox 和 Chrome的信息可以点击链接。

课后练习

调试


  1. 使用 Linux 上的 journalctl 或 macOS 上的 log show 命令来获取最近一天中超级用户的登录信息及其所执行的指令。如果找不到相关信息,您可以执行一些无害的命令,例如sudo ls 然后再次查看。

  2. 学习 pdb-tutorial 实践教程并熟悉相关的命令。更深入的信息您可以参考python-debugging-pdb教程。

  3. 安装 shellcheck 并尝试对下面的脚本进行检查。这段代码有什么问题吗?请修复相关问题。在您的编辑器中安装一个linter插件,这样它就可以自动地显示相关警告信息。

    #!/bin/sh
    ## Example: a typical script with several problems
    for f in $(ls *.m3u)
    do
      grep -qi hq.*mp3 $f \
        && echo -e 'Playlist $f contains a HQ file in mp3 format'
    done
    
  4. (进阶题) 请阅读 可逆调试 并尝试创建一个可以工作的例子(使用 rr 或 RevPDB)。

性能分析


  1. sorts.py 有一些排序算法的实现。请使用 cProfile 和 line_profiler 来比较插入排序和快速排序的性能。两种算法的瓶颈分别在哪里?然后使用 memory_profiler 来检查内存消耗,为什么插入排序更好一些?然后再看看原地排序版本的快排。附加题:使用 perf 来查看不同算法的循环次数及缓存命中及丢失情况。

  2. 这里有一些用于计算斐波那契数列 Python 代码,它为计算每个数字都定义了一个函数:

    #!/usr/bin/env python
    def fib0(): return 0
    
    def fib1(): return 1
    
    s = """def fib{}(): return fib{}() + fib{}()"""
    
    if __name__ == '__main__':
    
        for n in range(2, 10):
            exec(s.format(n, n-1, n-2))
        # from functools import lru_cache
        # for n in range(10):
        #     exec("fib{} = lru_cache(1)(fib{})".format(n, n))
        print(eval("fib9()"))
    

将代码拷贝到文件中使其变为一个可执行的程序。首先安装 pycallgraph和graphviz(如果您能够执行dot, 则说明已经安装了 GraphViz.)。并使用 pycallgraph graphviz -- ./fib.py 来执行代码并查看pycallgraph.png 这个文件。fib0 被调用了多少次?我们可以通过记忆法来对其进行优化。将注释掉的部分放开,然后重新生成图片。这回每个fibN 函数被调用了多少次?

  1. 我们经常会遇到的情况是某个我们希望去监听的端口已经被其他进程占用了。让我们通过进程的PID查找相应的进程。首先执行 python -m http.server 4444 启动一个最简单的 web 服务器来监听 4444 端口。在另外一个终端中,执行 lsof | grep LISTEN 打印出所有监听端口的进程及相应的端口。找到对应的 PID 然后使用 kill 停止该进程。

  2. 限制进程资源也是一个非常有用的技术。执行 stress -c 3 并使用htop 对 CPU 消耗进行可视化。现在,执行taskset --cpu-list 0,2 stress -c 3 并可视化。stress 占用了3个 CPU 吗?为什么没有?阅读man taskset来寻找答案。附加题:使用 cgroups来实现相同的操作,限制stress -m的内存使用。

  3. (进阶题) curl ipinfo.io 命令或执行 HTTP 请求并获取关于您 IP 的信息。打开 Wireshark 并抓取 curl 发起的请求和收到的回复报文。(提示:可以使用http进行过滤,只显示 HTTP 报文)

习题解答

调试


  1. 使用 Linux 上的 journalctl 或 macOS 上的 log show 命令来获取最近一天中超级用户的登录信息及其所执行的指令。如果找不到相关信息,您可以执行一些无害的命令,例如sudo ls 然后再次查看。

    ~ $ sudo ls 
    ~ $ journalctl --since "1d age" | grep sudo
     # 这里仅展示执行sudo ls指令后的相关日志
        [ommitted outputs]
    5月 06 20:32:25 laihj sudo[40419]:    laihj : TTY=pts/7 ; PWD=/home/laihj ; USER=root ; COMMAND=/usr/bin/ls
    5月 06 20:32:25 laihj sudo[40419]: pam_unix(sudo:session): session opened for user root(uid=0) by laihj(uid=1000)
    5月 06 20:32:25 laihj sudo[40419]: pam_unix(sudo:session): session closed for user root       
    
  2. 学习 pdb-tutorial 实践教程并熟悉相关的命令。更深入的信息您可以参考python-debugging-pdb教程。

  3. 安装 shellcheck 并尝试对下面的脚本进行检查。这段代码有什么问题吗?请修复相关问题。在您的编辑器中安装一个linter插件,这样它就可以自动地显示相关警告信息。

    #!/bin/sh
    ## Example: a typical script with several problems
    for f in $(ls *.m3u)
    do
      grep -qi hq.*mp3 $f \
        && echo -e 'Playlist $f contains a HQ file in mp3 format'
    done
    
    ~/debug $ sudo apt install shellcheck
    ~/debug $ shellcheck m3u.sh
    
    In m3u.sh line 3:
    for f in $(ls *.m3u)
            ^---------^ SC2045 (error): Iterating over ls output is fragile. Use globs.
                ^-- SC2035 (info): Use ./*glob* or -- *glob* so names with dashes won't become options.
    
    In m3u.sh line 5:
        grep -qi hq.*mp3 $f \
                ^-----^ SC2062 (warning): Quote the grep pattern so the shell won't interpret it.
                        ^-- SC2086 (info): Double quote to prevent globbing and word splitting.
    
    Did you mean:
        grep -qi hq.*mp3 "$f" \
    
    In m3u.sh line 6:
        && echo -e 'Playlist $f contains a HQ file in mp3 format'
                ^-- SC3037 (warning): In POSIX sh, echo flags are undefined.
                ^-- SC2016 (info): Expressions don't expand in single quotes, use double quotes for that.
    
    For more information:
    https://www.shellcheck.net/wiki/SC2045 -- Iterating over ls output is fragi...
    https://www.shellcheck.net/wiki/SC2062 -- Quote the grep pattern so the she...
    https://www.shellcheck.net/wiki/SC3037 -- In POSIX sh, echo flags are undef...
    
  • 在 Vim 中安装neomake插件来集成 shellcheck:

    ~/debug $ vim ~/.vimrc
     # 输入:
     # call plug#begin()
     # Plug 'neomake/neomake'
     # call plug#end()
     # 回到正常模式,按下" :w"保存
     # 然后,继续键入":PlugInstall"
     # 此时,会分割出一个安装插件的面板,安装完成后,退出
    ~/debug $ vim m3u.sh
     # 按下":Neomake"   
     # 问题行之前会有提示标识
    

The missing semester of your CS education--调试及性能分析_第3张图片

  • 根据shellcheck给出的信息,到其网站上查询各个语法问题,然后修改,最终效果如下:
    The missing semester of your CS education--调试及性能分析_第4张图片
  1. (进阶题) 请阅读 可逆调试 并尝试创建一个可以工作的例子(使用 rr 或 RevPDB)。

    此例主要参考了debug-c-and-c+±programs-with-rr

    # perf内置在linux-tools中,使用rr需要先安装perf
    ~/debug $ sudo apt install linux-tools-common linux-tools-generic linux-tools-`uname -r`
    ~/debug $ sudo apt install rr
    ~/debug $ echo 1 | sudo tee /proc/sys/kernel/perf_event_paranoid
    ~/debug $ gcc -g demo.c -o demo
    ~/debug $ ./demo
    f(0)=0
    f(1)=0
    f(2)=0
    f(3)=0
    # 预期输出结果为[0, 2, 4, 6]
    
    # -n选项:输出./demo的运行结果
    ~/debug $ sudo rr record -n ./demo
    rr: Saving execution to trace directory `/root/.local/share/rr/deom-1'.
    f(0)=0
    f(1)=0
    f(2)=0
    f(3)=0
    # 进入rr-debugger中调试代码
    ~/debug $ sudo rr replay
    
  • 在demo.c中,print_array只是打印出stru.a数组的内容,所以出错的地方应该在打印之前,即可能是multiply函数的调用,在此设置断点。
    (rr) b multiply # break简写为b
    Breakpoint 1 at 0x5568214c818c: file demorr.c, line 16.
    (rr) c  # continue简写为c
    Continuing.
    
    Breakpoint 1, multiply (a=0x5568214cb018 <stru> "", size=4, num=0) at demorr.c:16
    16              for (i=0; i<size; i++)
    
  • 注意到multiply中传入num的值为 0,正常应该是stru.num的初始值 2,使用watch来查看stru.num的值什么时候被改变的。
    (rr) watch -l stru.num
    Hardware watchpoint 2: -location stru.num
    # reverse-continue简写为rc,回退到watchpoint的值被更改的地方
    (rr) rc
    Continuing.
    
    Hardware watchpoint 2: -location stru.num
    # stru.num的值从 2 变为了 0(注意现在是使用rc反向调试)
    # stru.num的值的变更发生在initialize函数的调用中
    Old value = 0
    New value = 2
    initialize (a=0x5568214cb018 <stru> "", size=3) at demorr.c:10
    10                      a[size--] = 0;
    (rr) q
    
  • 观察initialize函数中的语句,发现size--先返回size值,再执行size=size-1的操作。而且,由于结构体中变量的内存空间是连续的,所以执行a[SIZE]=0时,把stru.num的值置为了 0。为此,将a[size--]=0修改为a[--size]=0,再观察输出结果。
    ~/debug $ vim demo.c
    ~/debug $ gcc -g demo.c -o demo
    ~/debug $ ./demo
    f(0)=0
    f(1)=2
    f(2)=4
    f(3)=6
    

性能分析


  1. sorts.py 有一些排序算法的实现。请使用 cProfile 和 line_profiler 来比较插入排序和快速排序的性能。两种算法的瓶颈分别在哪里?然后使用 memory_profiler 来检查内存消耗,为什么插入排序更好一些?然后再看看原地排序版本的快排。附加题:使用 perf 来查看不同算法的循环次数及缓存命中及丢失情况。
  • 使用cProfile比较算法性能

    ~/debug $ python3 -m cProfile -s tottime sorts.py | tac
    # tac反向输出,这里主要是为了显示表头,便于对比查看下一行命令的结果
    # 此处省略了该命令的其他输出结果
        77105    0.585    0.000    1.528    0.000 random.py:292(randrange)
    ncalls  tottime  percall  cumtime  percall filename:lineno(function)
    
    ~/debug $ python3 -m cProfile -s tottime sorts.py | grep sorts.py
    
    34696/1000    0.226    0.000    0.344    0.000 sorts.py:21(quicksort)
    33692/1000    0.214    0.000    0.271    0.000 sorts.py:30(quicksort_inplace)
        3000    0.124    0.000    1.842    0.001 sorts.py:6(<listcomp>)
        1000    0.052    0.000    0.054    0.000 sorts.py:10(insertionsort)
        16848    0.034    0.000    0.034    0.000 sorts.py:25(<listcomp>)
        16848    0.033    0.000    0.033    0.000 sorts.py:26(<listcomp>)
            3    0.021    0.007    2.610    0.870 sorts.py:4(test_sorted)
            1    0.000    0.000    2.614    2.614 sorts.py:1(<module>)
    
  • 使用line_profiler比较算法性能

    ~/debug $ vim sorts.py
    # 在"def insertionsort"和"def quicksort"行的上一行都插入装饰器:@profile
    
    ~/debug $ pip install line_profiler
    ~/debug $ kernprof -l -v sorts.py
    Wrote profile results to sorts.py.lprof
    Timer unit: 1e-06 s
    
    Total time: 1.4194 s
    File: sorts.py
    Function: insertionsort at line 10
    
    Line #      Hits         Time  Per Hit   % Time  Line Contents
    ==============================================================
        10                                           @profile
        11                                           def insertionsort(array):
        12
        13     24982      46841.7      1.9      3.3      for i in range(len(array)):
        14     24982      48489.9      1.9      3.4          j = i-1
        15     24982      47911.5      1.9      3.4          v = array[i]
        16    204731     425803.8      2.1     30.0          while j >= 0 and v < array[j]:
        17    204731     412800.8      2.0     29.1              array[j+1] = array[j]
        18    204731     385122.8      1.9     27.1              j -= 1
        19     24982      50528.9      2.0      3.6          array[j+1] = v
        20      1000       1905.3      1.9      0.1      return array
    
    Total time: 0.391964 s
    File: sorts.py
    Function: quicksort at line 22
    
    Line #      Hits         Time  Per Hit   % Time  Line Contents
    ==============================================================
        22                                           @profile
        23                                           def quicksort(array):
        24     17386      35795.4      2.1      9.1      if len(array) <= 1:
        25     17386      32118.3      1.8      8.2          return array
        26     16386      31515.4      1.9      8.0      pivot = array[0]
        27     16386     107962.3      6.6     27.5      left = [i for i in array[1:] if i < pivot]
        28     16386     108175.9      6.6     27.6      right = [i for i in array[1:] if i >= pivot]
        29     16386      76396.3      4.7     19.5      return quicksort(left) + [pivot] + quicksort(right)
    
    
    • 插入排序的耗时更高一些。快速排序的瓶颈在于 leftright的赋值,而插入排序的瓶颈在while循环。
  • 使用memory_profiler逐个检查算法的内存消耗

    ~/debug $ vim sorts.py
    # 仅在"def insertionsort"行上一行插入装饰器:@profile
    ~/debug $ python3 -m memory_profiler sorts.py
    Filename: sorts.py
    
    Line #    Mem usage    Increment  Occurrences   Line Contents
    =============================================================
        10   43.301 MiB   43.301 MiB        1000   @profile
        11                                         def insertionsort(array):
        12
        13   43.301 MiB    0.000 MiB       25733       for i in range(len(array)):
        14   43.301 MiB    0.000 MiB       24733           j = i-1
        15   43.301 MiB    0.000 MiB       24733           v = array[i]
        16   43.301 MiB    0.000 MiB      223646           while j >= 0 and v < array[j]:
        17   43.301 MiB    0.000 MiB      198913               array[j+1] = array[j]
        18   43.301 MiB    0.000 MiB      198913               j -= 1
        19   43.301 MiB    0.000 MiB       24733           array[j+1] = v
        20   43.301 MiB    0.000 MiB        1000       return array
    
    ~/debug $ vim sorts.py
    # 仅在"def quicksort"行上一行插入装饰器:@profile
    ~/debug $ python3 -m memory_profiler sorts.py
    Filename: sorts.py
    
    Line #    Mem usage    Increment  Occurrences   Line Contents
    =============================================================
        22   43.195 MiB   43.195 MiB       34330   @profile
        23                                         def quicksort(array):
        24   43.195 MiB    0.000 MiB       34330       if len(array) <= 1:
        25   43.195 MiB    0.000 MiB       17665           return array
        26   43.195 MiB    0.000 MiB       16665       pivot = array[0]
        27   43.195 MiB    0.000 MiB      159966       left = [i for i in array[1:] if i < pivot]
        28   43.195 MiB    0.000 MiB      159966       right = [i for i in array[1:] if i >= pivot]
        29   43.195 MiB    0.000 MiB       16665       return quicksort(left) + [pivot] + quicksort(right)
    
    ~/debug $ vim sorts.py
    # 仅在"def quicksort_inplace"行上一行插入装饰器:@profile
    ~/debug $ python3 -m memory_profiler sorts.py
    Filename: sorts.py
    
    Line #    Mem usage    Increment  Occurrences   Line Contents
    =============================================================
        31   43.227 MiB   43.227 MiB       33806   @profile
        32                                         def quicksort_inplace(array, low=0, high=None):
        33   43.227 MiB    0.000 MiB       33806       if len(array) <= 1:
        34   43.227 MiB    0.000 MiB          46           return array
        35   43.227 MiB    0.000 MiB       33760       if high is None:
        36   43.227 MiB    0.000 MiB         954           high = len(array)-1
        37   43.227 MiB    0.000 MiB       33760       if low >= high:
        38   43.227 MiB    0.000 MiB       17357           return array
        39
        40   43.227 MiB    0.000 MiB       16403       pivot = array[high]
        41   43.227 MiB    0.000 MiB       16403       j = low-1
        42   43.227 MiB    0.000 MiB      123582       for i in range(low, high):
        43   43.227 MiB    0.000 MiB      107179           if array[i] <= pivot:
        44   43.227 MiB    0.000 MiB       55913               j += 1
        45   43.227 MiB    0.000 MiB       55913               array[i], array[j] = array[j], array[i]
        46   43.227 MiB    0.000 MiB       16403       array[high], array[j+1] = array[j+1], array[high]
        47   43.227 MiB    0.000 MiB       16403       quicksort_inplace(array, low, j)
        48   43.227 MiB    0.000 MiB       16403       quicksort_inplace(array, j+2, high)
        49   43.227 MiB    0.000 MiB       16403       return array
    
    • 遗憾的是,按照上面的方法使用memory_profiler给出的结果无法作为这三种排序算法内存消耗对比的依据(光从数据上看,insertionsort的43.301MiB甚至还大于quicksort的43.195MiB,与预期结果相反)
    • 另外,观察三组结果中,函数的每一行的Increment(即执行该行所导致的内存占用的增减变化)均为 0!这是由于test_sorted用于测试的list太小了,长度仅为1~50,导致排序算法中每一行创建的变量内存占用也很小。如果直接使用一个长度为2000的list来测试:l = [random.randint(0,10000) for i in range(0, 2000)],会发现quicksort函数的LeftRight行的Increment数据不为 0(创建的list占用内存较大了)。与此同时,用该list测试insertionsort函数时,发现耗用时间较长。
    • 参考python-profiling-memory-profiling这篇文章,使用一个长度为 10 000的list测试冒泡排序的内存消耗,需要将近30分钟才输出结果。(使用memory_profiler要权衡时间与效率)
  • 使用perf检查每个算法的循环次数、缓存命中和丢失:

    • insertionsort的结果
    
    ~/debug $ vim sorts.py
    # 修改main函数删除for循环,改为:test_sorted(insertionsort)
    ~/debug $ sudo perf stat -e cycles,cache-references,cache-misses python3 sorts.py
    
    Performance counter stats for 'python3 sorts.py':
    
        187,253,954      cycles                                                  
            5,023,695      cache-references                                        
            891,768      cache-misses              #   17.751 % of all cache refs
    
        0.099464106 seconds time elapsed
    
        0.082930000 seconds user
        0.016586000 seconds sys
    
    • quicksort的结果
    ~/debug $ vim sorts.py
    # main函数的内容改为:test_sorted(quicksort)
    ~/debug $ sudo perf stat -e cycles,cache-references,cache-misses python3 sorts.py
    
    Performance counter stats for 'python3 sorts.py':
    
        192,741,421      cycles                                                  
            6,843,630      cache-references                                        
            898,594      cache-misses              #   13.130 % of all cache refs
    
        0.057831555 seconds time elapsed
    
        0.057863000 seconds user
        0.000000000 seconds sys
    
    • quicksort_inplace的结果
    ~/debug $ vim sorts.py
    # main函数改为:test_sorted(quicksort_inplace)
    ~/debug $ sudo perf stat -e cycles,cache-references,cache-misses python3 sorts.py
    
    Performance counter stats for 'python3 sorts.py':
    
        179,221,185      cycles                                                  
            5,700,092      cache-references                                        
            892,157      cache-misses              #   15.652 % of all cache refs
    
        0.097429528 seconds time elapsed
    
        0.089351000 seconds user
        0.008122000 seconds sys
    
  1. 这里有一些用于计算斐波那契数列 Python 代码,它为计算每个数字都定义了一个函数。将代码拷贝到文件中使其变为一个可执行的程序。首先安装 pycallgraph和graphviz(如果您能够执行dot, 则说明已经安装了 GraphViz.)。并使用 pycallgraph graphviz -- ./fib.py 来执行代码并查看pycallgraph.png 这个文件。fib0 被调用了多少次?我们可以通过记忆法来对其进行优化。将注释掉的部分放开,然后重新生成图片。这回每个fibN 函数被调用了多少次?

    ~/debug $ pip install "setuptools<58.0.0"
    # setuptools版本过高会导致安装pycallgraph失败
    ~/debug $ pip install pycallgraph
    ~/debug $ sudo apt install graphviz
    ~/debug $ pycallgraph graphviz -- ./fib.py
    34
    # 34是fib9()的返回值
    # 在~/debug目录中可以查看生成的图片
    # 其中,fib0被调用了21次
    

    The missing semester of your CS education--调试及性能分析_第5张图片

    ~/debug $ vim fib.py
    # 解除注释
    ~/debug $ pycallgraph graphviz -- ./fib.py
    34
    # 每个fibN都只被调用了1次
    

    The missing semester of your CS education--调试及性能分析_第6张图片

  2. 我们经常会遇到的情况是某个我们希望去监听的端口已经被其他进程占用了。让我们通过进程的PID查找相应的进程。首先执行 python -m http.server 4444 启动一个最简单的 web 服务器来监听 4444 端口。在另外一个终端中,执行 lsof | grep LISTEN 打印出所有监听端口的进程及相应的端口。找到对应的 PID 然后使用 kill 停止该进程。

     # 使用tmux,进入面板0
    ~ $ python3 -m http.server 4444
    Serving HTTP on 0.0.0.0 port 4444 (http://0.0.0.0:4444/) ...
    
     # ctrl+B,再按v,进入面板1
    ~ $ lsof | grep LISTEN | grep python
    python3   66102   laihj    3u     IPv4             246160       0t0        TCP *:4444 (LISTEN)
    ~ $ kill 66102
    
     # 此时,面板0中:
    ~/debug $ python3 -m http.server 4444
    Serving HTTP on 0.0.0.0 port 4444 (http://0.0.0.0:4444/) ...
    [1]    66102 terminated  python3 -m http.server 4444
    
  3. 限制进程资源也是一个非常有用的技术。执行 stress -c 3 并使用htop 对 CPU 消耗进行可视化。现在,执行taskset --cpu-list 0,2 stress -c 3 并可视化。stress 占用了3个 CPU 吗?为什么没有?阅读man taskset来寻找答案。附加题:使用 cgroups来实现相同的操作,限制stress -m的内存使用。

  • 通过htop使用详解先熟悉htop的界面。
  • 在正常运行状态下,htop的显示情况:
    ~ $ tmux 
    ~ $ htop
     # 进入htop界面
    
    The missing semester of your CS education--调试及性能分析_第7张图片
  • 为CPU添加载荷3(CPU stress)
     # 按下ctrl+b,v切换到新面板,执行:
    ~ $ stress -c 3
    

The missing semester of your CS education--调试及性能分析_第8张图片

  • 在taskset命令下增加CPU载荷
    ^C  # 先终止刚才的命令
    ~ $ taskset --cpu-list 0,2 stress -c 3
     # taskset的cup-list选项可以限制进程在特定的CPU上运行
     # 可以看到,stress -c 3仍创建了三个进程,但是这次仅消耗了2个CPU
    

The missing semester of your CS education--调试及性能分析_第9张图片

  • 先了解stress -m的用法:
    ~ $ sudo tldr stress
    stress
    A tool to stress test CPU, memory, and IO on a Linux system.More information: https://manned.org/stress.
    
    - Spawn 4 workers to stress test CPU:
    stress -c {{4}}
    
    - Spawn 2 workers to stress test IO and timeout after 5 seconds:
    stress -i {{2}} -t {{5}}
    
    - Spawn 2 workers to stress test memory (each worker allocates 256M bytes):
    stress -m {{2}} --vm-bytes {{256M}}
    
    - Spawn 2 workers spinning on write()/unlink() (each worker writes 1G bytes):
    stress -d {{2}} --hdd-bytes {{1GB}}
    
  • 最近版本的Ubuntu默认激活cgroup v2,可以参考Ubuntu激活cgroupv2。下面,将使用cgroupv2实现限制进程内存消耗的操作,更多信息可参考详解CgroupV2。
    • 设置,使用cgroup2
    ~ $ grep cgroup /proc/filesystems
    nodev   cgroup
    nodev   cgroup2
     # 输出结果有cgroup2,说明当前系统支持cgroup2
    ~ $ sudo vim /etc/default/grub
     # 查找变量GRUB_CMDLINE_LINUX_DEFAULT,将原来的行注释掉
     # 新增一行:GRUB_CMDLINE_LINUX_DEFAULT="systemd.unified_cgroup_hierarchy=1"
    ~ $ sudo update-grub
    ~ $ reboot
    
    • 重启后,检查:
    ~ $ cat /sys/fs/cgroup/cgroup.controllers
    cpuset cpu io memory hugetlb pids rdma misc
     # 这些是cgroup挂载的控制器
    ~ $ sudo su
    root@laihj:~# cd /sys/fs/cgroup
     # 该目录为cgroup的根root,在其下创建的子目录是其节点
    root@laihj:/sys/fs/cgroup# mkdir -p test test/cg
    root@laihj:/sys/fs/cgroup# ls test
    cg                     cgroup.type          memory.numa_stat
    cgroup.controllers      cpu.pressure         memory.oom.group
    cgroup.events           cpu.stat             memory.pressure
    cgroup.freeze           io.pressure          memory.stat
    cgroup.kill             memory.current       memory.swap.current
    cgroup.max.depth        memory.events        memory.swap.events
    cgroup.max.descendants  memory.events.local  memory.swap.high
    cgroup.procs            memory.high          memory.swap.max
    cgroup.stat             memory.low           pids.current
    cgroup.subtree_control  memory.max           pids.events
    cgroup.threads          memory.min           pids.max
    root@laihj:/sys/fs/cgroup# ls test/cg
    cgroup.controllers  cgroup.max.descendants  cgroup.type
    cgroup.events       cgroup.procs            cpu.pressure
    cgroup.freeze       cgroup.stat             cpu.stat
    cgroup.kill         cgroup.subtree_control  io.pressure
    cgroup.max.depth    cgroup.threads          memory.pressure
     # test节点挂载了memory控制器,所以目录下出现了"memory.*"文件
    
    • 为test/cg挂载memory控制器,并设置memory的使用大小
    root@laihj:/sys/fs/cgroup# cd test
    root@laihj:/sys/fs/cgroup/test# cat cgroup.subtree_control
    root@laihj:/sys/fs/cgroup/test# echo "+memory" > cgroup.subtree_control
    root@laihj:/sys/fs/cgroup/test# cat cgroup.subtree_control
    memory
    root@laihj:/sys/fs/cgroup/test# echo 100M > memory.max
    root@laihj:/sys/fs/cgroup/test# echo 0 > memory.swap.max
     # 设置memory的最大使用量为 100M,同时,必须限制内存交换空间的使用
    
    • 将当前的bash session pid写入cg中,接下来在bash中执行的所有命令会受到刚才的memory设置的影响(注意,除了根,进程只能驻留在叶节点(没有子cgroup目录的cgroup目录echo $$ > test/cgroup.procs会报错))
    root@laihj:/sys/fs/cgroup/test# echo $$ > cg/cgroup.procs
    
    root@laihj:/sys/fs/cgroup/test# stress -m 3 --vm-bytes 200M
    stress: info: [5018] dispatching hogs: 0 cpu, 0 io, 3 vm, 0 hdd
    stress: FAIL: [5018] (416) <-- worker 5020 got signal 9
    stress: WARN: [5018] (418) now reaping child worker processes
    stress: FAIL: [5018] (452) failed run completed in 0s
    
    root@laihj:/sys/fs/cgroup/test# stress -m 3 --vm-bytes 40M
     # 3 个 worker 各分配 40M,总的 120M,仍超过了设置的100M上限
    stress: info: [5030] dispatching hogs: 0 cpu, 0 io, 3 vm, 0 hdd
    stress: FAIL: [5030] (416) <-- worker 5032 got signal 9
    stress: WARN: [5030] (418) now reaping child worker processes
    stress: FAIL: [5030] (452) failed run completed in 0s
    
    root@laihj:/sys/fs/cgroup/test# stress -m 3 --vm-bytes 20M
    stress: info: [5034] dispatching hogs: 0 cpu, 0 io, 3 vm, 0 hdd
    ^C
     # 正常执行,按下ctrl+C终止stress命令
    
    • 删除cgroup下的节点,需要从叶节点开始(最内层的目录)
     # 确保test/cg中的进程全部停止,这里需要退出当前bash session,即关闭终端,然后,重新开启
    ~ $ sudo rmdir /sys/fs/cgroup/test/cg
    ~ $ sudo rmdir /sys/fs/cgroup/test
    
  1. (进阶题) curl ipinfo.io 命令或执行 HTTP 请求并获取关于您 IP 的信息。打开 Wireshark 并抓取 curl 发起的请求和收到的回复报文。(提示:可以使用http进行过滤,只显示 HTTP 报文)
    ~ $ sudo apt install wireshark
     # 安装过程中,会弹出页面,让你选择wireshark是否以superuser权限启动,选择Yes
     # 如果不小心选了No,可以执行:
    ~ $ sudo dpkg-reconfigure wireshark-common 
     # 重新勾选Yes
    ~ $ ifconfig
     # 显示网络信息,记下你自己的网络连接的名称,如我的是wlp1s0
    ~ $ sudo wireshark
     # 启动wireshark界面,在“捕获-选项”中,选择你的网络连接,如wlp1s0,然后点击开始,进入捕获录制模式
     # 重新回到终端,打开另一个终端窗口,或使用tmux创建新窗口,执行:
    ~ $ curl www.baidu.com
     # 在wireshark选项栏下面的过滤条中输入http,过滤掉其他的报头信息
    
    • 注意:必须关掉网络代理

The missing semester of your CS education--调试及性能分析_第10张图片

你可能感兴趣的:(计算机前置课程学习笔记,linux,服务器,运维)