threadpoolexecutor 是多数爬虫场景的合理起点,因其自动管理线程与任务队列、避免手动线程阻塞和 asyncio 复杂性,兼顾速度、稳定性与容错性。

为什么 concurrent.futures.ThreadPoolExecutor 是多数爬虫场景的合理起点
它不是最底层的控制方式,但足够快、够稳、够少出错——尤其当你只关心“发请求→取响应→解析→存结果”这条主链路时。ThreadPoolExecutor 自动管理线程生命周期和任务队列,避免手动 threading.Thread + join() 的阻塞陷阱,也绕开了 asyncio 那套 event loop 和协程适配的复杂性。
常见错误现象:urllib3.exceptions.MaxRetryError 或连接超时集中爆发,其实是没设好并发数或没配重试;或者主线程提前退出,子线程还在跑(忘了 executor.shutdown(wait=True))。
- 默认最大线程数是 CPU 核心数 × 5,对 I/O 密集型爬虫通常偏高,建议显式设为
max_workers=10起步 - 每个
submit()提交的是一个完整函数调用,别传裸的requests.get(url),要包成可调用对象(比如带异常捕获的封装函数) - 不推荐在
submit()里直接写解析逻辑——失败后难定位;应只做请求,返回Response对象再统一处理
如何安全地提交 URL 列表并收集结果
别用 map() 直接扔一串 URL 进去,除非你确定所有请求结构完全一致且无需单独容错。更稳妥的是用 submit() + as_completed(),它按完成顺序返回,便于流式处理,也方便跳过失败项继续跑。
使用场景:需要控制超时、记录失败 URL、动态调整请求头或代理、或后续要按响应状态码分流处理。
-
submit(fetch_page, url, timeout=10, headers=hdrs)比submit(requests.get, url, timeout=10)更可控——前者能加 try/except,后者异常会直接卡住 future -
as_completed(future_list)返回的是Future对象,必须调用.result()才真正抛异常;不调就永远不知道哪次请求挂了 - 如果某次请求返回
None或空内容,大概率是被反爬拦截(状态码 403/429)或网络中断,.result()不会自动重试,得自己在封装函数里加逻辑
requests.Session 和线程安全之间的坑
Session 本身不是线程安全的——多个线程共用同一个 Session 实例,可能触发连接池竞争、cookie 混乱或 ConnectionPool is full 报错。这不是 bug,是设计使然。
性能影响明显:共用 Session 看似省开销,实则因锁争抢反而拖慢整体吞吐;而每个线程配独立 Session,初始化成本极低(只是字典和连接池对象),但规避了全部同步开销。
- 正确做法:在每个提交的任务函数内部创建
requests.Session(),或用threading.local()绑定单线程实例(稍重,一般没必要) - 别在全局定义
session = requests.Session()然后传进submit()—— 多数情况它会被不同线程反复复用 - 如果真要用共享 Session(例如需保持登录态),必须配合
threading.Lock,但会显著降低并发效率,不如改用带 session 管理的异步库(如aiohttp)
什么时候该停手,换别的方案
当发现平均响应时间 > 2s、失败率 > 15%、或目标网站明确返回 429 Too Many Requests 时,ThreadPoolExecutor 已经到实用边界了。它不解决反爬、不自动降频、不维护上下文状态,强行堆线程只会让 IP 被封得更快。
容易被忽略的点:DNS 解析、SSL 握手、连接复用这些底层耗时,在多线程下会放大为资源争抢;而 asyncio + aiohttp 能把这部分压到单线程内调度,实际吞吐反而更高——但这需要重写整个请求逻辑,不是改两行就能切的。
- 若目标站有登录态、表单 Token、JS 渲染依赖,线程模型基本无解,得上
selenium或playwright(但别用多线程驱动浏览器,用进程或分布式) - 若需持久化大量中间状态(如 cookie 池、IP 代理轮换计数),线程间共享数据成本高,更适合用
multiprocessing或外部存储(Redis) - 别迷信“并发数越高越快”,真实瓶颈常在 DNS、TLS、远端服务器限流,而不是你本地线程数
Python免费学习笔记(深入):立即使用
在学习笔记中,你将探索 Python 的核心概念和高级技巧!











