
上周 review 一个同事的爬虫脚本,两千多行,注释里写着"已改成异步,性能提升十倍"。我在本地跑了一遍,比同步版本慢了将近一倍。问题出在哪?满屏的 await,却没有一个真正把控制权交出去。
这件事值得单独聊,因为它踩中了 Python 异步里最容易被误解的那个坑:以为加了 async 和 await,代码就自动并发了。
很多讲解一上来就把 concurrency 和 parallelism 混着用,这是混乱的起点。并行是同一时刻有多个任务在多个 CPU 核心上真正同时执行,需要多进程或多线程配合多核。并发是多个任务在一段时间内都往前推进,但不要求同一时刻都在跑。
asyncio 给的是并发,不是并行,而且是在单个线程里给的。它的全部本事,就是在一个线程内让多个协程交替占用那唯一的执行权。这一点不搞清楚,后面所有关于"async 快不快"的讨论都没有地基。
先看一段很多人都写过的代码:
import asyncio
async def task(name):
print(f"{name} 开始")
await asyncio.sleep(1)
print(f"{name} 结束")
async def main():
await asyncio.gather(task("A"), task("B"), task("C"))
asyncio.run(main())A、B、C 各等一秒,总耗时大约一秒。看起来三个任务"同时"完成。于是有人得出结论:把 time.sleep(1) 换成 await asyncio.sleep(1),代码就并发了。
这个推论只对了一半,而且是最危险的那一半。
asyncio.sleep 做的事情,是向事件循环注册一个一秒后的定时器,然后把当前协程挂起,把执行权交还给事件循环。一秒后定时器触发,事件循环再把协程唤醒接着跑。关键就在这个"挂起并交还"。换回 time.sleep,当前线程被系统调用阻塞,事件循环和所有协程一起卡在这条线程上,谁也动不了,并发在那一刻就塌了。
所以并发不是 await 关键字给的,是"让出控制权"这个动作给的。await 只是语法标记,它后面必须跟一个真正会挂起的对象(协程、Task、Future)。你要是 await 一个永远不会挂起的同步调用,等于把事件循环按在原地,其余协程全在排队。
要讲清楚"谁在帮你并发",得看事件循环手里有什么。asyncio 的事件循环维护一个就绪队列和一组 I/O 多路复用器(Linux 上用 epoll,macOS 用 kqueue,Windows 用 select 或 IOCP)。协程在 await 一个网络操作时,底层会把 socket 文件描述符交给多路复用器监听,协程本身挂起进入等待集;当某个 socket 可读或可写,多路复用器通知循环,循环把对应协程放回就绪队列继续执行。
这套机制能跑起来,前提是等待的对象是内核可知的 I/O 事件。网络读写天然符合,所以 aiohttp 这类库能让成百上千个请求共用一个线程还不互相阻塞。反过来,纯计算、磁盘同步读写、或者任何不经由事件循环多路复用的等待,都接不进这套调度,只能老老实实占着线程。
单线程加 GIL,带来两个绕不开的后果。
第一,协程让出控制权之后如果干的是 CPU 密集的活,比如大批量 JSON 解析、加解密、压缩,那切得再多也没用。GIL 保证同一时刻只有一个线程在执行 Python 字节码,协程切换并不会释放 GIL 去并行计算。把一万条记录丢进一个协程里循环解析,事件循环只能干等。
第二,只要有一个协程混进阻塞调用,整条循环就被拽住。time.sleep 自不必说,同步的 requests.get、未用异步接口的文件读写、甚至某些库里隐式的 DNS 解析,都会把线程堵死。我那个慢一倍的脚本,根子就在某个 fetch 里用了同步 requests,外面还包了一层 async。表面是 async 函数,里面却是会堵死线程的同步请求,事件循环转不起来,三个请求实际串行跑,还白多一层协程调度开销。
做数据采集的对这个场景最熟。抓几千个页面,目标站有频率限制,一个 IP 抓快了就封。这里有两件事必须分开想:并发靠谁调度,不被封靠谁提供出口。
并发这块,aiohttp 配 asyncio 就能解决。前面说的挂起在等网络响应时天然成立。不被封这块,靠代理 IP 把请求散到不同出口。
我们线上用亿牛云的隧道代理。它的工作方式是:你只配置一个固定入口地址,每次请求由隧道服务端从代理池里挑选出口 IP 并承担连接与轮换,客户端看到的始终是一个地址。对异步客户端而言,这意味着你不必在业务代码里维护 IP 列表、做轮询和失效重试,把入口地址挂上即可。
import asyncio
import aiohttp
# 亿牛云隧道代理入口,username:password 换成你自己的账号
PROXY = "http://你的用户名:你的密码@tunnel.16yun.cn:31111"
async def fetch(session, url):
try:
async with session.get(
url,
proxy=PROXY,
timeout=aiohttp.ClientTimeout(total=10),
) as resp:
return await resp.text()
except Exception as e:
return f"失败: {e}"
async def crawl(urls):
connector = aiohttp.TCPConnector(
limit=50, # 同时持有的连接上限
limit_per_host=10, # 单主机并发上限,避免打挂目标站
enable_cleanup_closed=True,
ttl_dns_cache=300, # DNS 缓存,挡掉同步解析的潜在卡顿
)
async with aiohttp.ClientSession(connector=connector) as session:
tasks = [fetch(session, u) for u in urls]
return await asyncio.gather(*tasks, return_exceptions=True)
if __name__ == "__main__":
urls = [f"https://example.com/page/{i}" for i in range(200)]
results = asyncio.run(crawl(urls))值得把 TCPConnector 的几个参数说清楚。limit 限制同时持有的连接数,不设这个值,几百个协程同时建连会把本地端口和目标站一起压垮。limit_per_host 可以单独限制对单个主机的并发,避免把某个站点打挂。默认的 keep-alive 复用能减少握手开销,enable_cleanup_closed 清理异常关闭的连接。ttl_dns_cache 默认开启,对反复请求同一域名的抓取很关键,因为 DNS 解析在某些实现里是同步阻塞的,缓存能挡掉一批潜在的卡顿。
如果不用隧道代理,改用自己维护的短效 IP 列表,麻烦会多出一块。几十个并发连接同时跑,你得有一个线程安全的 IP 池,分配时要加锁,某个 IP 失效要标记并换下一个,连接复用还要按 IP 分组。这些逻辑和你的业务无关,纯属给自己加戏。隧道代理把这些替你做了,客户端只看到一个入口,还能在代理层做连接复用和故障转移。代价是每次请求多经过一道代理,延迟略高,对抓取类任务通常可以接受。
这里职责要分清楚:事件循环负责"同时跑很多请求",亿牛云负责"让这些请求看起来来自不同地方"。前者是并发,后者是反封禁,两件事叠在一起才成立。但别再说"代理帮你并发了",那是另一层的事,代理管的是出口,不是调度。
光看代码里有 async 不算数,得量。最简单的一招是记时间:如果 N 个各需一秒网络等待的任务总耗时接近一秒而非 N 秒,说明它们在重叠。更扎实的做法是用 asyncio.Semaphore 显式控制并发度,并在进入和退出时打点:
import asyncio
import time
sem = asyncio.Semaphore(20)
async def job(i):
async with sem:
t0 = time.monotonic()
print(f"进入 {i} @ {t0:.2f}")
await asyncio.sleep(1) # 实际场景里是网络等待
print(f"退出 {i} @ {time.monotonic():.2f}")
async def main():
await asyncio.gather(*[job(i) for i in range(100)])
asyncio.run(main())如果日志里出现大量"进入"集中打印、再集中"退出",说明并发生效;如果进入和退出严格交替、总耗时等于各任务之和,那就是又退回串行了,八成是某处混进了阻塞调用。
连接数要设上限,上面已经说过,这是生产环境的第一条红线。
别在协程里掺同步库。requests 这类一旦进来,并发就废了。要么全异步(aiohttp、httpx 的 async 版),要么把同步调用丢进 loop.run_in_executor 扔给线程池,让它不堵在事件循环里。注意 run_in_executor 用的是默认线程池,池子大小要按负载调,别无脑丢。
gather 时某个任务抛异常,默认会取消其余任务。生产环境加 return_exceptions=True,或自己包一层 try,否则一个请求挂了全盘皆输。
asyncio 不是万能药。活要是 CPU 密集,它帮不上忙,得上多进程(ProcessPoolExecutor)把计算搬到其他核心,或者把热点写成释放 GIL 的 C 扩展。异步解决的是等待,不是计算。对应地,纯 I/O 密集且并发量不大时,线程池往往也够用,未必非要上 asyncio,别为了异步而异步。
所以再看到"把 time.sleep 换成 await asyncio.sleep"这种建议,先别照做。问一句:这段等待之后,控制权交还给谁了?是事件循环在调度别的协程,还是我换了个写法,线程照样被堵着?
想清楚谁在帮你并发,比背会多少个 async 关键字都重要。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。