bigywater.tech
爬虫工程 · 20

事件循环里的阻塞调用:解析 HTML 把整个爬虫卡住多久

asyncio 爬虫最隐蔽的性能问题是:在协程里放了一个同步的耗时调用。事件循环被卡住,所有其它协程(包括网络收发)一起停。这篇用一个心跳任务量化这种“卡住”,并对比线程池、进程池、to_thread 几种办法。

1293 ms
直接在协程里解析 20 个大页面时,事件循环的最大延迟(心跳任务测得)
557 ms
同样的 20 个页面,解析放进 4 个进程的进程池后的总耗时(线程池 1518 ms)
峰值 1
协程里直接调用同步的 requests.get 时,服务器看到的并发峰值——10 个“并发”变成串行

一次解析要多久

实测 · 本机执行输出
  lxml.html.fromstring + xpath 统计链接数:40000 个链接,每次 89 ms(5 次中位数)

4.7 MiB 的页面一次解析约 89 ms;如果每个页面抓完立刻在协程里同步解析,20 个页面就是 20 次连续的 89 ms,事件循环被卡住,期间别的页面的数据也收不进来。

把解析挪出事件循环

实测 · 本机执行输出
  直接在协程里解析(同步调用):20 个页面全部完成 1776 ms;心跳 7 次,最大延迟 1293 ms,延迟超过 50 ms 的 3 次;解析结果一致 True
  run_in_executor(None, …)(线程池):20 个页面全部完成 1518 ms;心跳 118 次,最大延迟 15 ms,延迟超过 50 ms 的 0 次;解析结果一致 True
  run_in_executor(ProcessPoolExecutor(4), …)(进程池):20 个页面全部完成 557 ms;心跳 67 次,最大延迟 13 ms,延迟超过 50 ms 的 0 次;解析结果一致 True

心跳任务显示:直接解析时最大延迟 1293 ms(多次运行 657–1293 ms),放进线程池后只剩 15 ms,进程池 13 ms。但线程池对 CPU 密集的 lxml 解析几乎没有加速(总耗时 1518 ms 对 1776 ms),进程池才让 4 个核心真正并行(557 ms)。线程池为什么没有加速,下一批文章会单独看(lxml 与 GIL);进程池要付序列化页面内容的代价,这里没有拆开测。

同步的网络调用

实测 · 本机执行输出
  协程里直接 requests.get:10 个请求共 1079 ms;心跳 1 次,最大延迟 1069 ms,延迟超过 50 ms 的 1 次;服务器同时处理的峰值 1
  aiohttp:10 个请求共 112 ms;心跳 10 次,最大延迟 2 ms,延迟超过 50 ms 的 0 次;服务器同时处理的峰值 10
  asyncio.to_thread(requests.get, …):10 个请求共 114 ms;心跳 10 次,最大延迟 2 ms,延迟超过 50 ms 的 0 次;服务器同时处理的峰值 10

在 async def 里直接写 requests.get(...) 是最典型的错误:服务器看到的并发峰值只有 1,10 个请求花了 1079 ms,事件循环整段时间没有醒过一次。换成 aiohttp 或者 asyncio.to_thread(requests.get, …) 都能恢复 10 个并发(约 112–114 ms)。

怎么发现

实测 · 本机执行输出
  Executing <Task finished name='Task-1' coro=<main() done, defined at …/dbg.py:3> … took 0.352 seconds   (进程退出码 0,这是 asyncio 记在 stderr 上的警告,不是异常)

asyncio 调试模式会在回调超过 slow_callback_duration(默认 0.1 秒)时在 stderr 上打一条 Executing … took … seconds 的警告。它不抛异常、不改变退出码,所以要把日志看起来。

分步演示

事件循环里的阻塞调用
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Python 3.14.8 / requests 2.34.2 / aiohttp 3.14.3 / lxml 6.1.1(系统 Python);所有请求只发给本机自己起的测试服务器(127.0.0.1,端口 9960 以上,不访问任何真实网站);服务器可以按实验需要制造延迟、断连、压缩、重定向等行为,并记录收到的原始请求;计时用 time.perf_counter,数字每次运行会有小幅抖动。页面是 4 万行表格(4.7 MiB),解析用 lxml.html.fromstring 加 xpath("count(//a)");心跳任务每 10 ms 醒一次,延迟 = 实际醒来时间 − 预期时间;进程池是 4 个进程并预先预热;aiohttp 3.14.3;每种做法跑一次(心跳延迟每次运行会有明显差异,直接解析的最大延迟我见到过 657 到 1293 ms)
    • 没有验证:线程池为什么没有加速 lxml 解析(是否 lxml 在解析期间释放了 GIL,下一批文章单独测);进程池里序列化页面的开销;uvloop 下的表现;解析结果很大时返回值的传输成本;用 asyncio.to_thread 之外的其它非阻塞化办法(如异步解析库);Python 自由线程构建
    京ICP备2026067502号-1京公网安备11010502063487号