Python异步编程
一、为什么要学异步编程?
想象你是一位餐厅服务员,现在有两种工作方式:
传统同步方式:
-
接待顾客A → 去厨房下单 → 等待厨师做菜(干等着不做事)
-
菜做好后送给A → 接待顾客B → 重复等待...
异步方式: -
接待顾客A → 去厨房下单 → 立即回来接待顾客B
-
当A的菜做好时,厨房会通知你 → 你再去送菜
异步编程的核心就是:让等待的时间不被浪费,特别适合处理大量I/O操作(如网络请求、文件读写)。
通过合理使用 async
和 await
,可以编写出高性能的并发程序,尤其适用于需要处理大量I/O操作的场景(如Web服务器、爬虫等)。
二、同步 vs 异步 vs 多线程对比
类型 | 资源消耗 | 适用场景 | 典型问题 |
---|---|---|---|
同步阻塞 | 低 | 简单任务、低频请求 | 性能差,无法处理高并发 |
多线程 | 中等 | I/O 密集型任务 | GIL 限制、锁竞争 |
多进程 | 高 | CPU 密集型任务 | 进程切换开销大,进程间通信开销大 |
异步非阻塞 | 极低 | I/O 密集型高并发任务 | 代码复杂度稍高 |
三、异步编程核心概念
1. 事件循环 (Event Loop)
就像餐厅的调度中心,持续监听并处理事件(如网络响应到达、定时任务触发)。
2. 协程 (Coroutine)
可以暂停和恢复的函数,用 async def
定义:
async def fetch_data(): # 这是一个协程 print("开始获取数据") await asyncio.sleep(1) # 模拟I/O等待 print("数据获取完成")
async
关键字的作用
定义协程函数:
使用 async def
声明的函数称为协程函数。调用该函数时,不会立即执行其内部代码,而是返回一个协程对象。
当直接调用一个协程函数时,它不会执行函数体内的任何代码,而是直接返回一个 cor程对象
(Coroutine Object)。此时,函数内部的代码(包括 await
之前的代码)均未被触发执行。
import asyncio async def my_async_func(): print("这是 await 之前的代码") # 不会立即执行 await asyncio.sleep(1) # 更不会执行到这里 print("这是 await 之后的代码") # 直接调用协程函数,返回协程对象,但代码未执行 coroutine = my_async_func() print("协程对象已创建:", coroutine) # 输出:<coroutine object my_async_func at ...>
此时不会有任何打印输出,因为函数体代码根本未运行。
协程的执行依赖事件循环:
要让协程函数内部的代码真正执行,必须通过以下方式之一:
-
将协程对象提交给事件循环(Event Loop)运行(例如
asyncio.run()
)。 -
在其他协程中通过
await
调用它。
正确执行协程的方式
# 方式1:使用 asyncio.run() 运行协程 async def main(): await my_async_func() asyncio.run(main()) # 输出: # 这是 await 之前的代码 # (等待1秒后) # 这是 await 之后的代码 # 方式2:直接运行协程 asyncio.run(my_async_func()) # 同上
await
关键字的作用
-
挂起当前协程:
表示"此处需要等待,当协程执行到await
表达式时,会暂停当前协程,将控制权交还给事件循环。此时,事件循环可以调度执行其他协程。只能在协程内使用。
async def fetch_data(): print("开始获取数据") await asyncio.sleep(2) # 模拟耗时操作(如网络请求) print("数据获取完成")
-
等待异步操作完成:
await
后通常跟随一个可等待对象(如另一个协程、Future 或 Task)。事件循环会监控这些对象的完成状态,当它们完成后,原协程会从暂停处恢复执行。
3.异步执行流程示例
假设有两个协程 task1
和 task2
:
4.概念总结
阶段 | 行为 |
---|---|
协程函数被(非协程)直接调用 | 返回协程对象,不执行任何代码(包括 await 前后的代码) |
协程被事件循环执行 | 从函数入口开始执行代码,直到遇到第一个 await 时暂停,等待异步操作完成 |
await 的作用 |
暂停当前协程,交出控制权给事件循环,等待右侧的异步操作完成后再恢复执行 |
四、快速入门:3分钟上手异步
基础示例:感受异步的时间魔法
import time # 非异步执行 def work(workname,second): print(f'{workname}:',workname) time.sleep(second) # 非阻塞等待 print(f'{workname}:',"完成") def main(): work("烤面包",3) work("煮咖啡",2) work("煎鸡蛋",1) main()
''' 非异步,只能串行完成任务,总耗时等于所有任务单独耗时总和:6秒! 烤面包: 开始 烤面包: 完成 煮咖啡: 开始 煮咖啡: 完成 煎鸡蛋: 开始 煎鸡蛋: 完成 '''
import asyncio # 异步执行 async def work(workname, second): print(f'{workname}:', "开始") await asyncio.sleep(second) # 非阻塞等待 print(f'{workname}:', "完成") async def main(): # 定义任务列表 works = [work("烤面包", 3), work("煮咖啡", 2), work("煎鸡蛋", 1)] # 并行执行所有任务 await asyncio.gather(*works) # “*” 符号在函数调用时用于 解包可迭代对象(如列表、元组等),等价于 gather(work("烤面包",3),work("煮咖啡",2),work("煎鸡蛋",1)) asyncio.run(main()) ''' 三个任务同时开始:总耗时 ≈3秒(同步执行需要6秒!) 烤面包: 开始 煮咖啡: 开始 煎鸡蛋: 开始 煎鸡蛋: 完成 煮咖啡: 完成 烤面包: 完成 '''
关键区别:同步 vs 异步
行为 | 同步代码(阻塞) | 异步代码(非阻塞) |
---|---|---|
等待I/O操作 | 整个线程被阻塞,无法执行其他任务 | 当前协程暂停,事件循环执行其他任务 |
资源占用 | 高(每个任务占用独立线程/进程) | 低(单线程内切换协程) |
适用场景 | CPU密集型任务 | I/O密集型任务(如网络请求、文件操作) |
五、实战进阶:异步网络请求
使用 aiohttp
实现高并发爬虫
import asyncio from datetime import datetime import aiohttp from bs4 import BeautifulSoup headers = {"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36"} async def fetch(session, url): try: async with session.get(url, headers=headers) as response: return await response.text() except Exception as e: print(f"请求{url}出错了", e) return None async def main(): urls = [ "https://www.baidu.com", "https://www.zhihu.com", "https://www.jd.com" ] * 10 # 重复10次模拟30个请求 async with aiohttp.ClientSession() as session: tasks = [fetch(session, url) for url in urls] results = await asyncio.gather(*tasks) # 统计成功数量 sucess = sum(1 for res in results if res) print(f"请求总数{len(urls)},成功总数:{sucess}") # 总共执行时间 ≈1秒(同步需要30秒+) bgtime = datetime.now() asyncio.run(main()) endtime = datetime.now() print("总耗时", endtime - bgtime) # 总耗时 0:00:01.393797
1. 如何控制并发量?
使用信号量 (Semaphore
):
import asyncio from datetime import datetime import aiohttp from bs4 import BeautifulSoup headers = {"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36"} async def fetch(session, url, semaphore): try: async with semaphore: async with session.get(url, headers=headers) as response: print(f"{url}开始执行") await asyncio.sleep(2) print(f"{url}执行完成") return await response.text() except Exception as e: print(f"请求{url}出错了", e) return None async def main(): urls = [ "https://www.baidu.com", "https://www.zhihu.com", "https://www.jd.com" ] * 10 # 重复10次模拟30个请求 async with aiohttp.ClientSession() as session: # 创建信号量 semaphore = asyncio.Semaphore(3) # 最大并发数3 # 创建任务列表(每个任务携带信号量:semaphore) tasks = [fetch(session, url, semaphore) for url in urls] # 并发执行所有任务(但最多同时3个) results = await asyncio.gather(*tasks) # 统计成功数量 sucess = sum(1 for res in results if res) print(f"请求总数{len(urls)},成功总数:{sucess}") asyncio.run(main()) ''' https://www.jd.com 开始执行 https://www.baidu.com开始执行 https://www.zhihu.com开始执行 https://www.jd.com 执行完成 https://www.baidu.com执行完成 https://www.zhihu.com执行完成 https://www.baidu.com开始执行 https://www.jd.com 开始执行 https://www.zhihu.com开始执行 https://www.baidu.com执行完成 https://www.jd.com 执行完成 https://www.baidu.com开始执行 #这里后面可能是打印错乱,但是可以得出结论,同时一定只有三个任务在执行 https://www.zhihu.com执行完成 '''
2. 如何处理超时?
aiohttp的timeout
aiohttp的timeout是针对单个请求的连接和读取超时
asyncio的wait_for超时
asyncio的wait_for是对整个协程的超时控制
最佳实践:多层超时控制
为全面防御各类超时问题,推荐组合使用以下策略:
import aiohttp
import asyncio
from aiohttp import ClientTimeout
async def fetch(session, url):
try:
# 1. 网络层超时:限制连接、传输的底层时间
async with session.get(url, timeout=ClientTimeout(
total=5, # 总超时##ClientTimeout(total=5)
表示整个请求(包含连接、发送、接收)的总时间不得超过 5 秒:从发起请求到完整接收响应体的最大耗时
connect=2, # 连接建立超时
sock_read=3 # 单次数据读取超时
)) as response:
# 2. 业务逻辑层超时:限制响应处理时间
html = await asyncio.wait_for(
process_response(response),
timeout=5
)
return html
except asyncio.TimeoutError:
print(f"请求超时: {url}")
return None
async def process_response(response):
# 模拟耗时操作(如解析大文件)
await asyncio.sleep(3)
return await response.text()
各超时参数的典型值参考
场景 | connect | sock_connect | sock_read | total |
---|---|---|---|---|
内网高速服务 | 1s | 1s | 2s | 5s |
公网常规 API | 3s | 3s | 5s | 10s |
高延迟网络 | 5s | 5s | 10s | 20s |
总结
-
ClientTimeout(total=5)
是 网络协议层的安全保障,防止底层操作无限挂起。 -
应结合
asyncio.wait_for
实现 业务逻辑层的超时控制(如响应解析、数据处理)。 -
合理配置超时参数可显著提升程序的 健壮性 和 用户体验。
补充:
ClientSession
的 会话级超时 和 session.get()
的 请求级超时
在 aiohttp 中,ClientSession
的 会话级超时 和 session.get()
的 请求级超时 是两个不同层级的超时控制。它们的核心区别在于 作用范围和优先级。以下是详细解释:
1. 作用范围对比
超时设置位置 | 作用范围 | 适用场景 |
---|---|---|
ClientSession 会话级超时 |
会话内所有请求的 默认超时 | 统一管理大部分请求的超时规则(全局默认值) |
session.get() 请求级超时 |
仅针对当前请求的 独立超时 | 针对特殊请求单独调整超时(覆盖会话级默认值) |
2. 优先级规则
-
请求级超时 > 会话级超时
如果同时在会话和请求中设置超时,请求级配置会覆盖会话级配置。
示例代码:
from aiohttp import ClientTimeout, ClientSession async with ClientSession( timeout=ClientTimeout(total=5) # 会话级超时:5秒 ) as session: # 请求1:使用会话级超时(total=5) await session.get("https://api.example.com/data") # 请求2:使用请求级超时(total=3,覆盖会话级设置) await session.get( "https://api.example.com/slow", timeout=ClientTimeout(total=3) # 优先级更高 )
3. 超时参数详解
ClientTimeout
对象支持多种细粒度超时配置:
timeout = ClientTimeout( total=10, # 总超时(从请求开始到响应结束) connect=3, # 建立TCP连接的超时(含DNS解析) sock_connect=2, # 单次TCP连接尝试的超时(DNS解析完成后) sock_read=5 # 从Socket读取数据的单次等待超时 )
超时触发场景:
-
connect=3
:若DNS解析或TCP握手超过3秒,触发超时。 -
sock_read=5
:若服务器在发送响应头或响应体时,连续5秒未发送数据,触发超时。 -
total=10
:若整个请求(从发起到完成)超过10秒,触发超时(优先级最高)。
4. 最佳实践
场景1:统一超时策略
所有请求使用相同的超时规则:
async with ClientSession( timeout=ClientTimeout(total=5) ) as session: # 所有请求默认使用 total=5 await session.get("https://api.example.com/1") await session.get("https://api.example.com/2")
场景2:差异化超时策略
大部分请求用默认超时,特殊请求单独调整:
async with ClientSession( timeout=ClientTimeout(total=5) ) as session: # 默认使用 total=5 await session.get("https://api.example.com/fast") # 高风险接口设置更短超时 await session.get( "https://api.example.com/unstable", timeout=ClientTimeout(total=2) ) # 大文件下载设置更长超时 await session.get( "https://api.example.com/large-file", timeout=ClientTimeout(total=30) )
5. 底层实现逻辑
-
会话级超时:
初始化ClientSession
时,将超时配置绑定到会话对象,作为后续所有请求的默认值。 -
请求级超时:
发起请求时,若指定了timeout
参数,会创建一个新的ClientTimeout
对象,覆盖会话级配置。
总结
-
会话级超时:统一管理默认规则,提高代码复用性。
-
请求级超时:灵活覆盖默认值,适应不同请求需求。
-
组合使用:既能保持代码简洁,又能精准控制关键请求。
3. 同步代码调用异步函数
方法1:直接运行事件循环
import asyncio async def async_task(): print("异步任务开始") await asyncio.sleep(1) print("异步任务完成") return "结果" # 同步函数中调用异步任务 def sync_function(): print("同步代码开始") result = asyncio.run(async_task()) # 启动事件循环 print("获取异步结果:", result) print("同步代码结束") sync_function()
输出:
同步代码开始
异步任务开始
(1秒后)
异步任务完成
获取异步结果: 结果
同步代码结束
方法2:在已有事件循环中运行
import asyncio async def async_task(): print("异步任务开始") await asyncio.sleep(1) return "结果" def sync_function(): loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) try: result = loop.run_until_complete(async_task()) print("获取异步结果:", result) finally: loop.close() sync_function()
注意:
-
在 Jupyter Notebook 或已有事件循环的环境(如 FastAPI)中,直接使用
await
:# 在 Jupyter Cell 中直接运行 await async_task() # 无需 asyncio.run()
七、最佳实践
-
避免阻塞操作
不要在协程中使用time.sleep()
,改用await asyncio.sleep()
-
优先使用异步库
同步库 异步替代 requests
aiohttp
sqlite3
aiosqlite
-
监控性能
使用uvloop
提升性能:
import uvloop uvloop.install() # 放在文件开头
八、总结与下一步
你已经掌握的:
-
✅ 异步编程的核心概念
-
✅ 协程的创建与使用
-
✅ 高效处理网络请求
-
✅ 常见问题解决方案
推荐学习路线:
-
深入学习
asyncio
官方文档 -
掌握
aiohttp
的高级用法 -
了解异步数据库操作(如
asyncpg
) -
探索 Web 框架(如
FastAPI
)
动手实践是掌握异步编程的最佳方式! 尝试用异步改写你现有的爬虫或API调用代码,感受性能的飞跃吧!
补充
await的使用
在Python异步编程中,正确使用await
关键字至关重要。以下是判断何时需要添加await
的分步指南:
1. 识别异步函数
函数定义:任何用async def
定义的函数为协程函数,调用时必须加await
。
async def fetch_data(): return "Data" async def main(): result = await fetch_data() # 必须加await print(result)
-
第三方库函数:若库的文档标明某函数是异步的(如
aiohttp.ClientSession.get()
),调用时需加await
。
2. 理解异步操作场景
以下操作通常需要await
:
-
网络请求(如
aiohttp
、httpx
) -
文件I/O(如
aiofiles
) -
数据库操作(如异步ORM库
tortoise-orm
) -
睡眠/延时(
asyncio.sleep()
) -
其他协程调用(包括标准库的异步API)
示例:
import aiohttp import asyncio async def fetch_url(url): async with aiohttp.ClientSession() as session: async with session.get(url) as response: # 需要await return await response.text() # 需要await async def main(): html = await fetch_url("https://example.com") # 需要await print(html[:100]) asyncio.run(main())
3. 避免常见错误
错误1:忘记加await
async def task(): print("Task done") async def main(): task() # ❌ 错误:未加await,task()不会执行 await task() # ✅ 正确 asyncio.run(main())
错误提示:RuntimeWarning: coroutine 'task' was never awaited
错误2:在同步函数中使用await
def sync_func(): await async_func() # ❌ 语法错误:不能在同步函数中使用await
4. 检查代码上下文
await
只能用于异步函数内部:
async def async_func(): await some_async_op() # ✅ 正确 def sync_func(): await some_async_op() # ❌ 语法错误
事件循环必须存在:异步代码需通过asyncio.run()
或事件循环启动:
# 正确写法 async def main(): await async_func() if __name__ == "__main__": asyncio.run(main())
5. 调试技巧
-
使用IDE提示:现代IDE(如PyCharm、VS Code)会标记未正确使用
await
的位置。 -
阅读错误信息:
-
SyntaxError: 'await' outside async function
→ 在同步函数中误用await
。 -
RuntimeWarning: coroutine 'xxx' was never awaited
→ 忘记加await
。
-
-
逐步注释法:若不确定某处是否需要
await
,可注释掉await
观察程序行为变化。
总结表格
场景 | 是否需要await | 示例 |
---|---|---|
调用async def 函数 |
是 | await fetch_data() |
调用同步函数 | 否 | requests.get(url) |
调用异步I/O操作 | 是 | await response.text() |
使用asyncio.sleep() |
是 | await asyncio.sleep(1) |
在同步函数中使用await |
否(语法错误) | def sync(): await ... ❌ |
最终原则
-
黄金法则:见到
async def
函数或异步库函数,调用时必加await
。 -
文档优先:第三方库的异步API需查阅文档确认用法。
-
保持简洁:将异步逻辑集中在
async
函数中,避免与同步代码混用。
为什么这样设计?
异步编程的核心是非阻塞协作式多任务。协程的延迟执行特性使得:
-
资源高效:可以创建大量协程对象而无需立即分配执行资源。
-
灵活调度:事件循环能自由决定何时执行哪个协程。
-
逻辑清晰:通过
await
显式声明异步操作的等待点。
asyncio.Lock异步锁
在异步编程中,尽管协程在单线程内交替执行,但如果多个协程需要修改同一个全局变量,仍然可能因操作被中断(通过await
)导致竞态条件。为了保证数据一致性,asyncio
提供了协程专用的锁机制(如asyncio.Lock
)。以下是详细示例和解释:
示例场景
多个协程同时对一个全局计数器 counter
进行递增操作,每次递增需要模拟一个耗时的I/O操作(用await asyncio.sleep(0.1)
)。
1. 无锁情况下的竞态条件
import asyncio counter = 0 # 全局变量 async def increment(): global counter temp = counter await asyncio.sleep(0.1) # 模拟I/O等待,协程在此处切换 counter = temp + 1 # 问题点:其他协程可能已经修改了counter async def main(): tasks = [asyncio.create_task(increment()) for _ in range(100)] await asyncio.gather(*tasks) print(f"Final counter value: {counter}") asyncio.run(main())
输出:
Final counter value: 1 # 预期是100,但因竞态条件导致结果错误
原因:
协程在 await asyncio.sleep(0.1)
处释放控制权,其他协程可能在此期间读取到相同的 temp
值,导致最终结果远小于预期。
2. 使用 asyncio.Lock
解决竞态条件
import asyncio counter = 0 lock = asyncio.Lock() # 协程专用锁 async def increment(): global counter async with lock: # 自动加锁和解锁 temp = counter await asyncio.sleep(0.1) # 协程切换时锁仍被持有 counter = temp + 1 async def main(): tasks = [asyncio.create_task(increment()) for _ in range(100)] await asyncio.gather(*tasks) print(f"Final counter value: {counter}") asyncio.run(main())
输出:
Final counter value: 100 # 结果正确
关键机制:
-
async with lock
确保代码块在执行期间不会被其他协程中断。 -
即使协程在
await
处切换,锁仍被当前协程持有,其他协程需等待锁释放后才能进入临界区。
锁的工作原理
-
加锁:协程通过
async with lock
进入临界区时,锁被占用。 -
等待:其他协程尝试获取锁时会被挂起,直到锁被释放。
-
解锁:协程退出
async with
代码块时自动释放锁。
其他同步原语
除了 Lock
,asyncio
还提供以下工具:
同步原语 | 用途 |
---|---|
asyncio.Semaphore |
限制同时访问资源的协程数量(如控制最大并发连接数) |
asyncio.Event |
协程间的事件通知(如等待某个条件达成后唤醒所有等待协程) |
asyncio.Condition |
复杂条件同步(类似 threading.Condition ) |
最佳实践
-
最小化锁的范围:只在操作共享资源时加锁,尽快释放。
-
避免嵌套锁:防止死锁(如协程A持有锁1,协程B持有锁2,互相等待对方释放)。
-
优先使用不可变数据:减少共享状态的需求(如使用函数式编程风格)。
总结
-
异步编程仍需同步:协程交替执行可能导致非原子操作的竞态条件。
-
使用
asyncio.Lock
:保护共享资源的原子性操作。 -
选择合适工具:根据场景选择
Semaphore
、Event
或Condition
。
通过合理使用同步机制,可以在保持异步高效并发的同时,确保数据的一致性。
异步锁 vs 多线程锁
特性 | 异步锁 (asyncio.Lock ) | 多线程锁 (threading.Lock ) |
---|---|---|
底层实现 | 用户态实现,基于事件循环调度 | 内核态实现,依赖操作系统线程调度 |
切换成本 | 纳秒级(无系统调用) | 微秒级(需陷入内核) |
阻塞行为 | 非阻塞,协程让出事件循环 | 阻塞线程,导致线程挂起 |
适用场景 | 单线程内协程同步 | 多线程同步 |
GIL 影响 | 无关(单线程无 GIL 竞争) | 受 GIL 限制 |
何时需要异步锁?
场景 | 是否需要锁 | 示例 |
---|---|---|
协程只读共享数据 | 否 | 读取全局配置 |
协程修改独立资源 | 否 | 每个协程操作自己的数据库连接 |
协程修改共享资源且操作包含 await |
是 | 全局计数器、共享文件写入
global counter |
协程修改共享资源但操作是原子的 | 否 | counter += 1 (无 await 中断) |
六、最佳实践
-
尽量减少共享状态:
使用不可变数据、线程隔离设计(如为每个协程分配独立资源)。 -
优先使用队列通信:
通过asyncio.Queue
实现协程间数据传递,避免直接共享变量。
import asyncio async def producer(queue): for i in range(10): await queue.put(i) await asyncio.sleep(0.1) async def consumer(queue): while True: item = await queue.get() print(f"消费: {item}") queue.task_done() async def main(): queue = asyncio.Queue() tasks = [ asyncio.create_task(producer(queue)), asyncio.create_task(consumer(queue)) ] await asyncio.gather(*tasks) asyncio.run(main())
-
锁的范围最小化:
仅保护必要代码块,尽快释放锁以提升并发度。
总结
-
异步编程绕过了 GIL:这是其性能优势的核心来源。
-
协程间仍可能竞争共享资源:需通过
asyncio.Lock
等机制同步。 -
锁的使用成本极低:异步锁的切换开销可忽略不计,不会成为性能瓶颈。