把入口写成async def不会让同步数据库驱动或文件I/O自动变成非阻塞操作,ASGI也不会让WSGI应用获得WebSocket能力。
先按应用签名、所需协议和依赖库选择接口,再逐项验证请求体分片、响应顺序、断开连接、生命周期和同步/异步边界。
是什么,为什么存在
WSGI(Web Server Gateway Interface)与ASGI(Asynchronous Server Gateway Interface)规定Python Web服务器怎样调用应用。它们不是服务器,也不是框架;它们是双方都能实现的边界契约。服务器负责HTTP解析、套接字和进程管理,框架则在这条边界内完成路由与业务处理。
WSGI面向同步HTTP。服务器为每个请求调用application(environ, start_response),应用调用start_response()提交状态与响应头,再返回一个产生bytes的可迭代对象。PEP 3333的目标是让服务器与框架可以独立选择,而不是给业务开发者提供新的高级Web API。
ASGI把边界扩展为async application(scope, receive, send)。scope描述一条连接,receive()提供入站事件,send()提交出站事件。不同子协议定义各自的scope与消息,因此同一种应用接口可以承载HTTP、WebSocket和lifespan。
两者的差异不等于“旧而慢”和“新而快”。WSGI很适合同步框架与短HTTP请求;ASGI适合需要WebSocket、长轮询、流式请求或大量并发等待的应用。CPU工作不会因为ASGI自动提速,同步依赖也不会因为被async def包裹就停止阻塞。
你通常在部署入口、框架适配器、中间件和故障堆栈里遇到这两个名称。选择时先看应用暴露哪种callable,再看服务器、反向代理和客户端链路是否支持需要的协议,不要只看产品名称。
| 维度 | WSGI | ASGI |
|---|---|---|
| 应用签名 | application(environ, start_response) | async application(scope, receive, send) |
| 入站数据 | environ与wsgi.input | scope与receive()事件 |
| 出站数据 | start_response()与Iterable[bytes] | send()事件 |
| 连接模型 | 一次调用对应一个HTTP请求 | 一次调用对应协议定义的一条连接 |
| 典型能力 | 同步HTTP | HTTP、WebSocket、lifespan |
工作原理
网络上的HTTP字节不会直接进入业务函数。服务器先把请求转换成WSGI环境或ASGI消息,应用再返回协议规定的数据。中间件同时扮演内侧应用的服务器和外侧服务器的应用,因此它必须保持两边的契约。
WSGI的一次调用
environ是内置dict,包含CGI风格字段和wsgi.input等WSGI字段。元数据与响应状态、响应头使用受规范约束的str;请求体和响应体使用字节。应用可以只读取所需字段,但不能假定所有可选字段都存在。
start_response(status, headers)设置响应状态和头部,应用返回的可迭代对象随后产生零个或多个字节块。服务器在请求正常结束、迭代报错或客户端提前断开时,都必须调用结果对象的close()(若存在)。应用也不能假定迭代器一定会被完全消费。
WSGI本身没有规定服务器要使用进程、线程还是其他调度方式。同步callable会占用执行它的worker,等待期间能否处理其他请求取决于服务器与worker模型。接口契约和并发部署是两个不同问题。
ASGI的scope与事件
ASGI服务器按连接调用应用一次。对HTTP而言,这条scope对应一个请求,即使底层HTTP/2连接复用多个流;对WebSocket而言,它持续到套接字关闭。请求体不在HTTP scope中,而是通过一个或多个http.request事件到达。
每个事件都是带有顶层type字段的dict。应用必须遵守子协议的顺序,例如先发送一次http.response.start,再发送一个或多个http.response.body;最后一个body事件把more_body设为False或省略它。WebSocket与lifespan使用不同的事件集合。
receive()和send()都是可等待callable。等待receive()让出执行权,直到有新事件;等待send()让服务器有机会把数据送入发送缓冲区并施加 背压(backpressure) 。它不表示远端客户端已经收到数据。
边界上的所有权
WSGI应用拉取wsgi.input并由服务器拉取响应迭代器;ASGI应用等待入站事件并主动发送出站事件。这个方向差异决定了中间件怎样缓存、流式转发和清理资源。把两种签名机械拼接起来不会构成正确适配器。
| 阶段 | WSGI责任 | ASGI责任 |
|---|---|---|
| 请求元数据 | 服务器构造environ | 服务器构造scope |
| 请求体 | 应用从文件式对象读取 | 应用重复等待http.request |
| 响应开始 | 应用调用start_response() | 应用发送http.response.start |
| 响应流 | 服务器逐块迭代 | 应用逐次等待send() |
| 提前结束 | 服务器关闭响应迭代器 | 应用处理取消、断开或send()异常 |
能力需要整条链路支持
ASGI HTTP子协议可以表示HTTP/1.0、HTTP/1.1与HTTP/2,但部署是否真正启用某个版本由服务器和上游链路决定。WebSocket也需要服务器与反向代理正确处理握手和连接升级。看到ASGI入口只能证明应用边界,不能证明生产路径已经具备所有协议能力。
WSGI应用可以通过适配器运行在ASGI服务器后面。规范要求同步WSGI callable在线程池中执行,这保留了HTTP兼容性,却不会给应用添加WebSocket或原生异步流处理。适配层还要负责字符串、字节、请求体和线程敏感资源的转换。
示例
下面四个程序直接驱动callable,不打开端口,也不依赖第三方框架。它们依次展示WSGI往返、ASGI分片消息、lifespan和同步I/O隔离;输出来自本地python3实际运行。
最小WSGI往返
这个小型驱动器提供应用实际读取的环境字段,并捕获start_response()。finally中的close()模拟服务器即使提前结束也要履行的清理责任。
from io import BytesIO
from urllib.parse import parse_qs
def application(environ, start_response):
query = parse_qs(environ.get("QUERY_STRING", ""))
name = query.get("name", ["world"])[0]
body = f"Hello, {name}!".encode()
headers = [
("Content-Type", "text/plain; charset=utf-8"),
("Content-Length", str(len(body))),
]
start_response("200 OK", headers)
return [body]
environ = {
"REQUEST_METHOD": "GET",
"PATH_INFO": "/hello",
"QUERY_STRING": "name=Ada",
"CONTENT_LENGTH": "0",
"wsgi.input": BytesIO(b""),
}
captured = {}
def start_response(status, headers):
captured.update(status=status, headers=headers)
return lambda data: None
result = application(environ, start_response)
try:
body = b"".join(result)
finally:
if hasattr(result, "close"):
result.close()
print(captured["status"])
print(dict(captured["headers"])["Content-Type"])
print(body.decode())200 OK
text/plain; charset=utf-8
Hello, Ada!真实服务器会补齐PEP 3333要求的环境字段,并把返回字节写入网络。这个驱动器只验证应用与接口的交互,不验证HTTP解析、代理头或套接字行为。框架测试客户端与这种进程内驱动器有同样的边界限制。
处理ASGI请求分片
ASGI请求体可以跨多个http.request事件到达。应用持续读取到more_body为假,再用两个body事件生成一个逻辑响应。
import asyncio
async def application(scope, receive, send):
assert scope["type"] == "http"
body = bytearray()
while True:
message = await receive()
if message["type"] == "http.disconnect":
return
body.extend(message.get("body", b""))
if not message.get("more_body", False):
break
await send({"type": "http.response.start", "status": 201,
"headers": [(b"content-type", b"text/plain")]})
await send({"type": "http.response.body", "body": b"received=",
"more_body": True})
await send({"type": "http.response.body",
"body": str(len(body)).encode(), "more_body": False})
incoming = iter([
{"type": "http.request", "body": b"abc", "more_body": True},
{"type": "http.request", "body": b"def", "more_body": False},
])
sent = []
async def receive():
return next(incoming)
async def send(message):
sent.append(message)
scope = {"type": "http", "method": "POST", "path": "/upload"}
asyncio.run(application(scope, receive, send))
for message in sent:
if message["type"] == "http.response.start":
print("start", message["status"], message["headers"])
else:
print("body", repr(message["body"]), message["more_body"])start 201 [(b'content-type', b'text/plain')]
body b'received=' True
body b'6' False两个输入片段合计六个字节,两个输出片段通过more_body组成一个响应。生产代码不能无限累加请求体;应在每次扩展缓冲区前后检查累计大小,超过限制就停止读取并按框架或服务器约定结束请求。
这个测试还没有模拟send()异常或任务取消。长轮询与流式响应需要分别测试“断开发生在下一次receive()”和“send()先抛出OSError”两条路径,因为并发下两者顺序不固定。
驱动lifespan
lifespan在处理请求的事件循环中完成资源初始化与关闭。示例把共享状态写入scope["state"],并明确确认startup与shutdown。
import asyncio
async def application(scope, receive, send):
assert scope["type"] == "lifespan"
state = scope["state"]
while True:
message = await receive()
if message["type"] == "lifespan.startup":
state["catalog"] = "ready"
await send({"type": "lifespan.startup.complete"})
elif message["type"] == "lifespan.shutdown":
state["catalog"] = "closed"
await send({"type": "lifespan.shutdown.complete"})
return
incoming = iter([
{"type": "lifespan.startup"},
{"type": "lifespan.shutdown"},
])
sent = []
async def receive():
return next(incoming)
async def send(message):
sent.append(message)
scope = {"type": "lifespan", "state": {}}
asyncio.run(application(scope, receive, send))
for message in sent:
print(message["type"])
print(scope["state"]["catalog"])lifespan.startup.complete
lifespan.shutdown.complete
closed支持lifespan state的服务器会把这个命名空间浅复制给后续请求scope。实际资源通常是连接池或客户端对象,而不是字符串。多进程服务器会在每个进程的事件循环中执行lifespan,所以初始化代码必须允许每个worker拥有自己的资源。
隔离同步I/O
已有同步客户端暂时无法替换时,可用asyncio.to_thread()把阻塞I/O移出事件循环线程。下面只验证调用与返回值,不用时间差冒充性能基准。
import asyncio
import time
def read_legacy_record(record_id):
time.sleep(0.01)
return {"id": record_id, "state": "paid"}
async def heartbeat():
await asyncio.sleep(0)
return "loop stayed runnable"
async def main():
record, pulse = await asyncio.gather(
asyncio.to_thread(read_legacy_record, 7),
heartbeat(),
)
print(pulse)
print(record)
asyncio.run(main())loop stayed runnable
{'id': 7, 'state': 'paid'}to_thread()主要用于否则会阻塞事件循环的I/O函数。它不是把任意CPU工作变快的开关,也不会自动让底层调用响应协程取消。线程池容量、连接池容量、超时和关闭行为仍需一起设计。
陷阱
把async def当成非阻塞保证
修复方法: 优先使用真正的异步库;暂时保留I/O型同步函数时,通过框架提供的同步边界或asyncio.to_thread()隔离,并测试超时、取消与线程池耗尽。CPU密集工作应使用适合它的进程或任务系统。
假定请求体已经完整
修复方法: WSGI按已验证的CONTENT_LENGTH或框架API读取;ASGI循环处理more_body,同时累计并强制大小上限。不要为了方便而把未知大小的请求完整读入内存。
把响应头转换成字典
修复方法: 把头部当作有序的二元组序列,按协议要求追加、替换或删除单个字段。为重复头、非ASCII路径以及空响应体增加边界测试,不要只测普通JSON响应。
破坏响应顺序与清理
修复方法: 用状态机测试合法事件序列,并在finally中释放由请求拥有的资源。分别覆盖正常完成、应用异常、客户端断开和服务器取消,不要把其中一种路径当作其余路径的证明。
把适配器当成能力升级
修复方法: 写清需要保留的子协议、流式行为、线程亲和性、上下文传播和取消语义。只在这个交集内使用适配器,并用真实服务器路径验证代理、超时与断开连接。
在错误的生命周期共享资源
修复方法: 在ASGI lifespan中按事件循环创建并关闭资源,通过request scope state或框架等价机制访问。明确检查单进程、多worker、重载和启动失败路径;WSGI资源则应遵守所选进程与线程模型的生命周期钩子。
流式传输、断开与背压
WSGI的响应流是服务器拉取的。PEP 3333要求服务器完成一个非空字节块的传输后再向迭代器请求下一块,因此应用可以通过合理分块限制自身缓冲。不过网络服务器仍可能有自己的缓冲,yield一次不等于远端立即看到一块数据。
响应迭代器可能没有走到结尾。客户端关闭连接、应用在后续块报错或服务器停止请求时,服务器会调用其close()。生成器中的finally可以释放与迭代生命周期绑定的资源,但数据库事务或文件仍应尽量用明确的上下文管理表达所有权。
ASGI的响应流由应用主动发送。await send()返回意味着协议服务器已处理该消息并把正文刷新到发送缓冲区,不保证客户端已经读取。应用只有在还有正文时才把more_body设为True;最后一个正文事件结束响应,之后继续发送会被忽略或报错。
ASGI的断开通知存在竞态。长响应可能先从send()得到OSError,也可能在下一次receive()看到http.disconnect。清理逻辑必须允许任一路径先发生,并且能够重复调用而不破坏状态。
请求侧也需要背压与上限。应用每次等待receive()后,应在解析前检查累计字节数、内容类型和剩余配额。先把整个未知请求拼进bytes,最后才检查大小,会让限制失去保护内存的作用。
| 风险 | WSGI观察点 | ASGI观察点 |
|---|---|---|
| 截断请求 | wsgi.input读取长度 | more_body循环 |
| 无界内存 | 全量read() | 累加所有request事件 |
| 响应未结束 | 迭代器未完成且未关闭 | 始终发送more_body: True |
| 客户端断开 | 迭代停止与close() | http.disconnect或send()异常 |
| 头部丢失 | 错误重写tuple列表 | 把重复字节头折叠为dict |
兼容层与生命周期
ASGI的HTTP设计保留了到WSGI的映射,但两种接口并不等价。适配器要把ASGI请求事件转换成文件式输入,把同步可迭代响应转换成异步发送事件,并在线程池运行WSGI应用。这个边界只覆盖HTTP可表达的交集。
线程池带来容量与取消问题。大量慢同步调用会占满线程,使新请求即使处在事件循环中也只能等待。取消等待适配器的协程不保证底层线程函数立刻停止,因此超时、幂等性和资源回收必须由被调用系统共同支持。
跨边界的上下文也要验证。Python 3.14的asyncio.to_thread()会传播当前contextvars.Context,但第三方框架的线程敏感资源可能还有更严格的要求。不要用裸to_thread()绕过框架明确提供的数据库或事务适配器。
lifespan解决的是事件循环内资源所有权。规范要求每个处理请求的事件循环执行一次lifespan;多进程部署因此会初始化多份连接池。容量规划要用“每worker的池大小× worker数”检查数据库或下游服务的总连接上限。
支持lifespan state时,服务器把state命名空间浅复制到请求scope。复制的是字典结构,不是池对象本身,因此请求看到的是同一个资源引用。中间件添加键时应使用不会冲突的名称,并对不提供state的服务器或测试驱动器作出明确处理。
启动失败不能伪装成启动完成。若资源无法初始化,应用应发送lifespan.startup.failed及可诊断消息;服务器随后记录并退出。捕获异常后仍发送.complete会让请求落到半初始化的应用上。
选择接口时,先写不可丢失的能力。只有同步HTTP且依赖均为同步时,WSGI的边界更直接;需要WebSocket、长连接或原生异步流时,使用ASGI。已有WSGI应用可以先适配再迁移,但迁移完成的证据是阻塞边界和协议测试,而不是入口文件改名。
延伸阅读
4个问题 · 1道输出预测题 · 1道找错题