Python异步编程从入门到不懵:asyncio实战踩坑指南

作为一个写了6年Python的人,我曾经对异步编程嗤之以鼻。

直到上个月做爬虫项目被同步IO卡得怀疑人生,才老老实实啃了半个月asyncio。这半个月踩的坑比我过去一年写同步代码踩的都多,今天就把最容易踩的8个坑整理出来,帮你少走弯路。

背景:我为什么被逼着学异步

上个月接了个爬虫需求,要爬1000个商品页面的价格数据。用同步的requests写,跑一次要20多分钟,客户嫌太慢,让我优化到5分钟以内。

我第一反应是开多线程,试了之后发现开10个线程也只能跑到10分钟,再开线程反而因为切换开销变慢了。这时候有人跟我说,你试试asyncio啊,IO密集型场景用异步爽得很。

说实话我一开始是拒绝的,觉得异步代码写起来绕,debug还麻烦。但实在没办法,硬着头皮学了半个月,最后把爬虫优化到了2分钟跑完,真香。

不过学习过程中踩的坑是真的多,很多教程都没提这些细节,我一个个踩过来差点放弃。今天把最常见的8个坑整理出来,都是我实际踩过的血泪教训。


坑1:别用time.sleep(),要用asyncio.sleep()

这应该是新手最容易踩的第一个坑,我一开始就中招了。

写测试代码的时候,想模拟一下IO等待,顺手就写了个time.sleep(1),结果运行起来发现跟同步代码一样慢,完全没有异步的效果。

python 复制代码
import asyncio
import time

async def test():
    print("开始等待")
    time.sleep(1)  # 这里有坑!
    print("等待结束")

async def main():
    await asyncio.gather(test(), test(), test())

asyncio.run(main())

你猜上面代码运行要多久?3秒!因为time.sleep()是同步阻塞的,会把整个事件循环卡住,等于三个协程串行执行了。
解决方案: 所有的等待操作都要用asyncio对应的异步版本,把time.sleep()换成asyncio.sleep()就行:

python 复制代码
async def test():
    print("开始等待")
    await asyncio.sleep(1)  # 正确写法
    print("等待结束")

改完之后运行只需要1秒,三个协程真正并行执行了。

血泪教训:只要是在async def的异步函数里,所有的IO操作都要找对应的异步版本,不要用同步的库,不然等于白写异步。

坑2:协程忘了加await,执行没反应还不报错

这个坑我踩了好多次,到现在有时候还会忘。

比如你写了个异步函数fetch_data(),调用的时候直接写fetch_data(),没加await,结果运行的时候啥反应都没有,也不报错,你还以为代码没执行。

python 复制代码
import asyncio

async def fetch_data():
    print("正在获取数据...")
    await asyncio.sleep(1)
    return {"code": 200, "data": "test"}

async def main():
    fetch_data()  # 这里没加await!
    print("执行完成")

asyncio.run(main())

运行上面的代码,你会发现只输出了"执行完成",fetch_data()里的打印完全没出来,也没有任何报错,新手遇到这个直接懵。

原因是你直接调用异步函数返回的是一个协程对象,并没有真正执行,必须加await才会调度执行。
解决方案: 调用任何异步函数都要加await:

python 复制代码
async def main():
    await fetch_data()  # 正确写法
    print("执行完成")

如果不需要等待结果,可以用asyncio.create_task()把它包装成任务丢到后台执行:

python 复制代码
async def main():
    task = asyncio.create_task(fetch_data())
    print("执行完成,后台会继续执行任务")
    # 后面可以await task等待结果

坑3:run_until_complete()和run_forever()别混用

如果你看过老一点的asyncio教程,可能会见到loop.run_until_complete()和loop.run_forever()这两个写法。

Python 3.7之后官方推荐用asyncio.run()来启动程序,但有时候还是会遇到需要自己操作事件循环的场景,这两个方法很容易搞混。

我上次写一个定时任务,想让程序一直跑,就写了:

python 复制代码
loop = asyncio.get_event_loop()
loop.run_until_complete(main())
loop.run_forever()

结果main()执行完之后程序直接退出了,run_forever()根本没执行。

原因是run_until_complete()会在传入的协程执行完成后自动停止事件循环,后面再调用run_forever()也没用了。
解决方案: 两个方法选一个就行,不要混用:

  • 如果你要跑一个有明确结束点的程序,用asyncio.run(main())就够了
  • 如果你要写一直运行的服务(比如爬虫调度器、API服务),用run_forever(),然后通过任务来控制逻辑
python 复制代码
# 正确的永久运行写法
async def main():
    while True:
        # 你的业务逻辑
        await asyncio.sleep(60)  # 每分钟执行一次

loop = asyncio.get_event_loop()
loop.create_task(main())
loop.run_forever()

坑4:异步函数里调用同步函数,整个事件循环卡住

这个是异步性能上的大坑,很多人写了异步代码发现还没同步快,大概率是这个原因。

比如你在异步函数里调用了同步的requests.get(),或者pandas的读文件操作,这些同步函数会阻塞整个事件循环,导致所有其他协程都卡住。

我上次就是在异步爬虫里不小心用了同步的requests,结果1000个请求跑了15分钟,还不如同步快。

python 复制代码
import asyncio
import requests

async def fetch(url):
    # 这里用了同步的requests,坑!
    resp = requests.get(url)
    return resp.text

async def main():
    urls = [f"https://example.com/page/{i}" for i in range(100)]
    tasks = [asyncio.create_task(fetch(url)) for url in urls]
    await asyncio.gather(*tasks)

上面的代码跟串行执行没区别,因为requests.get()是同步阻塞的,每次只能跑一个请求。
解决方案:

  1. 用对应的异步库替换同步库,比如requests换成aiohttp,同步文件操作换成aiofiles
  2. 如果没有异步版本,用asyncio.to_thread()把同步函数丢到线程里执行,不会阻塞事件循环:
python 复制代码
async def fetch(url):
    # 把同步requests丢到线程里执行
    resp = await asyncio.to_thread(requests.get, url)
    return resp.text

改完之后速度直接提升了10倍,这才是异步该有的效果。

坑5:协程里的异常没捕获,整个程序直接崩溃

异步代码的异常处理跟同步代码不太一样,新手很容易忽略。

比如你用asyncio.gather()跑了10个协程,其中一个抛了异常,如果你没捕获,整个程序会直接崩溃,剩下的9个协程也会被终止。

我上次爬100个页面,其中一个页面超时了,整个爬虫直接停了,剩下90个页面都没爬,白等了半天。

python 复制代码
async def fetch(url):
    await asyncio.sleep(1)
    if url == "https://example.com/page/50":
        raise Exception("请求超时")
    return url

async def main():
    urls = [f"https://example.com/page/{i}" for i in range(100)]
    # 这里没处理异常,某个协程报错整个程序崩溃
    results = await asyncio.gather(*[fetch(url) for url in urls])

解决方案:

  1. 给asyncio.gather()加return_exceptions=True参数,这样异常会作为结果返回,不会终止其他协程:
python 复制代码
results = await asyncio.gather(*[fetch(url) for url in urls], return_exceptions=True)
# 然后自己遍历结果判断是否有异常
for result in results:
    if isinstance(result, Exception):
        print(f"请求出错:{result}")
  1. 或者在协程内部自己捕获异常:
python 复制代码
async def fetch(url):
    try:
        await asyncio.sleep(1)
        if url == "https://example.com/page/50":
            raise Exception("请求超时")
        return url
    except Exception as e:
        print(f"请求{url}出错:{e}")
        return None

我更推荐第二种方式,在业务逻辑内部处理异常,更灵活。

坑6:asyncio.gather()返回结果顺序跟传入顺序一致,别搞错

这个坑不大,但很容易出逻辑bug。

很多人以为gather()的返回结果是按协程完成顺序排列的,其实不是,是按你传入协程的顺序排列的。

比如你传了三个协程A、B、C,哪怕C先完成,B其次,A最后完成,返回的结果列表还是[A的结果, B的结果, C的结果]。

我上次就是误以为是按完成顺序排列,把结果存到数据库的时候顺序错了,排查了好久才找到原因。

python 复制代码
async def test(name, delay):
    await asyncio.sleep(delay)
    return name

async def main():
    # 传入顺序:A(3秒), B(2秒), C(1秒)
    results = await asyncio.gather(
        test("A", 3),
        test("B", 2),
        test("C", 1)
    )
    print(results)  # 输出:['A', 'B', 'C'],不是['C', 'B', 'A']

这个特性其实很方便,你不用自己对应顺序,直接按传入的顺序取结果就行,但前提是你要知道这个规则,不然会写出bug。

坑7:不要在协程里随便修改全局变量,要加锁

很多人觉得异步是单线程的,所以修改全局变量不用加锁,这是大错特错。

虽然asyncio是单线程跑的,但协程会在IO等待的时候切换,如果你在两个协程里修改同一个全局变量,还是会出现竞态条件。

我上次写计数功能,两个协程都给全局变量count加1,跑1000次结果总是少几十个,就是因为没加锁。

python 复制代码
count = 0

async def add():
    global count
    # 这里分两步:读count,加1,写回count
    # 协程可能在这两步之间切换,导致数据覆盖
    tmp = count
    await asyncio.sleep(0.001)  # 模拟IO切换
    count = tmp + 1

async def main():
    tasks = [asyncio.create_task(add()) for _ in range(1000)]
    await asyncio.gather(*tasks)
    print(count)  # 输出肯定小于1000

运行上面的代码,count的结果总是在900多,从来没到过1000,就是因为协程切换的时候出现了数据覆盖。
解决方案: 用asyncio.Lock()加锁,修改共享变量的时候要获取锁:

python 复制代码
count = 0
lock = asyncio.Lock()

async def add():
    global count
    async with lock:  # 获取锁,其他协程会等锁释放
        tmp = count
        await asyncio.sleep(0.001)
        count = tmp + 1

改完之后count就会是1000了,不会少。

血泪教训:只要是多个协程修改同一个共享变量,不管是不是单线程,都要加锁。

坑8:Windows和Linux的事件循环差异,部署踩坑

这个坑是我部署到服务器的时候才遇到的,本地Windows跑的好好的,放到Linux上直接报错。

asyncio在不同平台的默认事件循环不一样:

  • Windows上默认用的是SelectorEventLoop,不支持很多高级特性,而且性能差
  • Linux上默认用的是EpollEventLoop,性能好,支持所有特性
    我本地Windows写的代码里用了一些Windows循环支持的特性,放到Linux上就报错了。还有就是Windows上可以直接用asyncio.run(),Linux上有时候需要手动设置循环。
    解决方案:
  1. 部署到Linux的时候,最好手动指定事件循环策略,确保用的是Epoll:
python 复制代码
import asyncio
import sys

if sys.platform == "linux":
    import uvloop
    asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())  # 用uvloop性能更好
  1. 尽量不要用平台特有的API,写跨平台兼容的代码
  2. 推荐用uvloop替换默认的asyncio循环,性能能提升2-3倍,谁用谁知道。

写在最后

其实asyncio没那么难,核心就是记住几个原则:

  1. 异步函数里所有IO操作都要用异步版本,别用同步的
  2. 调用异步函数必须加await,或者包装成task
  3. 共享变量修改要加锁
  4. 异常要捕获,别让整个程序崩溃

我一开始学的时候觉得这玩意儿太绕了,写多了之后发现其实比多线程多进程简单多了,没有那么多锁和进程间通信的问题。

现在我写IO密集型的项目首先想到的就是asyncio,效率提升真的很明显。如果你也经常写爬虫、API服务、定时任务这类IO密集型的代码,真的推荐你好好学一下asyncio,绝对不亏。

如果你在使用过程中遇到什么问题,欢迎评论区交流,我会尽量帮你解答。

相关推荐
2301_764150562 小时前
如何在 Laravel Excel 导入时校验并阻止重复列值
jvm·数据库·python
知兀2 小时前
【Result类】(使用/不使用<T> data的情况);自带静态方法、纯数据类;
java·开发语言
像一只黄油飞2 小时前
第二章-01-字面量
笔记·python·学习·零基础
达帮主2 小时前
25.C语言 递归函数
c语言·开发语言·汇编
2401_871696522 小时前
PHP源码对主板M.2插槽数量有要求吗_扩展性规划建议【方法】
jvm·数据库·python
qq_189807032 小时前
防止SQL注入的运维实践_实时清理数据库缓存与历史记录
jvm·数据库·python
weixin_458580122 小时前
MongoDB广告点击追踪如何建模_点击事件聚合与去重记录
jvm·数据库·python
justjinji2 小时前
CSS如何实现垂直居中对齐_CSS Grid容器内的完美居中方案
jvm·数据库·python
Shorasul2 小时前
Golang map怎么判断key存在_Golang map键值判断教程【通俗】
jvm·数据库·python