asyncio + aiohttp 更适合网页爬虫,因其在等待网络i/o时自动切换协程,单线程可并发数百请求;而 threading 在阻塞时空转且调度开销大,吞吐受限。

为什么 asyncio + aiohttp 比 threading 更适合网页爬虫
因为 DNS 解析、TCP 握手、TLS 协商、等待响应这些操作,90% 时间都在等网络 I/O,线程在这期间空转,调度开销反而拖慢整体吞吐。而 asyncio 在等待时自动切走,单线程就能并发跑几百个请求。
常见错误现象:threading.Thread 开 100 个线程后 CPU 不高但吞吐卡在 20 QPS,urllib.request 或 requests.get 阻塞主线程导致协程无法调度。
-
aiohttp.ClientSession必须复用,每次新建会重建连接池,触发重复 DNS 查询和 TCP 握手 - 别在协程里调
time.sleep(),改用await asyncio.sleep() - 如果目标站点有反爬,
aiohttp的并发太快容易被封 IP,得加semaphore控制并发数,不是靠“多开线程”解决
如何安全控制并发数并避免被封
硬设 asyncio.Semaphore(10) 是最直接的方式,但它只管请求数,不管每个请求耗时差异大时的资源堆积问题。
使用场景:爬取 5000 个 URL,其中 30% 响应超 5 秒,其余 200ms 内返回;若不限流,短请求会被长请求阻塞在队列里。
- 用
asyncio.Semaphore包裹session.get()调用,不是包裹整个fetch()函数 - 设置
timeout:传aiohttp.ClientTimeout(total=10, connect=3),避免单个请求卡死整个协程池 - 对 429、503 状态码做退避重试,但别用固定间隔,改用指数退避 + jitter:
await asyncio.sleep(min(60, 0.5 * (2 ** attempt) + random.uniform(0, 0.1)))
concurrent.futures.ThreadPoolExecutor 什么时候还值得用
当你要解析大量 HTML(比如用 lxml 或 BeautifulSoup)且 CPU 密集度高时,纯 asyncio 反而变慢——Python 的 async 不释放 GIL,CPU 绑定任务必须进线程池。
性能影响:在协程中同步解析 10MB HTML,可能阻塞事件循环 200ms,导致其他请求超时;扔给 ThreadPoolExecutor 后,事件循环照常运转。
- 初始化
ThreadPoolExecutor(max_workers=4)就够,再多反而因上下文切换增加开销 - 用
loop.run_in_executor(None, parse_html, html_text),第一个参数别写executor,否则无法被 event loop 正确回收 - 别把
requests.get塞进去——这是典型的“用线程模拟异步”,既没解决 I/O 等待,又引入线程管理成本
怎么持久化结果又不拖慢爬取速度
直接写文件或发 INSERT INTO 会变成串行瓶颈,尤其磁盘 IO 或数据库连接池有限时,协程一等就全卡住。
容易踩的坑:open(...).write() 在协程里调用,或用 sqlite3.connect() 在主线程里反复创建连接。
- 批量写入:攒够 100 条再
executemany,用asyncpg或aiomysql替代同步驱动 - 文件落地用
asyncio.to_thread()(Python 3.9+)包装json.dump或csv.writer,比自己建ThreadPoolExecutor更轻量 - 如果只是临时缓存中间结果,优先用内存结构(如
deque)+ 定期 flush,别每条都落盘
真正难的不是并发模型选型,是判断哪一步该 async、哪一步该 thread、哪一步该 batch——边界模糊时,先压测再切分。











