第三章 协程:用户态的轻量级并发单元
3.1 协程的本质与核心特性
协程(Coroutine)是用户态的轻量级执行单元,由程序(而非操作系统内核)控制调度,本质是通过 "协作式调度" 实现的状态机。与进程、线程的内核态调度不同,协程的切换完全在用户态完成,无需内核参与,因此具有极高的切换效率和极低的资源占用。
协程的核心特性可概括为 "三轻一协":
- 轻量级资源:一个协程仅占用数 KB 内存(主要用于保存栈帧和状态),一台机器可同时运行数十万甚至数百万个协程(进程 / 线程仅能运行数千个);
- 轻量级切换:协程切换仅需保存 / 恢复函数的上下文(如寄存器、程序计数器),无需内核态与用户态的切换(进程切换需千倍以上开销);
- 轻量级调度:协程由用户程序通过事件循环(Event Loop)调度,无需操作系统内核干预,调度策略可自定义;
- 协作式调度:协程必须主动放弃 CPU(如调用await挂起),其他协程才能获得执行机会,不存在抢占式调度(避免线程安全问题)。
从实现层面看,Python 协程经历了三个发展阶段:
-
生成器协程(Python 2.5+):基于生成器(yield/yield from)实现,本质是 "生成器的扩展",需手动管理状态与调度逻辑,缺乏专门的协程语法标识,边界模糊;
-
原生协程(Python 3.5+):引入 async def/await 语法糖,明确区分协程与生成器,协程成为独立的语法单元,调度逻辑更清晰,无需依赖生成器的迭代特性;
-
异步生态成熟(Python 3.7+):asyncio.run () 简化事件循环启动,无需手动创建、管理循环生命周期,aiohttp/aiofiles/asyncpg 等异步库全面完善,协程成为 Python 处理高并发 I/O 任务的主流方案,生态工具链与最佳实践逐步标准化。
3.2 四个核心概念
要真正掌握 Python 协程的使用,必须先理解其运行体系中的四个核心概念:协程函数、协程对象、事件循环与可等待对象。这四个概念相互关联,共同构成了协程的执行基础。
3.2.1 协程函数与协程对象
- 协程函数:以async def关键字定义的函数,是协程的 "模板"。与普通函数的本质区别在于:调用协程函数不会立即执行函数体,而是返回一个协程对象;
-
协程对象:协程函数的实例,内部存储了协程的执行状态(如当前执行到的代码行、局部变量值、栈帧信息)。协程对象本身无法直接执行,必须通过事件循环驱动。
import asyncio
定义协程函数(async def 是关键标识)
async def calculate_sum(a, b):
print(f"协程开始计算 {a} + {b}")
await asyncio.sleep(1) # 模拟耗时操作,主动挂起协程
result = a + b
print(f"协程计算完成,结果:{result}")
return result调用协程函数:返回协程对象,函数体未执行
coro = calculate_sum(3, 5)
print("协程对象类型:", type(coro)) # <class 'coroutine'>
print("协程对象状态(是否运行中):", coro.cr_running) # False错误用法:直接打印协程对象不会执行,且会触发警告
print(coro) # 输出 object calculate_sum at 0x0000023F7A8D1C40>
正确用法:通过事件循环驱动协程执行
asyncio.run(coro) # 执行后会输出计算过程,返回结果8
关键注意点:
- 协程函数内部不能使用yield关键字(否则会被识别为生成器函数),必须使用await挂起操作;
- 若协程对象未被事件循环驱动执行,Python 解释器会在程序退出时抛出RuntimeWarning,提示 "协程未被等待"。
3.2.2 事件循环:协程的 "调度中枢"
事件循环(Event Loop)是协程的核心调度机制,相当于协程的 "操作系统",负责管理所有协程的生命周期(创建、执行、挂起、恢复、销毁)。其工作原理可概括为 "事件驱动的循环队列模型",具体流程如下:
-
初始化阶段:创建事件循环实例,注册待执行的协程任务(通常通过asyncio.create_task()将协程对象包装为任务);
-
事件检测阶段:循环检测是否有 "就绪状态" 的任务(如 I/O 操作完成、定时器到期的任务);
-
任务执行阶段:取出就绪任务,执行其协程函数体,直到遇到await关键字(协程主动挂起);
-
状态切换阶段:将挂起的任务加入 "等待队列",继续执行下一个就绪任务;
-
循环终止阶段:当所有任务执行完成或被取消,事件循环退出,释放资源。
Python asyncio模块提供了事件循环的完整实现,Python 3.7+ 引入的asyncio.run()函数大幅简化了事件循环的使用 ------ 无需手动创建、启动和关闭事件循环,该函数会自动完成所有操作。
import asyncio
import time
async def task1():
print("任务1启动,耗时2秒")
await asyncio.sleep(2) # 挂起2秒,期间事件循环可执行其他任务
print("任务1完成")
return "任务1结果"
async def task2():
print("任务2启动,耗时1秒")
await asyncio.sleep(1) # 挂起1秒
print("任务2完成")
return "任务2结果"
async def main():
# 1. 方式一:使用asyncio.create_task()创建任务(立即加入事件循环)
start_time = time.time()
task_a = asyncio.create_task(task1())
task_b = asyncio.create_task(task2())
# 等待任务完成并获取结果(可单独等待,也可批量等待)
result_a = await task_a
result_b = await task_b
print(f"任务1结果:{result_a},任务2结果:{result_b}")
print(f"方式一总耗时:{time.time() - start_time:.2f}秒") # 约2秒(并发执行)
# 2. 方式二:使用asyncio.gather()批量管理任务(自动包装为任务)
start_time = time.time()
results = await asyncio.gather(task1(), task2()) # 接收协程对象,返回结果列表
print(f"批量任务结果:{results}") # ["任务1结果", "任务2结果"]
print(f"方式二总耗时:{time.time() - start_time:.2f}秒") # 约2秒
# 启动事件循环(asyncio.run() 自动管理事件循环生命周期)
asyncio.run(main())
两种任务管理方式的差异:
|---------|-----------------------------|---------------------------------------------|
| 特性 | asyncio.create_task() | asyncio.gather() |
| 输入参数 | 单个协程对象 | 多个协程对象(可变参数) |
| 返回值 | Task对象(可单独管理状态) | 结果列表(按输入协程顺序排列) |
| 任务状态管理 | 支持单独取消(task.cancel())、查询状态 | 不支持单独管理,需整体取消(return_exceptions=True可捕获异常) |
| 适用场景 | 动态添加任务、需单独控制的任务 | 批量固定任务、需统一等待结果的场景 |
3.2.3 可等待对象:await的 "合法操作数"
await关键字是协程挂起的核心语法,但其操作数必须是可等待对象(Awaitable)------ 即实现了__await__()方法的对象。Python 中常见的可等待对象分为三类,具体关系如下:
-
协程对象(Coroutine):最基础的可等待对象,由async def函数返回,直接支持await;
-
任务(Task):asyncio.create_task()的返回值,是协程对象的 "包装器",继承自Future,支持状态管理(如done()判断是否完成、result()获取结果);
-
未来对象(Future):低层级的异步结果容器,代表 "一个尚未完成的异步操作"(如 I/O 操作结果)。Task是Future的子类,协程的执行结果最终会存储在Future的结果属性中。
import asyncio
async def use_future():
# 1. 创建空的Future对象(初始状态为未完成)
future = asyncio.Future()
print("Future初始状态:", future.done()) # False# 2. 定义回调函数:当Future设置结果时触发 def set_future_result(): future.set_result("Future任务完成!") # 设置结果,Future状态变为完成 print("Future结果已设置") # 3. 用事件循环的call_soon()方法,立即执行回调函数 loop = asyncio.get_running_loop() loop.call_soon(set_future_result) # 4. 等待Future完成,获取结果(Future是可等待对象,支持await) result = await future print("获取Future结果:", result) # 输出 "Future任务完成!" print("Future最终状态:", future.done()) # Trueasyncio.run(use_future())
核心逻辑:Future相当于一个 "异步结果占位符",当异步操作(如 I/O)完成后,通过set_result()设置结果,等待该Future的协程会被自动唤醒并获取结果。
3.3 Python 协程的实战场景与案例
协程的核心优势是高效处理 I/O 密集型任务,因为在 I/O 等待期间(如网络请求、文件读写、数据库操作),协程会主动挂起并释放 CPU,让其他协程执行,从而最大化 CPU 利用率。以下通过三个典型场景,展示协程的实战用法。
3.3.1 异步网络请求:用aiohttp高效爬取
requests是 Python 常用的同步网络库,但在并发爬取时会因 I/O 等待阻塞线程;aiohttp是基于协程的异步 HTTP 客户端,配合asyncio可实现高并发网络请求,大幅提升爬取效率。
import asyncio
import aiohttp
# 异步爬取单个URL的页面长度
async def fetch_url(session, url):
try:
# async with 自动管理HTTP连接(类似同步的with,但支持await)
async with session.get(url, timeout=5) as response:
# 异步读取响应内容(避免I/O阻塞)
content = await response.text()
return {
"url": url,
"status": response.status, # 响应状态码(如200、404)
"content_length": len(content) # 页面内容长度
}
except Exception as e:
return {
"url": url,
"error": str(e) # 捕获异常(如超时、连接失败)
}
# 批量异步爬取多个URL
async def batch_fetch(urls):
# 创建异步HTTP会话(复用连接池,提升效率)
async with aiohttp.ClientSession() as session:
# 创建任务列表:每个URL对应一个爬取任务
tasks = [asyncio.create_task(fetch_url(session, url)) for url in urls]
# 等待所有任务完成,获取结果(可通过asyncio.as_completed()实现"完成一个处理一个")
results = await asyncio.gather(*tasks)
return results
if __name__ == "__main__":
# 待爬取的URL列表
target_urls = [
"https://www.baidu.com",
"https://www.github.com",
"https://www.python.org",
"https://www.zhihu.com",
"https://www.csdn.net"
]
# 启动异步爬取
start_time = asyncio.get_event_loop().time()
results = asyncio.run(batch_fetch(target_urls))
end_time = asyncio.get_event_loop().time()
# 打印爬取结果
print(f"爬取完成,总耗时:{end_time - start_time:.2f}秒")
for result in results:
if "error" in result:
print(f"失败 {result['url']}: {result['error']}")
else:
print(f"成功 {result['url']}: 状态码{result['status']}, 内容长度{result['content_length']}")
效率对比:若用requests同步爬取 5 个 URL,总耗时约为 "每个 URL 的 I/O 时间之和"(约 5-10 秒);而用aiohttp协程爬取,总耗时约等于 "最长单个 URL 的 I/O 时间"(约 1-2 秒),效率提升 5-10 倍。
3.3.2 异步文件读写:用aiofiles避免 I/O 阻塞
open()函数是 Python 的同步文件操作接口,在读写大文件或大量文件时,I/O 等待会阻塞线程;aiofiles是基于协程的异步文件操作库,通过async with和await语法,实现文件读写的异步化。
import asyncio
import aiofiles
# 异步写入文件
async def async_write(filename, content):
# async with 自动管理文件句柄(关闭文件)
async with aiofiles.open(filename, 'w', encoding='utf-8') as f:
await f.write(content) # 异步写入,I/O等待时挂起
print(f"已写入文件:{filename}")
# 异步读取文件
async def async_read(filename):
async with aiofiles.open(filename, 'r', encoding='utf-8') as f:
content = await f.read() # 异步读取(支持readline()、readlines())
print(f"读取文件 {filename},内容长度:{len(content)} 字节")
return content
# 批量处理文件(先写后读)
async def batch_file_operation():
# 1. 并发写入3个文件
write_tasks = [
async_write(f"test_{i}.txt", f"这是第{i}个测试文件,内容为:{i * 100}")
for i in range(3)
]
await asyncio.gather(*write_tasks)
# 2. 并发读取3个文件
read_tasks = [async_read(f"test_{i}.txt") for i in range(3)]
contents = await asyncio.gather(*read_tasks)
return contents
if __name__ == "__main__":
start_time = asyncio.get_event_loop().time()
contents = asyncio.run(batch_file_operation())
print(f"总耗时:{asyncio.get_event_loop().time() - start_time:.2f}秒")
# 可选:打印读取的内容
# for i, content in enumerate(contents):
# print(f"test_{i}.txt 内容:{content}")
3.3.3 协程的高级特性:超时、取消与异常处理
在实际开发中,协程可能遇到 "任务超时""需要主动取消""执行异常" 等场景,asyncio提供了对应的解决方案,确保协程的稳健运行。
(1)超时控制:asyncio.wait_for()
为协程设置超时时间,若协程在超时时间内未完成,会自动抛出asyncio.TimeoutError,避免无限等待。
import asyncio
async def slow_task():
print("慢任务启动,预计耗时3秒")
await asyncio.sleep(3) # 模拟耗时3秒的任务
print("慢任务完成")
return "慢任务结果"
async def main():
try:
# 设置超时时间2秒,任务未完成则抛出TimeoutError
result = await asyncio.wait_for(slow_task(), timeout=2)
print("任务结果:", result)
except asyncio.TimeoutError:
print("任务超时!(2秒内未完成)")
asyncio.run(main()) # 输出:慢任务启动... 任务超时!
(2)任务取消:Task.cancel()
通过Task对象的cancel()方法主动取消协程,被取消的协程会抛出asyncio.CancelledError,可在协程内部捕获该异常进行 "清理操作"(如释放资源)。
import asyncio
async def long_running_task():
try:
print("长时间任务启动,每1秒输出一次")
while True:
print("任务运行中...")
await asyncio.sleep(1) # 每1秒挂起一次
except asyncio.CancelledError:
# 捕获取消异常,执行清理逻辑
print("任务被取消,执行清理操作(如关闭连接、保存数据)...")
raise # 可选:重新抛出异常,让调用方感知取消
finally:
print("任务最终清理(无论是否取消,都会执行)")
async def main():
# 创建任务并启动
task = asyncio.create_task(long_running_task())
# 运行2秒后取消任务
await asyncio.sleep(2)
print("准备取消任务...")
task.cancel()
try:
# 等待任务完成(必须await,否则会触发警告)
await task
except asyncio.CancelledError:
print("主函数捕获到任务取消")
asyncio.run(main())
(3)异常处理:try-except与return_exceptions
协程执行过程中若抛出异常,需通过try-except捕获;若使用asyncio.gather()批量管理任务,可设置return_exceptions=True,让异常作为结果返回(而非直接抛出),便于统一处理。
import asyncio
async def normal_task():
await asyncio.sleep(1)
return "正常任务结果"
async def error_task():
await asyncio.sleep(0.5)
raise ValueError("任务执行出错!") # 模拟异常
async def main():
# 方式一:单独捕获异常
try:
await error_task()
except ValueError as e:
print("单独捕获异常:", e) # 输出 "任务执行出错!"
# 方式二:批量任务捕获异常(return_exceptions=True)
results = await asyncio.gather(
normal_task(),
error_task(),
return_exceptions=True # 异常会作为结果返回,不中断其他任务
)
print("批量任务结果:", results)
# 输出:["正常任务结果", ValueError("任务执行出错!")]
# 遍历结果,处理正常结果和异常
for result in results:
if isinstance(result, Exception):
print(f"处理异常:{result}")
else:
print(f"处理正常结果:{result}")
asyncio.run(main())
3.4 Python 协程的局限性
尽管协程在 I/O 密集型任务中表现优异,但也存在明显的局限性,需在实际开发中规避:
-
不适合 CPU 密集型任务:协程是协作式调度,若一个协程执行 CPU 密集型任务(如大量数学计算、数据加密),会长期占用 CPU 且不主动挂起,导致其他协程无法执行(事件循环阻塞)。此时需结合多进程(如multiprocessing),让 CPU 密集型任务在独立进程中执行,协程负责调度。
-
依赖异步生态:协程需配合异步库使用(如aiohttp、aiofiles、asyncpg),若使用同步库(如requests、open()、psycopg2),会阻塞事件循环,失去并发能力。目前 Python 异步生态已较完善,但仍有部分库仅支持同步操作。
-
调试难度较高:协程的执行流程由事件循环调度,而非线性执行,传统的 "打印日志""断点调试" 方式难以追踪协程的切换过程。虽有asyncio.debug()等调试工具,但整体调试体验仍不如同步代码。
-
协作式调度的风险:若协程忘记调用await挂起(如无限循环且无await),会导致事件循环 "卡死",所有协程无法执行。需在协程中确保有合理的await操作,或通过超时控制(asyncio.wait_for())避免此类问题。
-
GIL 的间接影响:协程运行在单个线程中,受 Python 全局解释器锁(GIL)限制,无法利用多核 CPU。若需充分利用多核,需通过 "多进程 + 协程" 的组合模式:每个进程运行一个事件循环,管理多个协程,进程间通过 IPC(如队列)通信。
第四章 进程、线程与协程的对比总结与选型策略
进程、线程、协程是 Python 并发编程的三大核心技术,三者在底层原理、资源占用、适用场景上存在本质差异。掌握三者的对比关系,是实现高效并发的关键。
4.1 核心维度对比
|---------|------------------------|----------------------|----------------------|
| 对比维度 | 进程(Process) | 线程(Thread) | 协程(Coroutine) |
| 调度层级 | 内核态(操作系统内核调度) | 内核态(操作系统内核调度) | 用户态(事件循环调度) |
| 资源占用 | 高(独立地址空间,MB 级内存) | 中(共享地址空间,栈空间 1-8MB) | 极低(仅保存状态,KB 级内存) |
| 切换开销 | 高(千倍级,需切换地址空间) | 中(十倍级,仅切换线程上下文) | 极低(微秒级,仅切换函数上下文) |
| 并发能力 | 低(支持数千个) | 中(支持数万个) | 极高(支持数百万个) |
| 数据共享 | 独立地址空间,需 IPC(队列 / 管道) | 共享地址空间,需锁保证线程安全 | 共享线程内存,无抢占,无需锁 |
| 线程安全 | 安全(进程隔离) | 不安全(需手动加锁) | 安全(协作式调度,无竞态条件) |
| GIL 影响 | 无(每个进程有独立 GIL) | 有(同一进程内线程竞争 GIL) | 有(运行在单个线程,受 GIL 限制) |
| 适用场景 | CPU 密集型任务、进程隔离需求 | I/O 密集型任务(中小型并发) | I/O 密集型任务(超大规模并发) |
| 实现复杂度 | 高(IPC 机制复杂) | 中(需处理线程安全) | 中(需熟悉异步语法和生态) |
4.2 典型场景选型策略
根据任务类型和并发需求,选择合适的技术方案,可最大化程序效率:
(1)CPU 密集型任务(如数据计算、机器学习、加密解密)
- 首选方案:多进程(multiprocessing、concurrent.futures.ProcessPoolExecutor)
原因:多进程避开 GIL,可利用多核 CPU,每个进程独立执行 CPU 密集型任务,无资源竞争。
- 补充方案:多进程 + 协程
若任务中包含少量 I/O 操作(如读取输入数据、保存结果),可在每个进程中运行协程,让 CPU 密集型任务在进程中执行,协程负责 I/O 调度,进一步提升效率。
(2)I/O 密集型任务(如网络请求、文件读写、数据库操作)
- 中小规模并发(千级以内):多线程(threading、concurrent.futures.ThreadPoolExecutor)
原因:实现简单,无需修改现有同步代码(如requests),I/O 等待期间线程释放 GIL,可提升并发效率。
- 超大规模并发(万级以上):协程(asyncio+ 异步库)
原因:资源占用极低,切换效率高,支持百万级并发,适合高并发服务器(如 API 网关、爬虫)。
(3)混合任务(既有 CPU 密集型,也有 I/O 密集型)
- 推荐方案:多进程 + 协程
架构设计:
-
主进程负责任务分发和结果汇总;
-
多个子进程(数量等于 CPU 核心数)负责执行 CPU 密集型任务;
-
每个子进程内运行一个事件循环,管理协程,处理 I/O 密集型任务(如获取任务数据、返回结果);
-
进程间通过队列(multiprocessing.Queue)通信,传递任务和结果。
4.3 实战组合案例:多进程 + 协程处理混合任务
以下案例展示 "多进程 + 协程" 的组合模式,处理 "CPU 密集型计算 + 异步网络请求" 的混合任务:
import asyncio
import multiprocessing
import aiohttp
# CPU密集型任务:计算大数字的平方(在独立进程中执行)
def cpu_intensive_task(num):
print(f"进程 {multiprocessing.current_process().pid} 计算 {num} 的平方")
result = num ** 2 # 模拟CPU密集型计算
return result
# 协程任务:异步获取数据,调用CPU密集型任务,返回结果
async def worker_coroutine(session, num, result_queue):
try:
# 1. 异步网络请求(I/O密集型)
async with session.get(f"https://httpbin.org/get?num={num}", timeout=3) as response:
data = await response.json()
print(f"协程获取数据:{data['args']}")
# 2. 调用CPU密集型任务(通过多进程执行)
# 此处简化:直接调用函数,实际中可通过进程池执行
cpu_result = cpu_intensive_task(num)
# 3. 保存结果到队列(进程间通信)
result_queue.put({
"input_num": num,
"cpu_result": cpu_result,
"network_data": data['args']
})
except Exception as e:
result_queue.put({"input_num": num, "error": str(e)})
# 子进程:运行事件循环,管理协程
def subprocess_worker(num_list, result_queue):
async def main():
async with aiohttp.ClientSession() as session:
# 创建协程任务列表
tasks = [worker_coroutine(session, num, result_queue) for num in num_list]
await asyncio.gather(*tasks)
# 子进程中启动事件循环
asyncio.run(main())
# 主进程:分发任务,启动子进程,汇总结果
def main():
# 1. 待处理的数字列表
num_list = [100000, 200000, 300000, 400000, 500000]
# 2. 进程间通信队列(用于传递结果)
result_queue = multiprocessing.Queue()
# 3. 拆分任务(按CPU核心数拆分,此处简化为2个进程)
cpu_count = multiprocessing.cpu_count()
task_chunks = [num_list[i::cpu_count] for i in range(cpu_count)]
# 4. 启动子进程
processes = []
for chunk in task_chunks:
if chunk: # 避免空任务
p = multiprocessing.Process(
target=subprocess_worker,
args=(chunk, result_queue)
)
p.start()
processes.append(p)
# 5. 等待子进程完成
for p in processes:
p.join()
# 6. 汇总结果
results = []
while not result_queue.empty():
results.append(result_queue.get())
print("\n最终结果汇总:")
for res in results:
print(res)
if __name__ == "__main__":
main()
总结
Python 并发编程的核心是 "根据任务类型选择合适的技术":
- 若需利用多核 CPU 处理 CPU 密集型任务,优先选择多进程;
- 若需处理中小规模 I/O 密集型任务,优先选择多线程(实现简单);
- 若需处理超大规模 I/O 密集型任务,优先选择协程(效率最高);
- 若任务混合 CPU 和 I/O 操作,优先选择多进程 + 协程的组合模式,兼顾多核利用和高并发。
掌握进程、线程、协程的底层原理和适用场景,不仅能提升程序的并发效率,还能帮助开发者构建更稳健、更易维护的并发系统。在实际开发中,需结合具体需求,灵活选择技术方案,避免 "过度设计" 或 "技术滥用"。