Python异步编程从入门到不懵:asyncio实战踩坑7连发

Python异步编程从入门到不懵:asyncio实战踩坑7连发

搞了两年Python异步编程,从一脸懵到勉强能用,踩过的坑能写本书。今天把最常见的7个坑整理出来,每个都是血泪教训,希望能帮你少走弯路。

我为什么非要学异步?

去年接了个需求:同时抓取200个API的数据,同步代码跑完要6分钟,老板说能不能快一点。我一查,每个请求等待时间就2秒,CPU全在发呆。

换成 asyncio + aiohttp,6分钟变15秒。当时觉得异步真香,结果后面踩的坑比省的时间还多。

所以这篇文章不是入门教程,而是一个"过来人"的踩坑清单。


坑1:在async函数里用了同步阻塞调用

这是新手100%会踩的坑,也是后果最严重的。

错误写法

python 复制代码
import asyncio
import time

async def fetch_data():
    # ❌ 用了 time.sleep,整个事件循环被卡住!
    time.sleep(2)
    return "数据到了"

async def main():
    # 以为会并发执行,结果串行等了6秒
    results = await asyncio.gather(
        fetch_data(),
        fetch_data(),
        fetch_data(),
    )
    print(results)

asyncio.run(main())  # 实际耗时 6 秒,不是 2 秒

正确写法

python 复制代码
async def fetch_data():
    # ✅ 用 asyncio.sleep 代替 time.sleep
    await asyncio.sleep(2)
    return "数据到了"

# 现在并发执行,总耗时约 2 秒

什么算"阻塞调用"?

不仅仅是 time.sleep,以下都是异步代码中的毒药

  • requests.get() → 换 aiohttphttpx
  • time.sleep() → 换 asyncio.sleep()
  • subprocess.run() → 换 asyncio.create_subprocess_exec()
  • 文件读写 → 换 aiofilesasyncio.to_thread()
  • 任何CPU密集型计算 → 换 asyncio.to_thread() 或进程池

划重点:async函数里只要出现同步阻塞,所有并发优势瞬间归零。


坑2:忘了await,协变变协程对象

这个坑太隐蔽了,Python不会报错,但结果完全不对。

错误写法

python 复制代码
async def get_user(user_id):
    await asyncio.sleep(0.5)
    return {"id": user_id, "name": "张三"}

async def main():
    # ❌ 忘了 await!result 是一个 coroutine 对象,不是字典
    result = get_user(1)
    print(result)  # <coroutine object get_user at 0x7f8...>
    print(result["name"])  # TypeError: 'coroutine' object is not subscriptable

正确写法

python 复制代码
async def main():
    # ✅ 加上 await
    result = await get_user(1)
    print(result["name"])  # 张三

怎么避免?

Python 3.11+ 会抛出 RuntimeWarning: coroutine was never awaited,一定要看警告!另外,装个 pylintruff,它们能静态检测出遗漏的 await

说实话,我至今偶尔还会犯这个错。习惯成自然需要时间。


坑3:Gather不处理异常,一个炸全炸

asyncio.gather 默认行为是:一个任务抛异常,整个gather直接炸。

错误写法

python 复制代码
async def fetch_url(url):
    if "bad" in url:
        raise ValueError(f"URL无效: {url}")
    await asyncio.sleep(1)
    return f"来自{url}的数据"

async def main():
    # ❌ 一个失败,全部白费
    results = await asyncio.gather(
        fetch_url("https://good.com"),
        fetch_url("https://bad.com"),  # 这个会炸
        fetch_url("https://ok.com"),
    )
    # 什么结果都拿不到

正确写法

python 复制代码
async def main():
    # ✅ return_exceptions=True,失败的返回异常对象,成功的正常返回
    results = await asyncio.gather(
        fetch_url("https://good.com"),
        fetch_url("https://bad.com"),
        fetch_url("https://ok.com"),
        return_exceptions=True,  # 关键参数!
    )
    
    for r in results:
        if isinstance(r, Exception):
            print(f"请求失败: {r}")
        else:
            print(f"请求成功: {r}")

或者用 TaskGroup(Python 3.11+),它的行为更清晰:

python 复制代码
async def main():
    results = {}
    async with asyncio.TaskGroup() as tg:
        tasks = []
        for url in ["https://good.com", "https://bad.com", "https://ok.com"]:
            task = tg.create_task(fetch_url(url))
            tasks.append(task)
    # TaskGroup 默认会在任何一个任务失败时取消其他任务
    # 如果你需要"失败不影响其他",还是用 gather + return_exceptions

我的建议:90%的场景用 gather(return_exceptions=True),除非你明确需要"一个炸全炸"的语义。


坑4:全局变量引发的并发数据污染

异步代码里用全局变量/类属性,很容易踩到数据竞争的坑。

错误写法

python 复制代码
class DataProcessor:
    result = []  # ❌ 类变量,所有协程共享!

    async def process(self, item):
        await asyncio.sleep(0.1)  # 模拟IO
        self.result.append(item)  # 并发append,可能丢数据

async def main():
    processor = DataProcessor()
    await asyncio.gather(*[
        processor.process(i) for i in range(100)
    ])
    print(len(processor.result))  # 可能不到100!

等等,你可能说Python有GIL啊,append不是原子操作吗?确实,list.append 在CPython里是原子的,但更复杂的操作(比如 result = result + [item])就不是了。

而且这不是重点。重点是逻辑上的数据污染------你以为是顺序处理,实际上协程随时会被切走,中间状态被其他协程读到。

正确写法

python 复制代码
async def process(item):
    await asyncio.sleep(0.1)
    return item  # 每个协程返回自己的结果

async def main():
    results = await asyncio.gather(*[
        process(i) for i in range(100)
    ])
    print(len(results))  # 一定是100

原则:尽量用返回值传递数据,少用全局状态。异步编程里,共享状态就是万恶之源。


坑5:无限创建Task导致资源耗尽

并发是好东西,但不是越多越好。

错误写法

python 复制代码
async def crawl(url):
    async with aiohttp.ClientSession() as session:
        async with session.get(url) as resp:
            return await resp.text()

async def main():
    urls = [f"https://api.example.com/page/{i}" for i in range(10000)]
    # ❌ 一次性创建10000个Task,连接池爆炸,内存爆炸
    tasks = [crawl(url) for url in urls]
    results = await asyncio.gather(*tasks)

10000个并发请求,要么被服务器限流返回429,要么本地连接池耗尽报错。

正确写法:用Semaphore控制并发数

python 复制代码
CONCURRENCY = 50  # 最大并发数

semaphore = asyncio.Semaphore(CONCURRENCY)

async def crawl(url):
    async with semaphore:  # ✅ 控制并发
        async with aiohttp.ClientSession() as session:
            async with session.get(url) as resp:
                return await resp.text()

async def main():
    urls = [f"https://api.example.com/page/{i}" for i in range(10000)]
    tasks = [crawl(url) for url in urls]
    results = await asyncio.gather(*tasks, return_exceptions=True)

更优雅的方式是 asyncio.Semaphore 配合 aiohttpTCPConnector 限制:

python 复制代码
connector = aiohttp.TCPConnector(limit=50)  # 连接池上限
async with aiohttp.ClientSession(connector=connector) as session:
    # 现在最多50个并发连接

50-100的并发数通常是甜蜜点,具体看目标服务器的承受能力。


坑6:异步上下文管理器的正确姿势

aiohttp.ClientSession 这类资源必须正确关闭,否则会泄漏连接。

错误写法

python 复制代码
async def fetch(url):
    # ❌ 每次请求都创建新的session,从不关闭!
    session = aiohttp.ClientSession()
    async with session.get(url) as resp:
        return await resp.text()
    # session永远不会被关闭,连接泄漏

正确写法

python 复制代码
async def fetch_all(urls):
    # ✅ 复用同一个session,用完自动关闭
    async with aiohttp.ClientSession() as session:
        tasks = []
        for url in urls:
            # 注意:session.get() 本身也是上下文管理器
            async def fetch_one(u):
                async with session.get(u) as resp:
                    return await resp.text()
            tasks.append(fetch_one(url))
        return await asyncio.gather(*tasks, return_exceptions=True)

关键点:ClientSession 是重量级对象,一个应用只创建一个,所有请求复用。

还有个坑:在async函数里创建session但没在同一个函数里关闭,Python会报警告 Unclosed client session。千万别忽略这个警告。


坑7:事件循环嵌套------Jupyter/已有循环中的陷阱

在Jupyter Notebook里跑asyncio代码,经常遇到这个报错:

复制代码
RuntimeError: This event loop is already running

原因是Jupyter本身就运行了一个事件循环,你再调用 asyncio.run() 就冲突了。

解决方案

python 复制代码
# 方案1:Jupyter里直接 await(它本身就是异步环境)
result = await fetch_data()  # 直接用,别 asyncio.run()

# 方案2:用 nest_asyncio(适用于必须在同步代码中跑异步的场景)
import nest_asyncio
nest_asyncio.apply()  # 打补丁,允许嵌套事件循环
result = asyncio.run(fetch_data())

# 方案3:Python 3.10+,在已有事件循环中提交任务
import asyncio
loop = asyncio.get_event_loop()
task = loop.create_task(fetch_data())
result = await task

方案1最干净。如果你在Jupyter里,直接 await 就完事了。


一个完整的实战示例

把上面的坑都避开,写一个"正确"的异步爬虫:

python 复制代码
import asyncio
import aiohttp
from typing import Optional

CONCURRENCY = 30

async def fetch_page(
    session: aiohttp.ClientSession,
    url: str,
    semaphore: asyncio.Semaphore,
) -> Optional[str]:
    """抓取单个页面,带并发控制和错误处理"""
    async with semaphore:
        try:
            async with session.get(url, timeout=aiohttp.ClientTimeout(total=10)) as resp:
                if resp.status == 200:
                    return await resp.text()
                else:
                    print(f"⚠️ {url} 返回状态码 {resp.status}")
                    return None
        except asyncio.TimeoutError:
            print(f"⏰ {url} 超时")
            return None
        except Exception as e:
            print(f"❌ {url} 请求失败: {e}")
            return None

async def crawl(urls: list[str]) -> list[str]:
    """并发爬取多个URL"""
    semaphore = asyncio.Semaphore(CONCURRENCY)
    
    # 复用session,带连接池限制
    connector = aiohttp.TCPConnector(limit=CONCURRENCY)
    async with aiohttp.ClientSession(connector=connector) as session:
        tasks = [fetch_page(session, url, semaphore) for url in urls]
        results = await asyncio.gather(*tasks, return_exceptions=True)
    
    # 过滤掉失败的结果
    return [r for r in results if isinstance(r, str)]

# 跑起来
if __name__ == "__main__":
    urls = [f"https://httpbin.org/delay/{i%3}" for i in range(100)]
    results = asyncio.run(crawl(urls))
    print(f"成功抓取 {len(results)} 个页面")

这个示例避开了前面提到的所有坑:

  • ✅ 没有同步阻塞调用
  • ✅ 所有 async 操作都 await
  • ✅ 用 gather(return_exceptions=True) 处理异常
  • ✅ 用返回值传递数据,不用全局变量
  • ✅ Semaphore + TCPConnector 双重并发控制
  • ✅ Session 复用并正确关闭

写在最后

Python异步编程的门槛不在语法------async/await 谁都会写------而在心智模型。你得时刻想着:这里是IO还是CPU?会不会阻塞事件循环?并发数要不要限制?资源有没有正确释放?

我的建议是:

  1. 先用同步代码跑通,确保逻辑没问题
  2. 只对IO密集型部分改异步,不要为了异步而异步
  3. gather(return_exceptions=True) 开始,别上来就玩花活
  4. 遇到 Unclosed session 之类的警告,立刻修,别拖

异步编程就像骑自行车,开始会摔,但一旦上手,真的回不去了。

有问题欢迎评论区交流,踩了新坑也欢迎分享,咱们一起填 😄


参考文档:Python asyncio官方文档

相关推荐
wjs20245 小时前
JavaScript 条件语句
开发语言
lulu12165440786 小时前
Claude Code Harness架构技术深度解析:生产级AI Agent工程化实践
java·人工智能·python·ai编程
阿里加多6 小时前
第 1 章:Go 并发编程概述
java·开发语言·数据库·spring·golang
2301_792674866 小时前
java学习day29(juc)
java·开发语言·学习
周末也要写八哥6 小时前
MATLAB R2025a超详细下载与安装教程(附安装包)
开发语言·matlab
blog_wanghao7 小时前
基于Qt的串口调试助手
开发语言·qt
7年前端辞职转AI8 小时前
Python 文件操作
python·编程语言
龙文浩_8 小时前
AI梯度下降与PyTorch张量操作技术指南
人工智能·pytorch·python·深度学习·神经网络·机器学习·自然语言处理
呱牛do it8 小时前
企业级绩效考核系统设计与实现:基于FastAPI + Vue3的全栈解决方案
python·fastapi