kp-019

异步并发:asyncio 与 aiohttp

进阶 ≈ 30 分钟 asyncioaiohttp并发协程Semaphore

前置知识

本文基于模型知识整理,建议核对官方文档(见 参考资料)。

一句话定义

asyncio 用单线程事件循环在等待网络 I/O 时切换执行其他协程,配合 aiohttp 可用极低资源实现数百并发请求——爬虫是异步模型最典型的受益场景。

为什么重要

抓取是纯 I/O 密集型工作:95% 的时间在等响应。同步代码 1000 个请求 × 0.2 秒 ≈ 200 秒;异步并发 20 路约 10–20 秒完成同样的活,且内存占用远低于线程池。它是自建下载器与 kp-036 云原生的技术底座。

前置知识

kp-006(HTTP 客户端经验)、kp-017(并发必须配限速)。

核心概念

  • 事件循环:调度就绪协程的单线程循环;协程在 await 处让出控制权。
  • 协程函数:async def 定义,调用返回协程对象,需 await 或交给循环执行。
  • Semaphore:并发闸门,限制同时在途的请求数。
  • gather(return_exceptions=True):并发执行一批协程且不让单个失败炸掉全局。
  • aiohttp.ClientSession:异步版 Session,连接池复用,必须作为上下文管理器使用。

原理与机制

同步与异步的耗时对比模型(1000 请求、单请求 0.2s、服务端不瓶颈):

同步串行:        1000 × 0.2s            ≈ 200s
同步+20线程:     ≈ 10s   (线程切换+内存开销大)
asyncio+20并发:  ≈ 10s   (单线程, 开销极小)
上限由「并发度 × 远端接受度」决定 → 礼貌限速仍是前提

标准模板(Semaphore 限并发 + 客户端超时 + 失败隔离):

import asyncio, aiohttp

async def fetch(session, sem, url):
    async with sem:                                  # 并发闸门
        try:
            async with session.get(url, timeout=aiohttp.ClientTimeout(total=20)) as r:
                r.raise_for_status()
                return await r.text()
        except Exception as e:
            return {"error": str(e), "url": url}     # 失败也返回, 不炸全局

async def main(urls):
    sem = asyncio.Semaphore(10)                      # 并发上限 10
    async with aiohttp.ClientSession() as session:   # 全局一个 Session
        results = await asyncio.gather(
            *(fetch(session, sem, u) for u in urls),
            return_exceptions=True,
        )
    return results

asyncio.run(main(["https://books.toscrape.com/"] * 30))

三条铁律:协程内禁用 time.sleep(会卡死整个循环,须用 asyncio.sleep);全局只建一个 ClientSession;并发上限与 QPS 必须显式设置(Semaphore/令牌桶),否则等于对目标站发动压力测试。

实例或案例

把 kp-010 的「列表收集 URL + 详情抓取」异步化:先 gather 并发抓完 50 个列表页汇总 5000 个详情 URL,再以并发 10 分批 gather 详情页——总耗时从小时级降到分钟级,而代码只比同步版多 5 行。

常见误区

  • 误区一:协程里用阻塞调用。 requests、time.sleep、同步 DB 驱动都会冻结事件循环;要么换异步库,要么 loop.run_in_executor 包裹。
  • 误区二:并发数设到几百。 触发对端限流甚至封禁,也打满本机 fd;10–20 并发 + 限速是礼貌默认值。
  • 误区三:gather 不带 return_exceptions。 一个 404 让整批任务报废;收集失败项单独重试才是工程做法。

自测题

  1. 为什么爬虫特别适合 asyncio?

答:爬虫是 I/O 密集(绝大部分时间在等网络),事件循环在等待期切换协程即可,无需线程开销。

  1. asyncio.Semaphore(10) 放在哪一层才正确?

答:包住每次请求的临界区(async with sem 内发起请求),使全任务同时在途请求 ≤ 10。

  1. 协程中调用 time.sleep(1) 的后果?

答:阻塞整个事件循环,所有协程停摆 1 秒;必须用 asyncio.sleep。

与其他知识点的关系

kp-018 的 Scrapy 内部正是 Twisted 异步框架(同思想不同实现);kp-022 的 worker 通常以 asyncio 编写;kp-036 无头集群的调度层也依赖异步。

延伸阅读

Python 官方 asyncio 文档「Coroutines and Tasks」;aiohttp 官方 quickstart。

相关知识点

学习进度