一、前言
在Python开发中,高并发场景(如API服务、网络爬虫、大模型调用、RAG应用)越来越常见,而异步编程正是解决这类场景的核心技术。相比于传统同步编程,异步编程能极大提升IO密集型任务的执行效率,避免CPU资源浪费;相比于多线程,异步编程开销更低、并发能力更强,尤其适合高并发场景下的开发。
前置要求:Python 3.7+(异步简化写法asyncio.run()为3.7+新增,低于该版本需升级,终端输入python --version可查看版本)。
二、异步编程核心基础
新手学习异步,最容易困惑的是"异步到底是什么""为什么比同步快"。我们先从同步与异步的对比入手,用生活场景+代码示例,直观理解两者的差异,再逐步过渡到异步的核心逻辑。
2.1 同步vs异步:生活例子+核心区别
先抛开复杂的技术概念,用生活中最常见的"泡茶"任务,理解同步与异步的本质区别。
2.1.1 生活场景类比
假设我们有3个任务需要完成:烧开水(2分钟)、洗杯子(1分钟)、泡茶(0.5分钟),两种执行方式如下:
-
同步执行(串行):烧水壶加水 → 傻等水开(2分钟,期间什么都不做) → 洗杯子(1分钟) → 泡茶(0.5分钟)。总耗时=2+1+0.5=3.5分钟,等待期间CPU(人)完全闲置。
-
异步执行(并发):烧水壶加水 → 不傻等水开,立刻去洗杯子(1分钟) → 洗杯子期间,水开了(剩余1分钟),继续洗杯子 → 洗完杯子,立刻泡茶(0.5分钟)。总耗时=2+0.5=2.5分钟,等待期间CPU(人)不闲置,高效利用时间。
这个例子的核心的是:同步是"傻等",异步是"不等"------等待某个任务(如烧水)的同时,去执行其他可执行的任务,从而提升整体效率。
2.1.2 核心区别对照表
| 特性 | 同步(Synchronous) | 异步(Asynchronous) |
|---|---|---|
| 执行方式 | 任务按顺序执行,上一个任务完成后,下一个才开始 | 任务并发执行,某个任务等待时,切换到其他任务执行 |
| 资源利用 | 等待期间(如IO操作),CPU完全闲置,资源浪费严重 | 等待期间,CPU去执行其他任务,资源利用率极高 |
| 底层逻辑 | 单线程串行,无需额外调度 | 单线程+事件循环,主动切换任务 |
| 适用场景 | 简单小任务、无IO等待(如简单数值计算) | IO密集型任务(网络请求、文件读写、大模型调用、数据库操作) |
| 代码复杂度 | 简单,逻辑直观 | 稍复杂,需掌握异步语法(async/await等) |
2.2 同步vs异步代码对比
我们用"模拟3个IO任务(每个任务等待一定时间)"的场景,写同步和异步代码,直观感受两者的效率差异,代码逐行添加注释,新手可直接复制运行,理解每一步的作用。
2.2.1 同步代码(基础对比,理解"傻等")
同步代码的核心是"串行执行",每个任务必须等上一个任务完成才能开始,总耗时是所有任务耗时之和。
python
# 1. 导入时间库,用于计时和模拟IO等待(如网络请求、文件读写)
import time
# 2. 定义同步任务函数:模拟IO等待操作
# 参数说明:name=任务名称,delay=等待时间(秒),模拟IO操作的耗时
def sync_task(name, delay):
print(f"【同步】任务{name}:开始等待{delay}秒(模拟IO操作)")
# 核心:time.sleep(delay) 是同步阻塞等待------程序啥也不干,就傻等delay秒
# 此时CPU完全闲置,无法执行其他任务
time.sleep(delay)
print(f"【同步】任务{name}:等待完成,继续执行下一个任务\n")
# 3. 程序入口(Python规范写法,保证代码只在直接运行时执行,避免导入时执行)
if __name__ == "__main__":
# 记录程序开始时间,用于计算总耗时
start_time = time.time()
# 4. 按顺序执行3个同步任务(串行执行,必须等上一个完成)
sync_task("A", 2) # 任务A:等待2秒
sync_task("B", 1) # 任务B:等待1秒(必须等A完成才能开始)
sync_task("C", 3) # 任务C:等待3秒(必须等B完成才能开始)
# 5. 计算并打印总耗时(保留2位小数,更直观)
total_time = time.time() - start_time
print(f"【同步】所有任务执行完成,总耗时:{total_time:.2f}秒")
执行结果(完全串行,总耗时=2+1+3=6秒):
python
【同步】任务A:开始等待2秒(模拟IO操作)
【同步】任务A:等待完成,继续执行下一个任务
【同步】任务B:开始等待1秒(模拟IO操作)
【同步】任务B:等待完成,继续执行下一个任务
【同步】任务C:开始等待3秒(模拟IO操作)
【同步】任务C:等待完成,继续执行下一个任务
【同步】所有任务执行完成,总耗时:6.00秒
关键总结:同步代码的问题在于"等待期间CPU闲置",哪怕有其他任务可以执行,也必须傻等上一个任务完成,效率极低。
2.2.2 异步代码(核心实现,理解"不等")
我们将上面的同步代码改写为异步,仅新增4个核心异步语法,就能实现并发执行,总耗时缩短为最长任务的耗时(3秒),效率直接翻倍。
python
# 1. 导入异步编程核心库(Python内置,无需额外安装)
# asyncio是异步编程的核心,提供事件循环、协程管理等功能
import asyncio
# 导入时间库,用于计算总耗时(和同步代码一致)
import time
# 2. 定义异步任务函数:用async def替代普通def,标记为异步函数
# 异步函数的核心特点:可以使用await语法,实现非阻塞等待
async def async_task(name, delay):
print(f"【异步】任务{name}:开始等待{delay}秒(模拟IO操作)")
# 核心:await asyncio.sleep(delay) 是异步非阻塞等待
# 含义:暂停当前任务,主动让出CPU,让事件循环去执行其他任务
# 等delay秒后,事件循环再回来继续执行当前任务
await asyncio.sleep(delay)
print(f"【异步】任务{name}:等待完成\n")
# 3. 定义主异步函数:用于管理所有异步任务
# 注意:异步任务不能直接在程序入口执行,必须通过主异步函数管理
async def main():
# 3.1 创建异步任务列表:将3个异步任务封装成可并发执行的Task
# asyncio.create_task():将异步函数转为Task,加入事件循环等待执行
# 此时任务已被事件循环接管,会在合适的时机执行(无需等待上一个任务)
task1 = asyncio.create_task(async_task("A", 2))
task2 = asyncio.create_task(async_task("B", 1))
task3 = asyncio.create_task(async_task("C", 3))
# 3.2 等待所有异步任务完成
# asyncio.gather(*tasks):等待列表中的所有Task完成,*tasks表示拆分成单个参数
# 这里会暂停main函数,直到所有任务都执行完毕
await asyncio.gather(task1, task2, task3)
# 4. 程序入口:启动异步程序
if __name__ == "__main__":
start_time = time.time()
# asyncio.run(main()):异步程序的"总开关"
# 作用:自动创建事件循环、执行main主异步函数、执行完成后关闭事件循环
# 无需手动管理事件循环,简化异步编程流程(Python 3.7+新增)
asyncio.run(main())
# 计算总耗时
total_time = time.time() - start_time
print(f"【异步】所有任务执行完成,总耗时:{total_time:.2f}秒")
执行结果(并发执行,总耗时≈3秒,即最长任务的耗时):
python
【异步】任务A:开始等待2秒(模拟IO操作)
【异步】任务B:开始等待1秒(模拟IO操作)
【异步】任务C:开始等待3秒(模拟IO操作)
【异步】任务B:等待完成
【异步】任务A:等待完成
【异步】任务C:等待完成
【异步】所有任务执行完成,总耗时:3.00秒
关键总结:异步代码的核心是"非阻塞等待"------当某个任务遇到await需要等待时,会主动让出CPU,让其他任务执行,从而实现并发,大幅提升效率。
2.2.3 异步代码执行流程拆解(新手必懂)
很多新手虽然能运行异步代码,但不理解底层执行流程,这里用时间轴拆解上面的异步代码,清晰看到"任务切换"的过程:
python
0秒:asyncio.run(main())启动事件循环,执行main函数,创建task1、task2、task3三个任务,事件循环依次启动这三个任务。
0秒(紧接着):执行task1(任务A),打印"开始等待2秒",遇到await asyncio.sleep(2),暂停task1,主动让出CPU,事件循环切换到task2。
0秒(紧接着):执行task2(任务B),打印"开始等待1秒",遇到await asyncio.sleep(1),暂停task2,主动让出CPU,事件循环切换到task3。
0秒(紧接着):执行task3(任务C),打印"开始等待3秒",遇到await asyncio.sleep(3),暂停task3,此时所有任务都处于等待状态,事件循环暂时"休息"。
1秒:task2的等待时间(1秒)到了,事件循环唤醒task2,执行剩余代码,打印"任务B:等待完成",task2执行完毕。
2秒:task1的等待时间(2秒)到了,事件循环唤醒task1,执行剩余代码,打印"任务A:等待完成",task1执行完毕。
3秒:task3的等待时间(3秒)到了,事件循环唤醒task3,执行剩余代码,打印"任务C:等待完成",task3执行完毕。
3秒(紧接着):asyncio.gather()检测到所有任务完成,main函数执行完毕,事件循环关闭,程序结束。
从流程可以看出:异步的"并发",本质是"任务切换"------等待时让出CPU,避免闲置,从而提升整体效率。
三、核心语法深度解析(新手必懂,避坑重点)
异步编程的核心语法只有4个:async def、await、yield、async for,再加上实战必备的锁机制(with db_lock)。本章逐字逐句拆解每个语法的作用、用法、常见错误,结合示例讲解,确保新手能彻底理解,避免踩坑。
3.1 async def:异步函数的"身份证"
3.1.1 语法格式
python
async def 函数名(参数列表):
# 函数体(可包含await、yield等异步语法)
pass
3.1.2 核心作用
标记一个函数为异步函数,告诉Python解释器:这个函数可以使用await、yield等异步语法,执行时不会阻塞事件循环,能主动让出CPU。
3.1.3 关键注意事项(避坑重点)
-
必须用async def定义:只要函数内部需要使用await或yield,就必须用async def定义,否则会报错(SyntaxError)。
-
异步函数调用不会直接执行:普通函数调用(如func())会直接执行并返回结果;而异步函数调用(如async_func())会返回一个"协程对象",不会直接执行,必须通过事件循环(如asyncio.run())或await触发执行。
-
示例对比(正确vs错误):
python
# 正确:用async def定义异步函数,可使用await
async def correct_async_func():
await asyncio.sleep(1)
print("异步函数执行完成")
# 错误1:普通def函数中使用await,报错
def wrong_sync_func1():
await asyncio.sleep(1) # 报错:SyntaxError: 'await' outside async function
# 错误2:直接调用异步函数,不会执行
async def async_func():
print("异步函数执行")
async_func() # 仅返回协程对象,不会打印任何内容,需用await或asyncio.run()触发
3.2 await:非阻塞等待的"核心开关"
await是异步编程的"灵魂",核心作用是"暂停当前协程,让出CPU,等待某个操作完成后再恢复执行",也是实现"不傻等"的关键。
3.2.1 语法格式
python
await 可等待对象
3.2.2 什么是"可等待对象"?
await后面必须跟"可等待对象",否则会报错,常见的可等待对象有3种:
-
异步函数(async def定义的函数)返回的协程对象;
-
asyncio.sleep()、asyncio.gather()等asyncio库提供的异步函数;
-
异步迭代器(如LLM的astream()返回的对象)、Future对象。
3.2.3 核心作用拆解(结合生活例子)
还是用"泡茶"场景,await就相当于"烧水壶加水后,不傻等,去洗杯子"------暂停当前任务(等水开),让出CPU(去洗杯子),等水开了(等待完成),再回来继续泡茶(恢复当前任务)。
代码层面,await的作用是:
-
暂停当前异步函数的执行;
-
将CPU资源让给事件循环,让事件循环去执行其他可执行的异步任务;
-
等待await后面的"可等待对象"执行完成;
-
可等待对象执行完成后,恢复当前异步函数的执行,继续执行await后面的代码。
3.2.4 常见错误与避坑技巧
-
错误1:await用在普通def函数中:如3.1.3中的错误示例,会直接报错,必须用async def定义函数。
-
错误2:用time.sleep()替代asyncio.sleep():time.sleep()是同步阻塞函数,哪怕放在async def函数中,也会卡住整个事件循环,导致异步失效(总耗时和同步一样)。
-
错误3:await后面跟普通函数:普通函数不是"可等待对象",用await修饰会报错。
python
import asyncio
import time
async def await_demo():
# 正确:await 可等待对象(asyncio.sleep是异步函数)
await asyncio.sleep(2)
print("异步等待完成")
# 错误2:用time.sleep(),同步阻塞,卡住事件循环
# time.sleep(2)
# 错误3:await 普通函数(非可等待对象)
# def normal_func():
# return 1
# await normal_func() # 报错:TypeError: object int can't be used in 'await' expression
asyncio.run(await_demo())
3.3 yield:异步生成器的"逐段返回"神器
yield是实现"流式返回"的核心语法,尤其适用于大模型流式回答、日志逐行输出、大数据分批处理等场景------函数不是一次性返回所有结果,而是"生成一个、返回一个",像流水线发货一样,逐段返回数据。
结合async def,yield就形成了"异步生成器",既能实现异步非阻塞,又能逐段返回数据,是RAG流式回答、大模型API调用的核心技术。
3.3.1 先懂普通yield(基础铺垫)
在学习异步生成器之前,先了解普通生成器(def + yield),理解yield的核心逻辑------逐段返回数据,函数暂停执行。
python
# 普通生成器函数:def + yield,逐段返回数据
def generate_data():
# 第一次遍历:返回"第一段数据",函数暂停在yield处
yield "第一段数据"
# 第二次遍历:从暂停处继续执行,返回"第二段数据",函数暂停
yield "第二段数据"
# 第三次遍历:从暂停处继续执行,返回"第三段数据",函数结束
yield "第三段数据"
# 遍历生成器:用普通for循环,拿一个、生成一个
for data in generate_data():
print(data)
执行结果(逐段输出):
python
第一段数据
第二段数据
第三段数据
核心特点:
-
yield不是"结束函数",而是"临时返回一个值,函数暂停在当前位置";
-
每次遍历生成器(for循环),函数从暂停处继续执行,直到下一个yield;
-
如果没有更多yield,函数结束,生成器迭代完成。
3.3.2 异步生成器:async def + yield(实战核心)
异步生成器是"异步函数"和"生成器"的结合,语法是async def + yield,核心作用是:在异步非阻塞的基础上,逐段返回流式数据。
3.3.2.1 语法格式
python
async def 异步生成器函数(参数列表):
# 函数体
yield 数据片段 # 逐段返回数据
await 可等待对象 # 可结合异步等待,实现非阻塞流式返回
3.3.2.2 核心应用场景
最常见的场景是"大模型流式回答":大模型生成回答时,不是一次性返回完整文本,而是先生成一个字、一个词,再逐段返回,前端拿到一个片段就显示一个片段,实现"打字机"效果,提升用户体验。
3.3.2.3 代码示例(模拟大模型流式回答)
python
import asyncio
# 异步生成器函数:模拟大模型流式回答,逐段返回文本
async def llm_stream_answer(prompt):
# 模拟大模型生成的文本片段(实际场景中,是从大模型API获取的流式片段)
answer_chunks = ["你", "好", "!", "我", "是", "异", "步", "生", "成", "器"]
for chunk in answer_chunks:
yield chunk # 逐段返回文本片段
# 模拟打字机效果:每个片段返回后,等待0.02秒再返回下一个
await asyncio.sleep(0.02)
# 调用异步生成器:必须用async for遍历(普通for会报错)
async def main():
# async for 遍历异步生成器,逐段获取数据
async for chunk in llm_stream_answer("你好"):
# end="":不换行,flush=True:实时打印(避免缓存)
print(chunk, end="", flush=True)
# 启动异步程序
asyncio.run(main())
执行效果:文本逐字输出,像打字机一样,而非一次性打印全部内容(你好!我是异步生成器)。
3.3.3 yield vs return(核心区别,避坑重点)
很多新手会混淆yield和return,两者的核心区别的是"一次性返回"和"逐段返回":
| 语法 | 核心作用 | 函数状态 | 适用场景 |
|---|---|---|---|
| return | 一次性返回所有结果 | 返回后,函数直接结束,无法继续执行 | 普通函数、无需流式返回的场景 |
| yield | 逐段返回数据,每次返回一个片段 | 返回后,函数暂停,下次遍历从暂停处继续执行 | 流式返回(大模型回答、日志输出)、分批处理数据 |
示例对比:
python
import asyncio
# 用return:一次性返回所有结果,函数结束
async def return_demo():
return ["你", "好", "!"]
# 用yield:逐段返回,函数暂停
async def yield_demo():
yield "你"
yield "好"
yield "!"
async def main():
# 调用return的异步函数:一次性获取所有结果
return_result = await return_demo()
print("return返回:", return_result) # 输出:return返回:['你', '好', '!']
# 调用yield的异步生成器:逐段获取结果
print("yield返回:", end="")
async for chunk in yield_demo():
print(chunk, end="", flush=True) # 输出:yield返回:你好!
asyncio.run(main())
3.4 async for:异步迭代器的"专属遍历工具"
async for是专门用于遍历"异步迭代器"的语法,比如异步生成器、大模型的astream()返回的对象、异步文件读取对象等。普通for循环无法遍历异步迭代器,必须用async for,否则会报错。
3.4.1 核心原因
异步迭代器的"迭代过程"是异步的(比如获取下一个片段需要等待IO操作,如大模型返回数据),普通for循环是同步的,无法处理异步等待,会卡住整个程序,因此必须用async for------异步遍历,等待下一个片段时,主动让出CPU,不阻塞事件循环。
3.4.2 语法格式
python
async for 变量 in 异步迭代器:
# 处理每一个迭代的片段
pass
3.4.3 代码示例(结合大模型流式调用)
实际开发中,大模型的流式接口(如llm.astream())返回的是异步迭代器,必须用async for遍历,逐段获取生成的文本:
python
import asyncio
# 模拟大模型流式接口:返回异步迭代器(实际场景中,无需自己实现,调用大模型API即可)
async def llm_astream(prompt):
answer = "异步编程是Python处理高并发IO场景的核心技术,适合大模型流式调用、网络爬虫等场景。"
# 逐字拆分文本,模拟流式返回
for char in answer:
yield char
await asyncio.sleep(0.01) # 模拟大模型生成延迟
# 异步遍历大模型流式结果
async def main():
prompt = "什么是异步编程?"
print(f"提问:{prompt}")
print("回答:", end="", flush=True)
# async for 遍历异步迭代器,逐段获取大模型生成的文本
async for chunk in llm_astream(prompt):
print(chunk, end="", flush=True)
asyncio.run(main())
执行效果:大模型的回答逐字输出,实现"打字机"效果,提升用户体验。
常见错误:用普通for遍历异步迭代器,会报错:
python
# 错误:用普通for遍历异步迭代器
async def main():
for chunk in llm_astream("什么是异步编程?"): # 报错:TypeError: 'async generator' object is not iterable
print(chunk)
3.5 with db_lock:高并发下的共享资源保护神
在高并发异步场景中,多个任务同时操作"共享资源"(如向量数据库、文件、全局变量)时,会出现"数据竞争"问题,导致数据重复、损坏、报错甚至程序崩溃。此时,就需要用互斥锁(db_lock)来保护共享资源,而with db_lock是最安全、最简洁的加锁方式。
3.5.1 核心概念:db_lock是什么?
db_lock是一个互斥锁(Mutex,也叫排他锁),你可以把它理解成"一个独占许可证":同一时间,只有一个任务能拿到这个许可证(加锁成功),其他任务必须等待许可证归还(解锁)后,才能拿到许可证,执行相关操作。
而with db_lock是Python的"上下文管理器"写法,等价于手动加锁+解锁,但更安全(避免忘记解锁导致死锁):
python
# 手动加锁/解锁(不推荐,容易忘解锁,导致死锁)
db_lock.acquire() # 获取锁(拿许可证)
try:
# 操作共享资源(如向量数据库写入)
vector_db.add_texts(texts=valid_texts)
finally:
db_lock.release() # 释放锁(还许可证),无论是否报错,都会执行
# 用with db_lock(推荐,自动加锁/解锁)
with db_lock:
# 操作共享资源,执行完自动解锁,无需手动处理
vector_db.add_texts(texts=valid_texts)
3.5.2 核心作用:解决数据竞争问题
代码中,vector_db.add_texts(texts=valid_texts)是操作"共享资源"(向量数据库)------比如多个用户同时调用generate_streaming_answer函数,都会执行这行代码,往同一个向量数据库里写入文本。
如果不加锁,多个任务会"抢着"写入数据,导致各种异常;如果加锁,同一时间只有一个任务能写入,保证数据安全。
3.5.3 加与不加db_lock的具体影响
场景:多用户同时调用函数,往向量数据库写入文本
| 状态 | 具体影响 | 典型问题 |
|---|---|---|
| 不加db_lock | 多个任务同时写入,出现数据竞争,数据不安全 | 1. 数据重复插入(同一文本多次写入);2. 数据损坏(一个任务写入一半,另一个任务覆盖);3. 数据库报错(锁冲突、文件被占用);4. 极端情况:数据库崩溃 |
| 加db_lock | 同一时间只有一个任务能写入,数据安全,但写入操作串行执行 | 写入效率轻微下降(合理代价),但保证数据完整一致,避免报错 |
3.5.4 异步场景下,为什么还需要锁?
很多新手会问:"异步是单线程,为什么还要加锁?" 核心原因有3点:
-
共享资源底层可能是多线程/多进程:你的代码是单线程异步,但向量数据库(如Chroma、FAISS)、文件系统的底层实现可能用了多线程,写入操作依然有并发风险。
-
异步任务切换导致的并发写入:异步事件循环中,多个任务会在await处切换------比如任务A在执行vector_db.add_texts()时,遇到await让出CPU,任务B接着执行写入操作,导致两个任务同时写入。
-
程序扩展需求:如果后续你的程序改成"多进程+异步"(比如用多进程提升并发能力),锁依然能保护跨进程的共享资源,避免后续修改代码时出现问题。
3.5.5 锁的"粒度"技巧(平衡安全与效率)
代码中,锁只包裹了vector_db.add_texts(...)这一小段代码,而不是整个函数------这是"最小锁粒度"原则,也是实战中的核心技巧:
-
只锁"必须串行执行"的代码(即操作共享资源的代码);
-
其他代码(如文本过滤、检索、LLM调用)依然可以并发执行;
-
目的:平衡"数据安全"和"并发性能",避免整个函数串行执行(那样异步就失去了意义)。
示例(正确的锁粒度):
python
async def generate_streaming_answer(question, knowledge_texts, similarity_threshold):
try:
# 1. 文本过滤:无需加锁,可并发执行
if knowledge_texts:
valid_texts = [text.strip() for text in knowledge_texts if text.strip()]
if valid_texts:
# 2. 操作共享资源:加锁,串行执行
with db_lock:
vector_db.add_texts(texts=valid_texts)
# 3. 检索、LLM调用:无需加锁,可并发执行
retrieved_results = retrieve_with_score(question, k=5)
# ... 后续逻辑
except Exception as e:
yield f"[错误] {str(e)}"
四、异步vs多线程:核心差异与适用场景
新手最容易混淆"异步"和"多线程"------两者都能处理并发,但核心原理、开销、适用场景完全不同。很多人误以为"异步就是多线程",其实两者有本质区别,本章用生活例子、代码对比、维度表格,彻底讲透两者的差异,帮助大家选对技术方案。
4.1 核心维度对比表
| 特性 | 异步(async/await) | 多线程(threading) |
|---|---|---|
| 核心原理 | 单线程 + 事件循环(协程主动让出CPU,协作式并发) | 多线程 + 操作系统调度(线程被动切换,抢占式并发) |
| 调度方式 | 协作式:只有协程遇到await时,才主动让出CPU,程序员可控制切换时机 | 抢占式:操作系统随时可能暂停一个线程,切换到另一个线程,程序员无法控制 |
| 切换开销 | 极低:程序内部函数调用级别的切换,无系统调用,资源占用可忽略(协程仅保存函数上下文) | 较高:操作系统内核态切换,需要消耗额外资源(线程栈默认几MB,还有内核调度成本) |
| 并发能力 | 极强:可支撑百万级协程并发(如同时处理10万+用户请求) | 较弱:最多支撑几千个线程(再多会耗尽内存/CPU,导致程序卡死) |
| GIL影响(Python) | 无影响:全程单线程,GIL一直被占用,不影响异步执行 | 有严重影响:CPU密集型任务无法真正并行(同一时间只有1个线程执行Python字节码),仅IO密集型任务能受益 |
| 线程安全 | 无需考虑:单线程执行,不存在多任务同时修改共享资源的问题(除了底层多线程实现的共享资源) | 需要考虑:多线程同时修改共享资源时,会出现数据竞争,必须加锁保护 |
| 编程难度 | 稍高:需掌握async/await/yield等异步语法,理解事件循环 | 较低:语法接近同步代码,新手容易上手 |
| 适用场景 | 高并发IO密集型:大模型流式调用、API服务、网络爬虫、实时聊天、数据库操作 | 低/中并发IO密集型:简单文件下载、少量数据库查询、老项目兼容(同步库) |
4.2 生活例子:餐厅服务模型(直观理解差异)
用"餐厅服务顾客"的场景,类比异步和多线程的差异,更容易理解:
假设餐厅有3个顾客(3个任务),每个顾客需要"点餐→等餐→上菜"(IO操作=等后厨做餐,耗时但不用服务员盯)。
4.2.1 多线程:雇多个服务员(多线程)
-
餐厅雇了3个服务员(3个线程),每个服务员盯1个顾客(1个任务);
-
顾客A点餐→服务员1把订单给后厨→站在原地傻等(线程阻塞),直到后厨喊"餐好了",再给A上菜;
-
同时,服务员2、3分别盯顾客B、C,流程一样;
-
问题:服务员(线程)数量有限(比如最多雇10个),雇多了老板(操作系统)管理成本高(线程切换开销),且服务员傻等时完全闲置。
4.2.2 异步:1个全能服务员(事件循环+协程)
-
餐厅只雇1个全能服务员(事件循环+协程),同时盯3个顾客;
-
顾客A点餐→服务员把订单给后厨→不傻等,立刻去服务顾客B(协程await时让出CPU);
-
后厨喊"顾客A的餐好了"→服务员暂停服务B,回去给A上菜→上完再继续服务B;
-
核心:服务员全程不闲着,1个人干3个人的活,且管理成本极低(不用雇多人)。
4.3 代码对比:IO任务的两种实现方式
用"模拟3个IO任务(每个等待2秒)"的场景,分别用多线程和异步实现,直观感受两者的效率和代码差异。
4.3.1 多线程版本(threading)
python
import threading
import time
# 定义任务函数(每个线程执行这个函数)
def thread_task(name):
print(f"【多线程】任务{name}:开始等待2秒(模拟IO操作)")
time.sleep(2) # 同步阻塞等待,线程傻等,占着资源
print(f"【多线程】任务{name}:等待完成\n")
if __name__ == "__main__":
start_time = time.time()
# 创建3个线程,每个线程处理1个任务
threads = [
threading.Thread(target=thread_task, args=("A",)),
threading.Thread(target=thread_task, args=("B",)),
threading.Thread(target=thread_task, args=("C",))
]
# 启动所有线程
for t in threads:
t.start()
# 等待所有线程结束(join():阻塞主线程,直到子线程全部完成)
for t in threads:
t.join()
print(f"【多线程】总耗时:{time.time()-start_time:.2f}秒")
执行结果(总耗时≈2秒,和异步一样,但底层逻辑不同):
python
【多线程】任务A:开始等待2秒(模拟IO操作)
【多线程】任务B:开始等待2秒(模拟IO操作)
【多线程】任务C:开始等待2秒(模拟IO操作)
【多线程】任务A:等待完成
【多线程】任务B:等待完成
【多线程】任务C:等待完成
【多线程】总耗时:2.00秒
核心问题:3个线程在time.sleep(2)时都是"阻塞状态",占着系统资源(线程栈、内核调度表);如果开1000个线程,系统开销会急剧上升,甚至卡死。
4.3.2 异步版本(async/await)
python
import asyncio
import time
async def async_task(name):
print(f"【异步】任务{name}:开始等待2秒(模拟IO操作)")
await asyncio.sleep(2) # 异步非阻塞等待,主动让出CPU
print(f"【异步】任务{name}:等待完成\n")
async def main():
start_time = time.time()
tasks = [
asyncio.create_task(async_task("A")),
asyncio.create_task(async_task("B")),
asyncio.create_task(async_task("C"))
]
# 等待所有异步任务完成
await asyncio.gather(*tasks)
# 计算并打印总耗时
total_time = time.time() - start_time
print(f"【异步】总耗时:{total_time:.2f}秒")
# 程序入口:启动异步程序
if __name__ == "__main__":
asyncio.run(main())
执行结果(总耗时≈2秒,与多线程耗时一致,但资源开销更低):
python
【异步】任务A:开始等待2秒(模拟IO操作)
【异步】任务B:开始等待2秒(模拟IO操作)
【异步】任务C:开始等待2秒(模拟IO操作)
【异步】任务A:等待完成
【异步】任务B:等待完成
【异步】任务C:等待完成
【异步】总耗时:2.00秒
核心优势:同样实现2秒并发完成3个IO任务,但异步仅用1个线程,无线程切换开销,资源占用极低;即使扩展到1000个任务,也能稳定运行,不会出现多线程的内存耗尽问题。