Python 异步编程入门:从同步到 asyncio 的思维转换
做 LAN 聊天系统的 WebSocket 服务端时,第一次真正需要 Python 异步。这篇从思维转换的角度,讲清楚 asyncio 的核心。
同步代码为什么"卡"
同步的 HTTP 服务器处理两个请求时,第一个没完成第二个就得等——这叫阻塞。阻塞发生在哪里?主要在 I/O 等待:读文件、网络收发、数据库查询。
# 同步:等 resp 期间啥也干不了
resp = requests.get("https://api.example.com")
协程:让出控制权
async/await 的本质是协程让出:执行到 await 时,把控制权还给事件循环,等 I/O 完成再回来继续:
import asyncio
async def fetch(url):
print(f"开始 {url}")
await asyncio.sleep(1) # 模拟 I/O,让出控制权
print(f"完成 {url}")
return url
async def main():
# 并发执行三个任务
results = await asyncio.gather(
fetch("a.com"), fetch("b.com"), fetch("c.com"),
)
print(results)
asyncio.run(main())
# 总耗时约 1 秒,而不是 3 秒
关键理解:await asyncio.sleep(1) 不是"睡 1 秒",而是"告诉事件循环:我暂停 1 秒,你先去跑别的"。
事件循环:调度器
asyncio.run() 启动一个事件循环,它维护一个任务队列,不断执行就绪的协程。这就是"单线程并发"的秘密——不是并行,而是协同式调度。
真实场景:WebSocket 服务端
聊天系统的核心需求是"多个客户端同时连接,各自收发消息"。用异步最合适:
import asyncio
import websockets
async def handler(ws, path):
async for message in ws: # 等待消息时让出控制权
await ws.send(f"echo: {message}") # 发送也异步
async def main():
async with websockets.serve(handler, "0.0.0.0", 8765):
await asyncio.Future() # 永远运行
asyncio.run(main())
一个连接阻塞在 async for 等待消息时,其他连接照样收发——这就是异步的价值。
心跳与并发写:两个真实坑
坑一:假死连接
长时间不通信,NAT 会静默断开 TCP,两端都不知道。要主动发心跳:
async def heartbeat(ws):
while True:
await asyncio.sleep(30)
try:
await ws.send('{"type":"ping"}')
except Exception:
break # 连接已死
坑二:并发写同一个连接
多个协程同时 ws.send 会竞争底层连接。用锁串行化:
class Connection:
def __init__(self, ws):
self.ws = ws
self._lock = asyncio.Lock()
async def send(self, data):
async with self._lock:
await self.ws.send(data)
同步代码混入异步:最大的坑
在协程里调用同步阻塞函数,会把整个事件循环卡死:
# ❌ 同步 requests.get 阻塞事件循环,所有连接都卡住
async def handler(ws):
data = requests.get("https://api.example.com") # 卡!
解法:用 asyncio.to_thread 丢到线程池,或用异步版库(httpx.AsyncClient):
async def handler(ws):
data = await asyncio.to_thread(requests.get, "https://api.example.com")
规则:协程里只能用异步 I/O,同步阻塞操作必须丢线程池。
小结
asyncio 的思维转换三步:
- 理解
await= 让出控制权,不是"等结果"; - 理解事件循环 = 协作式调度器,单线程并发;
- 牢记铁律:协程里不跑阻塞代码。
想清楚这三件事,异步编程就从"魔法"变成了"常规工具"。