Python aiohttp:用单线程撑起万人访问的 Web 服务器
引言:把"服务员巡台"用到极致
上一篇我们学了 asyncio:一个线程通过事件循环并发处理多个 IO 任务,就像一个服务员照顾 10 桌客人。
但你可能注意到了:上一篇的例子是客户端——我们的程序主动去抓三个网站。这相当于服务员只给自家人上菜,威力有限。
如果把 asyncio 用在服务器端呢?
想象一下:你的服务员不再服务自家,而是开了一家餐厅,门口排了几千个客人。传统做法是每来一桌客人就雇一个新服务员(多线程),餐厅很快人满为患、工资都发不起。而 asyncio 的做法是:一个服务员加上"巡台小本子"(事件循环),同时照顾所有客人——客人看菜单时他去服务别人,厨房上菜慢时他绝不空等。一个人撑起整个餐厅。
aiohttp 就是基于 asyncio 实现的 HTTP 框架,让你能用单线程写出支撑高并发的 Web 服务器。这一篇,我们从"为什么服务器端更需要异步"讲起,手把手搭一个 aiohttp Web 服务器,补充客户端用法,最后通过常见误区解析帮你避开生产环境的坑。
一、为什么服务器端才是 asyncio 的主战场?
1.1 客户端 vs 服务器端
| 场景 | 客户端(爬虫) | 服务器端(Web 服务) |
|---|---|---|
| 并发量 | 自己控制,几十几百个任务 | 用户决定,可能瞬间几千个请求 |
| 持续时间 | 抓完就结束 | 7×24 小时不间断 |
| 资源消耗 | 无所谓,跑完就释放 | 线程/进程多了服务器直接崩溃 |
| 异步收益 | 快一点 | 生死攸关 |
生活化理解:客户端爬虫像周末去逛街,多等一会儿没关系;服务器像春运火车站售票窗口,每个窗口(线程)慢一秒,后面就积压几千人。
1.2 HTTP 连接就是典型的 IO 操作
Web 服务器处理请求时,绝大部分时间在等待:
- 等待客户端把请求数据发过来(网络 IO);
- 等待数据库返回查询结果(数据库 IO);
- 等待调用第三方接口(外部 API IO);
- 等待把响应发回客户端(网络 IO)。
真正 CPU 计算的时间不到 1%。这意味着:单线程 + 事件循环能把等待时间全部利用起来,一个线程轻松扛住几千并发连接。
1.3 aiohttp 是什么?
asyncio 只实现了底层的 TCP、UDP、SSL 协议,相当于给了你"锅碗瓢盆";而 aiohttp 在这些基础上封装好了 HTTP 协议,相当于给你一套现成的厨房——你只管做菜(写业务逻辑),不用自己砌灶台。
aiohttp 同时提供:
- Web 服务器框架(类似 Flask,但支持异步);
- HTTP 客户端(类似 requests,但支持异步)。
二、动手搭建:第一个 aiohttp Web 服务器
2.1 安装
pip install aiohttp2.2 需求
写一个 HTTP 服务器,处理两种 URL:
/—— 首页,返回Index Page;/{name}—— 根据 URL 里的名字,返回Hello, {name}!。
2.3 完整代码
# app.py
from aiohttp import web
async def index(request):
"""处理首页 / 的请求"""
text = "<h1>Index Page</h1>"
return web.Response(text=text, content_type="text/html")
async def hello(request):
"""处理 /{name} 的请求,从 URL 中提取 name 参数"""
name = request.match_info.get("name", "World")
text = f"<h1>Hello, {name}</h1>"
return web.Response(text=text, content_type="text/html")
# 创建应用
app = web.Application()
# 添加路由:URL 模式 → 处理函数
app.add_routes([
web.get("/", index),
web.get("/{name}", hello)
])
if __name__ == "__main__":
# 启动服务器(默认监听 8080 端口)
web.run_app(app)2.4 运行与测试
直接运行:
python app.py打开浏览器访问:
http://localhost:8080/→ 页面显示大标题 Index Pagehttp://localhost:8080/Bob→ 页面显示 Hello, Bobhttp://localhost:8080/Alice→ 页面显示 Hello, Alice
2.5 代码逐行拆解
处理函数:async def index(request)
- 必须是
async函数——这是 aiohttp 与 Flask 最大的不同,异步处理才能发挥并发威力; request参数封装了 HTTP 请求的全部信息(URL、参数、请求头、body 等);- 返回
web.Response对象,content_type="text/html"告诉浏览器"这是 HTML 页面,按网页渲染"。
URL 参数提取:request.match_info.get("name", "World")
- 路由
/{name}中的{name}是占位符,实际访问/Bob时,match_info里就有{"name": "Bob"}; .get("name", "World")表示取不到时默认返回"World"。
路由注册:app.add_routes([web.get("/", index), ...])
web.get()表示只接受 GET 请求(还有web.post()、web.put()等对应其他 HTTP 方法);- 把 URL 模式和处理函数绑定,类似餐厅的"菜单 → 厨师"对照表。
启动服务器:web.run_app(app)
- 内部自动创建事件循环,监听 8080 端口;
- 每来一个 HTTP 请求,事件循环就把对应的处理函数包装成协程任务调度执行;
- 全程单线程,但成百上千的请求可以并发处理。
2.6 生活化理解
整个流程像餐厅接单:
- 路由表是菜单——客人点什么菜(访问什么 URL),就找对应的厨师(处理函数);
- 处理函数是
async的——厨师做菜时如果需要等烤箱(IO 操作),不会干站着,而是先去炒别桌的菜; run_app()是开店营业——门口挂出招牌(监听端口),客人络绎不绝,一个后厨团队(事件循环)高效运转。
三、进阶:aiohttp 作为异步 HTTP 客户端
aiohttp 不只是服务器,也是强大的异步 HTTP 客户端,可以替代 requests 用于高并发爬虫。
3.1 对比:requests vs aiohttp 客户端
用 requests 抓 3 个网站(串行):
import requests
for url in ["https://a.com", "https://b.com", "https://c.com"]:
r = requests.get(url) # 每个都要等前一个完成
print(r.status_code)用 aiohttp 抓 3 个网站(并发):
import asyncio
import aiohttp
async def fetch(session, url):
async with session.get(url) as response:
print(url, response.status)
async def main():
async with aiohttp.ClientSession() as session:
tasks = [fetch(session, url) for url in
["https://a.com", "https://b.com", "https://c.com"]]
await asyncio.gather(*tasks) # 三个请求同时发出
asyncio.run(main())关键点:ClientSession 类似浏览器的一个"会话",可以在其中并发发起多个请求,共享连接池和 Cookie。
3.2 典型应用场景
- 高并发爬虫:同时抓几百个页面,速度比 requests 快几十倍;
- 微服务内部调用:服务 A 同时调用服务 B、C、D 的接口,聚合结果后返回;
- 批量数据同步:并发地把本地数据推送到多个远程系统。
四、aiohttp vs Flask:该选哪个?
| 对比项 | Flask(同步) | aiohttp(异步) |
|---|---|---|
| 并发模型 | 每个请求一个线程(或进程) | 单线程 + 协程并发 |
| 高并发能力 | 弱,几百并发就吃力 | 强,单机可扛数千并发 |
| 学习曲线 | 平缓,新手友好 | 需要先理解 asyncio |
| 生态库 | 丰富(requests、SQLAlchemy 等) | 需选异步版本(aiohttp、aiomysql) |
| 适用场景 | 中小型项目、内部工具、快速原型 | 高并发 API、实时服务、微服务网关 |
简单决策:
- 项目并发量小(内部系统、日活几百)、团队不熟异步 → Flask 省心;
- 项目并发量大(对外 API、秒杀活动)、需要长连接(WebSocket)→ aiohttp 或其他异步框架(FastAPI、Sanic)。
五、常见误区解析
误区一:aiohttp 处理函数里可以用 requests
严重错误。这是最致命的坑:
async def bad_handler(request):
r = requests.get("https://api.example.com") # 同步调用!
return web.Response(text=r.text)requests.get() 是同步阻塞的,执行它时整个事件循环被卡死,服务器瞬间从"高并发"变成"单线程串行",所有用户的请求都排队等这一个请求完成。
正确做法:协程里必须用异步客户端——aiohttp.ClientSession 或 httpx(异步模式)。
误区二:处理函数里用 time.sleep() 模拟耗时操作
错误,原因同上——time.sleep() 会睡死整个线程:
async def bad(request):
time.sleep(5) # 全服务器卡 5 秒
async def good(request):
await asyncio.sleep(5) # 只有当前请求等 5 秒,其他请求正常处理误区三:数据库操作用普通驱动(如 PyMySQL)
错误。数据库查询是典型 IO 操作,用同步驱动会阻塞事件循环。必须选异步驱动:
| 数据库 | 同步驱动 | 异步驱动 |
|---|---|---|
| MySQL | PyMySQL | aiomysql |
| Redis | redis-py | aioredis |
| MongoDB | pymongo | motor |
误区四:async 函数能提高 CPU 计算速度
错误。async 解决的是 IO 等待问题,不是计算速度问题。如果处理函数里全是复杂计算(比如图像处理),async 毫无帮助,反而因为单线程而更慢。CPU 密集任务应该用多进程,或把计算扔到线程池:
# 把同步 CPU 密集任务扔进线程池,避免阻塞事件循环
result = await loop.run_in_executor(None, cpu_intensive_func, args)误区五:aiohttp 的并发数没有上限
过度乐观。单线程协程并发也有极限:每个连接都占内存和文件描述符,几万个连接就可能耗尽资源。生产环境通常需要:
- 多进程 + asyncio:开多个进程,每个进程跑一个事件循环(利用多核);
- 前置 Nginx:做负载均衡和静态资源分发;
- 限制并发数:用
asyncio.Semaphore控制同时执行的任务数量,防止压垮下游服务。
误区六:只要用了 aiohttp 就自动高并发
错误。高并发的前提是全链路异步:处理函数是 async、HTTP 调用用 aiohttp、数据库用异步驱动……任何一个环节混入同步调用,整个链路就退回串行。这就像高速公路上只要有一辆车抛锚,所有车都得堵着。
六、小结
- aiohttp 是基于 asyncio 的 HTTP 框架,把"单线程高并发"从客户端带到了服务器端——这正是异步 IO 价值最大的战场;
- 写 aiohttp 服务器的三步:定义
async处理函数 →app.add_routes()注册路由 →web.run_app()启动; - URL 参数通过
{name}占位符和request.match_info提取,响应通过web.Response构造; - aiohttp 也可作异步 HTTP 客户端,配合
asyncio.gather()实现高并发爬虫; - 铁律:协程内禁止一切同步阻塞调用(requests、time.sleep、同步数据库驱动),否则事件循环卡死,高并发变单线程;
- 选型建议:小项目用 Flask,高并发用 aiohttp/FastAPI;CPU 密集任务交给多进程,别指望 async 提速;
- 生产部署记得:多进程利用多核 + Nginx 负载均衡 + Semaphore 限制并发,才能稳稳撑起万人访问。
至此,我们从协程原理(Basic52)到 asyncio 调度(Basic53)再到 aiohttp 实战(本篇),完成了 Python 异步编程的完整链路。接下来可以尝试用 aiohttp + aiomysql 写一个真正的异步 API 服务,把知识变成生产力。