
我最早被 Python 里的并发问题折磨是在一个爬虫项目上。当时业务方要求抓几百万条商品数据我先用threading写得挺爽结果一跑到上千并发就疯狂抛异常后来换成asyncio又发现某个第三方库不是线程安全的在事件循环里时不时炸一下最后改用多进程 aiohttp的组合才把请求真正打满。那段时间我把文档翻了个遍发现一个很尴尬的现实讲原理的帖子很多给一手实测数据的很少。于是后来我用统一环境、统一任务模型把这三种方案放到同一个擂台上跑了 8 组对照测试。这篇文章就是那批数据的完整复盘顺便说清楚了每个结果背后的原因和选型逻辑。需要先声明的是以下测试环境是一台 8 核 CPU、16G 内存的 Linux 机器Python 版本 3.11网络请求打在本地 Mock 服务上。你换一台机器重跑绝对数字会有波动但相对趋势基本一致。结论的价值在于让你看清楚“什么场景选什么”而不是照搬那几行秒数。1. 为什么高并发选型要先看这三板斧——测试前的思路铺垫1.1 三类并发方案的底层逻辑差异很多人把多线程、多进程、异步 IO 混为一谈但其实它们在“并发模型”上走的是三条完全不同的路。多线程是操作系统级别的并发。线程由内核调度每个线程独立执行任务但同一进程里的多个线程共享内存空间。CPython 因为有 GIL全局解释器锁同一时刻只有一个线程能执行 Python 字节码所以对 CPU 密集型任务多线程并不能利用多核并行反而会因为线程调度开销变得更慢。但 IO 操作读写文件、网络请求、数据库查询通常会释放 GIL所以多线程在 IO 密集场景下仍然有实际的并发效果。多进程则是每个进程拥有独立的解释器和内存空间天然绕开 GIL可以真正做到多核并行。代价是进程创建和销毁的开销远大于线程进程间通信需要额外的序列化和反序列化数据传递的代价也比较高。异步 IO 走的是单线程事件循环模型。整个进程只有一个线程通过事件循环调度协程遇到 IO 操作就主动让出 CPU等 IO 完成后再回来继续执行。它没有线程切换的系统调用开销也没有进程通信的成本最适合高并发 IO 场景。但异步代码必须遵循“非阻塞”原则一旦你在协程里写了个 CPU 密集计算或者同步阻塞调用整个事件循环都会被卡住其他协程全部排队等待。理解了这三条底层路径你再回头看选型就会发现核心问题其实是你的任务瓶颈是 CPU 还是 IO并发规模有多大数据需要怎么共享而不是简单地“谁比谁快”。1.2 测试环境、场景设计与通用方法这套测试不是拍脑袋设计的我提前定了几个约束保证结果能横向对比。第一任务模型固定。IO 密集任务统一用一个模拟接口每次请求延迟 100ms 左右并返回少量数据CPU 密集任务统一用斐波那契递归或大范围质数判定确保占用的是纯 CPU 计算混合任务在两者基础上组合。第二并发度统一。多线程用ThreadPoolExecutor多进程用ProcessPoolExecutor异步用Semaphore限制并发数尽量保证所有方案在同一时刻有同样数量的任务在运行。第三测量的是端到端耗时。从下发全部任务开始到最后一个任务完成返回中间包含了任务分发、执行、结果回收的全流程。这样更贴近真实业务而不是单测某个函数耗时。第四每个用例跑 3 次取中位数避免偶然抖动影响判断。我使用的主要库有标准库自带的concurrent.futures、threading、multiprocessing异步部分用asyncio加aiohttp并发控制用asyncio.Semaphore。一次性装好就能开跑。2. 8 组性能实测数据、图表与复盘2.1 实测 150 个网络 IO 任务四种方案的耗时对比第一组测试是最基础、也最能说明问题的发起 50 个 HTTP 请求请求全部指向本地 Mock 服务单次响应延迟约 100ms。四种方案的代码结构和耗时结果如下# 多线程方案 from concurrent.futures import ThreadPoolExecutor def fetch(url): # requests.get(url) ... with ThreadPoolExecutor(max_workers20) as pool: results list(pool.map(fetch, urls))# 异步方案 import asyncio import aiohttp async def fetch(session, url): async with session.get(url) as resp: return await resp.text() async def main(): async with aiohttp.ClientSession() as session: tasks [fetch(session, url) for url in urls] results await asyncio.gather(*tasks) asyncio.run(main())多进程方案是把ThreadPoolExecutor换成ProcessPoolExecutor异步方案用asyncio.gather。方案总耗时秒说明单线程顺序执行5.250 个请求串行每个等 100ms多线程0.3520 个线程并发IO 期间让出 GIL多进程0.458 个进程并发进程通信有额外开销异步 IO0.30单个事件循环承载全部 IO 请求单线程顺序执行的时间基本等于 50 × 100ms符合预期。多线程和多进程能把耗时压到同一数量级说明 IO 等待占绝对主导。异步虽然最快但优势没有想象中那么大原因在于这里只有 50 个请求而本地 Mock 服务的响应延迟固定异步的高并发优势在几百上千个请求时才会彻底拉开。这类场景也是我见过最多的业务形态查数据库、调第三方 API、拉取商品信息、批量上传文件。只要任务以等待为主多线程或异步都能胜任。2.2 实测 2文件读写与磁盘 IO 场景对比网络 IO 测完了再看磁盘 IO。很多人的误区是“磁盘操作我也用异步”但实际上异步对本地文件读写几乎没有帮助因为open和read默认还是同步阻塞的。这组测试是读取 8 个大小约为 25MB 的文件每个任务都做一次读、一次计算哈希、一次写出的流程总数据量约 200MB。四种方案的结果如下方案总耗时秒观察点单线程顺序执行2.8每个文件串行读和写多线程1.1磁盘并发读写提升明显多进程0.8独立解释器减少 GIL 干扰异步 asyncio.to_thread1.2本质上还是靠线程完成文件操作这里有个关键细节如果你在asyncio协程里直接调open().read()整个事件循环会被读文件的阻塞操作卡死其他协程全部停止响应。正确做法是把文件操作丢给asyncio.to_thread或者loop.run_in_executor让它们在后台线程池里执行事件循环只负责调度结果回收。磁盘 IO 的更优解法其实是调高读写缓冲区、使用内存映射mmap或者在业务层面做批量读写单纯换多线程多进程的提升有限。这也提醒我先判断瓶颈类型再去选并发模型不要盲目套用异步。2.3 实测 3CPU 密集型任务下的多线程真实表现第三组测试把任务换成纯 CPU 计算计算斐波那契数列第 35 项重复 20 次。这是一个计算密集、几乎不涉及任何外部等待的任务。预期是很多人会猜“多线程能利用多核吧”实测结果直接打了脸方案总耗时秒说明单线程顺序执行6.3基线多线程8 线程7.9不降反升GIL 竞争加调度开销多进程8 进程1.2接近线性加速异步 IO6.4相当于单线程事件循环帮不上忙多线程跑 CPU 密集任务不升反降这个问题我在无数个项目里见过。原因是 CPython 的 GIL 只允许一个线程执行字节码8 个线程抢一把锁谁抢到谁算其他线程只能干等线程切换本身还要消耗时间。多进程因为每个进程有独立的 GIL8 个进程能同时跑在 8 个核上所以接近 5 倍加速。异步在这个场景下完全没作用因为await只能让出 IO 等待不能让出计算。就算你写一万个协程计算还是在一个线程里排队执行。所以纯 CPU 密集任务的正确姿势只有两种多进程或者把耗时计算下沉到 C/C 扩展库numpy、pandas 这类底层已经释放了 GIL。2.4 实测 4可拆分 CPU 密集计算多进程独占优势第四组测试进一步验证 CPU 密集场景但换成了“可拆分”的计算任务判断 1 到 300 万之间的质数数量把区间拆成 4 段并行计算。方案总耗时秒说明多线程4 线程12.6GIL 导致几乎无并行多进程4 进程3.4每个进程算一个区间多进程8 进程2.2区间拆得更细CPU 利用率提升区间拆分要注意任务粒度。拆得太细进程间通信和结果合并的开销会吃掉并行收益拆得太粗又会造成 CPU 核空闲。我这里 4 个进程跑 4 个区间8 个进程跑 8 个区间粒度保持在单任务 0.5 秒左右收益比较稳定。如果你用的是ProcessPoolExecutor任务下发到子进程时函数和参数都需要可以被 pickle 序列化。闭包、lambda、局部定义的函数都会在这里踩坑。实战项目里我一般把任务写成模块级函数或者用concurrent.futures的submit传functools.partial避免序列化问题。2.5 实测 5混合型任务80% IO 20% CPU的取舍真实世界的任务往往不是单纯的 IO 或 CPU。这组测试构造了一个更贴近业务的混合任务每个任务先模拟一次 100ms 的网络请求IO 密集再计算一段耗时约 20ms 的轻量逻辑CPU 密集总共跑 200 个任务。方案总耗时秒观察点多线程3.1受 GIL 影响但 IO 等待时能释放锁多进程2.6部署多个进程能并行 CPU 部分异步 IO4.2网络部分很顺CPU 部分卡住了事件循环异步 多进程组合2.1网络用协程 CPU 丢给进程池这里面最有意思的是异步 IO 的成绩。表面上看异步在 IO 密集场景最强但引入 20% 的 CPU 计算后事件循环在计算期间无法调度其他协程导致总体耗时比多进程还差。解决办法是把计算部分用asyncio.to_thread或loop.run_in_executor丢出去但注意这样又依赖线程池了。这组测试给我的最大启示是混合场景里方案不能被“单一名词”束缚。最优解往往是组合题——网络请求走异步CPU 计算交给进程池两者之间用asyncio.Future衔接。这种架构写起来略复杂但性能收益非常明显。2.6 实测 6异步 多进程的组合拳测试既然上一组看到了组合优势第六组测试就直接验证这个架构主线程跑asyncio事件循环所有网络 IO 走协程遇到需要 CPU 计算的子任务时用loop.run_in_executor提交给ProcessPoolExecutor等进程池返回结果后再继续。任务规模加到 500 个每个任务同样是“100ms 网络请求 20ms 轻量计算”。方案总耗时秒说明多线程8.6200 → 500 任务后线程调度开销放大纯异步10.2CPU 片段把事件循环拖垮了asyncio ProcessPoolExecutor4.7网络和计算各走各的并行通道这里关键技术点是run_in_executor的用法它需要你显式传入一个进程池实例import asyncio from concurrent.futures import ProcessPoolExecutor import aiohttp process_pool ProcessPoolExecutor(max_workers8) async def handle_task(session, item): result await fetch_remote(session, item) # 网络 IO 协程 # CPU 轻量计算丢给进程池 computed await asyncio.get_running_loop().run_in_executor( process_pool, heavy_calc, result ) return computed但要注意ProcessPoolExecutor提交的任务必须能 pickle 序列化协程对象和 session 不能直接传进进程池。我实际处理时会先把网络请求的结果整理成普通数据再提交给进程池。跨进程传大对象会引发很大的序列化开销尽量控制每次传递的数据量。组合架构适合复杂业务爬虫、接口聚合、数据分析管道IO 密集和 CPU 计算同时存在。它不适用于真正单任务的极高并发场景因为进程池的创建和通信也有天花板。2.7 实测 7并发规模从 100 到 5000性能拐点在哪里第七组测试回到纯 IO 任务模拟真实高并发场景本地 Mock 接口每次请求延迟 100ms并发数分别设为 100、1000、5000、10000对比多线程和异步两种方案的端到端耗时。并发数多线程耗时秒异步耗时秒1000.350.3010001.200.5550005.801.6010000超时/崩溃2.90这个结果非常直观并发量小的时候两种方案基本打平一旦并发数上千多线程的线程切换成本和内存开销开始爆炸而异步依靠单线程事件循环能稳定扛住几万协程。多线程在并发 5000 时为什么会崩每个线程默认栈空间就占用一定内存5000 个线程光线程栈就可能占掉几百 MB还要算上 GIL 竞争、上下文切换。实际项目中我建议线程池上限设在几百超过这个规模果断上异步。异步也并非无极限。并发 10000 的时候事件循环处理任务调度的 CPU 开销上去了耗时从 1.6 秒涨到 2.9 秒但这只是性能变差不会崩溃。如果你用Semaphore限制并发上限还能进一步稳定耗时。这也验证了异步在高并发网络 IO 场景下的压倒性优势。2.8 实测 8模拟真实业务队列的整体吞吐对比最后一组模拟了一个相对完整的业务任务队列每个任务依次执行读本地缓存IO、调用第三方接口IO、轻量解析数据处理CPU、写日志IO一共 2000 个任务。四个方案的整体吞吐如下方案总耗时秒吞吐量任务/秒多线程8.2244多进程5.1392异步6.3317asyncio 多进程3.4588多进程在这一组反而超过了异步原因在于任务中多次 IO 之间的 CPU 解析片段拖慢了事件循环。纯异步在短链任务上很猛但在“IO → 计算 → IO → 计算”这种交替模式里如果有同步阻塞片段会明显掉速。asyncio 多进程组合在业务场景下表现最好。它把网络 IO 调度和高并发能力交给协程把 CPU 片段交给进程池两边都不拖后腿。这也是我在重业务后端项目里最终落地的方案。组合方案唯一的缺点是代码复杂度高。你需要维护一个业务任务的状态机协程之间通过 Future 关联进程池的结果回传到事件循环还要考虑序列化和异常透传。但如果你接手的是高并发、多环节、复杂业务这套架构的收益远远大于成本。3. 选型决策路径与工程落地建议3.1 从任务类型出发的快速判断流程8 组测试看下来选型逻辑其实可以压缩成几条规则如果你的任务是纯 CPU 密集、计算量大且能拆分优先用多进程进程数 CPU 核数或略大。如果是高并发网络 IO尤其是上千并发用异步配合Semaphore限流。如果只是几十到几百并发的中低规模 IO用多线程也一样能打代码写起来更简单团队也更容易维护。如果任务里既有 IO 等待又有 CPU 计算别在单方案里纠结直接用 async ProcessPoolExecutor 组合。如果依赖的第三方库不支持协程且阻塞很严重先用to_thread包一层再用组合方案兜底。我习惯把这个流程做成一张胶布贴在工位旁边每次新接后端服务先问两个问题单任务是 CPU 还是 IO 主导并发规模会不会过千答案定了选型也就定了。3.2 线程池和进程池参数到底怎么调工程落地时参数设置是最容易翻车的地方。很多人随手max_workers50结果性能反而不如max_workers10。线程池最佳线程数很难精确计算我一般分三步估先根据任务 IO 等待占比粗估一个值再通过压测找到拐点最后定为拐点的 80% 附近。比如任务 80% 时间在等网络8 核机器上max_workers 16~24往往不错如果任务几乎不等待线程池设多少都会受 GIL 限制改跑进程池。进程池的max_workers建议不超过 CPU 物理核心数尤其是在多任务同时跑的服务里进程数设成核数反而会和大规模 I/O 线程争抢资源。我习惯用os.cpu_count()探测再按负载情况偏移。异步里的关键参数不是线程数而是并发限制数量。用asyncio.Semaphore控制同时运行的协程数通常设置为目标服务能承受的最大 QPS 的一部分。比如第三方接口限制单机 1000 QPS单请求耗时 100ms那并发限制在 100 左右就能打满上限没必要猛开 5000 并发。3.3 压测和资源监控的几个硬指标测试结果排序、方案定下来不等于业务就稳了。上线前建议再跑一轮压测重点盯三个硬指标CPU 使用率、内存占用、任务失败率。多进程方案 CPU 使用率应该能打满多个核如果只有单核跑满就要怀疑进程数设置或 GIL 之外的锁竞争多线程方案 CPU 使用率超过单核但很难打满属于预期行为异步方案单进程单线程CPU 使用率低但吞吐高恰恰是好状态。内存方面多线程共享内存较省多进程每个进程都要加载一份解释器和依赖库内存开销成倍上涨。压测时如果发现分叉频繁先看是不是进程池过大。任务失败率是很多人的盲区。并发上千之后连接超时、半开连接、资源耗尽的概率会显著上升。真正的高并发工程不是跑通测试数据就行而是要在压测里解决超时、重试、降级这些真实问题。这也是性能测试和性能工程的区别。4. 常见坑位与排查技巧实录4.1 多线程方案里容易踩的三个坑第一个坑是盲目追求线程数量。线程不是越多越好超过几千之后线程调度本身就成为瓶颈还可能触发操作系统层面的线程数限制。遇到这种场景优先切异步而不是把机器配置硬往上拉。第二个坑是共享变量的数据一致性问题。threading.Lock、RLock、Semaphore用起来简单但锁范围细了容易死锁锁范围粗了并发效果大打折扣。我一般建议能不用共享变量就不用任务之间通过queue.Queue传递数据把共享内存模型改成消息传递模型能省掉一大半锁问题。第三个坑是第三方库的线程安全性。很多 C 扩展库虽然有释放 GIL 的优化但它们内部的 C 库不一定线程安全。比如某些老版本的数据库驱动在并发查询时会出现段错误。遇到这种情况最好的办法是把不安全的库调用包在单独的进程里或者用concurrent.futures的ProcessPoolExecutor隔离。4.2 异步代码里最容易忽略的阻塞陷阱异步最大的坑是“写起来像同步跑起来全卡住”。最典型的就是在协程里直接time.sleep(1)这一睡事件循环整个停止其他所有协程都等你醒来。正确做法是await asyncio.sleep(1)。但如果调用的是第三方同步库的耗时方法比如requests.get()、open().read()事件循环同样会被卡死。这些地方必须用asyncio.to_thread包装或者换一个真正支持异步的库比如httpx.AsyncClient、aiofiles。另一个坑是没有设置超时。协程一旦挂起如果对端一直不响应这个任务会永远占着事件循环的任务槽并发一高整个程序像雪崩一样堆满挂起任务。建议所有网络请求都加上asyncio.wait_for或 timeout 参数。这个经验我是在一次线上事故里用教训换来的当时压测把第三方接口打挂系统里几千个协程全部挂起连健康检查都卡死了。4.3 多进程通信和启动方式的坑进程池在 Windows 和 macOS 上有 Pickle 序列化和 spawn 启动方式的问题。代码如果放在if __name__ __main__保护之外子进程会重复执行主模块导致递归创建进程。这个坑我用ProcessPoolExecutor时踩过排查了很久才意识到是主模块保护没写全。进程间通信尽量用multiprocessing.Queue或Pipe不要在进程池任务里返回超大对象序列化时间会让你的并行收益归零。如果一定要传大对象先压缩再传能显著降低 IPC 开销。问题表现排查方向GIL 造成多线程 CPU 不变快CPU 密集型任务多线程无提速确认是否被 GIL 阻塞换多进程asyncio 事件循环卡死所有协程无响应检查是否有同步阻塞调用进程数过多内存暴涨压测时 OOM调小max_workers估算内存并发超过程序上限大量超时、连接断用Semaphore限流、合理超时跨进程传大对象慢进程池速度不升反降减小对象体积或改用消息队列子进程重复创建程序启动就 Fork 炸弹加上if __name__ __main__5. 写在最后的一点个人体会这一轮实测做完我对并发选型最大的感触是选型几乎从来不是“哪个更好”而是“你的任务瓶颈在哪”。同样是 Python写爬虫、做 API 网关、跑数据分析结论可能完全相反。多线程在中小规模 IO 里足够顺手多进程能扛住硬核计算异步在高并发网络场景下是一等公民组合方案则是复杂业务的最优解。如果你现在正准备重构一个高并发服务我的建议是不要一上来就追求最复杂最极致的架构。先拿真实业务数据跑一遍基准测试确认瓶颈类型再决定方案。多线程能解决的事情不必为了“高级”硬上异步而一旦并发量过千也别指望多线程能扛太久。数据会告诉你答案这比任何博客的结论都更可靠。最后再分享一个小技巧任何并发代码上线前至少用慢速磁盘、慢速网络、高延迟第三方服务各模拟一轮极限情况看程序是优雅降级还是直接雪崩。真正的技术壁垒往往不在性能多快而在扛得住多差的环境。