bigywater.tech
爬虫工程 · 24

lxml 解析与 GIL:多线程能不能并行

“lxml 是 C 写的,线程应该能并行吧?”在爬虫里这是个常见假设。这篇用 hashlib、zlib 这样已知释放 GIL 的操作,和纯 Python 循环这样已知不释放的操作做参照,把 lxml 的解析、xpath、序列化逐项量一遍,看多线程到底能不能加速。

0.68–1.15 倍
lxml 解析 HTML 用 8 个线程的加速比范围——不能并行
约 4.1 倍
etree.tostring 序列化用 8 个线程的加速比——能并行
约 2 倍
XML 解析(etree.fromstring)用 8 个线程的加速比

先用对照组校准

实测 · 本机执行输出
  任务                                        单次      1线程     2线程     4线程     8线程
  对照:hashlib.sha256(8 MiB)               3.3 ms    52 ms   1.95x   3.81x   5.61x
  对照:zlib.compress(8 MiB)              132.7 ms  2138 ms   1.99x   3.89x   5.63x
  对照:纯 Python 循环                        13.2 ms   213 ms   0.99x   0.98x   1.00x
  对照:re.findall(Python 正则,文本)            1.0 ms    16 ms   0.92x   0.91x   0.92x

sha256 和 zlib 在 8 线程下达到约 5.6 倍,说明这台机器上线程确实能并行;纯 Python 循环和 re.findall 约 1 倍(正则略慢于 1)。后面所有 lxml 数字都和这两端比。

HTML 解析

实测 · 本机执行输出
  任务                                        单次      1线程     2线程     4线程     8线程
  lxml.html.fromstring(bytes)           14.5 ms   274 ms   0.90x   0.98x   0.90x
  etree.HTML(bytes)(C 层 HTML 解析器)       15.6 ms   299 ms   1.08x   1.09x   1.08x
  lxml.html.parse(文件路径)                 14.9 ms   279 ms   0.98x   0.98x   0.68x
  lxml.html.parse(BytesIO)              17.8 ms   322 ms   1.15x   1.15x   1.14x

lxml.html.fromstring、etree.HTML、lxml.html.parse(不论文件还是 BytesIO)在 8 线程下都在 0.68–1.15 倍之间,几乎完全串行。etree.HTML 直接调用 C 层的解析器,结果和 lxml.html 一样,说明不是 Python 包装层的原因。解析器内部为什么不能并行,我没有查源码。

XML 解析

实测 · 本机执行输出
  任务                                        单次      1线程     2线程     4线程     8线程
  etree.fromstring(XML bytes)            3.4 ms    67 ms   1.31x   1.68x   2.10x
  etree.parse(XML 文件路径)                  3.5 ms    70 ms   1.35x   1.65x   1.98x

同一个库的 XML 解析器可以并行,8 线程约 2 倍(fromstring 2.10 倍,从文件 1.98 倍),但远没有 sha256 那样接近线性。

解析之后

实测 · 本机执行输出
  任务                                        单次      1线程     2线程     4线程     8线程
  xpath(已解析的树,每线程用自己的树)                  3.6 ms   101 ms   1.11x   1.11x   1.07x
  cssselect 风格:iter + 逐元素读 text          2.1 ms    36 ms   0.92x   0.99x   0.99x
  etree.tostring(序列化)                    8.1 ms   132 ms   1.69x   3.80x   4.14x

xpath 和逐元素取值没有加速,序列化 tostring 约 4.1 倍(多次运行 4.1–5.2 倍)。所以“解析 + 抽取”流程里线程基本帮不上忙,只有写出去的那一步能并行。要并行解析和抽取,上一篇的进程池才是办法。

分步演示

lxml 与 GIL
本步的对象

    本文用到的工具与边界

    • 本机 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,数字每次运行会有小幅抖动。每个负载做 16 个任务,用 ThreadPoolExecutor 的 1、2、4、8 个线程跑,取 3 次中位数;加速比 = 1 线程总耗时 / N 线程总耗时;HTML 页面 8000 行表格(约 0.8 MB),XML 9000 个 item;xpath 与迭代实验里每个任务用自己的一棵树(预先解析好);对照组:hashlib.sha256 和 zlib.compress(level 1)处理 8 MiB 随机数据,纯 Python 求平方和,Python re.findall;lxml 6.1.1;整个实验跑了 3 遍,数字有明显波动(HTML 解析 0.68–1.15,tostring 4.1–5.2),文中取最后一遍并标出范围
    • 没有验证:lxml 的 HTML 解析器为什么不释放 GIL(没有读源码,也没有查文档);自由线程(no-GIL)构建下的表现;不同页面大小、不同 libxml2 版本下的结果;selectolax、html5lib 等其它解析库;解析 + xpath 联合时的并行度;进程池里 lxml 的内存开销
    京ICP备2026067502号-1京公网安备11010502063487号