- 可观测性
- 系统编程
【免费下载链接】psutil
Cross-platform lib for process and system monitoring in Python
导读
本文以 psutil 项目在 2025 年的一次真实测试基础设施优化为主线,系统讲解如何通过禁用插件自动加载、选择性启用插件、monkey-patch 兼容pytest-xdist标记、以及收紧测试收集范围等手段,将单测启动时间从约 0.42 秒压到约 0.30 秒(约 28% 提升)。读完本文,你将掌握一套可复制的 pytest 启动性能排查方法论,以及 psutil 仓库中落地这套优化所需的具体命令、配置与源码改动位置。
一、背景:psutil 从 unittest 迁移到 pytest
psutil 是一个跨平台的 Python 进程与系统监控库,其测试体系长期建立在标准库unittest之上。在使用了 17 年之后,项目开始逐步迁移到 pytest(对应 GitHub issue 2446)。迁移的核心动机有两个:
- 原生的
assert语句:测试中可以直接写assert x == y,失败时 pytest 会自动进行断言重写,输出更清晰的差异信息。 - pytest-xdist 带来的免费并行能力:无需改动测试代码即可通过多进程并行跑测试。
需要强调的是,psutil 的测试类仍然继承自unittest.TestCase,并且刻意不使用conftest.py或 fixture(相关理由记录在 PR 2456 中)。也就是说,这次迁移走的是"最小侵入"路线:保留 unittest 的类体系,仅借用 pytest 的运行器与断言体验。
迁移之后,一个绕不开的问题浮现出来:pytest 的启动速度太慢。对于开发者在命令行反复执行单个测试的场景,这构成了实实在在的开发体验瓶颈。
二、问题复现:pytest 启动到底有多慢
2.1 基准测量
为了量化问题,作者选取了一个执行时间几乎可以忽略的简单测试作为基准——位于 tests/test_misc.py 的TestMisc::test_version,它的全部工作只是校验psutil.__version__与version_info拼接结果一致:
def test_version(self): assert ( '.'.join([str(x) for x in psutil.version_info]) == psutil.__version__ )分别用 pytest 和 unittest 运行同一个测试,对比启动耗时:
$ time python3 -m pytest psutil/tests/test_misc.py::TestMisc::test_version 1 passed in 0.05s real 0m0,427s $ time python3 -m unittest psutil.tests.test_misc.TestMisc.test_version Ran 1 test in 0.000s real 0m0,204s结论很直观:pytest 单测启动约 0.427 秒,unittest 约 0.204 秒,pytest 几乎是前者的两倍。测试本身只消耗 0.05 秒,剩下的大约 0.37 秒全部花在启动与收集阶段——对于一个在开发中会被反复执行的命令来说,这个开销相当可观。
2.2 时间花在哪:导入开销
先用简单的导入实验定位瓶颈。分别测量import pytest、import unittest、import psutil三个模块的耗时:
$ time python3 -c "import pytest" real 0m0,151s $ time python3 -c "import unittest" real 0m0,065s $ time python3 -c "import psutil" real 0m0,056s数据非常清晰:仅仅import pytest就要花掉 0.15 秒,占整个启动时间的三分之一以上。这部分是 pytest 框架本身的模块体系(_pytest包内大量子模块、插件基础设施、断言重写机制等)带来的固有成本,无法通过配置消除。而import psutil本身只有 0.056 秒,说明被测库并不是瓶颈。
由此得出第一个方法论结论:pytest 的启动开销大头在框架导入本身,这部分几乎无解;真正可以优化的是插件自动加载带来的额外导入成本。
三、优化一:禁用插件自动加载(PYTEST_DISABLE_PLUGIN_AUTOLOAD)
3.1 发现"隐藏"的插件
pytest 的一个隐藏行为是:它会自动加载当前 Python 环境中安装的所有 pytest 插件,即使测试根本用不到它们。通过以下命令可以查看实际加载了哪些插件(输出已截断):
$ pytest --trace-config --collect-only ... active plugins: ... setupplan : ~/.local/lib/python3.12/site-packages/_pytest/setupplan.py stepwise : ~/.local/lib/python3.12/site-packages/_pytest/stepwise.py warnings : ~/.local/lib/python3.12/site-packages/_pytest/warnings.py logging : ~/.local/lib/python3.12/site-packages/_pytest/logging.py reports : ~/.local/lib/python3.12/site-packages/_pytest/reports.py python_path : ~/.local/lib/python3.12/site-packages/_pytest/python_path.py unraisableexception : ~/.local/lib/python3.12/site-packages/_pytest/unraisableexception.py threadexception : ~/.local/lib/python3.12/site-packages/_pytest/threadexception.py faulthandler : ~/.local/lib/python3.12/site-packages/_pytest/faulthandler.py instafail : ~/.local/lib/python3.12/site-packages/pytest_instafail.py anyio : ~/.local/lib/python3.12/site-packages/anyio/pytest_plugin.py pytest_cov : ~/.local/lib/python3.12/site-packages/pytest_cov/plugin.py subtests : ~/.local/lib/python3.12/site-packages/pytest_subtests/plugin.py xdist : ~/.local/lib/python3.12/site-packages/xdist/plugin.py xdist.looponfail : ~/.local/lib/python3.12/site-packages/xdist/looponfail.py ...可以看到,除了 pytest 自带的内置插件(setupplan、warnings、logging、faulthandler等),环境中还自动加载了一堆第三方插件:anyio、pytest_cov、instafail、subtests、xdist、xdist.looponfail等。每一个插件都会带来额外的 import 与 hook 注册开销。
3.2 用环境变量一键禁用
pytest 提供了环境变量PYTEST_DISABLE_PLUGIN_AUTOLOAD,设置为任意非空值(惯例用1)即可跳过所有插件的自动加载。再次运行--trace-config验证效果:
$ PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 pytest --trace-config --collect-only下列第三方插件从加载列表中消失了:
anyio pytest_cov pytest_instafail pytest_subtests xdist xdist.looponfail3.3 实测收益
禁用自动加载后重新跑基准测试:
$ time PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 python3 -m pytest psutil/tests/test_misc.py::TestMisc::test_version 1 passed in 0.05s real 0m0,285s从 0.427 秒降到 0.285 秒,约 40% 的提升。仅仅一个环境变量就砍掉了三分之一以上的启动时间,性价比极高。
四、优化二:用-p选择性启用真正需要的插件
禁用自动加载之后,instafail、subtests等真正需要的插件也不会被加载了。解决方案是显式用-p参数按需启用。psutil 实际依赖pytest-instafail(失败即时报出错误)和pytest-subtests(unittest 风格子测试支持),先只启用这两个:
$ time PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 python3 -m pytest -p instafail -p subtests ... real 0m0,320s时间回升到 0.320 秒——每个-p插件都会带来一定的 import 成本,但整体仍明显优于最初的 0.427 秒。接着把pytest-xdist也加上:
$ time PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 python3 -m pytest -p instafail -p subtests -p xdist ... real 0m0,369s0.369 秒。pytest-xdist的导入代价不算大,但在不需要并行执行测试时,这笔开销依然是"冤枉钱"。于是自然的想法是:仅在真正需要并行时再启用xdist。
这一思路也真实体现在 psutil 的工程配置里。在 Makefile 中,测试环境变量统一封装为:
PYTHON_ENV_VARS = PYTHONWARNINGS=always PYTHONUNBUFFERED=1 PSUTIL_DEBUG=1 PSUTIL_TESTING=1 PYTEST_DISABLE_PLUGIN_AUTOLOAD=1而make test与make test-parallel两个目标则分别对应串行与并行两种形态(见 Makefile):
test: ## Run all tests (except memleak tests). # To run a specific test do `make test ARGS=tests/test_process.py::TestProcess::test_cmdline` $(RUN_TEST) $(ARGS) test-parallel: ## Run all tests (except memleak tests) in parallel. $(RUN_TEST) -n auto --dist loadgroup -m 'not isolated' $(ARGS) $(RUN_TEST) -m isolated $(ARGS)此外,pyproject.toml 的[tool.pytest.ini_options]中通过addopts显式声明了插件启用与禁用策略:
[tool.pytest.ini_options] addopts = [ "--capture=no", "--instafail", "--no-header", "--tb=short", "--verbose", "-p instafail", "-p no:doctest", "-p no:junitxml", "-p no:nose", "-p no:pastebin", "-p xdist", ] strict_config = true strict_markers = true可以看到:-p instafail、-p xdist显式开启所需插件,同时用-p no:doctest、-p no:junitxml、-p no:nose、-p no:pastebin显式关闭用不到的内置插件,与博客中"按需启用"的思路完全一致。--durations=5则会在每次跑测试时汇报最慢的 5 个用例,方便持续监控性能。
五、优化三:处理 pytest-xdist 禁用后的标记警告
5.1 问题现象
如果直接禁用pytest-xdist,psutil 的测试依然可以运行,但会出现警告:
psutil/tests/test_testutils.py:367 ~/svn/psutil/psutil/tests/test_testutils.py:367: PytestUnknownMarkWarning: Unknown pytest.mark.xdist_group - is this a typo? You can register custom marks to avoid this warning - for details, see https://docs.pytest.org/en/stable/how-to/mark.html @pytest.mark.xdist_group(name="serial")原因是 psutil 中有一批需要串行执行的测试,它们被@pytest.mark.xdist_group(name="serial")装饰,确保在并行模式下被分配到同一个 worker,避免并发争抢系统级资源。当pytest-xdist被禁用后,xdist_group这个标记随之消失,pytest 就会把装饰器当成"未知标记"报警告。
5.2 解决方案:monkey-patch 一个假的xdist_group
psutil 的解法非常巧妙——在 tests/init.py 中用PYTEST_PARALLEL常量判断是否处于并行模式:
PYTEST_PARALLEL = "PYTEST_XDIST_WORKER" in os.environ # `make test-parallel`pytest-xdist会在每个 worker 进程中设置PYTEST_XDIST_WORKER环境变量,因此该常量在并行模式下为True。接着,在串行模式下注入一个 no-op 版本的xdist_group:
import pytest, functools PYTEST_PARALLEL = "PYTEST_XDIST_WORKER" in os.environ # True if running parallel tests if not PYTEST_PARALLEL: def fake_xdist_group(*_args, **_kwargs): """Mimics `@pytest.mark.xdist_group` decorator. No-op: it just calls the test method or return the decorated class.""" def wrapper(obj): @functools.wraps(obj) def inner(*args, **kwargs): return obj(*args, **kwargs) return obj if isinstance(obj, type) else inner return wrapper pytest.mark.xdist_group = fake_xdist_group # monkey patch这段代码的要点:
- 用
functools.wraps保留原函数元信息(名字、文档字符串、签名等),避免影响 pytest 的收集与断言定位; - 对类对象(
isinstance(obj, type))直接原样返回,对函数则返回一个透明包装; - 通过 monkey-patch
pytest.mark.xdist_group,让所有@pytest.mark.xdist_group(name="serial")装饰的测试在串行模式下"哑化"为普通测试,警告随之消失。
在并行模式下(PYTEST_PARALLEL为真),这段代码不生效,xdist_group保持真实行为,配合如下命令手动启用 xdist 并行:
$ python3 -m pytest -p xdist -n auto --dist loadgroup-n auto让 xdist 根据 CPU 核数自动决定 worker 数量,--dist loadgroup则告诉 xdist 把属于同一xdist_group的测试分配到同一 worker——这正是serial组测试所需的调度语义。
5.3 仓库中的实际使用
在 psutil 测试代码中,serial与isolated两个标记定义在 tests/init.py:
# `@serial` decorator: put all marked tests on the same xdist worker, # so they don't run concurrently in the same process. Needed by tests # that share the same system-wide resource (e.g. a socket) and must not # overlap. serial = pytest.mark.xdist_group(name="serial") # `@isolated` decorator: these tests are skipped under xdist and run in # a second, separate pytest run (`-m isolated`) that uses a single # process. They need a quiet, non-xdist environment, because # they measure noisy per-process or system counters. isolated = pytest.mark.isolated@serial:将标记的测试固定在同一个 xdist worker 上,避免共享系统资源(如端口、ss/netstat对比等)的测试并发冲突,典型用在 tests/test_connections.py、tests/test_linux.py 等文件中;@isolated:标记的测试在并行模式下被跳过,转而在第二阶段的单进程 pytest 运行中执行(即make test-parallel的第二条命令-m isolated),因为它们测量的是对噪音敏感的进程/系统计数器。
PYTEST_PARALLEL还被用于内存泄漏测试的细节控制,例如 tests/test_memleaks.py:
MemoryLeakTestCase.verbosity = 0 if PYTEST_PARALLEL else 1可见这个环境变量判断已经成为 psutil 测试套件中"串行/并行模式分叉"的标准开关。
六、优化四:收紧测试收集范围(testpaths + python_files)
6.1 问题与配置
pytest 默认会递归扫描整个目录树来寻找测试文件,扫描范围越大,收集阶段耗时越长。psutil 通过 pyproject.toml 显式限定:
[tool.pytest.ini_options] testpaths = ["tests/"] python_files = ["test_*.py"]testpaths:告诉 pytest 只在这一个目录下找测试,不再全仓库扫描;python_files:只把匹配test_*.py的文件当作测试文件,跳过其他无关.py文件。
从源码可以看到,psutil 的测试文件确实全部符合test_*.py命名约定(如tests/test_process.py、tests/test_system.py、tests/test_linux.py等,见 tests 目录)。需要留意的是,博客成文时测试目录位于psutil/tests/,而当前仓库布局中测试已统一迁移到根目录的tests/,因此testpaths对应写作["tests/"]——原理完全一致。
6.2 收益
仅仅这两行配置,就让测试收集时间从 0.20 秒降到 0.17 秒,又省下了约 0.03 秒。虽然数值不大,但这是一次性、零维护成本的常驻收益。
另外,Makefile 中还有两个特殊的测试目标体现了同样的"限定范围"思想:
test-docs: ## Run doc sanity tests (outside testpaths, run on demand). $(MAKE) -C docs test ARGS="$(ARGS)" test-bots: ## Run GitHub bot tests (outside testpaths, run on demand). $(PYTHON) -m pytest -o addopts="" .github/workflows/tests/ $(ARGS)文档测试与 CI 机器人测试位于testpaths之外,需要按需单独运行——这也解释了为何它们被排除在常规测试路径之外。
七、效果汇总与结论
把所有优化叠加,最终成果如下:
| 优化项 | 手段 | 启动耗时 |
|---|---|---|
| 基线 | 无 | 0.427s |
| 禁用插件自动加载 | PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 | 0.285s |
| 按需启用插件 | -p instafail -p subtests | 0.320s |
| 再加 xdist | -p xdist | 0.369s |
| 收紧收集范围 | testpaths+python_files | 再省 ~0.03s |
总计约 0.12 秒的优化,使 pytest 启动时间从 0.42 秒降至约 0.30 秒(约 28%)。作者特别指出:对于完整测试套件(成千上万个用例)而言,这 0.12 秒微不足道;但对于开发中高频反复执行的单测命令,28% 的提升感受非常明显。最终成果合入 PR 2538。
值得一提的是,psutil 后续还引入了"两阶段并行"策略(见上文make test-parallel):第一阶段用 xdist 并行跑非isolated测试,第二阶段用单进程串行跑isolated测试。这是对博客所述 xdist 处理方案的进一步演进,也说明"串行/并行分治"是大型测试套件兼顾速度与正确性的通用手法。
八、可复用的优化清单
把本文的方法提炼为一份可直接套用的 checklist:
- 量化现状:用
time python3 -m pytest <单个用例>与time python3 -m unittest <同一用例>对比,确认问题确实出在 pytest 启动而非测试本身。 - 定位导入成本:分别
time python3 -c "import pytest"、time python3 -c "import <被测库>",判断开销大头在框架还是被测代码。 - 审查插件加载:运行
pytest --trace-config --collect-only查看自动加载的插件清单。 - 禁用自动加载:设置
PYTEST_DISABLE_PLUGIN_AUTOLOAD=1,再通过-p <plugin>只启用真正用到的插件;用-p no:<plugin>关闭不需要的内置插件。 - 处理缺失标记:若禁用了提供自定义标记的插件(如
xdist_group),用环境变量(如PYTEST_XDIST_WORKER)判断运行模式,并对缺失的标记做 no-op monkey-patch。 - 收紧收集范围:在
pyproject.toml或pytest.ini中配置testpaths与python_files,让 pytest 只扫描测试目录与测试文件。 - 固化配置:将环境变量、插件参数统一封装进 Makefile 或 CI 配置,确保团队成员与 CI 环境获得一致的加速效果。
对 psutil 仓库而言,以上每一项优化都能在 Makefile、pyproject.toml、tests/init.py 与 tests/conftest.py 中找到对应的工程化落地,感兴趣可以对照源码继续深入。
附:本文数据与结论均来自 psutil 官方博客 docs/blog/2025/speedup-pytest-startup.rst,并结合当前仓库源码(Makefile、pyproject.toml、tests/init.py)核实。文中实测耗时基于作者的 Python 3.12 开发环境,具体数值会随机器与插件环境变化。
- 可观测性
- 系统编程
【免费下载链接】psutil
Cross-platform lib for process and system monitoring in Python
相关推荐
vim-nerdtree-syntax-highlight与vim-devicons完美搭配指南
vim nerdtree syntax highlight与vim devicons完美搭配指南 vim nerdtree syntax highlight是一
10 分钟把智能门锁接入 Home Assistant:远程控制与访客密码完整实操
10 分钟把智能门锁接入 Home Assistant:远程控制与访客密码完整实操 出差第二天早上,你掏出手机想确认前门是否反锁;保洁阿姨周六上午来,你要在她出
文档教程智能家居物联网Notepad-- 完整指南:3 分钟快速装好免费跨平台文本编辑器
Notepad 完整指南:3 分钟快速装好免费跨平台文本编辑器 从老系统导出的文件,拿到新电脑上打开常常是满屏乱码;同一份代码仓库在 Windows 上没问题,
桌面应用
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考