异步编程核心原理:从IO模型到协程状态机,一次啃透Python并发底座
你有没有遇到过这种情况:一个接口要同时请求五个下游服务,串行跑要两秒,并行跑却死活快不起来;写了个爬虫,开两百个线程采集,结果CPU才用了百分之十几,磁盘IO倒是打满了;听说过asyncio很厉害,翻文档看到一堆Task、Future、Coroutine、gather、wait,脑子直接宕机。
怕浪猫曾经在这上面栽过无数跟头。从最初用多线程写并发,到后来硬啃asyncio源码,再到带着团队做异步框架选型,踩的坑够填满一个游泳池了。我是怕浪猫,今天这篇文章,就把异步编程的底层原理一次讲透——从操作系统的IO多路复用模型,到Python事件循环的实现细节,再到协程状态机的逐行拆解,最后对比多进程、多线程、协程三大并发模型的选型策略。
这篇文章信息密度很高,建议先收藏再读。
一、IO模型:一切的起点
1.1 为什么需要IO多路复用
先说一个最根本的问题:网络IO为什么慢?
因为网络IO的本质是"等"。等数据从网卡到内核缓冲区,等数据从内核缓冲区到用户空间。这个"等"的过程中,线程是被阻塞的。如果你用同步阻塞模型,一个线程只能处理一个连接,一千个连接就要一千个线程,线程本身是昂贵的资源——每个线程默认占8MB栈空间,一千个就是8GB。
并发不是让所有事情同时发生,而是让等待的时间不浪费。
IO多路复用的核心思想就是:一个线程,同时监控多个文件描述符(fd),哪个fd有数据了就处理哪个。这样一千个连接只需要一个线程就能搞定。
1.2 select:最早的方案
select是POSIX标准定义的IO多路复用方案,诞生于1983年。它的API长这样:
import select
import socket
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind(('0.0.0.0', 8888))
server.listen(1024)
server.setblocking(False)
inputs = [server]
while True:
readable, _, exceptional = select.select(inputs, [], inputs, 1)
for s in readable:
if s is server:
conn, addr = s.accept()
conn.setblocking(False)
inputs.append(conn)
else:
data = s.recv(1024)
if data:
s.send(data)
else:
inputs.remove(s)
s.close()select的工作原理是把所有fd拷贝到内核空间,内核遍历检查哪些fd就绪,然后返回就绪的fd数量,用户空间再遍历找出具体是哪些fd。这有两个致命缺陷:
第一,fd数量限制。select使用位图(bitmap)存储fd,默认最大1024个。对于高并发场景完全不够用。
第二,性能随fd数量线性下降。每次调用select都要把全部fd从用户空间拷贝到内核空间,内核也要线性遍历所有fd。一万个连接里只有十个就绪,也得遍历一万次。
技术选型的第一步不是找最好的方案,而是弄清楚每个方案的瓶颈在哪。
1.3 poll:解决了数量限制,没解决性能
poll在1990年代引入,用链表代替位图存储fd,消除了1024的数量限制。但核心机制和select一样——仍然是线性遍历,仍然是全量拷贝。性能特性几乎相同,只是能监控更多fd了。
poll的结构体定义如下:
struct pollfd {
int fd; // 文件描述符
short events; // 关注的事件
short revents; // 返回的就绪事件
};和select比,poll只是换了个数据结构,算法复杂度依然是O(n)。在实际工程中,poll几乎没有被广泛使用过,因为epoll很快就出现了。
1.4 epoll:Linux高并发的基石
epoll是Linux 2.6内核引入的(2003年),从根本上解决了select/poll的性能问题。它引入了三个关键API:
epoll_create:创建epoll实例,返回一个fdepoll_ctl:注册/修改/删除要监控的fdepoll_wait:等待事件发生
epoll的核心优势在于"事件驱动注册制"。fd通过epoll_ctl注册到内核的红黑树中,内核在有数据到达时通过回调机制把就绪fd放到一个双向链表里。epoll_wait只需要检查这个链表,时间复杂度O(1)。
Python中用epoll的原始写法(Linux平台):
import select
import socket
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.bind(('0.0.0.0', 8888))
server.listen(1024)
server.setblocking(False)
epoll = select.epoll()
epoll.register(server.fileno(), select.EPOLLIN)
connections = {}
try:
while True:
events = epoll.poll(1)
for fileno, event in events:
if fileno == server.fileno():
conn, addr = server.accept()
conn.setblocking(False)
epoll.register(conn.fileno(), select.EPOLLIN)
connections[conn.fileno()] = conn
elif event & select.EPOLLIN:
data = connections[fileno].recv(1024)
if data:
connections[fileno].send(data)
else:
epoll.unregister(fileno)
connections[fileno].close()
del connections[fileno]
finally:
epoll.unregister(server.fileno())
epoll.close()
server.close()三个模型的核心差异一目了然:
| 对比维度 | select | poll | epoll |
|---|---|---|---|
| fd数量限制 | 1024(FD_SETSIZE) | 无限制 | 无限制 |
| 数据结构 | 位图 | 链表 | 红黑树+双向链表 |
| 时间复杂度 | O(n) | O(n) | O(1)(就绪链表) |
| fd拷贝 | 每次全量拷贝 | 每次全量拷贝 | 注册时拷贝一次 |
| 触发模式 | LT(水平触发) | LT | LT+ET(边沿触发) |
| 平台支持 | 跨平台 | Unix-like | Linux only |
| 适用场景 | 连接数少 | 兼容性需求 | 高并发服务 |
macOS上对应的是kqueue,Windows上是IOCP,机制不同但思路类似——都是事件驱动的IO多路复用。
epoll不是银弹,但在Linux高并发场景下,它确实是最接近银弹的东西。
二、Python事件循环:asyncio的发动机
2.1 事件循环架构拆解
asyncio的事件循环不是什么黑魔法,它的核心架构可以拆成三层:
第一层是Selector——IO多路复用的封装。asyncio用selectors标准库抽象了不同平台的IO多路复用实现。在Linux上默认用EpollSelector,macOS上用KqueueSelector,Windows上用SelectSelector。
第二层是Transport——传输层抽象。它负责底层的读写操作,把Selector检测到的IO事件转化为具体的数据收发。有SocketTransport、SSLTransport、PipeTransport等。
第三层是Protocol——协议层抽象。它定义了数据解析的规则,比如HTTP协议怎么解析、WebSocket协议怎么握手。Protocol收到Transport传来的原始字节流,按协议规则解析成结构化数据。
看一下selectors标准库的结构:
import selectors
# DefaultSelector会自动选择当前平台最优的selector
sel = selectors.DefaultSelector()
print(type(sel))
# Linux上输出: <class 'selectors.EpollSelector'>
# macOS上输出: <class 'selectors.KqueueSelector'>
# Windows上输出: <class 'selectors.SelectSelector'>DefaultSelector的选择逻辑很清晰:优先用epoll/kqueue,退而求其次用poll,最后兜底用select。这个设计保证了asyncio在任何平台上都能跑,但性能取决于平台。
2.2 事件循环的运行逻辑
事件循环的核心是一个无限循环,每一轮叫做一个"tick"。每个tick做这些事:
- 调用selector.select(timeout)等待IO事件
- 处理就绪的IO事件(回调Transport的回调函数)
- 执行就绪的定时器回调
- 执行通过call_soon注册的回调
简化版的事件循环可以这么写:
import selectors
import time
from collections import deque
class SimpleEventLoop:
def __init__(self):
self.selector = selectors.DefaultSelector()
self._ready = deque()
self._stopping = False
def call_soon(self, callback, *args):
self._ready.append((callback, args))
def run_forever(self):
while not self._stopping:
# 执行就绪回调
ntodo = len(self._ready)
for _ in range(ntodo):
callback, args = self._ready.popleft()
callback(*args)
# 处理IO事件
events = self.selector.select(0.01)
for key, mask in events:
callback = key.data
callback(key.fileobj, mask)
def stop(self):
self._stopping = True当然真实的asyncio事件循环比这复杂得多——还要处理定时器、信号、线程间通信、异常传播等。但核心骨架就是这样。
2.3 uvloop:把性能拉到极限
uvloop是用Cython写的asyncio事件循环实现,底层基于libuv(Node.js用的就是libuv)。它的性能比asyncio默认实现快2-4倍,接近Go和Node.js的水平。
安装和使用非常简单:
# pip install uvloop
import asyncio
import uvloop
# 设置uvloop为默认事件循环(Linux/macOS)
uvloop.install()
async def main():
print("running on uvloop")
asyncio.run(main())怕浪猫在做一个IM长连接网关时做过 benchmark 对比,处理一万并发连接时:
| 指标 | asyncio默认循环 | uvloop |
|---|---|---|
| QPS | 12,000 | 38,000 |
| 平均延迟 | 42ms | 14ms |
| P99延迟 | 120ms | 38ms |
| CPU占用 | 85% | 62% |
| 内存占用 | 240MB | 180MB |
uvloop的性能优势主要来自三个方面:Cython实现避免了Python层的函数调用开销;libuv的IO处理经过Node.js社区的大规模验证和优化;内部数据结构选择更激进(比如用 intrusive list 管理 callback)。
选框架的时候,benchmark 是参考而不是结论。你的业务场景才是最终裁判。
但注意,uvloop不支持Windows。Windows上只能用ProactorEventLoop,它基于IOCP,性能也不差,但和uvloop有差距。
三、协程演进史:从yield到async/await
3.1 生成器协程:yield的跨界
Python协程的源头可以追溯到生成器(generator)。生成器本来是用来生成序列的,但yield关键字有个副作用——它能暂停函数执行,把控制权还给调用者。这恰好就是协程的核心特性:暂停和恢复。
PEP 342给生成器加了send()方法,让生成器不仅能产出值,还能接收值。这就让生成器有了做协程的潜力:
def simple_coroutine():
print("协程启动")
x = yield # 接收send传来的值
print(f"收到: {x}")
y = yield x * 2 # 产出值并接收新值
print(f"收到: {y}")
coro = simple_coroutine()
next(coro) # 启动协程,输出"协程启动",停在yield处
coro.send(10) # 输出"收到: 10",返回20
# coro.send(20) # 输出"收到: 20",抛出StopIteration3.2 yield from:协程的委托
PEP 380引入了yield from,解决了一个痛点:一个协程想调用另一个协程,得手动处理StopIteration异常。yield from让生成器能把另一个生成器的操作"代理"过来:
def fetch_data():
yield "连接数据库"
yield "执行查询"
return {"id": 1, "name": "怕浪猫"}
def process_data():
result = yield from fetch_data()
print(f"处理结果: {result}")
return "完成"
coro = process_data()
print(next(coro)) # "连接数据库"
print(next(coro)) # "执行查询"
try:
next(coro) # 输出"处理结果: ...",抛出StopIteration: 完成
except StopIteration as e:
print(f"最终结果: {e.value}")yield from不仅简化了代码,更重要的是它建立了协程之间的组合能力。一个协程可以委托另一个协程,形成调用链。这就是后来async/await的前身。
3.3 async/await:原生协程的诞生
Python 3.5引入了async def和await两个关键字(PEP 492),协程正式成为Python的一等公民。async def定义的函数不再是生成器,而是原生协程(native coroutine):
import asyncio
async def fetch_user(user_id):
print(f"开始获取用户 {user_id}")
await asyncio.sleep(0.5) # 模拟异步IO
return {"id": user_id, "name": "怕浪猫"}
async def main():
user = await fetch_user(42)
print(f"获取到: {user}")
asyncio.run(main())await的本质和yield from一样——把控制权交给事件循环,等awaitable对象完成后恢复执行。但语义更清晰,且async def定义的函数不会自动变成生成器。
3.4 三种协程装饰器的区别
在演进过程中出现过三种定义协程的方式,很多人搞混:
import types
import asyncio
# 方式1: @types.coroutine —— 把生成器函数标记为协程
@types.coroutine
def gen_coroutine():
yield
return "done"
# 方式2: @asyncio.coroutine —— asyncio提供的兼容装饰器(3.11已移除)
@asyncio.coroutine
def old_style_coroutine():
yield from asyncio.sleep(0.1)
return "done"
# 方式3: async def —— 原生协程(推荐)
async def native_coroutine():
await asyncio.sleep(0.1)
return "done"三者的区别:
@types.coroutine:把基于生成器的协程标记为asyncio兼容的协程,await可以await它。这是最底层的机制。@asyncio.coroutine:asyncio提供的语法糖,兼容generator-based和native协程。Python 3.8标记为废弃,3.11彻底移除。async def:原生协程,Python推荐的方式。不需要装饰器,语法清晰,性能最好。
新项目只用 async def 就够了。装饰器方案是历史包袱,理解原理即可,不需要再用。
3.5 协程状态机
每个协程对象在生命周期中有四个状态。理解这个状态机对调试异步代码至关重要:
import inspect
async def demo():
await asyncio.sleep(0.1)
return "完成"
coro = demo()
print(inspect.getcoroutinestate(coro)) # CORO_CREATED
# 启动协程(用事件循环驱动)
async def runner():
result = await coro
return result
# coro在await前是 CORO_SUSPENDED
# 在执行时是 CORO_RUNNING
# 执行完毕后是 CORO_CLOSED
asyncio.run(runner())
print(inspect.getcoroutinestate(coro)) # CORO_CLOSED四个状态的转换关系:
| 状态 | 含义 | 进入条件 |
|---|---|---|
| CORO_CREATED | 已创建未启动 | 调用async def返回协程对象 |
| CORO_SUSPENDED | 暂停等待 | 遇到await,交出控制权 |
| CORO_RUNNING | 正在执行 | 事件循环调度到该协程 |
| CORO_CLOSED | 执行完毕或关闭 | 正常返回或调用close() |
踩坑提示:如果你创建了协程对象但没有await它(也没有包装成Task),协程会一直停留在CORO_CREATED状态,Python会发出"coroutine was never awaited"的警告。这是新手最常遇到的错误之一。
async def important_work():
await asyncio.sleep(1)
print("重要工作完成")
# 错误:创建了协程但忘了await
important_work() # RuntimeWarning: coroutine was never awaited
# 正确:用await或create_task
# await important_work()
# task = asyncio.create_task(important_work())创建协程不等于执行协程。就像写了待办事项不等于做了这件事。
四、Task、Future与并发原语
4.1 Task vs Future vs Coroutine
这三个概念是asyncio的核心,但很多人分不清。
Coroutine是最基本的异步单元。async def函数调用后返回一个coroutine对象,它需要被事件循环驱动才能执行。单独的coroutine对象不会自动运行。
Future是一个低级别的awaitable对象,代表一个异步操作的最终结果。它有状态机(Pending/Cancelled/Finished),可以添加回调。你通常不需要直接创建Future,它更多是作为底层机制存在。
Task是Future的子类,用来包装协程。当你创建一个Task时,事件循环会在下一次tick时自动开始执行这个协程。Task负责把协程的执行结果存到Future里。
import asyncio
async def work(name, duration):
await asyncio.sleep(duration)
return f"{name}完成"
async def main():
# Coroutine:不自动执行
coro = work("任务A", 1)
print(type(coro)) # <class 'coroutine'>
# Task:自动调度执行
task = asyncio.create_task(work("任务B", 0.5))
print(type(task)) # <class 'Task'>
print(task.done()) # False
# Future:手动控制结果
future = asyncio.Future()
print(type(future)) # <class 'Future'>
# 可以给Future设置结果
future.set_result("手动设置的结果")
print(future.done()) # True
result = await task
print(result) # "任务B完成"
asyncio.run(main())三者的层级关系:Coroutine -> Task(包装协程) -> Future(Task的父类)。日常开发中,你主要和Coroutine、Task打交道,Future偶尔在写底层库时会用到。
4.2 gather vs wait vs as_completed
这三个是asyncio中实现并发的三件套,各有适用场景。
asyncio.gather():批量并发,等所有完成,按输入顺序返回结果。适合"我需要同时请求多个接口,等全部返回"的场景。
import asyncio
import aiohttp
async def fetch_url(session, url):
async with session.get(url) as resp:
return await resp.text()
async def main():
urls = [
"https://httpbin.org/delay/1",
"https://httpbin.org/delay/2",
"https://httpbin.org/delay/3",
]
async with aiohttp.ClientSession() as session:
# gather按输入顺序返回结果,即使delay/3先完成也排在第三位
results = await asyncio.gather(
*[fetch_url(session, url) for url in urls]
)
for i, result in enumerate(results):
print(f"结果{i}: {len(result)}字节")
asyncio.run(main())asyncio.wait():批量并发,可以按条件等待(FIRST_COMPLETED或ALL_COMPLETED),返回两个集合(done和pending)。适合"谁先完成就处理谁,或者全部完成再统一处理"的灵活场景。
import asyncio
async def task_with_timeout():
try:
# 等第一个完成就返回
done, pending = await asyncio.wait(
[asyncio.sleep(1, result="A"),
asyncio.sleep(2, result="B"),
asyncio.sleep(3, result="C")],
return_when=asyncio.FIRST_COMPLETED
)
for t in done:
print(f"第一个完成: {t.result()}")
# 取消未完成的任务
for t in pending:
t.cancel()
except Exception as e:
print(f"出错: {e}")
asyncio.run(task_with_timeout())asyncio.as_completed():批量并发,按完成顺序逐个返回。适合"实时展示进度,谁完成就立刻处理"的场景。
import asyncio
import random
async def random_task(name):
delay = random.uniform(0.5, 3)
await asyncio.sleep(delay)
return f"{name}({delay:.2f}s)"
async def main():
tasks = [random_task(f"任务{i}") for i in range(5)]
for coro in asyncio.as_completed(tasks):
result = await coro
print(f"完成: {result}")
asyncio.run(main())| 对比维度 | gather | wait | as_completed |
|---|---|---|---|
| 返回值 | 结果列表(有序) | (done, pending)集合 | 迭代器,逐个返回 |
| 异常处理 | 默认传播,可设return_exceptions | 异常存在Future中 | await时抛出 |
| 完成顺序 | 按输入顺序 | 取决于return_when | 按完成顺序 |
| 取消任务 | 支持批量取消 | 需手动取消pending | 需手动管理 |
| 典型场景 | 批量请求等全部返回 | 竞速或超时控制 | 实时进度展示 |
gather 是"等所有人到齐再开饭",as_completed 是"谁到了谁先吃"。
4.3 asyncio.Queue与生产者-消费者模式
asyncio.Queue是协程间通信的核心原语。它和queue.Queue的API几乎一样,但put和get都是协程方法,会自动在事件循环中协作:
import asyncio
import random
async def producer(queue, producer_id):
for i in range(5):
item = f"P{producer_id}-产品{i}"
await asyncio.sleep(random.uniform(0.1, 0.5))
await queue.put(item)
print(f"生产者{producer_id} 生产: {item}")
# 放入结束标记
await queue.put(None)
async def consumer(queue, consumer_id):
while True:
item = await queue.get()
if item is None:
queue.task_done()
break
print(f"消费者{consumer_id} 消费: {item}")
await asyncio.sleep(random.uniform(0.2, 0.4))
queue.task_done()
async def main():
queue = asyncio.Queue(maxsize=10)
# 2个生产者,3个消费者
producers = [producer(queue, i) for i in range(2)]
consumers = [consumer(queue, i) for i in range(3)]
# 每个生产者放一个None,消费者收到None就退出
# 这里简化处理,实际需要根据生产者数量控制
await asyncio.gather(*producers)
# 放足够的None让所有消费者退出
for _ in range(3):
await queue.put(None)
await asyncio.gather(*consumers)
asyncio.run(main())踩坑提醒:asyncio.Queue的maxsize是协程级别的背压控制。当队列满时,producer的put会阻塞(交出控制权),直到consumer取走数据。这和threading.Queue的阻塞行为类似,但不会阻塞线程——它阻塞的是协程,事件循环可以继续运行其他协程。
五、多进程:用空间换并行
5.1 multiprocessing核心组件
当任务是CPU密集型时,多线程因为GIL的限制基本没用。这时候需要多进程——每个进程有独立的Python解释器和GIL,能真正利用多核。
multiprocessing模块提供了四个核心组件:
from multiprocessing import Process, Queue, Pipe, Manager, Pool
import os
import time
def cpu_heavy_task(n, result_queue):
"""CPU密集型任务:计算质数"""
count = 0
for num in range(2, n):
is_prime = True
for i in range(2, int(num ** 0.5) + 1):
if num % i == 0:
is_prime = False
break
if is_prime:
count += 1
result_queue.put((os.getpid(), count))
if __name__ == '__main__':
start = time.time()
result_queue = Queue()
# 创建4个进程并行计算
processes = []
ranges = [250000, 250000, 250000, 250000]
for r in ranges:
p = Process(target=cpu_heavy_task, args=(r, result_queue))
processes.append(p)
p.start()
for p in processes:
p.join()
total = 0
while not result_queue.empty():
pid, count = result_queue.get()
print(f"进程{pid} 找到{count}个质数")
total += count
print(f"总计: {total}个质数, 耗时: {time.time()-start:.2f}s")5.2 进程间通信(IPC)
进程之间内存隔离,数据交换必须通过IPC机制。multiprocessing提供了三种主要方式:
Queue:进程安全的FIFO队列,底层用管道和锁实现。适合一对多或多对多的数据传递。
Pipe:两个进程之间的双向管道。性能比Queue高,但只能两个进程之间用。
共享内存(Value/Array):直接在共享内存中创建变量,多个进程可以同时读写,需要加锁。
from multiprocessing import Process, Value, Array, Lock
import time
def increment(counter, lock):
for _ in range(100000):
with lock:
counter.value += 1
def modify_array(arr, lock):
for _ in range(5):
with lock:
for i in range(len(arr)):
arr[i] += 1
if __name__ == '__main__':
counter = Value('i', 0) # 'i'表示整型
arr = Array('i', range(5)) # 共享数组
lock = Lock()
procs = []
for _ in range(4):
p = Process(target=increment, args=(counter, lock))
procs.append(p)
p.start()
# 修改共享数组
p2 = Process(target=modify_array, args=(arr, lock))
p2.start()
p2.join()
for p in procs:
p.join()
print(f"计数器最终值: {counter.value}") # 400000
print(f"数组最终值: {list(arr)}") # [5, 6, 7, 8, 9]Manager:提供一个服务进程,管理可以被多个进程共享的Python对象(dict、list、Namespace等)。比共享内存灵活,但性能较低,因为每次访问都要跨进程通信。
from multiprocessing import Manager, Process
def worker(shared_dict, key, value):
shared_dict[key] = value
shared_dict['count'] += 1
if __name__ == '__main__':
with Manager() as manager:
shared_dict = manager.dict()
shared_dict['count'] = 0
procs = []
for i in range(5):
p = Process(target=worker, args=(shared_dict, f'key{i}', f'value{i}'))
procs.append(p)
p.start()
for p in procs:
p.join()
print(dict(shared_dict))
# {'count': 5, 'key0': 'value0', ...}5.3 三种启动方式:fork vs spawn vs forkserver
multiprocessing有三种启动子进程的方式,行为差异很大:
import multiprocessing as mp
# 查看默认启动方式
print(mp.get_start_method())
# Linux: 'fork'
# macOS: 'fork' (3.8之前) / 'spawn' (3.8+)
# Windows: 'spawn'
# 设置启动方式
# mp.set_start_method('spawn')| 启动方式 | 原理 | 优点 | 缺点 | 平台 |
|---|---|---|---|---|
| fork | 复制父进程内存空间 | 启动快,共享内存 | 不安全(线程问题) | Unix |
| spawn | 全新进程,重新导入模块 | 安全,可序列化 | 启动慢,初始化重 | 全平台 |
| forkserver | 预先fork服务进程 | 兼顾安全和速度 | 复杂,资源固定 | Unix |
fork最大的坑是:如果父进程有线程在运行(比如用了线程池),fork之后子进程只会有当前线程的副本,其他线程"消失"了,但它们持有的锁状态还在。这会导致死锁。所以Python 3.8开始,macOS默认改成了spawn。
fork 是"复制粘贴一个自己",spawn 是"从头培养一个新人"。前者快但容易出幺蛾子。
5.4 进程池:Pool的用法
手动管理进程太繁琐,Pool提供了进程池的封装:
from multiprocessing import Pool
import time
def process_item(item):
time.sleep(0.1) # 模拟处理
return item * item
if __name__ == '__main__':
items = list(range(20))
# 方式1: map(阻塞,有序)
with Pool(processes=4) as pool:
results = pool.map(process_item, items)
print(results[:5]) # [0, 1, 4, 9, 16]
# 方式2: imap(迭代器,有序,非阻塞)
with Pool(processes=4) as pool:
for result in pool.imap(process_item, items, chunksize=4):
print(result, end=' ')
# 方式3: starmap(多参数)
def multi_arg(a, b):
return a + b
with Pool(processes=4) as pool:
results = pool.starmap(multi_arg, [(1, 2), (3, 4), (5, 6)])
print(results) # [3, 7, 11]chunksize参数很关键。它控制每个进程一次取多少任务。默认情况下每个任务单独分配,任务多时进程间通信开销大。设置chunksize=4表示每个进程一次取4个任务批量处理,通信次数减少4倍。
六、多线程:GIL下的并发
6.1 threading核心组件
threading模块提供了完整的线程同步原语。先看一个标准的线程安全计数器:
import threading
import time
class ThreadSafeCounter:
def __init__(self):
self._value = 0
self._lock = threading.Lock()
def increment(self, n=1):
with self._lock:
self._value += n
@property
def value(self):
with self._lock:
return self._value
def worker(counter, iterations):
for _ in range(iterations):
counter.increment()
counter = ThreadSafeCounter()
threads = []
for _ in range(10):
t = threading.Thread(target=worker, args=(counter, 10000))
threads.append(t)
t.start()
for t in threads:
t.join()
print(f"最终值: {counter.value}") # 1000006.2 Lock vs RLock vs Condition vs Semaphore vs Event
threading提供了五种同步原语,各有用途:
Lock:最基础的互斥锁。同一时间只允许一个线程获取。不可重入——同一个线程获取两次会死锁。
RLock:可重入锁。同一个线程可以多次acquire,需要等次数的release才释放。适合递归调用场景。
import threading
class RecursiveService:
def __init__(self):
self._lock = threading.RLock()
def outer_method(self):
with self._lock:
print("外层方法获取锁")
self.inner_method()
def inner_method(self):
with self._lock: # RLock允许同线程再次获取
print("内层方法获取锁")
# 如果用Lock,这里会死锁
service = RecursiveService()
service.outer_method()Condition:条件变量。配合Lock使用,可以wait/notify。适合"等待某个条件满足"的场景。
import threading
import time
import random
class BoundedBuffer:
def __init__(self, capacity):
self._buffer = []
self._capacity = capacity
self._cond = threading.Condition()
def put(self, item):
with self._cond:
while len(self._buffer) >= self._capacity:
self._cond.wait() # 缓冲区满,等待
self._buffer.append(item)
self._cond.notify_all() # 通知消费者
def get(self):
with self._cond:
while not self._buffer:
self._cond.wait() # 缓冲区空,等待
item = self._buffer.pop(0)
self._cond.notify_all() # 通知生产者
return itemSemaphore:信号量。允许N个线程同时访问。适合资源池场景。
import threading
import time
# 限制同时3个线程访问
sem = threading.Semaphore(3)
def limited_worker(worker_id):
with sem:
print(f"Worker {worker_id} 开始工作")
time.sleep(1)
print(f"Worker {worker_id} 完成")
threads = [threading.Thread(target=limited_worker, args=(i,)) for i in range(10)]
for t in threads:
t.start()
for t in threads:
t.join()
# 每次只有3个worker同时工作Event:事件标志。一个线程设置事件,其他线程等待事件。适合"通知所有等待者"的场景。
6.3 GIL:绕不开的话题
GIL(Global Interpreter Lock)是CPython最臭名昭著的设计。它是一个互斥锁,保证同一时间只有一个线程执行Python字节码。
为什么需要GIL?因为CPython的内存管理不是线程安全的。引用计数机制在多线程下会出错,加细粒度锁性能开销太大,所以用一把全局锁解决。
GIL对IO密集型任务和CPU密集型任务的影响完全不同:
import threading
import time
import math
# CPU密集型:GIL导致多线程比单线程还慢
def cpu_work():
result = 0
for i in range(10_000_000):
result += math.sqrt(i)
return result
start = time.time()
cpu_work()
cpu_work()
print(f"串行: {time.time()-start:.2f}s")
start = time.time()
t1 = threading.Thread(target=cpu_work)
t2 = threading.Thread(target=cpu_work)
t1.start(); t2.start()
t1.join(); t2.join()
print(f"多线程: {time.time()-start:.2f}s")
# 串行可能比多线程快,因为线程切换有开销对于IO密集型任务(网络请求、文件读写),GIL会在IO操作时释放,所以多线程能有效提升吞吐:
import threading
import time
import urllib.request
def fetch_url(url):
with urllib.request.urlopen(url) as resp:
return len(resp.read())
urls = ["https://httpbin.org/delay/1"] * 5
# 串行
start = time.time()
for url in urls:
fetch_url(url)
print(f"串行: {time.time()-start:.2f}s") # ~5s
# 多线程
start = time.time()
threads = [threading.Thread(target=fetch_url, args=(url,)) for url in urls]
for t in threads:
t.start()
for t in threads:
t.join()
print(f"多线程: {time.time()-start:.2f}s") # ~1sGIL 不是 Python 的耻辱柱,而是工程妥协的产物。理解它、绕过它,比抱怨它有用得多。
七、concurrent.futures:统一的并发接口
7.1 ThreadPoolExecutor vs ProcessPoolExecutor
concurrent.futures是Python 3.2引入的高层并发接口,把线程池和进程池统一到了一套API下。它的设计理念是:你不需要关心底层用的是线程还是进程,只需要选择合适的Executor。
选型原则一句话:IO密集型用ThreadPoolExecutor,CPU密集型用ProcessPoolExecutor。
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
import time
import math
# IO密集型任务
def fetch_data(url):
import urllib.request
with urllib.request.urlopen(url) as resp:
return len(resp.read())
# CPU密集型任务
def cpu_compute(n):
result = 0
for i in range(n):
result += math.sqrt(i)
return result
# 线程池处理IO密集型
def run_io_tasks():
urls = ["https://httpbin.org/delay/1"] * 5
with ThreadPoolExecutor(max_workers=5) as executor:
results = list(executor.map(fetch_data, urls))
print(f"IO任务结果: {results}")
# 进程池处理CPU密集型
def run_cpu_tasks():
numbers = [5_000_000] * 4
with ProcessPoolExecutor(max_workers=4) as executor:
results = list(executor.map(cpu_compute, numbers))
print(f"CPU任务结果: {results[:2]}")
if __name__ == '__main__':
start = time.time()
run_io_tasks()
print(f"IO任务耗时: {time.time()-start:.2f}s")
start = time.time()
run_cpu_tasks()
print(f"CPU任务耗时: {time.time()-start:.2f}s")7.2 submit vs map
Executor有两种提交任务的方式:
submit:提交单个任务,返回Future对象。可以附加回调,处理异常更灵活。
from concurrent.futures import ThreadPoolExecutor, as_completed
import random
import time
def risky_task(task_id):
time.sleep(random.uniform(0.1, 0.5))
if random.random() < 0.3:
raise ValueError(f"任务{task_id}失败了")
return f"任务{task_id}成功"
with ThreadPoolExecutor(max_workers=4) as executor:
# submit + as_completed:按完成顺序处理
futures = {executor.submit(risky_task, i): i for i in range(10)}
for future in as_completed(futures):
task_id = futures[future]
try:
result = future.result()
print(f" {result}")
except ValueError as e:
print(f" 错误: {e}")map:批量提交,按输入顺序返回结果。语法简洁,但异常会延迟到迭代时抛出,且不能单独处理每个任务的成功/失败。
from concurrent.futures import ProcessPoolExecutor
def square(n):
return n * n
with ProcessPoolExecutor(max_workers=4) as executor:
# map按输入顺序返回
results = list(executor.map(square, range(10)))
print(results) # [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]7.3 Future对象与回调机制
Future是concurrent.futures的核心抽象。它代表一个异步操作的最终结果,提供了以下能力:
from concurrent.futures import ThreadPoolExecutor
import time
def long_running_task(n):
time.sleep(n)
return f"耗时{n}秒的任务完成"
# Future的核心方法
with ThreadPoolExecutor(max_workers=2) as executor:
future = executor.submit(long_running_task, 2)
# 检查状态
print(f"运行中: {future.running()}") # True
print(f"已完成: {future.done()}") # False
# 添加回调(任务完成时自动调用)
def on_complete(fut):
try:
print(f"回调收到结果: {fut.result()}")
except Exception as e:
print(f"回调收到异常: {e}")
future.add_done_callback(on_complete)
# 阻塞等待结果(可设超时)
result = future.result(timeout=5)
print(f"直接获取: {result}")
# 取消任务(如果还没开始执行)
future2 = executor.submit(long_running_task, 5)
cancelled = future2.cancel()
print(f"取消成功: {cancelled}") # True或False回调机制在实际工程中非常有用。比如你可以在任务完成后自动触发下一步处理,而不需要阻塞等待:
from concurrent.futures import ThreadPoolExecutor
import time
def download(url):
time.sleep(1) # 模拟下载
return f"downloaded:{url}"
def parse(content):
time.sleep(0.5) # 模拟解析
return f"parsed:{content}"
def save(data):
time.sleep(0.3) # 模拟保存
return f"saved:{data}"
# 回调链:下载 -> 解析 -> 保存
with ThreadPoolExecutor(max_workers=3) as pool:
future = pool.submit(download, "https://example.com/data")
def on_downloaded(fut):
content = fut.result()
parse_future = pool.submit(parse, content)
parse_future.add_done_callback(on_parsed)
def on_parsed(fut):
data = fut.result()
save_future = pool.submit(save, data)
save_future.add_done_callback(
lambda f: print(f"全链路完成: {f.result()}")
)
future.add_done_callback(on_downloaded)
time.sleep(3) # 等待整个链路完成回调地狱不是Promise的专利,Future用不好一样能写出面条代码。适时用async/await重构。
八、三大并发模型选型指南
8.1 对比表
| 维度 | 多进程 | 多线程 | 协程 |
|---|---|---|---|
| 并行度 | 真并行(多核) | 伪并行(GIL) | 伪并行(单线程) |
| 内存开销 | 大(~10-100MB/进程) | 中(~8MB栈/线程) | 小(~KB级/协程) |
| 切换开销 | 大(内核态切换) | 中(内核态切换) | 小(用户态切换) |
| 通信成本 | 高(IPC) | 低(共享内存) | 极低(同一事件循环) |
| 适用场景 | CPU密集型 | IO密集型(简单) | IO密集型(高并发) |
| 编程复杂度 | 中 | 高(锁问题) | 高(异步思维) |
| 调试难度 | 中 | 高(竞态条件) | 高(调用栈不连续) |
| 扩展性 | 有限(进程数) | 有限(线程数) | 高(万级协程) |
| 第三方库兼容 | 好 | 好 | 差(需要async库) |
8.2 选型决策步骤
怕浪猫总结了一个"五步决策法",照着走就行:
第一步:判断任务类型。CPU密集型选多进程,IO密集型选多线程或协程。
第二步:评估并发规模。百级以下选多线程,千级以上选协程。
第三步:检查依赖库。如果核心依赖库没有async版本(比如某些数据库驱动),要么换库,要么用多线程+异步桥接。
第四步:评估团队熟悉度。如果团队对asyncio不熟悉,先用多线程+concurrent.futures上手,后续再迁移。
第五步:考虑混合架构。CPU密集型部分用多进程,IO密集型部分用协程,两者通过消息队列解耦。
# 混合架构示例:多进程处理CPU任务 + 主线程异步IO
import asyncio
from concurrent.futures import ProcessPoolExecutor
import math
async def async_coordinator():
loop = asyncio.get_event_loop()
executor = ProcessPoolExecutor(max_workers=4)
# 把CPU密集型任务丢给进程池
futures = []
for i in range(4):
future = loop.run_in_executor(
executor,
cpu_heavy_compute,
2_000_000 + i * 500_000
)
futures.append(future)
# 异步等待所有结果
results = await asyncio.gather(*futures)
executor.shutdown()
return results
def cpu_heavy_compute(n):
result = 0
for i in range(n):
result += math.sqrt(i)
return result
if __name__ == '__main__':
results = asyncio.run(async_coordinator())
print(f"结果: {results}")没有银弹,只有合适的场景用合适的工具。混合架构往往是最务实的选择。
九、实战踩坑录
坑1:协程没await
# 错误代码
async def batch_process():
tasks = []
for item in items:
# 忘记await,协程没执行
process_item(item) # 这里应该用create_task
# 正确代码
async def batch_process():
tasks = []
for item in items:
task = asyncio.create_task(process_item(item))
tasks.append(task)
await asyncio.gather(*tasks)坑2:在协程里调用阻塞IO
import asyncio
import time
async def bad_handler():
# requests是同步库,会阻塞事件循环!
# import requests
# resp = requests.get("https://httpbin.org/delay/1")
# 正确做法:用aiohttp
import aiohttp
async with aiohttp.ClientSession() as session:
async with session.get("https://httpbin.org/delay/1") as resp:
return await resp.text()
# 如果非要用同步库,用run_in_executor
async def workaround_handler():
import requests
loop = asyncio.get_event_loop()
# 把同步阻塞调用丢到线程池
result = await loop.run_in_executor(
None, # 默认线程池
lambda: requests.get("https://httpbin.org/delay/1").text
)
return result坑3:进程池里用了不可序列化的对象
from concurrent.futures import ProcessPoolExecutor
class BadWorker:
def __init__(self, name):
self.name = name
def __call__(self, x):
return x * 2
# 这会报错:BadWorker无法pickle
# worker = BadWorker("test")
# with ProcessPoolExecutor() as pool:
# result = pool.map(worker, range(10))
# 正确做法:用顶层函数
def top_level_worker(x):
return x * 2
with ProcessPoolExecutor() as pool:
result = list(pool.map(top_level_worker, range(10)))坑4:Lock跨进程使用
from multiprocessing import Process, Lock
import threading
# 错误:用threading.Lock给多进程加锁
# threading.Lock在fork后不会共享,每个进程有独立的锁
# lock = threading.Lock() # 错!
# 正确:用multiprocessing.Lock
mp_lock = Lock()
def safe_write(filename, content):
with mp_lock:
with open(filename, 'a') as f:
f.write(content + '\n')
if __name__ == '__main__':
procs = [
Process(target=safe_write, args=('output.txt', f'line{i}'))
for i in range(5)
]
for p in procs:
p.start()
for p in procs:
p.join()坑5:事件循环嵌套
import asyncio
async def outer():
# 错误:在协程里调用asyncio.run会报错
# "asyncio.run() cannot be called from a running event loop"
# asyncio.run(inner()) # 错!
# 正确:直接await
result = await inner()
return result
async def inner():
await asyncio.sleep(0.1)
return "inner result"
# asyncio.run只能在最外层调用
asyncio.run(outer())踩坑不可怕,可怕的是同一个坑踩两次。记录下来,让未来的自己少走弯路。
十、性能基准测试
怕浪猫用一台4核8G的机器做了组对比测试,任务是对一个列表中的100万个数字做质数判断(CPU密集型)和1000个URL的HTTP请求(IO密集型):
| 方案 | CPU密集型(100万质数) | IO密集型(1000请求) |
|---|---|---|
| 串行 | 8.2s | 100s |
| 多线程(4线程) | 9.1s(比串行还慢) | 3.2s |
| 多进程(4进程) | 2.3s | 3.5s |
| 协程(1000协程) | 8.0s(单线程无优势) | 1.8s |
| 协程+uvloop | 8.0s | 1.1s |
| 多进程+协程 | 2.1s | 1.5s |
结论很明确:CPU密集型任务,多进程是唯一正解;IO密集型任务,协程碾压一切;混合型任务,多进程+协程的组合最优。
收藏引导
这篇文章从操作系统IO模型一路讲到Python并发工程实践,信息量很大。如果你觉得有用,先收藏起来,后面遇到并发问题随时翻出来对照。
互动引导
你在实际项目中用的是哪种并发方案?遇到过什么坑?欢迎在评论区聊聊你的经历。如果对文章中某个点有疑问,也欢迎提问,怕浪猫会一一回复。
追更引导
这是Python实战训练营系列的第6期。如果你跟着系列看到这里,说明你对Python底层的求知欲很强——这是好事。下一期我们会进入异步框架与并发模式,讲aiohttp、FastAPI异步、异步数据库驱动、asyncio生产环境部署等内容,难度会再上一个台阶。关注我,别掉队。
系列进度 6/16
下章预告:异步框架与并发模式——aiohttp实战、FastAPI异步原理、异步数据库操作、生产环境asyncio部署方案。
怕浪猫说
异步编程是Python进阶的一道分水岭。很多人会写async def,但不理解事件循环、不懂得区分Task和Future、不知道GIL什么时候释放什么时候不释放。写出来的异步代码跑起来比同步还慢,还不知道问题出在哪。
怕浪猫的建议是:先把这篇的底层原理吃透,再动手写异步代码。理解了IO多路复用和事件循环的运行机制,你看asyncio的源码就像看小说一样流畅。不理解原理就去用框架,那不是在写代码,是在碰运气。
代码写多了就知道,真正值钱的不是会用什么框架,而是理解框架背后的原理。原理通了,换什么框架都能快速上手。原理不通,框架换十个也是一样的水平。
下期见。