精通-Python(五)

124 阅读1小时+

精通 Python(五)

原文:zh.annas-archive.org/md5/37ba6447e713c9bd5373842650e2e5f3

译者:飞龙

协议:CC BY-NC-SA 4.0

第十一章:调试-解决错误

上一章向您展示了如何向代码添加日志记录和测试,但无论您有多少测试,您总会有 bug。最大的问题始终是用户输入,因为不可能测试所有可能的输入,这意味着在某个时候,我们将需要调试代码。

有许多调试技术,而且很可能您已经使用了其中一些。在本章中,我们将专注于打印/跟踪调试和交互式调试。

使用打印语句、堆栈跟踪和日志记录进行调试是最通用的方法之一,很可能是您使用过的第一种调试方法。即使print 'Hello world'也可以被视为这种类型,因为输出将向您显示代码正在正确执行。显然没有必要解释如何以及在何处放置打印语句来调试代码,但使用装饰器和其他 Python 模块有一些很好的技巧,使得这种类型的调试更加有用,比如faulthandler

交互式调试是一种更复杂的调试方法。它允许您在程序运行时调试程序。使用这种方法,甚至可以在应用程序运行时更改变量并在任何所需的地方暂停应用程序。缺点是它需要一些关于调试器命令的知识才能真正有用。

总之,我们将涵盖以下主题:

  • 使用printtraceloggingfaulthandler进行调试

  • 使用pdb进行交互式调试

非交互式调试

最基本的调试形式是在代码中添加简单的打印语句,以查看仍在工作和不在工作的内容。这在各种情况下都很有用,并且可能有助于解决大部分问题。在本章后面,我们将展示一些交互式调试方法,但这些方法并不总是适用。在多线程环境中,交互式调试往往变得困难甚至不可能,而在封闭的远程服务器上,您可能也需要不同的解决方案。这两种方法都有其优点,但我个人 90%的时间都选择非交互式调试,因为简单的打印/日志语句通常足以分析问题的原因。

这是一个基本示例(我已经知道做类似的事情)使用生成器可以如下所示:

>>> def spam_generator():
...     print('a')
...     yield 'spam'
...     print('b')
...     yield 'spam!'
...     print('c')
...     yield 'SPAM!'
...     print('d')

>>> generator = spam_generator()

>>> next(generator)
a
'spam'

>>> next(generator)
b
'spam!'

这清楚地显示了代码的执行情况,因此也清楚地显示了代码未执行的情况。如果没有这个例子,您可能会期望在spam_generator()调用之后立即出现第一个打印,因为它是一个生成器。然而,执行完全停滞,直到我们yield一个项目。假设在第一个yield之前有一些设置代码,它将不会在实际调用next之前运行。

虽然这是使用打印语句调试函数的最简单方法之一,但绝对不是最佳方法。我们可以从制作一个自动打印函数开始,该函数会自动递增字母:

>>> import string

>>> def print_character():
...     i = 0
...     while True:
...         print('Letter: %r' % string.ascii_letters[i])
...         i = (i + 1) % len(string.ascii_letters)
...         yield
>>> # Always initialize
>>> print_character = print_character()

>>> next(print_character)
Letter: 'a'
>>> next(print_character)
Letter: 'b'
>>> next(print_character)
Letter: 'c'

虽然打印语句生成器比裸打印语句稍好一些,但帮助并不是很大。在运行代码时,看到实际执行了哪些行将更有用。我们可以使用inspect.currentframe手动执行此操作,但没有必要进行黑客攻击。Python 为您提供了一些专用工具。

使用跟踪检查脚本

简单的打印语句在许多情况下都很有用,因为您几乎可以在几乎每个应用程序中轻松地加入打印语句。无论是远程还是本地,使用线程还是使用多进程,都没有关系。它几乎可以在任何地方工作,使其成为最通用的解决方案,除了日志记录之外。然而,通用解决方案通常不是最佳解决方案。对于最常见的情况,有更好的解决方案可用。其中之一是trace模块。它为您提供了一种跟踪每次执行、函数之间关系以及其他一些内容的方法。

为了演示,我们将使用我们之前的代码,但不包括打印语句:

def eggs_generator():
    yield 'eggs'
    yield 'EGGS!'

def spam_generator():
    yield 'spam'
    yield 'spam!'
    yield 'SPAM!'

generator = spam_generator()
print(next(generator))
print(next(generator))

generator = eggs_generator()
print(next(generator))

我们将使用 trace 模块执行它:

# python3 -m trace --trace --timing tracing.py
 **--- modulename: tracing, funcname: <module>
0.00 tracing.py(1): def eggs_generator():
0.00 tracing.py(6): def spam_generator():
0.00 tracing.py(11): generator = spam_generator()
0.00 tracing.py(12): print(next(generator))
 **--- modulename: tracing, funcname: spam_generator
0.00 tracing.py(7):     yield 'spam'
spam
0.00 tracing.py(13): print(next(generator))
 **--- modulename: tracing, funcname: spam_generator
0.00 tracing.py(8):     yield 'spam!'
spam!
0.00 tracing.py(15): generator = eggs_generator()
 **--- modulename: tracing, funcname: spam_generator
0.00 tracing.py(16): print(next(generator))
 **--- modulename: tracing, funcname: eggs_generator
0.00 tracing.py(2):     yield 'eggs'
eggs
 **--- modulename: trace, funcname: _unsettrace
0.00 trace.py(77):         sys.settrace(None)

相当不错,不是吗?它准确地显示了正在执行的每一行代码以及函数名称,更重要的是,显示了每一行代码是由哪个语句(或多个语句)引起的。此外,它还显示了它相对于程序开始时间的执行时间。这是由于--timing标志。

你可能期望,这个输出有点太啰嗦了,不能普遍适用。尽管你可以选择使用命令行参数来忽略特定的模块和目录,但在许多情况下仍然太啰嗦了。所以让我们来尝试下一个解决方案——上下文管理器。前面的输出已经揭示了一些trace的内部情况。最后一行显示了一个sys.settrace调用,这正是我们需要的手动跟踪:

import- sys
import trace as trace_module
import contextlib

@contextlib.contextmanager
def trace(count=False, trace=True, timing=True):
    tracer = trace_module.Trace(
        count=count, trace=trace, timing=timing)
    sys.settrace(tracer.globaltrace)
    yield tracer
    sys.settrace(None)

    result = tracer.results()
    result.write_results(show_missing=False, summary=True)

def eggs_generator():
    yield 'eggs'
    yield 'EGGS!'

def spam_generator():
    yield 'spam'
    yield 'spam!'
    yield 'SPAM!'

with trace():
    generator = spam_generator()
    print(next(generator))
    print(next(generator))

generator = eggs_generator()
print(next(generator))

当作为常规 Python 文件执行时,返回:

# python3 tracing.py
 **--- modulename: tracing, funcname: spam_generator
0.00 tracing.py(24):     yield 'spam'
spam
 **--- modulename: tracing, funcname: spam_generator
0.00 tracing.py(25):     yield 'spam!'
spam!
 **--- modulename: contextlib, funcname: __exit__
0.00 contextlib.py(64):         if type is None:
0.00 contextlib.py(65):             try:
0.00 contextlib.py(66):                 next(self.gen)
 **--- modulename: tracing, funcname: trace
0.00 tracing.py(12):     sys.settrace(None)

这段代码立即揭示了跟踪代码的内部操作:它使用sys.settrace告诉 Python 解释器在执行每个语句时将其发送到哪里。鉴于此,将函数编写为装饰器显然是微不足道的,但如果你需要的话,我会把它留给你作为一个练习。

从中还可以得到的另一个收获是,你可以通过包装tracer.globaltrace轻松地向你的跟踪函数添加额外的过滤器。该函数接受以下参数(来自标准 Python 文档):

参数描述
Call调用函数(或进入某些其他代码块)。调用全局跟踪函数;argNone。返回值指定了本地跟踪函数。
Line解释器即将执行新的一行代码或重新执行循环的条件。调用本地跟踪函数;argNone。返回值指定了新的本地跟踪函数。有关其工作原理的详细解释,请参阅Objects/lnotab_notes.txt
return一个函数(或其他代码块)即将返回。调用本地跟踪函数;arg是将要返回的值,或者如果事件是由引发异常引起的,则为None。跟踪函数的返回值被忽略。
exception这意味着发生了异常。调用本地跟踪函数;arg是一个元组(exceptionvaluetraceback)。返回值指定了新的本地跟踪函数。
c_call即将调用一个 C 函数。这可能是一个扩展函数或内置函数。arg是 C 函数对象。
c_return一个 C 函数已经返回,arg是 C 函数对象。
c_exception一个 C 函数引发了异常,arg是 C 函数对象。

正如你所期望的那样,通过一个简单的过滤函数,你可以轻松地确保只返回特定的函数,而不是通常会得到的长列表。你真的不应该低估使用几个导入来跟踪代码生成的数据量。前面的上下文管理器代码产生了 300 多行输出。

使用日志进行调试

在第十章中,测试和日志 - 为错误做准备,我们看到了如何创建自定义记录器,为它们设置级别,并为特定级别添加处理程序。我们将使用logging.DEBUG级别进行日志记录,这本身并不特别,但通过一些装饰器,我们可以添加一些非常有用的仅用于调试的代码。

每当我调试时,我总是发现了解函数的输入和输出非常有用。使用装饰器的基本版本足够简单;只需打印argskwargs,就完成了。以下示例稍微深入一些。通过使用inspect模块,我们还可以检索默认参数,从而可以在所有情况下显示所有参数及其参数名和值,即使未指定参数也可以。

import pprint
import inspect
import logging
import functools

logging.basicConfig(level=logging.DEBUG)

def debug(function):
    @functools.wraps(function)
    def _debug(*args, **kwargs):
        try:
            result = function(*args, **kwargs)
        finally:
            # Extract the signature from the function
            signature = inspect.signature(function)
            # Fill the arguments
            arguments = signature.bind(*args, **kwargs)
            # NOTE: This only works for Python 3.5 and up!
            arguments.apply_defaults()

            logging.debug('%s(%s): %s' % (
                function.__qualname__,
                ', '.join('%s=%r' % (k, v) for k, v in
                          arguments.arguments.items()),
                pprint.pformat(result),
            ))

    return _debug

@debug
def spam(a, b=123):
    return 'some spam'

spam(1)
spam(1, 456)
spam(b=1, a=456)

返回以下输出:

# python3 logged.py
DEBUG:root:spam(a=1, b=123): 'some spam'
DEBUG:root:spam(a=1, b=456): 'some spam'
DEBUG:root:spam(a=456, b=1): 'some spam'

当然非常好,因为我们清楚地知道函数何时被调用,使用了哪些参数,以及返回了什么。但是,这可能只有在您积极调试代码时才会执行。您还可以通过添加特定于调试的记录器使代码中的常规logging.debug语句更加有用,该记录器显示更多信息。只需用前面示例的日志配置替换此示例:

import logging

log_format = (
    '[%(relativeCreated)d %(levelname)s] '
    '%(pathname)s:%(lineno)d:%(funcName)s: %(message)s'
)
logging.basicConfig(level=logging.DEBUG, format=log_format)

那么你的结果会是这样的:

# time python3 logged.py
[0 DEBUG] logged.py:31:_debug: spam(a=1, b=123): 'some spam'
[0 DEBUG] logged.py:31:_debug: spam(a=1, b=456): 'some spam'
[0 DEBUG] logged.py:31:_debug: spam(a=456, b=1): 'some spam'
python3 logged.py  0.04s user 0.01s system 96% cpu 0.048 total

它显示相对于应用程序启动的时间(毫秒)和日志级别。然后是一个标识块,显示产生日志的文件名、行号和函数名。当然,最后还有一条消息。

显示无异常的调用堆栈

在查看代码的运行方式和原因时,通常有必要查看整个堆栈跟踪。当然,简单地引发异常是一个选择。但是,那将终止当前的代码执行,这通常不是我们要寻找的。这就是traceback模块派上用场的地方。只需几行简单的代码,我们就可以得到完整的(或有限的,如果您愿意的话)堆栈列表:

import traceback

class Spam(object):

    def run(self):
        print('Before stack print')
        traceback.print_stack()
        print('After stack print')

class Eggs(Spam):
    pass

if __name__ == '__main__':
    eggs = Eggs()
    eggs.run()

这导致以下结果:

# python3 traceback_test.py
Before stack print
 **File "traceback_test.py", line 18, in <module>
 **eggs.run()
 **File "traceback_test.py", line 8, in run
 **traceback.print_stack()
After stack print

如您所见,回溯只是简单地打印而没有任何异常。traceback模块实际上有很多其他方法,用于基于异常等打印回溯,但您可能不经常需要它们。最有用的可能是limit参数;此参数允许您将堆栈跟踪限制为有用的部分。例如,如果您使用装饰器或辅助函数添加了此代码,则可能不需要在堆栈跟踪中包含它们。这就是limit参数的作用所在:

import traceback

class Spam(object):

    def run(self):
        print('Before stack print')
        traceback.print_stack(limit=-1)
        print('After stack print')

class Eggs(Spam):
    pass

if __name__ == '__main__':
    eggs = Eggs()
    eggs.run()

这导致以下结果:

# python3 traceback_test.py
Before stack print
 **File "traceback_test.py", line 18, in <module>
 **eggs.run()
After stack print

如您所见,print_stack函数本身现在已从堆栈跟踪中隐藏,这使得一切都变得更加清晰。

注意

在 Python 3.5 中添加了负限制支持。在那之前,只支持正限制。

调试 asyncio

asyncio模块有一些特殊规定,使得调试变得更容易一些。鉴于asyncio内部函数的异步特性,这是一个非常受欢迎的功能。在调试多线程/多进程函数或类时可能会很困难——因为并发类可以轻松并行更改环境变量——而使用asyncio,情况可能会更加困难。

注意

在大多数 Linux/Unix/Mac shell 会话中,可以使用它作为前缀设置环境变量:

SOME_ENVIRONMENT_VARIABLE=value python3 script.py

此外,可以使用export为当前 shell 会话进行配置:

export SOME_ENVIRONMENT_VARIABLE=value

可以使用以下行来获取当前值:

echo $SOME_ENVIRONMENT_VARIABLE

在 Windows 上,可以使用set命令为本地 shell 会话配置环境变量:

set SOME_ENVIRONMENT_VARIABLE=value

可以使用以下行来获取当前值:

set SOME_ENVIRONMENT_VARIABLE

使用PYTHONASYNCIODEBUG环境设置启用调试模式时,asyncio模块将检查每个定义的协程是否实际运行:

import asyncio

@asyncio.coroutine
def printer():
    print('This is a coroutine')

printer()

这导致打印器协程出现错误,这里从未产生过:

# PYTHONASYNCIODEBUG=1 python3 asyncio_test.py
<CoroWrapper printer() running, defined at asyncio_test.py:4, created at asyncio_test.py:8> was never yielded from
Coroutine object created at (most recent call last):
 **File "asyncio_test.py", line 8, in <module>
 **printer()

另外,event循环默认会有一些日志消息:

import asyncio
import logging

logging.basicConfig(level=logging.DEBUG)
loop = asyncio.get_event_loop()

这导致以下调试消息:

# PYTHONASYNCIODEBUG=1 python3 asyncio_test.py
DEBUG:asyncio:Using selector: KqueueSelector
DEBUG:asyncio:Close <_UnixSelectorEventLoop running=False closed=False debug=True>

你可能会想为什么我们使用PYTHONASYNCIODEBUG标志而不是loop.set_debug(True)。原因是有些情况下这种方法不起作用,因为调试启用得太晚。例如,当尝试在前面的printer()中使用loop.set_debug(True)时,你会发现单独使用loop.set_debug(True)时不会出现任何错误。

启用调试后,以下内容将发生变化:

  • 未被 yield 的协程(如前面的行所示)将引发异常。

  • 从“错误”的线程调用协程会引发异常。

  • 选择器的执行时间将被记录。

  • 慢回调(超过 100 毫秒)将被记录。可以通过loop.slow_callback_duration修改此超时时间。

  • 当资源未正确关闭时,将引发警告。

  • 在执行之前被销毁的任务将被记录。

使用 faulthandler 处理崩溃

faulthandler模块在调试真正低级的崩溃时很有帮助,也就是说,只有在使用对内存的低级访问时才可能发生的崩溃,比如 C 扩展。

例如,这里有一小段代码,会导致你的 Python 解释器崩溃:

import ctypes

# Get memory address 0, your kernel shouldn't allow this:
ctypes.string_at(0)

它会产生类似以下的结果:

# python faulthandler_test.py
zsh: segmentation fault  python faulthandler_test.py

当然,这是一个相当丑陋的响应,而且没有处理错误的可能性。以防你想知道,使用try/except结构在这些情况下也无济于事。以下代码将以完全相同的方式崩溃:

import ctypes

try:
    # Get memory address 0, your kernel shouldn't allow this:
    ctypes.string_at(0)
except Exception as e:
    print('Got exception:', e)

这就是faulthandler模块的作用。它仍然会导致解释器崩溃,但至少你会看到一个正确的错误消息,所以如果你(或任何子库)与原始内存有任何交互,这是一个很好的默认选择:

import ctypes
import faulthandler

faulthandler.enable()

# Get memory address 0, your kernel shouldn't allow this:
ctypes.string_at(0)

它会产生类似以下的结果:

# python faulthandler_test.py
Fatal Python error: Segmentation fault

Current thread 0x00007fff79171300 (most recent call first):
 **File "ctypes/__init__.py", line 491 in string_at
 **File "faulthandler_test.py", line 7 in <module>
zsh: segmentation fault  python faulthandler_test.py

显然,以这种方式退出 Python 应用程序是不可取的,因为代码不会以正常的清理退出。资源不会被干净地关闭,退出处理程序也不会被调用。如果你以某种方式需要捕获这种行为,最好的办法是将 Python 可执行文件包装在一个单独的脚本中。

交互式调试

现在我们已经讨论了一些基本的调试方法,这些方法总是有效的,我们将看一些更高级的调试技术。之前的调试方法通过修改代码和/或预见使变量和堆栈可见。这一次,我们将看一种稍微更智能的方法,即在需要时以交互方式执行相同的操作。

按需控制台

在测试一些 Python 代码时,你可能已经使用过交互式控制台几次,因为它是测试 Python 代码的一个简单而有效的工具。你可能不知道的是,从你的代码中启动自己的 shell 实际上是很简单的。因此,每当你想从代码的特定点进入常规 shell 时,这是很容易实现的:

import code

def spam():
    eggs = 123
    print('The begin of spam')
    code.interact(banner='', local=locals())
    print('The end of spam')
    print('The value of eggs: %s' % eggs)

if __name__ == '__main__':
    spam()

在执行时,我们将在交互式控制台中间停下来:

# python3 test_code.py
The begin of spam
>>> eggs
123
>>> eggs = 456
>>>
The end of spam
The value of eggs: 123

要退出这个控制台,我们可以在 Linux/Mac 系统上使用*^d*(Ctrl + d),在 Windows 系统上使用*^z*(Ctrl + Z)。

这里需要注意的一件重要的事情是,这两者之间的范围是不共享的。尽管我们传递了locals()以便共享本地变量以方便使用,但这种关系并不是双向的。结果是,即使我们在交互会话中将eggs设置为456,它也不会传递到外部函数。如果你愿意,你可以通过直接操作(例如设置属性)来修改外部范围的变量,但所有在本地声明的变量都将保持本地。

使用 pdb 进行调试

在实际调试代码时,常规的交互式控制台并不适用。通过一些努力,你可以让它工作,但它并不方便调试,因为你只能看到当前的范围,不能轻松地在堆栈中跳转。使用pdb(Python 调试器)可以轻松实现这一点。让我们看一个使用pdb的简单例子:

import pdb

def spam():
    eggs = 123
    print('The begin of spam')
    pdb.set_trace()
    print('The end of spam')
    print('The value of eggs: %s' % eggs)

if __name__ == '__main__':
    spam()

这个例子与前一段中的例子几乎完全相同,只是这一次我们最终进入了pdb控制台,而不是常规的交互式控制台。所以让我们试试交互式调试器:

# python3 test_pdb.py
The begin of spam
> test_pdb.py(8)spam()
-> print('The end of spam')
(Pdb) eggs
123
(Pdb) eggs = 456
(Pdb) continue
The end of spam
The value of eggs: 456

正如你所看到的,我们现在实际上修改了eggs的值。在这种情况下,我们使用了完整的continue命令,但所有pdb命令也有简写版本。因此,使用c而不是continue会得到相同的结果。只需输入eggs(或任何其他变量)将显示内容,并且设置变量将简单地设置它,就像我们从交互式会话中期望的那样。

要开始使用pdb,首先显示了最有用的(完整)命令列表及其简写:

CommandExplanation
h(elp)显示命令列表(本列表)。
h(elp) command显示给定命令的帮助信息。
w(here)当前堆栈跟踪,箭头指向当前帧。
d(own)移动到堆栈中的下一个帧。
u(p)移动到堆栈中的较旧帧。
s(tep)执行当前行并尽快停止。
n(ext)执行当前行并停在当前函数内的下一行。
r(eturn)继续执行,直到函数返回。
c(ont(inue))继续执行直到下一个断点。
l(ist) [first[, last]]列出(默认情况下,11 行)当前行周围的源代码行。
ll &#124; longlist列出当前函数或帧的所有源代码。
source expression列出给定对象的源代码。这类似于 longlist。
a(rgs)打印当前函数的参数。
pp expression漂亮地打印给定的表达式。
b(reak)显示断点列表。
b(reak) [filename:]lineno在给定的行号和(可选)文件处设置断点。
b(reak) function[, condition]在给定的函数处设置断点。条件是一个必须评估为True的表达式,断点才能起作用。
cl(ear) [filename:]lineno清除这一行的断点(或断点)。
cl(ear) breakpoint [breakpoint ...]清除这些编号的断点(或断点)。
Command列出所有定义的命令。
command breakpoint指定在遇到给定断点时执行的命令列表。使用end命令结束列表。
Alias列出所有别名。

| alias name command | 创建一个别名。命令可以是任何有效的 Python 表达式,所以你可以这样做来打印对象的所有属性:

alias pd pp %1.__dict__** 

|

unalias name删除别名。
! statement在堆栈的当前位置执行语句。通常情况下不需要!符号,但如果与调试器命令发生冲突,这可能会有用。例如,尝试b = 123
Interact打开一个类似于前一段的交互式会话。请注意,设置在该局部范围内的变量不会被传递。

断点

这是一个相当长的列表,但你可能会经常使用其中的大部分。为了突出显示前表中显示的选项之一,让我们演示断点的设置和使用:

import pdb

def spam():
    print('The begin of spam')
    print('The end of spam')

if __name__ == '__main__':
    pdb.set_trace()
    spam()

到目前为止,没有发生什么新的事情,但现在让我们打开交互式调试会话,如下所示:

# python3 test_pdb.py
> test_pdb.py(11)<module>()
-> while True:
(Pdb) source spam  # View the source of spam
 **4     def spam():
 **5         print('The begin of spam')
 **6         print('The end of spam')

(Pdb) b 5  # Add a breakpoint to line 5
Breakpoint 1 at test_pdb.py:5

(Pdb) w  # Where shows the current line
> test_pdb.py(11)<module>()
-> while True:

(Pdb) c  # Continue (until the next breakpoint or exception)
> test_pdb.py(5)spam()
-> print('The begin of spam')

(Pdb) w  # Where again
 **test_pdb.py(12)<module>()
-> spam()
> test_pdb.py(5)spam()
-> print('The begin of spam')

(Pdb) ll  # List the lines of the current function
 **4     def spam():
 **5 B->     print('The begin of spam')
 **6         print('The end of spam')

(Pdb) b  # Show the breakpoints
Num Type         Disp Enb   Where
1   breakpoint   keep yes   at test_pdb.py:5
 **breakpoint already hit 1 time

(Pdb) cl 1  # Clear breakpoint 1
Deleted breakpoint 1 at test_pdb.py:5

输出很多,但实际上并不像看起来那么复杂:

  1. 首先,我们使用source spam命令查看spam函数的源代码。

  2. 在那之后,我们知道了第一个print语句的行号,我们用它在第 5 行放置了一个断点(b 5)。

  3. 为了检查我们是否仍然在正确的位置,我们使用了w命令。

  4. 由于断点已设置,我们使用c继续到下一个断点。

  5. 在第 5 行的断点停下后,我们再次使用w来确认。

  6. 使用ll列出当前函数的代码。

  7. 使用b列出断点。

  8. 再次使用cl 1移除断点,断点号来自于前一个命令。

一开始似乎有点复杂,但你会发现,一旦你尝试了几次,它实际上是一种非常方便的调试方式。

为了使它更好用,这次我们将只在eggs = 3时执行断点。代码基本上是一样的,尽管在这种情况下我们需要一个变量:

import pdb

def spam(eggs):
    print('eggs:', eggs)

if __name__ == '__main__':
    pdb.set_trace()
    for i in range(5):
        spam(i)

现在,让我们执行代码,并确保它只在特定时间中断:

# python3 test_breakpoint.py
> test_breakpoint.py(10)<module>()
-> for i in range(5):
(Pdb) source spam
 **4     def spam(eggs):
 **5         print('eggs:', eggs)
(Pdb) b 5, eggs == 3  # Add a breakpoint to line 5 whenever eggs=3
Breakpoint 1 at test_breakpoint.py:5
(Pdb) c  # Continue
eggs: 0
eggs: 1
eggs: 2
> test_breakpoint.py(5)spam()
-> print('eggs:', eggs)
(Pdb) a  # Show function arguments
eggs = 3
(Pdb) c  # Continue
eggs: 3
eggs: 4

总结我们所做的:

  1. 首先,使用source spam,我们查找了行号。

  2. 之后,我们使用eggs == 3条件放置了一个断点。

  3. 然后我们使用c继续执行。如你所见,值012都正常打印出来了。

  4. 断点在值3处被触发。为了验证这一点,我们使用a来查看函数参数。

  5. 然后我们继续执行剩下的代码。

捕获异常

所有这些都是手动调用pdb.set_trace()函数,但一般情况下,你只是运行你的应用程序,并不真的期望出现问题。这就是异常捕获非常有用的地方。除了自己导入pdb,你也可以将脚本作为模块通过pdb运行。让我们来看看这段代码,一旦它遇到零除法就会中断:

print('This still works')
1/0
print('We shouldnt reach this code')

如果我们使用pdb参数运行它,每当它崩溃时我们就会进入 Python 调试器:

# python3 -m pdb test_zero.py
> test_zero.py(1)<module>()
-> print('This still works')
(Pdb) w  # Where
 **bdb.py(431)run()
-> exec(cmd, globals, locals)
 **<string>(1)<module>()
> test_zero.py(1)<module>()
-> print('This still works')
(Pdb) s  # Step into the next statement
This still works
> test_zero.py(2)<module>()
-> 1/0
(Pdb) c  # Continue
Traceback (most recent call last):
 **File "pdb.py", line 1661, in main
 **pdb._runscript(mainpyfile)
 **File "pdb.py", line 1542, in _runscript
 **self.run(statement)
 **File "bdb.py", line 431, in run
 **exec(cmd, globals, locals)
 **File "<string>", line 1, in <module>
 **File "test_zero.py", line 2, in <module>
 **1/0
ZeroDivisionError: division by zero
Uncaught exception. Entering post mortem debugging
Running 'cont' or 'step' will restart the program
> test_zero.py(2)<module>()
-> 1/0

提示

pdb中一个有用的小技巧是使用Enter按钮,默认情况下,它会再次执行先前执行的命令。当逐步执行程序时,这非常有用。

命令

commands命令有点复杂,但非常有用。它允许你在遇到特定断点时执行命令。为了说明这一点,让我们再从一个简单的例子开始:

import pdb

def spam(eggs):
    print('eggs:', eggs)

if __name__ == '__main__':
    pdb.set_trace()
    for i in range(5):
        spam(i)

代码足够简单,所以现在我们将添加断点和命令,如下所示:

# python3 test_breakpoint.py
> test_breakpoint.py(10)<module>()
-> for i in range(3):
(Pdb) b spam  # Add a breakpoint to function spam
Breakpoint 1 at test_breakpoint.py:4
(Pdb) commands 1  # Add a command to breakpoint 1
(com) print('The value of eggs: %s' % eggs)
(com) end  # End the entering of the commands
(Pdb) c  # Continue
The value of eggs: 0
> test_breakpoint.py(5)spam()
-> print('eggs:', eggs)
(Pdb) c  # Continue
eggs: 0
The value of eggs: 1
> test_breakpoint.py(5)spam()
-> print('eggs:', eggs)
(Pdb) cl 1  # Clear breakpoint 1
Deleted breakpoint 1 at test_breakpoint.py:4
(Pdb) c  # Continue
eggs: 1
eggs: 2

正如你所看到的,我们可以很容易地向断点添加命令。在移除断点后,这些命令显然不会再被执行。

使用 ipdb 进行调试

通用的 Python 控制台虽然有用,但有时会有点粗糙。IPython 控制台提供了许多额外功能,使其成为一个更好用的控制台。其中一个功能是更方便的调试器。

首先确保你已经安装了ipdb

pip install ipdb

接下来,让我们再次尝试使用我们之前的脚本进行调试。唯一的小改变是,我们现在导入的是ipdb而不是pdb

import ipdb

def spam(eggs):
    print('eggs:', eggs)

if __name__ == '__main__':
    ipdb.set_trace()
    for i in range(3):
        spam(i)

然后我们执行它:

# python3 test_ipdb.py
> test_ipdb.py(10)<module>()
 **9     ipdb.set_trace()
---> 10     for i in range(3):
 **11         spam(i)

ipdb> b spam  # Set a breakpoint
Breakpoint 1 at test_ipdb.py:4
ipdb> c  # Continue (until exception or breakpoint)
> test_ipdb.py(5)spam()
1     4 def spam(eggs):
----> 5     print('eggs:', eggs)
 **6

ipdb> a  # Show the arguments
eggs = 0
ipdb> c  # Continue
eggs: 0
> test_ipdb.py(5)spam()
1     4 def spam(eggs):
----> 5     print('eggs:', eggs)
 **6

ipdb>   # Repeat the previous command, so continue again
eggs: 1
> test_ipdb.py(5)spam()
1     4 def spam(eggs):
----> 5     print('eggs:', eggs)
 **6

ipdb> cl 1  # Remove breakpoint 1
Deleted breakpoint 1 at test_ipdb.py:4
ipdb> c  # Continue
eggs: 2

命令都是一样的,但在我看来输出更易读一些。实际版本还包括语法高亮,使输出更容易跟踪。

简而言之,在大多数情况下,你可以简单地用ipdb替换pdb来获得一个更直观的调试器。但我也会给你推荐ipdb上下文管理器:

import ipdb

with ipdb.launch_ipdb_on_exception():
    main()

这就像看起来的那样方便。它只是将ipdb连接到你的异常中,这样你可以在需要时轻松调试。将其与应用程序的调试标志结合使用,可以轻松地在需要时允许调试。

其他调试器

pdbipdb只是众多可用于 Python 的调试器中的两个。目前一些值得注意的调试器如下:

  • pudb:这提供了一个全屏命令行调试器

  • pdbpp:这是对常规pdb的一个钩子

  • rpdb2:这是一个允许连接到运行中(远程)应用程序的远程调试器

  • Werkzeug:这是一个基于 Web 的调试器,允许在运行时调试 Web 应用程序

当然还有许多其他调试器,并没有一个绝对最好的。就像所有工具一样,它们都有各自的优势和缺陷,而最适合你当前目的的工具只有你自己才能决定。很可能你当前使用的 Python IDE 已经集成了调试器。

调试服务

除了在遇到问题时进行调试之外,有时您只需要跟踪错误以供以后调试。特别是在与远程服务器一起工作时,这些可以非常宝贵,可以检测 Python 进程何时以及如何发生故障。此外,这些服务还提供错误分组,使它们比简单的异常类型脚本更有用,后者可能会快速填满您的收件箱。

一个很好的开源解决方案,用于跟踪错误的是sentry。如果您需要一个提供性能跟踪的完整解决方案,那么 Opbeat 和 Newrelic 都是非常好的解决方案;它们提供免费和付费版本。请注意,所有这些解决方案还支持跟踪其他语言,例如 JavaScript。

总结

本章介绍了一些不同的调试技术和陷阱。当然,关于调试还有很多可以说的,但我希望您现在已经获得了一个很好的调试 Python 代码的视角。交互式调试技术对于单线程应用程序和可用交互式会话的位置非常有用。但由于情况并非总是如此,我们还讨论了一些非交互式选项。

以下是本章讨论的所有要点概述:

  • 使用非交互式调试:

  • 打印

  • 记录

  • 跟踪

  • 回溯

  • asyncio

  • 故障处理程序

  • 使用pdbipdb进行交互式调试

在下一章中,我们将看到如何监视和改善 CPU 和内存性能,以及查找和修复内存泄漏。

第十二章:性能-跟踪和减少内存和 CPU 使用

在我们谈论性能之前,有一句Donald Knuth的话您需要首先考虑:

“真正的问题在于程序员花了太多时间在错误的地方和错误的时间上担心效率;过早的优化是编程中所有邪恶的根源(或至少是大部分)。”

注意

Donald Knuth 经常被称为算法分析之父。他的书系计算机编程艺术可以被认为是所有基本算法的圣经。

只要您选择了正确的数据结构和正确的算法,性能就不应该成为一个值得担忧的问题。这并不意味着您应该完全忽略性能,而是确保您选择正确的战斗,并且只在实际需要时进行优化。微观/过早的优化肯定很有趣,但很少有用。

我们已经在第二章中看到了许多数据结构的性能特征,Pythonic Syntax, Common Pitfalls, and Style Guide,所以我们不会讨论那个,但我们会向您展示如何测量性能以及如何检测问题。有些情况下,微观优化会产生影响,但在测量性能之前,您不会知道。

在本章中,我们将涵盖:

  • 分析 CPU 使用情况

  • 分析内存使用

  • 学习如何正确比较性能指标

  • 优化性能

  • 查找和修复内存泄漏

什么是性能?

性能是一个非常广泛的术语。它有许多不同的含义,在许多情况下被错误地定义。您可能听过类似于“语言 X 比 Python 快”的说法。然而,这种说法本质上是错误的。Python 既不快也不慢;Python 是一种编程语言,语言根本没有性能指标。如果有人说 CPython 解释器对于语言 X 比解释器 Y 快或慢,那是可能的。代码的性能特征在不同的解释器之间可能有很大的差异。只需看看这个小测试:

# python3 -m timeit '"".join(str(i) for i in range(10000))'
100 loops, best of 3: 2.91 msec per loop
# python2 -m timeit '"".join(str(i) for i in range(10000))'
100 loops, best of 3: 2.13 msec per loop
# pypy -m timeit '"".join(str(i) for i in range(10000))'
1000 loops, best of 3: 677 usec per loop

三种不同的解释器,性能差异巨大!所有都是 Python,但解释器显然不同。看到这个基准测试,您可能会想要完全放弃 CPython 解释器,只使用 Pypy。这类基准测试的危险在于它们很少提供任何有意义的结果。对于这个有限的例子,Pypy 解释器比 CPython3 解释器快大约四倍,但这与一般情况毫无关系。唯一可以安全得出的结论是,这个特定版本的 Pypy 解释器比这个特定版本的 CPython3 快四倍以上,对于任何其他测试和解释器版本,结果可能大不相同。

Timeit-比较代码片段的性能

在我们开始改进性能之前,我们需要一种可靠的方法来衡量它。Python 有一个非常好的模块(timeit),专门用于测量代码片段的执行时间。它多次执行一小段代码,以确保变化尽可能小,并使测量相对干净。如果您想比较几个代码片段,这非常有用。以下是示例执行:

# python3 -m timeit 'x=[]; [x.insert(0, i) for i in range(10000)]'
10 loops, best of 3: 30.2 msec per loop
# python3 -m timeit 'x=[]; [x.append(i) for i in range(10000)]'
1000 loops, best of 3: 1.01 msec per loop
# python3 -m timeit 'x=[i for i in range(10000)]'
1000 loops, best of 3: 381 usec per loop
# python3 -m timeit 'x=list(range(10000))'
10000 loops, best of 3: 212 usec per loop

这些例子展示了list.insertlist.append、列表推导和list函数之间的性能差异。但更重要的是,它演示了如何使用timeit命令。当然,该命令也可以用于常规脚本,但timeit模块只接受要执行的语句作为字符串,这有点麻烦。幸运的是,您可以通过将代码包装在一个函数中并计时该函数来轻松解决这个问题:

import timeit

def test_list():
    return list(range(10000))

def test_list_comprehension():
    return [i for i in range(10000)]

def test_append():
    x = []
    for i in range(10000):
        x.append(i)

    return x

def test_insert():
    x = []
    for i in range(10000):
        x.insert(0, i)

    return x

def benchmark(function, number=100, repeat=10):
    # Measure the execution times
    times = timeit.repeat(function, number=number, globals=globals())
    # The repeat function gives `repeat` results so we take the min()
    # and divide it by the number of runs
    time = min(times) / number
    print('%d loops, best of %d: %9.6fs :: %s' % (
        number, repeat, time, function))

if __name__ == '__main__':
    benchmark('test_list()')
    benchmark('test_list_comprehension()')
    benchmark('test_append()')
    benchmark('test_insert()')

执行此操作时,您将得到以下内容:

# python3 test_timeit.py
100 loops, best of 10:  0.000238s :: test_list()
100 loops, best of 10:  0.000407s :: test_list_comprehension()
100 loops, best of 10:  0.000838s :: test_append()
100 loops, best of 10:  0.031795s :: test_insert()

您可能已经注意到,这个脚本仍然有点基础。而常规版本会一直尝试,直到达到 0.2 秒或更多,这个脚本只有固定数量的执行。不幸的是,timeit模块并没有完全考虑重用,所以除了从脚本中调用timeit.main()之外,你几乎没有办法重用这个逻辑。

个人建议使用 IPython,因为它可以更轻松地进行测量:

# ipython3
In [1]: import test_timeit
In [2]: %timeit test_timeit.test_list()
1000 loops, best of 3: 255 µs per loop
In [3]: %timeit test_timeit.test_list_comprehension()
1000 loops, best of 3: 430 µs per loop
In [4]: %timeit test_timeit.test_append()
1000 loops, best of 3: 934 µs per loop
In [5]: %timeit test_timeit.test_insert()
10 loops, best of 3: 31.6 ms per loop

在这种情况下,IPython 会自动处理字符串包装和globals()的传递。不过,这一切都非常有限,只适用于比较多种执行相同操作的方法。在完整的 Python 应用程序中,有更多的方法可用。

提示

要查看 IPython 函数和常规模块的源代码,可以在 IPython shell 中输入object??来返回源代码。在这种情况下,只需输入timeit??来查看timeit IPython 函数的定义。

您可以自己实现%timeit函数的最简单方法就是简单地调用timeit.main

import timeit

timeit.main(args=['[x for x in range(1000000)]'])

timeit模块的内部并没有什么特别之处。一个基本版本可以只用evaltime.perf_counter(Python 中可用的最高分辨率计时器)的组合来实现:

import time
import functools

TIMEIT_TEMPLATE = '''
import time

def run(number):
    %(setup)s
    start = time.perf_counter()
    for i in range(number):
        %(statement)s
    return time.perf_counter() - start
'''

def timeit(statement='pass', setup='pass', repeat=1, number=1000000,
           globals_=None):
    # Get or create globals
    globals_ = globals() if globals_ is None else globals_

    # Create the test code so we can separate the namespace
    src = TIMEIT_TEMPLATE % dict(
        statement=statement,
        setup=setup,
        number=number,
    )
    # Compile the source
    code = compile(src, '<source>', 'exec')

    # Define locals for the benchmarked code
    locals_ = {}

    # Execute the code so we can get the benchmark fuction
    exec(code, globals_, locals_)

    # Get the run function
    run = functools.partial(locals_['run'], number=number)
    for i in range(repeat):
        yield run()

timeit代码实际上在检查输入方面更加先进,但这个例子大致展示了timeit.repeat函数如何实现。

要在 IPython 中注册自己的函数,需要使用一些 IPython 魔术。请注意,这个魔术并不是双关语。负责这些命令的 IPython 模块实际上被称为magic。为了演示:

from IPython.core import magic

@magic.register_line_magic(line):
    import timeit
    timeit.main(args[line])

要了解更多关于 IPython 中自定义魔术的信息,请查看 IPython 文档ipython.org/ipython-doc/3/config/custommagics.html

cProfile – 查找最慢的组件

profile模块使得分析脚本/应用程序中使用的相对 CPU 周期变得非常容易。但一定要小心,不要将其与timeit模块的结果进行比较。虽然timeit模块尽可能准确地提供执行代码片段所需的绝对时间的基准,但profile模块只对相对结果有用。原因是,分析代码本身会导致减速,因此结果与非分析代码不可比。然而,有一种方法可以使其更加准确,但稍后再详细介绍。

注意

在这一部分,我们将讨论profile模块,但在示例中我们实际上将使用cProfile模块。cProfile模块是纯 Pythonprofile模块的高性能仿真。

首次分析运行

让我们从第五章中的 Fibonacci 函数进行分析,装饰器-通过装饰实现代码重用,分别使用缓存函数和不使用缓存函数。首先,代码:

import sys
import functools

@functools.lru_cache()
def fibonacci_cached(n):
    if n < 2:
        return n
    else:
        return fibonacci_cached(n - 1) + fibonacci_cached(n - 2)

def fibonacci(n):
    if n < 2:
        return n
    else:
        return fibonacci(n - 1) + fibonacci(n - 2)

if __name__ == '__main__':
    n = 30
    if sys.argv[-1] == 'cache':
        fibonacci_cached(n)
    else:
        fibonacci(n)

注意

为了可读性,所有cProfile统计数据将在所有cProfile输出中剥离percallcumtime列。这些列对于这些示例的目的来说是无关紧要的。

首先我们将不使用缓存来执行函数:

# python3 -m cProfile -s calls test_fibonacci.py no_cache
 **2692557 function calls (21 primitive calls) in 0.815
 **seconds

 **Ordered by: call count

 **ncalls tottime percall filename:lineno(function)
2692537/1   0.815   0.815 test_fibonacci.py:13(fibonacci)
 **7   0.000   0.000 {built-in method builtins.getattr}
 **5   0.000   0.000 {built-in method builtins.setattr}
 **1   0.000   0.000 {method 'update' of 'dict' objects}
 **1   0.000   0.000 {built-in method builtins.isinstance}
 **1   0.000   0.000 functools.py:422(decorating_function)
 **1   0.000   0.815 test_fibonacci.py:1(<module>)
 **1   0.000   0.000 {method 'disable' of '_lsprof.Profiler'}
 **1   0.000   0.815 {built-in method builtins.exec}
 **1   0.000   0.000 functools.py:43(update_wrapper)
        1   0.000   0.000 functools.py:391(lru_cache)

这是相当多的调用,不是吗?显然,我们调用了test_fibonacci函数将近 300 万次。这就是分析模块提供了很多见解的地方。让我们进一步分析一下指标:

  • Ncalls:对函数进行的调用次数

  • Tottime:在此函数内部花费的总时间(以秒为单位),不包括所有子函数

Percall,tottime / ncalls

  • Cumtime:在此函数内部花费的总时间,包括子函数

Percall,cumtime / ncalls

哪个对你的用例最有用取决于情况。使用默认输出中的-s参数可以很容易地改变排序顺序。但现在让我们看看缓存版本的结果。再次,只有简化的输出:

# python3 -m cProfile -s calls test_fibonacci.py cache
 **51 function calls (21 primitive calls) in 0.000 seconds

 **Ordered by: call count

 **ncalls tottime percall filename:lineno(function)
 **31/1   0.000   0.000 test_fibonacci.py:5(fibonacci_cached)
 **7   0.000   0.000 {built-in method builtins.getattr}
 **5   0.000   0.000 {built-in method builtins.setattr}
 **1   0.000   0.000 test_fibonacci.py:1(<module>)
 **1   0.000   0.000 {built-in method builtins.isinstance}
 **1   0.000   0.000 {built-in method builtins.exec}
 **1   0.000   0.000 functools.py:422(decorating_function)
 **1   0.000   0.000 {method 'disable' of '_lsprof.Profiler'}
 **1   0.000   0.000 {method 'update' of 'dict' objects}
 **1   0.000   0.000 functools.py:391(lru_cache)
 **1   0.000   0.000 functools.py:43(update_wrapper)

这次我们看到tottime0.000,因为它太快了,无法测量。但是,虽然fibonacci_cached函数仍然是执行次数最多的函数,但它只执行了 31 次,而不是 300 万次。

校准你的性能分析器

为了说明profilecProfile之间的差异,让我们再次尝试使用profile模块进行未缓存的运行。提醒一下,这会慢得多,所以如果它有点停滞,不要感到惊讶:

# python3 -m profile -s calls test_fibonacci.py no_cache
         2692558 function calls (22 primitive calls) in 7.696 seconds

   Ordered by: call count

   ncalls tottime percall filename:lineno(function)
2692537/1   7.695   7.695 test_fibonacci.py:13(fibonacci)
        7   0.000   0.000 :0(getattr)
        5   0.000   0.000 :0(setattr)
        1   0.000   0.000 :0(isinstance)
        1   0.001   0.001 :0(setprofile)
        1   0.000   0.000 :0(update)
        1   0.000   0.000 functools.py:43(update_wrapper)
        1   0.000   7.696 profile:0(<code object <module> ...>)
        1   0.000   7.695 test_fibonacci.py:1(<module>)
        1   0.000   0.000 functools.py:391(lru_cache)
        1   0.000   7.695 :0(exec)
        1   0.000   0.000 functools.py:422(decorating_function)
        0   0.000         profile:0(profiler)

巨大的差异,不是吗?现在代码几乎慢了 10 倍,唯一的区别是使用了纯 Python 的profile模块而不是cProfile模块。这确实表明了profile模块存在很大的问题。模块本身的开销足以扭曲结果,这意味着我们应该考虑这种偏差。这就是Profile.calibrate()函数要处理的问题,它计算了 profile 模块所产生的偏差。为了计算偏差,我们可以使用以下脚本:

import profile

if __name__ == '__main__':
    profiler = profile.Profile()
    for i in range(10):
        print(profiler.calibrate(100000))

数字会略有不同,但是你应该能够使用这段代码得到一个公平的偏差估计。如果数字仍然有很大的变化,你可以将试验次数从100000增加到更大的值。这种校准只适用于 profile 模块,但是如果你想要更准确的结果,而cProfile模块由于继承或者不受你的平台支持而无法使用,你可以使用这段代码来全局设置你的偏差并获得更准确的结果:

import profile

# The number here is bias calculated earlier
profile.Profile.bias = 2.0939406059394783e-06

对于特定的Profile实例:

import profile

profiler = profile.Profile(bias=2.0939406059394783e-06)

一般来说,较小的偏差比较大的偏差更好,因为较大的偏差可能会导致非常奇怪的结果。在某些情况下,甚至会得到负的时间。让我们试试我们的斐波那契代码:

import sys
import pstats
import profile
import functools

@functools.lru_cache()
def fibonacci_cached(n):
    if n < 2:
        return n
    else:
        return fibonacci_cached(n - 1) + fibonacci_cached(n - 2)

def fibonacci(n):
    if n < 2:
        return n
    else:
        return fibonacci(n - 1) + fibonacci(n - 2)

if __name__ == '__main__':
    profiler = profile.Profile(bias=2.0939406059394783e-06)
    n = 30

    if sys.argv[-1] == 'cache':
        profiler.runcall(fibonacci_cached, n)
    else:
        profiler.runcall(fibonacci, n)

    stats = pstats.Stats(profiler).sort_stats('calls')
    stats.print_stats()

在运行时,确实出现了我使用了一个太大的偏差:

# python3 test_fibonacci.py no_cache
 **2692539 function calls (3 primitive calls) in -0.778
 **seconds

 **Ordered by: call count

 **ncalls tottime percall filename:lineno(function)
2692537/1  -0.778  -0.778 test_fibonacci.py:15(fibonacci)
 **1   0.000   0.000 :0(setprofile)
 **1   0.000  -0.778 profile:0(<function fibonacci at 0x...>)
 **0   0.000         profile:0(profiler)

不过,它展示了代码如何正确使用。你甚至可以在脚本中使用类似这样的片段来整合偏差计算:

import profile

if __name__ == '__main__':
    profiler = profile.Profile()
    profiler.bias = profiler.calibrate(100000)

使用装饰器进行选择性性能分析

使用装饰器计算简单的时间很容易,但是性能分析也很重要。两者都很有用,但是目标不同。让我们看看两种选择:

import cProfile
import datetime
import functools

def timer(function):
    @functools.wraps(function)
    def _timer(*args, **kwargs):
        start = datetime.datetime.now()
        try:
            return function(*args, **kwargs)
        finally:
            end = datetime.datetime.now()
            print('%s: %s' % (function.__name__, end - start))
    return _timer

def profiler(function):
    @functools.wraps(function)
    def _profiler(*args, **kwargs):
        profiler = cProfile.Profile()
        try:
            profiler.enable()
            return function(*args, **kwargs)
        finally:
            profiler.disable()
            profiler.print_stats()
    return _profiler

@profiler
def profiled_fibonacci(n):
    return fibonacci(n)

@timer
def timed_fibonacci(n):
    return fibonacci(n)

def fibonacci(n):
    if n < 2:
        return n
    else:
        return fibonacci(n - 1) + fibonacci(n - 2)

if __name__ == '__main__':
    timed_fibonacci(32)
    profiled_fibonacci(32)

代码足够简单,只是一个基本的计时器和性能分析器打印一些默认的统计数据。哪种对你来说更好取决于你的用例,但它们肯定都有用。这种选择性性能分析的额外优势是输出更有限,有助于可读性:

# python3 test_fibonacci.py
 **timed_fibonacci: 0:00:01.050200
 **7049157 function calls (3 primitive calls) in 2.024
 **seconds

 **Ordered by: standard name

 **ncalls tottime percall filename:lineno(function)
 **1   0.000   2.024 test_fibonacci.py:31(profiled_fibonacci)
7049155/1   2.024   2.024 test_fibonacci.py:41(fibonacci)
 **1   0.000   0.000 {method 'disable' of '_lsprof.Profiler'}

如你所见,性能分析器仍然使代码变慢了大约两倍,但它确实可用。

使用性能分析统计

为了获得更复杂的性能分析结果,我们将对pystone脚本进行性能分析。pystone脚本是一个内部的 Python 性能测试,它相当彻底地对 Python 解释器进行基准测试。首先,让我们使用这个脚本创建统计数据:

from test import pystone
import cProfile

if __name__ == '__main__':
    profiler = cProfile.Profile()
    profiler.runcall(pystone.main)
    profiler.dump_stats('pystone.profile')

在执行脚本时,你应该会得到类似这样的结果:

# python3 test_pystone.py
Pystone(1.2) time for 50000 passes = 0.725432
This machine benchmarks at 68924.4 pystones/second

运行脚本后,你应该会得到一个包含性能分析结果的pystone.profile文件。这些结果可以通过 Python 捆绑的pstats模块查看:

import pstats

stats = pstats.Stats('pystone.profile')
stats.strip_dirs()
stats.sort_stats('calls', 'cumtime')
stats.print_stats(10)

在某些情况下,将多次测量的结果结合起来可能是有趣的。可以通过指定多个文件或使用stats.add(*filenames)来实现。但首先,让我们看看常规输出:

# python3 parse_statistics.py

 **1050012 function calls in 0.776 seconds

 **Ordered by: call count, cumulative time
 **List reduced from 21 to 10 due to restriction <10>

 **ncalls  tottime  percall  cumtime  percall filename:lineno(function)
 **150000    0.032    0.000    0.032    0.000 pystone.py:214(Proc7)
 **150000    0.027    0.000    0.027    0.000 pystone.py:232(Func1)
 **100000    0.016    0.000    0.016    0.000 {built-in method builtins.chr}
 **100000    0.010    0.000    0.010    0.000 {built-in method builtins.ord}
 **50002    0.029    0.000    0.029    0.000 pystone.py:52(__init__)
 **50000    0.127    0.000    0.294    0.000 pystone.py:144(Proc1)
 **50000    0.094    0.000    0.094    0.000 pystone.py:219(Proc8)
 **50000    0.048    0.000    0.077    0.000 pystone.py:60(copy)
 **50000    0.051    0.000    0.061    0.000 pystone.py:240(Func2)
 **50000    0.031    0.000    0.043    0.000 pystone.py:171(Proc3)

显然,参数可以很容易地修改以改变排序顺序和输出行数。但这并不是统计数据的唯一可能性。有很多包可以解析这些结果并可视化它们。其中一个选择是 RunSnakeRun,虽然有用,但目前不支持 Python 3。此外,我们还有 QCacheGrind,一个非常好的性能分析统计可视化工具,但需要一些手动编译才能运行,或者当然也可以搜索到二进制文件。

让我们看看 QCacheGrind 的输出。在 Windows 的情况下,QCacheGrindWin 包提供了一个二进制文件,而在 Linux 中,它很可能通过您的软件包管理器提供,在 OS X 中,您可以尝试brew install qcachegrind --with-graphviz。但是还有一个包你需要:pyprof2calltree包。它将profile输出转换为 QCacheGrind 理解的格式。因此,在简单的pip install pyprof2calltree之后,我们现在可以将profile文件转换为callgrind文件:

# pyprof2calltree -i pystone.profile -o pystone.callgrind
writing converted data to: pystone.callgrind
# qcachegrind pystone.callgrind

这将导致运行QCacheGrind应用程序。切换到适当的标签后,您应该看到类似以下图像的东西:

使用 profile 统计信息

对于这样一个简单的脚本,几乎所有的输出都有效。然而,对于完整的应用程序,像 QCacheGrind 这样的工具是非常宝贵的。查看 QCacheGrind 生成的输出,立即就能看出哪个进程花费了最多的时间。右上角的结构显示了更大的矩形,如果花费的时间更长,这是对 CPU 时间块的非常有用的可视化。左边的列表与cProfile非常相似,因此没有什么新鲜的。右下角的树可能非常有价值,也可能非常无用,就像在这种情况下一样。它显示了函数中所占 CPU 时间的百分比,更重要的是,该函数与其他函数的关系。

因为这些工具根据输入进行缩放,所以结果对于几乎任何应用程序都是有用的。无论一个函数需要 100 毫秒还是 100 分钟,都没有关系,输出将显示慢的部分的清晰概述,这是我们要尝试修复的问题。

行分析器

line_profiler实际上不是 Python 捆绑的包,但它太有用了,不能忽视。虽然常规的profile模块对某个块内的所有(子)函数进行分析,但line_profiler允许在函数内逐行进行分析。斐波那契函数在这里并不是最合适的,但我们可以使用一个素数生成器。但首先,安装line_profiler

 **pip install line_profiler

现在我们已经安装了line_profiler模块(以及kernprof命令),让我们测试line_profiler

import itertools

@profile
def primes():
    n = 2
    primes = set()
    while True:
        for p in primes:
            if n % p == 0:
                break
        else:
            primes.add(n)
            yield n
        n += 1

if __name__ == '__main__':
    total = 0
    n = 2000
    for prime in itertools.islice(primes(), n):
        total += prime

    print('The sum of the first %d primes is %d' % (n, total))

你可能想知道profile装饰器是从哪里来的。它来自line_profiler模块,这就是为什么我们必须用kernprof命令运行脚本的原因:

# kernprof -l test_primes.py
The sum of the first 2000 primes is 16274627
Wrote profile results to test_primes.py.lprof

正如命令所说,结果已经写入了test_primes.py.lprof文件。因此,为了便于阅读,让我们查看该文件的输出,跳过Time列:

# python3 -m line_profiler test_primes.py.lprof
Timer unit: 1e-06 s

Total time: 2.33179 s
File: test_primes.py
Function: primes at line 4

Line #      Hits   Per Hit   % Time  Line Contents
==================================================
 **4                               @profile
 **5                               def primes():
 **6         1       3.0      0.0      n = 2
 **7         1       1.0      0.0      primes = set()
 **8         1       0.0      0.0      while True:
 **9   2058163       0.5     43.1          for p in primes:
 **10   2056163       0.6     56.0              if n % p == 0:
 **11     15388       0.5      0.3                  break
 **12                                       else:
 **13      2000       1.2      0.1              primes.add(n)
 **14      2000       0.5      0.0              yield n
 **15     17387       0.6      0.4          n += 1

很棒的输出,不是吗?这使得在一小段代码中找到慢的部分变得微不足道。在这段代码中,慢显然是来自循环,但在其他代码中可能不那么清楚。

注意

这个模块也可以作为 IPython 扩展添加,这样就可以在 IPython 中使用%lprun命令。要加载扩展,可以在 IPython shell 中使用load_ext命令%load_ext line_profiler

提高性能

关于性能优化可以说很多,但实际上,如果你已经读完整本书,你就会知道大部分编写快速代码的 Python 特定技术。应用程序性能中最重要的因素始终是算法的选择,以及数据结构。在list中搜索项目几乎总是比在dictset中搜索项目更糟糕的想法。

使用正确的算法

在任何应用程序中,正确选择的算法是远远最重要的性能特征,这就是为什么我重复强调它以说明错误选择的结果:

In [1]: a = list(range(1000000))

In [2]: b = dict.fromkeys(range(1000000))

In [3]: %timeit 'x' in a
10 loops, best of 3: 20.5 ms per loop

In [4]: %timeit 'x' in b
10000000 loops, best of 3: 41.6 ns per loop

检查一个项目是否在list中是一个O(n)操作,而检查一个项目是否在dict中是一个O(1)操作。当n=1000000时有很大的差异,显然,在这个简单的测试中,对于 100 万个项目,它快了 500 倍。

所有其他性能提示结合在一起可能使您的代码速度加快一倍,但使用正确的算法可能会带来更大的改进。使用需要O(n)时间的算法而不是O(n²)时间将使您的代码对于n=10001000倍,对于更大的n,差异只会进一步增加。

全局解释器锁

CPython 解释器最隐晦的组件之一是全局解释器锁(GIL),这是为了防止内存损坏而需要的互斥锁。Python 内存管理器不是线程安全的,这就是为什么需要 GIL。没有 GIL,多个线程可能同时更改内存,导致各种意外和潜在的危险结果。

那么 GIL 在现实应用中的影响是什么?在单线程应用程序中,它根本没有任何影响,实际上是一种非常快速的内存一致性方法。然而,在多线程应用程序中,它可能会稍微减慢应用程序的速度,因为一次只有一个线程可以访问 GIL。因此,如果您的代码需要频繁访问 GIL,可能会受益于一些重构。

幸运的是,Python 提供了一些其他并行处理选项:我们之前看到的asyncio模块和我们将在第十三章中看到的multiprocessing库,多进程-当单个 CPU 核心不够用

尝试与 if

在许多语言中,try/except类型的块会带来相当大的性能损失,但在 Python 中并非如此。并不是说if语句很重,但如果您期望您的try/except大部分时间都成功,只在罕见情况下失败,那绝对是一个有效的替代方案。不过,始终专注于可读性和传达代码目的。如果使用if语句更清晰表达代码的意图,就使用if语句。如果try/except以更好的方式传达意图,就使用它。

列表与生成器

使用生成器懒惰地评估代码几乎总是比计算整个数据集更好的主意。性能优化的最重要规则可能是,不要计算任何您不打算使用的东西。如果您不确定自己是否需要它,请不要计算它。

不要忘记您可以轻松地链接多个生成器,因此只有在实际需要时才计算所有内容。但要小心,这不会导致重新计算;itertools.tee通常比完全重新计算结果更好。

字符串连接

您可能已经看到过一些基准测试,表明使用+=比连接字符串要慢得多。在某个时候,这确实产生了很大的差异。然而,使用 Python 3 后,大部分差异已经消失。

In [1]: %%timeit
 **...: s = ''
 **...: for i in range(1000000):
 **...:     s += str(i)
 **...:
1 loops, best of 3: 362 ms per loop

In [2]: %%timeit
 **...: ss = []
 **...: for i in range(1000000):
 **...:     ss.append(str(i))
 **...: s = ''.join(ss)
 **...:
1 loops, best of 3: 332 ms per loop

In [3]: %timeit ''.join(str(i) for i in range(1000000))
1 loops, best of 3: 324 ms per loop

In [4]: %timeit ''.join([str(i) for i in range(1000000)])
1 loops, best of 3: 294 ms per loop

当然仍然存在一些差异,但它们非常小,建议忽略它们,选择最可读的选项。

加法与生成器

与字符串连接一样,一度显著差异现在已经微乎其微。

In [1]: %%timeit
   ...: x = 0
   ...: for i in range(1000000):
   ...:     x += i
   ...:
10 loops, best of 3: 73.2 ms per loop

In [2]: %timeit x = sum(i for i in range(1000000))
10 loops, best of 3: 75.3 ms per loop

In [3]: %timeit x = sum([i for i in range(1000000)])
10 loops, best of 3: 71.2 ms per loop

In [4]: %timeit x = sum(range(1000000))
10 loops, best of 3: 25.6 ms per loop

然而,有助于让 Python 使用本机函数来处理一切,就像在最后一个示例中所看到的那样。

映射与生成器和列表推导

再次强调,可读性比性能更重要。有一些情况下,map比列表推导和生成器更快,但只有当map函数可以使用预定义函数时才是如此。一旦您需要使用lambda,实际上速度就会变慢。不过这并不重要,因为无论如何,可读性应该是关键,使用生成器或列表推导而不是map

In [1]: %timeit list(map(lambda x: x/2, range(1000000)))
10 loops, best of 3: 182 ms per loop

In [2]: %timeit list(x/2 for x in range(1000000))
10 loops, best of 3: 122 ms per loop

In [3]: %timeit [x/2 for x in range(1000000)]
10 loops, best of 3: 84.7 ms per loop

正如你所看到的,列表推导式显然比生成器快得多。然而,在许多情况下,我仍然会推荐使用生成器而不是列表推导式,仅仅是因为内存使用和潜在的惰性。如果由于某种原因你只打算使用前 10 个项目,那么通过计算完整的项目列表,你仍然会浪费大量资源。

缓存

我们已经在第五章中介绍了functools.lru_cache装饰器,装饰器-通过装饰实现代码重用,但它的重要性不容小觑。无论你的代码有多快多聪明,不必计算结果总是更好的,这就是缓存的作用。根据你的用例,有许多选项可用。在一个简单的脚本中,functools.lru_cache是一个很好的选择,但在多次执行应用程序时,cPickle模块也可以拯救生命。

如果涉及多个服务器,我建议看看Redis。Redis 服务器是一个单线程的内存服务器,非常快速,并且有许多有用的数据结构可用。如果你看到关于使用 Memcached 提高性能的文章或教程,只需在所有地方用 Redis 替换 Memcached。Redis 在各个方面都优于 Memcached,在其最基本的形式下,API 是兼容的。

延迟导入

应用程序加载时间的一个常见问题是,在程序启动时立即加载所有内容,而实际上许多应用程序并不需要这样做,应用程序的某些部分只在实际使用时才需要加载。为了方便起见,可以偶尔将导入移动到函数内部,以便按需加载。

虽然在某些情况下这是一个有效的策略,但我通常不推荐它,原因有两个:

  1. 它使你的代码不够清晰;在文件顶部以相同的风格放置所有导入可以提高可读性。

  2. 它并没有使代码更快,因为它只是将加载时间移到不同的部分。

使用优化库

这实际上是一个非常广泛的提示,但仍然很有用。如果有一个非常优化的库适合你的目的,你很可能无法在没有大量努力的情况下击败它的性能。像numpypandasscipysklearn这样的库都经过了高度优化,它们的本机操作可能非常快。如果它们适合你的目的,请务必尝试一下。只是为了说明numpy相对于纯 Python 有多快,请参考以下内容:

In [1]: import numpy

In [2]: a = list(range(1000000))

In [3]: b = numpy.arange(1000000)

In [4]: %timeit c = [x for x in a if x > 500000]
10 loops, best of 3: 44 ms per loop

In [5]: %timeit d = b[b > 500000]
1000 loops, best of 3: 1.61 ms per loop

numpy代码与 Python 代码完全相同,只是它使用numpy数组而不是 Python 列表。这个小差异使代码快了 25 倍以上。

即时编译

即时JIT)编译是一种在运行时动态编译(部分)应用程序的方法。因为在运行时有更多的信息可用,这可能会产生巨大的影响,并使你的应用程序运行得更快。

numba包为你提供了选择性的 JIT 编译,允许你标记与 JIT 编译器兼容的函数。基本上,如果你的函数遵循基于输入的函数式编程范式,那么它很可能会与 JIT 编译器一起工作。

numba JIT 编译器的基本示例:

import numba

@numba.jit
def sum(array):
    total = 0.0
    for value in array:
        total += value
    return value

这些用例有限,但如果你使用numpy或 pandas,你很可能会从numba中受益。

另一个非常有趣的事实是,numba不仅支持 CPU 优化执行,还支持 GPU。这意味着对于某些操作,你可以使用视频卡中的快速处理器来处理结果。

将代码的部分转换为 C

我们将在第十四章中看到更多关于这个问题的内容,但是如果真的需要高性能,那么本地 C 函数可以帮助很多。这甚至不必那么困难。Cython 模块使得用性能非常接近本地 C 代码编写代码的过程变得非常简单。

以下是 Cython 手册中用于近似π值的示例:

cdef inline double recip_square(int i):
    return 1./(i*i)

def approx_pi(int n=10000000):
    cdef double val = 0.
    cdef int k
    for k in xrange(1,n+1):
        val += recip_square(k)
    return (6 * val)**.5

虽然有一些小的差异,比如使用cdef代替def以及对值和参数进行类型定义,但代码基本上与常规 Python 代码相同,但速度肯定要快得多。

内存使用

到目前为止,我们只看了执行时间,忽略了脚本的内存使用。在许多情况下,执行时间是最重要的,但内存使用也不容忽视。在几乎所有情况下,CPU 和内存是相互交换的;代码要么使用大量 CPU,要么使用大量内存,这意味着两者都非常重要。

Tracemalloc

监视内存使用曾经是只能通过外部 Python 模块(如DowserHeapy)实现的事情。虽然这些模块仍然有效,但由于tracemalloc模块的出现,它们现在基本上已经过时了。让我们尝试一下tracemalloc模块,看看现在监视内存使用有多容易:

import tracemalloc

if __name__ == '__main__':
    tracemalloc.start()

    # Reserve some memory
    x = list(range(1000000))

    # Import some modules
    import os
    import sys
    import asyncio

    # Take a snapshot to calculate the memory usage
    snapshot = tracemalloc.take_snapshot()
    for statistic in snapshot.statistics('lineno')[:10]:
        print(statistic)

结果是:

# python3 test_tracemalloc.py
test_tracemalloc.py:8: size=35.3 MiB, count=999745, average=37 B
<frozen importlib._bootstrap_external>:473: size=1909 KiB, count=20212, average=97 B
<frozen importlib._bootstrap>:222: size=895 KiB, count=3798, average=241 B
collections/__init__.py:412: size=103 KiB, count=1451, average=72 B
<string>:5: size=36.6 KiB, count=133, average=282 B
collections/__init__.py:406: size=29.9 KiB, count=15, average=2039 B
abc.py:133: size=26.1 KiB, count=102, average=262 B
ipaddress.py:608: size=21.3 KiB, count=182, average=120 B
<frozen importlib._bootstrap_external>:53: size=21.2 KiB, count=140, average=155 B
types.py:234: size=15.3 KiB, count=124, average=127 B

您可以很容易地看到代码的每个部分分配了内存以及可能浪费了多少。虽然可能仍然不清楚哪一部分实际上导致了内存使用,但我们将在接下来的部分中看到有关此问题的解决方案。

内存分析器

memory_profiler模块与前面讨论的line_profiler非常相似,但用于内存使用。安装它就像pip install memory_profiler一样容易,但是强烈建议(在 Windows 的情况下是必需的)安装可选的pip install psutil,因为它可以大大提高性能。为了测试line_profiler,我们将使用以下脚本:

import memory_profiler

@memory_profiler.profile
def main():
    n = 100000
    a = [i for i in range(n)]
    b = [i for i in range(n)]
    c = list(range(n))
    d = list(range(n))
    e = dict.fromkeys(a, b)
    f = dict.fromkeys(c, d)

if __name__ == '__main__':
    main()

请注意,尽管这里实际上导入了memory_profiler,但这并不是严格要求的。它也可以通过python3 -m memory_profiler your_scripts.py来执行:

# python3 test_memory_profiler.py
Filename: test_memory_profiler.py

Line #    Mem usage    Increment   Line Contents
================================================
 **4     11.0 MiB      0.0 MiB   @memory_profiler.profile
 **5                             def main():
 **6     11.0 MiB      0.0 MiB       n = 100000
 **7     14.6 MiB      3.5 MiB       a = [i for i in range(n)]
 **8     17.8 MiB      3.2 MiB       b = [i for i in range(n)]
 **9     21.7 MiB      3.9 MiB       c = list(range(n))
 **10     25.5 MiB      3.9 MiB       d = list(range(n))
 **11     38.0 MiB     12.5 MiB       e = dict.fromkeys(a, b)
 **12     44.1 MiB      6.1 MiB       f = dict.fromkeys(c, d)

尽管一切都按预期运行,但您可能会对这里代码行使用的内存量不同而感到困惑。为什么a占用3.5 MiB,而b只占用3.2 MiB?这是由 Python 内存分配代码引起的;它以较大的块保留内存,然后在内部进行细分和重复使用。另一个问题是memory_profiler在内部进行快照,这导致在某些情况下将内存归因于错误的变量。这些变化应该足够小,以至于最终不会产生很大的差异,但是应该预期会有一些变化。

注意

这个模块也可以作为 IPython 扩展添加,这样就可以在 IPython 中使用%mprun命令。要加载扩展,可以从 IPython shell 使用%load_ext memory_profiler命令。另一个非常有用的命令是%memit,它是%timeit命令的内存等价命令。

内存泄漏

这些模块的使用通常会受到对内存泄漏的搜索的限制。特别是tracemalloc模块具有一些功能,使得这一过程相当容易。Python 内存管理系统相当简单;它只是有一个简单的引用计数器来查看对象是否被使用。虽然在大多数情况下这很有效,但当涉及到循环引用时,它很容易引入内存泄漏。带有泄漏检测代码的内存泄漏的基本前提如下:

 1 import tracemalloc
 2
 3
 4 class Spam(object):
 5     index = 0
 6     cache = {}
 7
 8     def __init__(self):
 9         Spam.index += 1
10         self.cache[Spam.index] = self
11
12
13 class Eggs(object):
14     eggs = []
15
16     def __init__(self):
17         self.eggs.append(self)
18
19
20 if __name__ == '__main__':
21     # Initialize some variables to ignore them from the leak
22     # detection
23     n = 200000
24     spam = Spam()
25
26     tracemalloc.start()
27     # Your application should initialize here
28
29     snapshot_a = tracemalloc.take_snapshot()
30     # This code should be the memory leaking part
31     for i in range(n):
32         Spam()
33
34     Spam.cache = {}
35     snapshot_b = tracemalloc.take_snapshot()
36     # And optionally more leaking code here
37     for i in range(n):
38         a = Eggs()
39         b = Eggs()
40         a.b = b
41         b.a = a
42
43     Eggs.eggs = []
44     snapshot_c = tracemalloc.take_snapshot()
45
46     print('The first leak:')
47     statistics = snapshot_b.compare_to(snapshot_a, 'lineno')
48     for statistic in statistics[:10]:
49         print(statistic)
50
51     print('\nThe second leak:')
52     statistics = snapshot_c.compare_to(snapshot_b, 'lineno')
53     for statistic in statistics[:10]:
54         print(statistic)

让我们看看这段代码实际上有多糟糕的内存泄漏:

# python3 test_leak.py
The first leak:
tracemalloc.py:349: size=528 B (+528 B), count=3 (+3), average=176 B
test_leak.py:34: size=288 B (+288 B), count=2 (+2), average=144 B
test_leak.py:32: size=120 B (+120 B), count=2 (+2), average=60 B
tracemalloc.py:485: size=64 B (+64 B), count=1 (+1), average=64 B
tracemalloc.py:487: size=56 B (+56 B), count=1 (+1), average=56 B
tracemalloc.py:277: size=32 B (+32 B), count=1 (+1), average=32 B
test_leak.py:31: size=28 B (+28 B), count=1 (+1), average=28 B
test_leak.py:9: size=28 B (+28 B), count=1 (+1), average=28 B

The second leak:
test_leak.py:41: size=18.3 MiB (+18.3 MiB), count=400000 (+400000), average=48 B
test_leak.py:40: size=18.3 MiB (+18.3 MiB), count=400000 (+400000), average=48 B
test_leak.py:38: size=10.7 MiB (+10.7 MiB), count=200001 (+200001), average=56 B
test_leak.py:39: size=10.7 MiB (+10.7 MiB), count=200002 (+200002), average=56 B
tracemalloc.py:349: size=680 B (+152 B), count=6 (+3), average=113 B
test_leak.py:17: size=72 B (+72 B), count=1 (+1), average=72 B
test_leak.py:43: size=64 B (+64 B), count=1 (+1), average=64 B
test_leak.py:32: size=56 B (-64 B), count=1 (-1), average=56 B
tracemalloc.py:487: size=112 B (+56 B), count=2 (+1), average=56 B
tracemalloc.py:277: size=64 B (+32 B), count=2 (+1), average=32 B

在绝对内存使用上,增加并不是很大,但肯定有一点泄漏。第一个泄漏是微不足道的;在最后一次迭代中,我们看到增加了 28 字节,几乎可以忽略不计。然而第二个泄漏泄漏了很多,并在增加了 18.3 兆字节。这些都是内存泄漏,Python 垃圾收集器(gc)足够聪明,最终会清除循环引用,但在达到一定限制之前不会清除它们。很快就会了解更多。

每当你想要有一个不会导致内存泄漏的循环引用时,weakref模块是可用的。它创建的引用不计入对象引用计数。在我们看weakref模块之前,让我们通过 Python 垃圾收集器(gc)的眼睛来看一下对象引用本身:

import gc

class Eggs(object):

    def __init__(self, name):
        self.name = name

    def __repr__(self):
        return '<%s: %s>' % (self.__class__.__name__, self.name)

# Create the objects
a = Eggs('a')
b = Eggs('b')

# Add some circular references
a.b = a
b.a = b

# Remove the objects
del a
del b

# See if the objects are still there
print('Before manual collection:')
for object_ in gc.get_objects():
    if isinstance(object_, Eggs):
        print('\t', object_, gc.get_referents(object_))

print('After manual collection:')
gc.collect()
for object_ in gc.get_objects():
    if isinstance(object_, Eggs):
        print('\t', object_, gc.get_referents(object_))

print('Thresholds:', gc.get_threshold())

现在让我们看看输出:

# python3 test_refcount.py
Before manual collection:
 **<Eggs: a> [{'b': <Eggs: a>, 'name': 'a'}, <class '__main__.Eggs'>]
 **<Eggs: b> [{'name': 'b', 'a': <Eggs: b>}, <class '__main__.Eggs'>]
After manual collection:
Thresholds: (700, 10, 10)

正如我们在这里看到的,直到我们手动调用垃圾收集器之前,Eggs对象将一直留在内存中。即使明确删除了对象。那么这是否意味着你总是需要手动调用gc.collect()来删除这些引用?幸运的是,不需要,因为 Python 垃圾收集器在达到阈值后会自动进行收集。默认情况下,Python 垃圾收集器的阈值设置为三代被收集对象的700, 10, 10。收集器跟踪 Python 中所有的内存分配和释放,一旦分配次数减去释放次数达到 700,如果对象不再被引用,它就会被移除,如果它仍然有引用,它就会被移动到下一代。对于第 2 和第 3 代,重复相同的操作,尽管阈值较低为 10。

这引出了一个问题:在哪里和何时手动调用垃圾收集器是有用的?由于 Python 内存分配器重用内存块并且很少释放它,对于长时间运行的脚本,垃圾收集器可能非常有用。这正是我推荐使用它的地方:在内存紧张的环境中长时间运行的脚本,特别是在分配大量内存之前。

更重要的是,gc模块在寻找内存泄漏时也可以帮助你很多。tracemalloc模块可以显示占用最多内存的部分,但gc模块可以帮助你找到最多定义的对象。只是要小心设置垃圾收集器的调试设置,比如gc.set_debug(gc.DEBUG_LEAK);即使你自己没有保留任何内存,它也会返回大量输出。重新审视我们之前的SpamEggs脚本,让我们看看垃圾收集模块是如何使用内存的:

import gc
import collections

class Spam(object):
    index = 0
    cache = {}

    def __init__(self):
        Spam.index += 1
        self.cache[Spam.index] = self

class Eggs(object):
    eggs = []

    def __init__(self):
        self.eggs.append(self)

if __name__ == '__main__':
    n = 200000
    for i in range(n):
        Spam()

    for i in range(n):
        a = Eggs()
        b = Eggs()
        a.b = b
        b.a = a

    Spam.cache = {}
    Eggs.eggs = []
    objects = collections.Counter()
    for object_ in gc.get_objects():
        objects[type(object_)] += 1

    for object_, count in objects.most_common(5):
        print('%d: %s' % (count, object_))

输出可能与你已经预期的非常接近:

# python3 test_leak.py
400617: <class 'dict'>
400000: <class '__main__.Eggs'>
962: <class 'wrapper_descriptor'>
920: <class 'function'>
625: <class 'method_descriptor'>

大量的dict对象是因为类的内部状态,但除此之外,我们只看到了Eggs对象,就像我们所期望的那样。Spam对象被垃圾收集器正确地移除了,因为它们和所有引用都被移除了。Eggs对象无法被移除,因为存在循环引用。现在我们将使用weakref模块重复相同的示例,看看它是否有所不同:

import gc
import weakref
import collections

class Eggs(object):
    eggs = []

    def __init__(self):
        self.eggs.append(self)

if __name__ == '__main__':
    n = 200000
    for i in range(n):
        a = Eggs()
        b = Eggs()
        a.b = weakref.ref(b)
        b.a = weakref.ref(a)

    Eggs.eggs = []
    objects = collections.Counter()
    for object_ in gc.get_objects():
        objects[type(object_)] += 1

    for object_, count in objects.most_common(5):
        print('%d: %s' % (count, object_))

现在让我们看看这次剩下了什么:

# python3 test_leak.py
962: <class 'wrapper_descriptor'>
919: <class 'function'>
625: <class 'method_descriptor'>
618: <class 'dict'>
535: <class 'builtin_function_or_method'>

除了一些标准内置的 Python 对象,这正是我们所希望的。但要小心弱引用,因为如果被引用的对象消失了,它们很容易爆炸:

import weakref

class Eggs(object):
    pass

if __name__ == '__main__':
    a = Eggs()
    b = Eggs()
    a.b = weakref.ref(b)

    print(a.b())
    del b
    print(a.b())

这导致一个有效的引用和一个无效的引用:

# python3 test_weakref.py
<__main__.Eggs object at 0x104891a20>
None

减少内存使用

总的来说,在 Python 中,内存使用可能不是你最大的问题,但了解如何减少内存使用仍然是有用的。在尝试减少内存使用时,重要的是要了解 Python 如何分配内存。

Python 内存管理器中有四个你需要了解的概念:

  • 首先是堆。堆是所有 Python 管理内存的集合。请注意,这与常规堆是分开的,混合两者可能导致内存损坏和崩溃。

  • 其次是 arena。这些是 Python 从系统请求的块。这些块每个固定大小为 256 KiB,它们是构成堆的对象。

  • 第三,我们有 pools。这些是构成 arena 的内存块。这些块每个大小为 4 KiB。由于 pools 和 arenas 具有固定大小,它们是简单的数组。

  • 第四,最后,我们有 blocks。Python 对象存储在这些 blocks 中,每个 block 的格式取决于数据类型。由于整数占用的空间比字符多,为了效率使用了不同的块大小。

现在我们知道了内存是如何分配的,我们也可以理解它如何被返回给操作系统。每当一个 arena 完全为空时,它可以并且将被释放。为了增加这种情况发生的可能性,一些启发式方法被用来最大化更满的 arenas 的使用。

注意

重要的是要注意,常规堆和 Python 堆是分开维护的,混合它们可能导致应用程序的损坏和/或崩溃。除非你编写自己的扩展,否则你可能永远不必担心手动内存分配。

生成器与列表

最重要的提示是尽可能使用生成器。Python 3 已经在很大程度上用生成器替换了列表,但是牢记这一点确实很值得,因为它不仅节省了内存,而且在不需要一次性保留所有内存时也节省了 CPU。

为了说明区别:

Line #    Mem usage    Increment   Line Contents
================================================
 **4     11.0 MiB      0.0 MiB   @memory_profiler.profile
 **5                             def main():
 **6     11.0 MiB      0.0 MiB    a = range(1000000)
 **7     49.7 MiB     38.6 MiB    b = list(range(1000000))

range() 生成器所占用的内存非常小,甚至不值一提,而数字列表占用了 38.6 MiB

重新创建集合与删除项目

关于 Python 中集合的一个非常重要的细节是,其中许多集合只能增长;它们不会自行收缩。为了说明:

Line #    Mem usage    Increment   Line Contents
================================================
 **4     11.5 MiB      0.0 MiB   @memory_profiler.profile
 **5                             def main():
 **6                             # Generate a huge dict
 **7     26.3 MiB     14.8 MiB   a = dict.fromkeys(range(100000))
 **8
 **9                             # Remove all items
 **10     26.3 MiB      0.0 MiB   for k in list(a.keys()):
 **11     26.3 MiB      0.0 MiB   del a[k]
 **12
 **13                             # Recreate the dict
 **14     23.6 MiB     -2.8 MiB   a = dict((k, v) for k, v in a.items())

这是使用列表和字典时最常见的内存使用错误之一。除了重新创建对象,当然还有使用生成器的选项,这样内存根本就不会被分配。

使用 slots

如果你长时间使用 Python,你可能已经见过类的 __slots__ 功能。它允许你指定你想在类中存储哪些字段,并通过不实现 instance.__dict__ 跳过所有其他字段。虽然这种方法确实在类定义中节省了一点内存,但我不建议使用它,因为使用它有几个缺点。最重要的一个是它使继承变得不明显(给没有 __slots__ 的子类添加 __slots__ 没有效果)。它还使得不可能在运行时修改类属性,并且默认情况下破坏了 weakref。最后,具有 slots 的类不能在没有定义 __getstate__ 函数的情况下被 pickle。

然而,为了完整起见,这里演示了 slots 功能和内存使用的差异:

import memory_profiler

class Slots(object):
    __slots__ = 'index', 'name', 'description'

    def __init__(self, index):
        self.index = index
        self.name = 'slot %d' % index
        self.description = 'some slot with index %d' % index

class NoSlots(object):

    def __init__(self, index):
        self.index = index
        self.name = 'slot %d' % index
        self.description = 'some slot with index %d' % index

@memory_profiler.profile
def main():
    slots = [Slots(i) for i in range(25000)]
    no_slots = [NoSlots(i) for i in range(25000)]
    return slots, no_slots

if __name__ == '__main__':
    main()

和内存使用情况:

# python3 test_slots.py
Filename: test_slots.py

Line #    Mem usage    Increment   Line Contents
================================================
 **21     11.1 MiB      0.0 MiB   @memory_profiler.profile
 **22                             def main():
 **23     17.0 MiB      5.9 MiB   slots = [Slots(i) for i in range(25000)]
 **24     25.0 MiB      8.0 MiB   no_slots = [NoSlots(i) for i in range(25000)]
 **25     25.0 MiB      0.0 MiB   return slots, no_slots

你可能会认为这不是一个公平的比较,因为它们都存储了大量数据,这扭曲了结果。你的确是对的,因为“裸”比较只存储 index 而不存储其他内容,结果是 2 MiB 对比 4.5 MiB。但让我们诚实一点,如果你不打算存储数据,那么创建类实例有什么意义呢?这就是为什么我建议不要使用 __slots__,而是建议使用元组或 collections.namedtuple,如果内存很重要的话。还有一种更节省内存的结构,那就是 array 模块。它几乎是以裸内存数组的方式存储数据。请注意,这通常比列表慢得多,使用起来也不那么方便。

性能监控

到目前为止,我们已经看到了如何测量和改进 CPU 和内存性能,但有一个部分我们完全忽略了。由于数据量的增长等外部因素导致的性能变化是非常难以预测的。在现实生活中的应用程序中,瓶颈并不是恒定的。它们一直在变化,曾经非常快的代码一旦增加了更多的负载就可能变慢。

因此,我建议实施一个监控解决方案,随时间跟踪任何事物的性能。性能监控的一个大问题是,您无法知道未来会出现什么减速以及原因是什么。我甚至曾经因为 Memcached 和 Redis 调用而导致网站减速。这些都是仅用于缓存的内存服务器,响应时间在毫秒内,这使得减速变得极不可能,直到您进行了 100 次以上的缓存调用,缓存服务器的延迟从 0.1 毫秒增加到 2 毫秒,突然之间这 100 次调用需要 200 毫秒而不是 10 毫秒。即使 200 毫秒听起来仍然很少,但如果您的总页面加载时间通常低于 100 毫秒,那么这突然间就是一个巨大的增加,而且肯定是显而易见的。

为了监控性能并能够随时间跟踪变化并找到负责的组件,我个人非常喜欢 Statsd 统计收集服务器以及 Graphite 接口。尽管可用性有点欠缺,但结果是一个图形界面,您可以动态查询以分析性能何时、何地以及如何改变。为了能够使用这些,您将需要将应用程序的指标发送到 Statsd 服务器。为了做到这一点,我编写了 Python-Statsd(pypi.python.org/pypi/python-statsd)和 Django-Statsd(pypi.python.org/pypi/django-statsd)包。这些包允许您从头到尾监控您的应用程序,在 Django 的情况下,您将能够监控每个应用程序或视图的性能,并在其中查看所有组件,例如数据库、模板和缓存层。这样,您就可以准确地知道是什么导致了网站(或应用程序)的减速。

总结

在性能方面,没有圣杯,也没有一种方法可以确保在所有情况下都实现最佳性能。然而,这不应该让您担心,因为在大多数情况下,您永远不需要调整性能,如果需要,一个小调整可能就能解决您的问题。现在您应该能够找到代码中的性能问题和内存泄漏,这才是最重要的,所以请尽量克制自己,只有在真正需要时才进行调整。

本章最重要的要点是:

  • 在投入任何努力之前进行测试。使一些函数更快似乎是一个很大的成就,但实际上很少需要。

  • 选择正确的数据结构/算法比任何其他性能优化更有效。

  • 循环引用会耗尽内存,直到垃圾收集器开始清理。

  • 插槽不值得付出努力。

下一章将讨论多进程,这是一个使您的脚本能够轻松利用多个处理器的库。如果您无法从脚本中挤出更多性能,多进程可能是您的答案,因为每个(远程?)CPU 核心都可以使您的脚本更快。