系列文章

Python 从入门到精通

第 32 / 36 篇

从环境安装和基础语法出发,逐步学习工程实践、自动化、数据处理与 Web 开发。

  1. 01
    Python 怎么装、怎么用
  2. 02
    变量、数字和字符串
  3. 03
    列表:把一组数据放在一起
  4. 04
    元组、集合和字典
  5. 05
    条件判断:让程序做选择
  6. 06
    循环:重复的事交给程序
  7. 07
    函数:把代码整理成可复用的块
  8. 08
    模块与包:拆分你的程序
  9. 09
    输入、输出与字符串格式化
  10. 10
    文件读写:保存程序的数据
  11. 11
    异常处理:程序出错时怎么办
  12. 12
    基础阶段练习:命令行记账本
  13. 13
    类和对象:面向对象入门
  14. 14
    继承、组合与特殊方法
  15. 15
    迭代器与生成器
  16. 16
    列表推导式与生成器表达式
  17. 17
    装饰器:给函数增加能力
  18. 18
    上下文管理器与 with
  19. 19
    类型标注与 dataclass
  20. 20
    正则表达式:从文本中找规律
  21. 21
    日期、时间与时区
  22. 22
    日志与调试
  23. 23
    虚拟环境与依赖管理
  24. 24
    测试:让修改不再提心吊胆
  25. 25
    网络请求:用 Python 调用 API
  26. 26
    网页解析与合规采集
  27. 27
    操作 Excel、CSV 与批量文件
  28. 28
    SQLite:给程序加一个数据库
  29. 29
    数据分析入门:NumPy 与 Pandas
  30. 30
    画图:把数据变得直观
  31. 31
    Flask 入门:做一个小网站
  32. 32
    异步编程:同时处理多项任务正在阅读
  33. 33
    线程、进程与并发选择
  34. 34
    性能分析与优化
  35. 35
    项目结构、配置与发布
  36. 36
    综合项目:从需求到上线

查看整个系列 →

你的程序要同时做很多事:一边下载文件、一边处理数据、一边等用户输入。如果一件件排队做,光等网络就能等到天荒地老。异步编程(asyncio)就是解决”等待型任务”的利器——等待的时候去做别的事。

为什么需要异步

先看一个痛点:抓 5 个网页,同步写法要等完一个再等下一个:

import time
import requests

urls = ["https://www.nsn0704y.cn"] * 5

start = time.time()
for url in urls:
    requests.get(url, timeout=10)   # 每个等 0.5 秒,5 个就是 2.5 秒
print(f"同步耗时:{time.time() - start:.2f} 秒")

网络请求大部分时间都在(对方响应)。等的时候 CPU 闲着,纯浪费。异步的思路:发出去请求,不等它回来,先去发下一个,都发完了再统一收结果。

async / await:两个关键字

import asyncio

async def say_hello():
    print("你好")
    await asyncio.sleep(1)      # 模拟等待(比如等网络)
    print("一秒后")

# 直接调用不会执行!async 函数返回协程对象
# say_hello()  ← 啥也不会发生

# 要用事件循环跑起来
asyncio.run(say_hello())

规则:

  • async def 定义协程(可暂停的函数)
  • await 后面跟”可能等待”的操作,遇到等待就让出控制权去干别的
  • 协程必须用 asyncio.run() 或事件循环启动,不能直接调用

并发执行:gather

asyncio.gather 让多个协程同时跑,一起等结果:

import asyncio

async def fetch_one(name, seconds):
    print(f"{name} 开始")
    await asyncio.sleep(seconds)      # 模拟网络等待
    print(f"{name} 完成")
    return f"{name} 的数据"

async def main():
    # 三个协程并发执行,总耗时 ≈ 最长的那个(3 秒),不是 1+2+3=6 秒
    results = await asyncio.gather(
        fetch_one("任务A", 1),
        fetch_one("任务B", 2),
        fetch_one("任务C", 3),
    )
    print(results)    # ['任务A 的数据', '任务B 的数据', '任务C 的数据']

asyncio.run(main())
任务A 开始
任务B 开始
任务C 开始
任务A 完成
任务B 完成
任务C 完成

看到没:三个任务”同时”开始(几乎同时打印”开始”),总时间从 6 秒变 3 秒。这就是异步的威力。

aiohttp:异步版 requests

requests 是同步库,不能配合 await。异步网络请求用 aiohttp

pip install aiohttp
import asyncio
import aiohttp

async def fetch(session, url):
    async with session.get(url) as resp:
        return resp.status, url

async def main():
    urls = [
        "https://www.nsn0704y.cn",
        "https://www.baidu.com",
        "https://www.bilibili.com",
    ]
    async with aiohttp.ClientSession() as session:
        # 并发抓取所有 URL
        results = await asyncio.gather(
            *(fetch(session, url) for url in urls)
        )
        for status, url in results:
            print(f"{status} {url}")

asyncio.run(main())

和同步代码长得几乎一样,只是:session.get 前加 await、用 async with、外面套 async def

实战:并发抓取提速对比

import asyncio
import time
import aiohttp

async def fetch_one(session, url):
    async with session.get(url, timeout=10) as resp:
        return len(await resp.text())

async def fetch_all(urls):
    async with aiohttp.ClientSession() as session:
        return await asyncio.gather(*(fetch_one(session, u) for u in urls))

urls = ["https://www.nsn0704y.cn"] * 10

start = time.time()
sizes = asyncio.run(fetch_all(urls))
print(f"异步 10 个请求:{time.time() - start:.2f} 秒")

# 对比:同步 requests 逐个来
import requests
start = time.time()
for u in urls:
    requests.get(u, timeout=10)
print(f"同步 10 个请求:{time.time() - start:.2f} 秒")

网络环境好的话,异步能快 5-10 倍。注意频率控制:抓别人网站时别并发太高,加个信号量限流(asyncio.Semaphore)。

超时和异常

import asyncio

async def slow_task():
    await asyncio.sleep(10)
    return "太慢了"

async def main():
    try:
        # 超时控制:等 2 秒,等不到就取消
        result = await asyncio.wait_for(slow_task(), timeout=2)
        print(result)
    except asyncio.TimeoutError:
        print("任务超时了")

    # 单个协程出错,gather 默认会抛异常
    try:
        await asyncio.gather(
            maybe_fail(1),
            maybe_fail(0),   # 这个会出错
        )
    except ValueError as e:
        print(f"捕获错误:{e}")
    # 想忽略单个错误用 return_exceptions=True:
    # results = await asyncio.gather(..., return_exceptions=True)

async def maybe_fail(x):
    if x == 0:
        raise ValueError("x 不能是 0")
    return x

asyncio.run(main())

网络代码的三大件在异步里一样不能少:超时、异常处理、频率控制

异步 vs 多线程:怎么选

很多人在这里纠结,一句话分清:

  • IO 密集(等网络、等磁盘、等数据库):用异步——等待时让出 CPU,单线程就能扛大量并发
  • CPU 密集(大量计算、数据处理):用多进程/多线程(下篇讲)——需要多核并行
  • 混合场景:异步为主,计算部分用线程池 asyncio.to_thread() 丢出去

记不住就记:等得多的用异步,算得多的用多进程

新手坑

坑 1:忘了 await。session.get(url) 没加 await,返回的是协程对象不是响应,运行时报”coroutine was never awaited”警告。检查每个异步调用。

坑 2:在异步里用同步库。requests 不能配合 await,会阻塞整个事件循环(等于所有任务排队等它)。用 aiohttp。

坑 3:并发太高被 ban。抓网站用 asyncio.Semaphore(5) 限流,别一股脑全发。

小结

  • async def 定义协程,await 暂停等待,asyncio.run() 启动
  • asyncio.gather 并发跑多个协程,总耗时≈最慢那个
  • 异步网络用 aiohttp(requests 会阻塞事件循环)
  • 超时 wait_for、异常 return_exceptions、限流 Semaphore
  • 选型:IO 密集用异步,CPU 密集用多进程

练习

  1. 用 aiohttp 并发请求 5 个 URL,统计总耗时,和同步版对比
  2. 用 asyncio.Semaphore(3) 限流,写一个”每次最多 3 个并发”的抓取任务
  3. 思考:为什么在 async 函数里调用同步的 requests.get 会拖慢所有任务?”事件循环”在这中间扮演什么角色?