2025年,异步编程已成为Python高并发场景下的标配技能。无论是爬取千级页面,还是构建高性能API,async/await + asyncio都能让I/O密集型任务近乎线性加速。本文从一个完整的“异步图片爬虫”案例切入,逐步拆解协程、事件循环、任务编排等核心概念,并扩展至异步Web服务,帮你彻底掌握Python异步编程。
一、为什么你需要异步编程?
传统同步代码在遇到I/O操作(如网络请求、文件读写)时会阻塞线程,导致CPU空闲等待。而异步编程通过协程(Coroutine)在单个线程内实现任务的协作式切换:遇到I/O等待时自动挂起,执行其他任务,待I/O完成再恢复。这使得单线程也能承载数千个并发连接,资源消耗远低于多线程/多进程。
Python的asyncio模块提供了事件循环(Event Loop)来调度协程。配合async/await语法,代码看起来和同步代码几乎一样,但内部却是非阻塞的。
二、核心概念:协程、可等待对象与事件循环
- 协程函数:使用
async def定义的函数。调用它不会立即执行,而是返回一个协程对象。 - 可等待对象:协程、Task、Future。使用
await可以挂起当前协程,等待结果。 - 事件循环:运行协程的调度器。通过
asyncio.run()启动。
下面是最简入门代码:
import asyncio
async def say_hello():
print("Hello")
await asyncio.sleep(1) # 模拟I/O等待
print("World")
asyncio.run(say_hello())
asyncio.sleep(1)是非阻塞的:在等待的1秒内,事件循环可以执行其他协程。
三、实战案例:异步图片爬虫(并发下载20张图)
我们以Lorem Picsum提供的随机图片为例,用aiohttp实现高并发下载。先安装依赖:pip install aiohttp。
3.1 定义异步下载函数
import aiohttp
import asyncio
import os
SAVE_DIR = "async_images"
os.makedirs(SAVE_DIR, exist_ok=True)
async def download_one(session, url, index):
print(f"开始下载第 {index} 张: {url}")
async with session.get(url) as response:
if response.status == 200:
content = await response.read()
file_path = os.path.join(SAVE_DIR, f"img_{index}.jpg")
with open(file_path, "wb") as f:
f.write(content)
print(f"第 {index} 张下载完成,大小: {len(content)} 字节")
else:
print(f"第 {index} 张下载失败,状态码: {response.status}")
注意:session.get和response.read()都是异步非阻塞的,await会挂起当前协程直到数据就绪。
3.2 编排并发任务
async def main():
# 创建共享会话(连接池复用)
async with aiohttp.ClientSession() as session:
tasks = []
# 生成20个不同的图片URL(picsum.photos 随机返回图片)
for i in range(20):
url = f"https://picsum.photos/800/600?random={i}"
task = asyncio.create_task(download_one(session, url, i+1))
tasks.append(task)
# 等待所有任务完成
await asyncio.gather(*tasks)
if __name__ == "__main__":
asyncio.run(main())
asyncio.create_task()将协程包装成Task并立即调度到事件循环。asyncio.gather()等待所有Task完成。运行脚本,你将看到图片几乎同时开始下载,总耗时仅相当于单张最慢下载时间,而非20倍。
3.3 运行效果与性能对比
在普通网络下,同步顺序下载20张图约需40~60秒,而异步并发版本仅需3~5秒(取决于网络带宽)。这是因为等待I/O时CPU在干其他活。
四、进阶技巧:信号量控制并发数与异常处理
如果无限制并发,可能会被目标网站封IP或导致本地资源耗尽。使用asyncio.Semaphore控制同时进行的任务数:
sem = asyncio.Semaphore(5) # 最多5个并发
async def download_with_sem(session, url, index):
async with sem: # 获取信号量
return await download_one(session, url, index)
# 在main中创建任务时使用 download_with_sem 即可
另外,asyncio.gather默认一任务异常就取消其他任务。若想每个任务独立,可设置return_exceptions=True:
results = await asyncio.gather(*tasks, return_exceptions=True)
for r in results:
if isinstance(r, Exception):
print(f"任务异常: {r}")
五、扩展:构建一个异步Web API (基于aiohttp)
异步编程不仅用于客户端,也适用于服务端。下面用aiohttp搭建一个简易API,返回图片下载状态。
5.1 服务端代码
from aiohttp import web
async def handle_download(request):
# 模拟异步下载任务
await asyncio.sleep(2)
return web.json_response({"status": "downloaded", "url": str(request.url)})
app = web.Application()
app.router.add_get('/download', handle_download)
if __name__ == '__main__':
web.run_app(app, port=8080)
启动后访问 http://localhost:8080/download,2秒后返回JSON。由于是异步,同时处理100个请求时,每个请求都不会阻塞其他请求。
5.2 压测简单对比
使用wrk或ab工具对同步Flask和异步aiohttp进行压测(模拟100并发),你会发现aiohttp的吞吐量是Flask的3~5倍以上,而内存占用更低。
六、常见陷阱与最佳实践
- 不要在协程中使用阻塞库:如
requests、time.sleep()。它们会阻塞整个线程,破坏异步效果。应使用aiohttp、asyncio.sleep()等。 - 慎用同步锁:多线程锁(如
threading.Lock)在协程中无效,应使用asyncio.Lock。 - 避免创建过多Task:大量任务可能导致内存飙升。推荐使用
Semaphore或asyncio.Queue进行限流。 - 使用
asyncio.run()作为入口:它会自动创建事件循环、管理协程生命周期,并清理资源。
七、总结
Python异步编程通过async/await + asyncio让单线程并发变得优雅且高效。本文从协程基础出发,完成了异步图片爬虫和简单API两个实战案例,并介绍了信号量控制、异常处理等进阶技巧。掌握这些,你就能在爬虫、Web服务、消息队列等场景中轻松驾驭高并发。
下一步可以尝试结合aiofiles做异步文件操作,或使用FastAPI(基于asyncio)构建生产级API。异步编程的世界远不止于此,但你已经拿到了最重要的钥匙。
本文所有代码均在 Python 3.10+ 环境下测试通过。如果你在运行中遇到问题,欢迎交流探讨。

