并发编程实践建议
约 1712 字大约 6 分钟
2026-05-10
并发编程的难点不在于启动几个线程或协程,而在于选择合适模型,并把异常、超时、取消、退出和共享状态处理好。
- 先写正确,再考虑并发
- 根据任务类型选择方案
- 优先使用高级接口
- 控制并发数量
- 1先写同步版本,确认结果对,再加并发 —— 不要把“并发”当作 PR 的第一动作。
- 2限并发:线程池/进程池设 max_workers、asyncio 用 Semaphore、Queue 设 maxsize。
- 3所有外部 IO 调用都设超时;future.result(timeout=...)、asyncio.wait_for(..., timeout=...)。
- 4任务异常策略二选一:单任务失败继续 → try/except 包内部;整体失败退出 → 让异常往外抛。
- 5日志里带任务标识(URL/文件名/user_id),否则并发出错时看不出哪个任务挂了。
并发编程的难点不在于启动几个线程或协程,而在于选择合适模型,并把异常、超时、取消、退出和共享状态处理好。
先写正确,再考虑并发
不要一开始就并发化。建议先写同步版本,确认逻辑正确,再通过测量找到瓶颈。
并发会增加复杂性:
- 调试更难
- 错误更隐蔽
- 日志顺序可能混乱
- 资源竞争更复杂
根据任务类型选择方案
| 任务类型 | 推荐方案 |
|---|---|
| 少量 IO 任务 | 多线程或线程池 |
| 大量网络 IO | asyncio |
| CPU 密集型 | 多进程或进程池 |
| 简单批量任务 | concurrent.futures |
| 需要共享状态 | 尽量用队列或消息传递 |
优先使用高级接口
相比手动管理线程和进程,concurrent.futures 更适合大多数批量任务。
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=10) as executor:
results = executor.map(handle, items)代码更短,异常处理和资源释放也更容易管理。
控制并发数量
不要一次性创建无限任务。
- 线程池设置
max_workers - 进程池设置
max_workers - asyncio 使用
Semaphore - 队列设置
maxsize
过高并发可能导致:
- 内存暴涨
- 文件描述符耗尽
- 数据库连接耗尽
- 被远程服务限流
- 本机 CPU 调度开销过大
设置超时
所有外部 IO 都应该考虑超时。
future.result(timeout=5)asyncio 中:
await asyncio.wait_for(task(), timeout=5)没有超时的并发程序,可能因为一个任务卡住而永远无法结束。
正确处理异常
线程池和进程池中的异常会在 future.result() 时抛出。asyncio 中的异常也需要显式处理。
不要让后台任务静默失败。
for future in as_completed(futures):
try:
result = future.result()
except Exception as exc:
print(f'任务失败:{exc}')减少共享可变状态
共享可变状态是并发 bug 的主要来源。
优先级建议:
- 不共享,每个任务处理自己的数据。
- 通过 Queue 传递数据。
- 使用不可变对象。
- 最后才考虑锁。
优雅退出
并发程序要考虑如何停止。
线程消费者可以使用哨兵值:
SENTINEL = object()
q.put(SENTINEL)asyncio 任务可以使用 cancel(),并在协程中捕获 CancelledError 清理资源。
日志与调试
并发程序中建议日志包含:
- 线程名或任务 ID
- 请求 ID
- 开始时间和结束时间
- 异常堆栈
- 关键状态变化
线程中可以使用:
threading.current_thread().nameasyncio 中可以给 task 命名,或在日志中带上业务 ID。
不要为了并发而并发
并发不是银弹。以下情况可能不需要并发:
- 数据量很小
- 任务执行很快
- 瓶颈不在等待或计算
- 并发后增加的复杂性超过收益
一套更稳的并发开发顺序
写并发代码时,推荐按这个顺序来:
- 先写同步版本,确认结果正确;
- 加少量并发,比如 3~5 个任务;
- 打印任务开始、结束、失败信息;
- 加超时和异常处理;
- 增加并发数量,观察是否真的变快;
- 最后再封装成可复用函数或类。
不要一开始就把并发数量开到很大。很多问题只有在小并发下先看清楚,后面才好扩大。
并发数量要有上限
无论线程、进程还是 asyncio,都要控制并发数量。
| 场景 | 并发过高的后果 |
|---|---|
| 请求外部 API | 被限流、被封、失败率上升 |
| 读写磁盘 | 磁盘抖动,整体变慢 |
| CPU 计算 | 进程切换变多,内存暴涨 |
| 数据库查询 | 连接池耗尽,拖垮数据库 |
“并发越高越快”是误区。正确目标是找到一个稳定、可控、对外部系统友好的并发数量。
错误处理要放在任务内部还是外部
如果希望单个任务失败不影响其他任务,可以在任务内部捕获异常并返回结果对象:
def handle(item):
try:
# do work
return {'ok': True, 'item': item}
except Exception as exc:
return {'ok': False, 'item': item, 'error': str(exc)}如果希望任何一个任务失败就让整体失败,可以让异常抛出去,由外层统一处理。
两种方式都可以,关键是要明确策略,不要让异常悄悄消失。
日志比 print 更适合真实项目
练习时用 print 没问题。项目里建议用 logging:
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
logger.info('任务开始')
logger.exception('任务失败')并发程序里日志要包含任务标识,例如 URL、文件名、用户 ID 或任务 ID。否则多条日志混在一起,很难判断是哪一个任务出错。
什么时候应该放弃并发
有些任务不值得并发化:
- 数据量很小;
- 同步版本已经足够快;
- 任务依赖顺序很强;
- 错误恢复比加速更重要;
- 外部服务明确限制低频访问。
并发是工具,不是目标。代码清晰、结果正确、失败可排查,通常比“看起来很并发”更重要。
总结
并发编程实践中最重要的不是 API,而是工程判断:任务是什么类型、瓶颈在哪里、能否限制并发、如何处理失败、如何退出。好的并发代码应该可控、可观察、可恢复,而不是只追求“同时跑很多任务”。
- 并发开发先写正确同步版,再小规模并发、加日志、超时和异常处理。
- 线程、进程、asyncio 都要限制并发数量,并发过高常常会更慢更不稳定。
- 异常处理策略要明确:单任务失败继续,还是整体失败退出,不要让异常静默消失。
版权所有
版权归属:Shuo Liu
